BraveYouWorlds/TheLostRealms · all monthsRe-ran the walkthrough against the same clean baseline with the combat timer off. Both regression cases from the first run now unlock their authored hook: asking Mira about the Chancel three times opens her lore instead of minting "The Price of the First Descent", and climbing to the silenced bell opens the Bell-Tower Market's instead of minting "Why the Bells Are Gagged". The gate is respected on the way there — the first two asks produce atmosphere and no unlock. Three hooks opened in nine turns where the old build could open none. The hardened directives stopped lore being minted early, but not on the unlock turn itself: each of the three unlocks was accompanied by a compendium entry about the same subject, paraphrasing the authored text closely enough that the player now reads the same secret twice. The duplicate carries a subject field that matches the unlocked hook's name exactly, which makes this enforceable in discoverLore rather than in the prompt — a directive asks the model not to do it, a guard means it cannot. The driver's per-turn timeout held: nine turns, no timeouts, no wedges, against two hangs in the previous run. Also recorded two ways a run can silently start from the wrong place — gameplay autosaves over the named browser save, so a baseline has to be loaded from the file on disk, and the DM checkbox had been left ticked from earlier work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First driven walkthrough against the clean baseline. It stopped at 8 of 48 objectives after 16 turns, and the reason it stopped is worth more than a finished walk would have been. The world generator authors lore and a loreKey on items, entities and rooms, and describes the key identically for all three as the condition the player must satisfy. Only the item third is implemented: itemLoreUnlock in the GM contract and unlockItemLore in the engine. There is no entity or room equivalent, so the only thing that flips those flags is the DM's checkbox in the Compendium. For Verengrad that is 11 room hooks and 8 entity hooks — 19 of the plan's 48 objectives — unreachable by any player action. The field comments say "the player satisfies loreKey, or a DM can toggle it", so this looks like an unfinished half rather than a DM-only design. What happens instead is that the GM improvises new lore and files it in the compendium: asking Mira about the Chancel produced "The Price of the First Descent", climbing to the silenced bell produced "Why the Bells Are Gagged". Good lore, freshly written, neither touching the authored hook it was plainly answering. Two further findings limited the run. Combat is on a real-time countdown, so a driver deliberating for twenty seconds forfeits its turn — "you hesitate and do nothing" — and no fight got past round one. And reloading to clear a wedged driver cleared the engine's combat flag but not the GM's sense of the scene, which then refused to let the character walk away from a fight it still believed was underway. Mira's gate reproduced exactly from a different baseline, which makes it stable enough to encode as a learned prerequisite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fight below ground ended with the sprite simply gone, and whatever the author had put in the monster's pack went with it. Now the square holds its remains, as a real container carrying what it was carrying. The feature is deliberately small, because a corpse is a CONTAINER ON A SQUARE and the dungeon already has those. Registering one in dungeonChests gets the sidebar row, the click, the contents list, the GM annotation and the save for nothing — dungeonChestAt, dungeonChestVerdict and listContainerContentsInStory all work on it unchanged. Three joints, each with a silent failure behind it: ONE SPRITE, TWO IDS a tile is a container when its ITEMS spec has an openTex. The pile of bones an author places has none — it is scenery. So giving `bones` an openTex would have turned every authored pile in every dungeon into a lootable container. `remains` is a separate id wearing the same texture, kept out of the Builder's palette because it is placed by dying rather than by drawing. PLAY, NOT MAP the swap mutates the live grid, so it rides in the crawler's playState (§10) — otherwise the bones would vanish on the next descent while the loot the party left in them stayed in the save, a container with nothing on screen to correspond to. Restoring it is a re-application rather than a Set to fill: start() has rebuilt the author's map with the monster standing, so the swap has to be made again. A key naming a square the author has since redrawn is dropped. MOVED, NOT COPIED the pack comes off the Entity into the corpse and the Entity's own list is cleared, or the same loot is readable twice. The prose is the only place the two kinds diverge: a corpse is picked through and left where it lies, not opened and lidded shut, and the GM note says searched rather than opened. Also removes dungeonKills, which nothing read and the save never wrote — a second tally that would have disagreed with the crawler's the first time a save was loaded. Verified in a browser end to end on the crawler side: a skeleton killed leaves bones on its square, chests() reports a container where it reported none, monsterOn goes null, the party walks onto the square it barred, and the play state round-trips through a rebuilt map. And in the game page against the real makeItem, which confirmed the trap the code guards: a bare `classes` lands in subtypes, so reading it.classes would have been quietly false for every corpse. tests: +test_dungeon_remains.js (lifted and run, not just matched); test_dungeon_play_state now round-trips a kill. 427/427. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
beat_the_calling shipped unlocked in the world data, carrying unlockedAt "7th of Frostmark" — a timestamp from a session that was exported into the world, six days after the 1st the world actually starts on. Left alone, every new game began with its opening beat already done and the player never had to speak to Mira at all. The save was replaced with a clean export, but the world file it came from still carried the flag, so the next export would have reintroduced it. Patched as a bounded text edit rather than a re-serialize, so an 18 MB file changes by two lines instead of reflowing entirely. The plan is rebuilt on that clean save: 46 of 48 objectives are now provable, and the only two satisfied at the start are the room the character stands in and the rope coil they carry. Speaking to Mira is a real objective again — and with the beat locked, her refusal to let anyone descend before it is unlocked becomes live, which is the narrative gate the compiler cannot see from the data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pinned run is only reproducible if it starts from a known state, and opening the app does not give you one — it resumes its own autosave. Copying a named save over the resume slot and reloading goes through the app's real resume path and lands exactly on the baseline: loaded that way, the live session graded 3 of 48, the same as grading the save file on disk. Worth writing the key lookup down. Saves are keyed tlr_save:<name>\0<world> with a NUL separator, so a hand-typed key with a space in it finds nothing and reads as a missing save rather than a typo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first validation run against Verengrad worked, and everything that cost time doing it is worth writing down before it is forgotten. The most useful finding is that a compiled plan and a live run disagree, for a good reason. The compiler reads travel out of rooms[].exits, so it believed the Drowned Narthex was reachable immediately. Mira stops the descent until the opening beat is unlocked by talking to her, and no authored field says so. Two conversational turns unlocked the beat and the identical "go down" then succeeded. A plan states what the data permits; a run discovers what the fiction permits. A blocked step is evidence to record, not a failure to report. The rest is operational: the app resumes its own IndexedDB autosave rather than any baseline file, so a pinned run has to load its starting save deliberately — on screen the resumed session was indistinguishable from the pristine one and still graded 7 of 48 instead of 3. Input coordinates must come from the DOM rather than a screenshot, since the window can resize between the two and a command typed into nothing still looks like a turn when the world clock ticks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The load-bearing problem: beginCombat() takes NAMES and resolves them with findEntityAnywhere(), which searches world.rooms for a living Entity. A dungeon monster is a sprite on a grid square — no Entity exists — so the two only meet if the sprite is given a body. That is the trick a dungeon chest already uses. The map holds what the author configured, and the game materialises the real game object on demand: makeEntity() from the `ref` the monster editor stored, plus its authored pack through makeItem(). Its hit points, race and behaviour are the world's own rather than a second stat-block invented down here, and a sprite the author never gave a kind stays scenery. Where the body goes is the other half. The party's currentRoomId never changes below ground — the square REPLACES the room in the dossier rather than moving them — so the room they descended from is still, to every other system, where they are. Standing the foe there makes it "here" for findEntityAnywhere, entitiesInRoom, the combat bar and loot, with nothing taught about dungeons. It is taken out when the fight ends. Two ways in: walking into a monster, and coming to stand beside one. onStatusChanged fires on every change of square and was the only signal saying the party had arrived somewhere; the game had never used it. A monster bars its square — checked at the step, NOT in canPass, which is also how hasSightTo traces a line: blocking there would hide every item and door behind a monster. Verified in a browser: stepping into a skeleton left the party put with "Skeleton bars the way", while canPass and hasSightTo past it both still returned true. A fight has to resolve, so isBlocked now covers it. That exposed the movement pad honouring isBlocked nowhere — only the keys did, which was harmless while it meant "a modal is up" and not once it means "a fight is on". The bottom of the screen changes hands: the pad goes and the dice bag takes its place — subtraction and addition in one gesture, since every pad button is an action combat disallows and a fight asks for rolls — and the combat bar moves into the gap between the GM overlay and the dice bag, because out of the dungeon it sits behind the opaque crawl view. Both are MOVED, not copied: one dice bag, one combat bar, one set of handlers, and the bar is written to from six places. Killing a monster clears its sprite as PLAY state, not map, so the author's monster is back next visit like a chest's lid. tests/test_dungeon_combat.js; 19 of its checks fail on revert. 426/426. Not yet driven end-to-end in the game itself — the crawler half is browser-verified, the game half is wired and statically checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The plan was built from a save with seven rooms already walked and the opening beat already unlocked, so a run from it inherited that progress and could never exercise it. Rebuilt against the clean START save: 45 of 48 objectives are now provable, up from 39. The three that remain satisfied at the start are structural rather than stale — the character stands in Scaffold Landing, the opening beat is unlocked by design, and the Frayed Rope Coil is starting kit. That last one does mean the identically-named coil on the rope bridge cannot be proved separately; the plan matches items by name and cannot tell two copies apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every tier of testing needs to agree on what a beat being unlocked means, so the predicate evaluator is one implementation used from two places: here in Node against a save file, and injected into the live page where world and player are globals. Sharing the code is the point — two implementations would drift and quietly disagree about whether a run passed. Predicates read state, never narration. The GM describes taking the shard differently every run; inventoryHas is either true or it is not. Name matching is tolerant of case and punctuation and strict about structure, since failing a real objective over a hyphen would train you to ignore the report. Also counts the starting kit as already-satisfied when compiling, so the compiler and the grader now report the same baseline (9 of 48 on Verengrad) rather than differing by the items the character begins with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Guide tab already lists everything a world holds, but nothing gives you an order you can replay. build-walkthrough.js derives one from the authored world alone — no GM call, no clock, no randomness — so the same world always yields byte-identical output and the emitted plan can be checked in and diffed. A diff then says exactly what a world edit changed about achievability. It emits an objective graph as well as a linear route, because two consumers want different things: a pinned conformance run replays the route and asserts each step, while an honest run with live RNG walks the graph adaptively and uses the reachability data afterwards to judge whether a defeat was fair or a bug. Assertions are state predicates, never prose. The GM is a language model and will narrate the same step differently every run, so a step passes when the world state moves, whatever was said about it. On Verengrad it reports no errors, and finds eight catalog items placed in no room and named by no beat reward. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last six long-standing failures. No app change — every one was an
assertion pinned to a literal the code legitimately grew past, while
the behaviour it names went on working. Each fix was checked by
breaking the real thing and confirming the assertion now fails.
Two selector-list growths, the same break twice: `#world-inner-factions
{ position: relative` and `.msg-room .room-title-link {` both stopped
matching when a second selector joined the rule. Matched inside one
rule now, so the list can grow.
A statement that became a block: `if (sub === 'world')
renderRegionsStitch();` gained a sibling call.
A count that was never the point: test_editor_io compared every
"Import or export" group in the file to TABS.length + 1. It drifted on
any unrelated tab and never checked the groups belonged to the tabs
under test; it broke when an Add button joined the group and the label
became "Add, import or export X". Asked per tab now, by name.
A section name that was never created: enable_respawns wanted a
"Gameplay" heading. The checkbox is under "Story", and every wiring and
persistence check was passing throughout. It asserts the checkbox is
reachable in the Settings popup, under some section.
And two that had come to pin the opposite of the design. The quick
popups were moved OUT of #view-story on purpose — the markup says why:
at #app level a tab switch does not hide a popup the player left open —
while the test still asserted they lived inside the Story view. Their
`right` offsets were pinned to exact pixels and both moved together to
clear the sidebar; the arrangement being tested is that the item detail
opens to the LEFT, so it compares the two numerically.
One of my own fixes was too weak on the first pass and is worth the
note: "at least 2 sites use roomTitleLinkHTML" still passed after the
helper was dropped from two of four. It is paired now with the
invariant that matters — a room's title is never rendered as a bare
escaped name.
425/425.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLAll three failed against correct code, for two harness reasons.
The app walks story text with document.createTreeWalker and
NodeFilter.SHOW_TEXT. Neither is defined in these tests' hand-rolled
DOM, so every path reaching addMsg threw ReferenceError there — and the
app's own try/catch swallowed it, turning a missing browser global into
a silently absent log line. test_ambient_comment was asserting that an
exception gets swallowed; the produced-beat path it names was never
executed at all. One test in the suite already mocks these two, which
is how the gap was found.
And gmFetch does `await getApiKey()` before it calls fetch, so a
dispatch reaches the network a microtask later than the call that
started it. test_room_ambient read FETCH_COUNT synchronously and said
so in a comment — "the async body runs to the fetch synchronously" —
true when gmFetch called fetch directly, quietly false since the key
lookup became async. It settles first now, including before the checks
expecting ZERO calls: read synchronously, "no fetch happened" only ever
meant "not yet", so those were passing for no reason.
No app change. Traced the produced-beat log to gameLog('ambient', …)
and confirmed it does exactly what the tests expect; with the globals
mocked, two pass outright.
419/425, from 416.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL`Fixed monster filter logic` tightened monsterEntities() from "neither npc nor animal" to `type === 'monster'`, and two things still described the old rule. test_editor_fauna_split.js pinned the old spelling and failed on main (before and independently of the commit ahead of it). Its intent — "monsterEntities excludes animals" — is unchanged and the new form is stricter, so it now checks the new one under the same label. And the comment above the three rosters still said a being that is neither npc nor animal "falls to Monsters, so any legacy or odd type still has a home", which the tightening makes untrue. Nothing is left homeless by it either way: makeEntity puts every type through canonicalizeEntityType, which folds `enemy` onto monster and anything unrecognised onto npc, so a live entity's type is always one of the three. The comment says that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
With the monster brush picked, clicking an existing monster took it off the map with no dialog. Re-clicking is how you lift a thing from a tile, which is right for a pile of bones and wrong for a monster carrying a kind, an id and a pack. The chest's Remove button has always asked; this is the same question from the other direction, over the same predicate — one tileCarriesWork(), so the button and the brush cannot disagree about what is worth asking over. A frame rate deliberately does not count. It arrives from the palette default the instant a monster is placed, so counting it would put a dialog in front of place-then-immediately-unplace, which is the one case where clicking again really is just an undo. Undo covers all of it either way. That is not the point: the cost of a stray click here is noticing it, and a dialog is cheaper than wondering what happened to the goblin. confirmModal is a promise and applyTool is not, so the removal happens after the click has returned — with its own snapshot and commit, and a re-read of the tile in case it went another way while the dialog was up. The item brush also DRAGS, so painting stops first: without that, the pointer moving while the dialog is open keeps firing applyTool over the tiles behind it. Driven in a browser, five cases. A bare monster re-clicked lifts silently. An authored one asks; Cancel leaves its id, kind and pack untouched; Remove takes it; Undo brings it back whole. A chest with contents asks the same way. Bones never do. And a drag across three authored monsters raises one dialog, stops the drag, and leaves all three standing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The monster editor's Inventory picker rendered as a browser-default dropdown: rgb(239,239,239) with black Arial and square corners, sitting directly under a themed gold-on-dark field. Measured, it had no author styling at all — its only rule set flex sizing, so every colour came from the UA. Two things behind that. The picker's select shares the field rule above it now rather than carrying a third copy of the same declarations. And the Dungeon Builder is a separate page with its own stylesheet, so it never inherited the shared select rule the game got — every select in it was still native. That rule is now in this sheet too, scoped to #db-root: appearance:none, an arrow drawn from gradients in a theme variable, and the padding to keep a long option from running under it. Last in the sheet, so its padding beats the per-control rules above. Fixing the picker fixes both panels at once, since the chest's Contents and the monster's Inventory are the one shared control. Swept every select in the Builder afterwards, the way the game's were: across the empty editor, the monster panel and the chest panel, all of them report appearance:none and rgb(13,11,8), none is too narrow, and an item name long enough to overflow does not clip under the arrow. Covered in tests/test_select_theming.js, which is the right home for it — one topic, two stylesheets. 4 of its new checks fail on revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A placed monster's editor gains two sections. The sprite and the
creature are different things: the art is what the party sees, what the
thing IS lives in the game's entity catalog, and only the game has
that. So the map stores a ref and the game resolves it, exactly as a
chest's contents store item refs. Nothing resolves it yet — nothing
fights below ground — and the panel says so.
The game already published its item catalog to localStorage for the
chest picker, so its monsters ride in the same digest. Filtered to the
canonical `monster` type rather than the Editor's Entities > Monsters
bucket, which takes anything that is neither npc nor animal so no odd
or legacy type is homeless: a picker choosing what a monster IS wants
the precise type. Canonicalised first, so a legacy `type: 'enemy'`
entry is seen for the monster it is. From the CATALOG, not the
creatures placed in rooms, because a sprite stands for a kind.
The inventory was a control that already existed. A chest's Contents
and a monster's Inventory are the same thing twice, so there is now one
buildItemPicker. Worth extracting because the fiddly parts are what
would drift between copies: a missing id still shows as its id, the
same id stacks rather than making a second row, and a quantity joins
the undo history on blur.
Two bugs fell out, both shipped in the previous commit.
drawMonsterProps threw "rateOf is not defined" on every open —
extracting the preview inlined the rate into the call and left the
frame-rate field reaching for a name that no longer existed. The
assertion covering it passed throughout, because it only looked at the
call. It now checks the name is declared, and so are the five other
helpers that panel uses; verified by restoring the bug and watching it
fail.
And drawProps checks the palette chip's panel first, since it has no
cell behind it — so a chip left showing outranked the square being
selected, and placing a monster brought up the KIND's panel with only a
frame rate instead of the new monster's own. selectTile and selectFace
now let go of it.
Verified in a browser against a seeded catalog: the panel lists Barrow
Wight and Gnasher, picking one stores {ref:"wight"}, adding two items
with one repeated stores loot [{rusty_blade,2},{bone_charm,1}], it all
survives a map round trip, and the chest's own Contents still work
through the shared picker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLClicking a monster in the Monsters palette now opens a panel for the
KIND: the sprite at size and moving, and the frame rate a newly placed
one is born with. No ID — two skeletons need different ones, so that
stays on the square. An item chip opens nothing, because a chest's
lock, trap and contents are all per chest.
It is a default, not a global. The rate is copied onto the cell at
placement and the cell is the only thing the renderer reads, so
retuning the palette changes the next one placed and leaves every
monster already tuned by hand alone. Reading the type's rate at draw
time would have been fewer lines and would have quietly retuned the
whole dungeon from a palette click. The rates live on the map as
`mon: { skeleton: 9 }`, written only once there is something to say and
read back through the setter so unknown ids and silly rates drop.
Two panels want the preview now, so it is one builder taking its rate
as a callback rather than a number — the picture follows the field
while it is typed, and there is no second animation to keep in step
with spriteFrameU.
Measuring the request found a real defect underneath it. Clicking a
chip with nothing selected opened nothing; what was showing was a panel
left over from a previous placement — and picking the Hallway piece
left it up just the same. Nothing cleared the selection when the brush
changed, so a properties panel for a square sat over the map through
every stroke of every other tool. Choosing a tool is not editing a
thing, so picking any brush now lets go, through one pickTool() that
all six palette handlers route through rather than six copies.
Verified in a browser: the chip panel is titled "Monster" and headed
"Skeleton — the kind, not one of them"; with the type at 4 a placed
monster came out {fps:4}; retuning to 9 left that one at 4 and the next
was born at 9; the map serialised {"skeleton":9} and survived a round
trip; and an edit in progress survives walking away to another brush,
since blur beats the clear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLTwo changes, and the second is an argument the Builder has now made three times. Export/Import leave the app header for the top-right corner of the map editor. They belong to the dungeon, and the dungeon is what the Editor tab is about; in the header they sat beside a hint about which keys walk the party around, which is the other tab's business. That corner already held the coordinate readout, and two things absolutely positioned at the same coordinates collide with no error, so the cheaper one moved — the readout is transient, up only while the cursor is over the map. Verified in a browser: the block sits at top 14 and right 302, clear of the tool panel, with itself as the topmost element at its own coordinates; the readout is at the opposite corner; Export still downloads from its new home. Drawing a secret wall now opens its editor, exactly as dropping a chest or a monster does. A secret with no lock ID is just a wall that opens when somebody searches it, and the ID — the thing that wires it to a switch — is only reachable from that panel. Only the secret brush does this: an open way, a torch and a plain wall are finished the moment they are drawn, and a door's lock belongs to the Lock brush, so a panel on each would be noise on every stroke of a drag. Confirmed both ways in a browser — Secret leaves the panel open on "Secret wall at 8, 2 · N face" with its Lock ID field, Wall on the same tile leaves it shut. Also: an export of a layout-only dungeon reported itself as "0.0 MB", which reads as a failure rather than a small file. It now picks a unit that fits. One existing assertion caught itself lying on the way through. "The buttons are on the right of the header" was a lazy [\s\S]*? from #db-header-right to btn-export-all — with nothing anchoring the end it ran the length of the document and matched the button in its new home. It passed, and meant nothing. Both blocks are extracted and checked separately now: the buttons are in one and provably not in the other. New tests/test_secret_wall_editor.js (4 of its 6 checks fail on revert); test_dungeon_bundle_io.js updated, 8 failing on revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Opened when a monster is dropped and when one is selected, exactly as
the chest editor is, and for the same reason: a thing with settings
reachable only by re-picking the brush that made it is a thing nobody
finds.
The preview is the point. A frame-rate number is an abstraction until
you can see what it does to the sprite, so it animates from the same
texture and frame count the dungeon draws, at whatever rate is
currently typed. It steps frames with the same arithmetic spriteFrameU
uses — a preview stepping them differently would be a picture of a
different animation — and repaints only when the picture changes,
rather than sixty times a second at 1 fps.
The rate is per MONSTER, not per texture, so two skeletons off one
sheet can shamble at different speeds. It rides on the cell as
mon: { id, fps }, the same shape and guards as chest, kept only while
the tile still carries the thing it describes. Declared in frames per
second because that is the unit an author types; the default of 14 is
the flame's 0.014 frames/ms, so an untouched monster keeps time with
the torches.
Select needed the widening the chest already forced. pick() always
names a nearest edge, so on a tile with a door every click in the half
facing it took the door. The condition is now `standing` — something
here has settings — which is what the outer-quarter band was always
about. One mechanism for both, not a second copy.
Verified in a browser end to end: placing a monster opens the panel
titled "Selected monster" with ID and Frames/sec fields and a canvas;
the preview animates (5 distinct captions in 10 samples); typing
gate-sentry and 2 stores {id,fps} on the cell, serialises as `mn`, and
survives a map round trip; Select takes the monster from the middle of
a tile and the door from its east edge; the preview loop stops when the
panel changes.
The frame rate itself is unit-tested rather than measured on canvas.
The canvas harness was wrong twice: at 30Hz it cannot resolve 28fps
(that run aliased down to an apparent 6, lower than 14 — which reads
like a bug in the code under test), and its absolute counts ran ~5x
high while its ratios were right. Driving spriteFrameU directly gives
1->0.9/s, 7->6.9, 28->27.9, unset->14, 999 clamped to 30.
Five assertions in three existing tests were pinned to source strings
that legitimately changed; all updated in place, keeping their intent.
And this file's own lift of spriteFrameU threw against the old source
instead of failing — exit 1, zero FAIL lines, no clue — so it is
wrapped and reports the error as a failure like any other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe Map group's Export/Import were never a backup: they write
serialiseMap() alone — geometry, no images, no frame counts. Uploaded
art is the bulk of a dungeon and the only part the editor cannot
redraw. The new header pair carries { map, tex, frames, meta }: byte
for byte what persist() writes to IndexedDB, built by the same
dungeonPayload() so an export cannot fall behind the save. Both pairs
are kept and relabelled, since two exports producing different files
are only confusing if neither says which.
Three decisions. The identity does not round-trip — dungeonId comes
from the URL and says which dungeon of which world this Builder is
pointed at, so Import means "make THIS dungeon look like that file",
never "become that file", and importing someone else's export is not a
collision. Import replaces rather than merges: a slot the file does not
dress goes back to its default, or importing a dungeon with plain walls
would leave whatever wall you had. And a bare old map still opens,
recognised by shape because a file written before this has no version
field.
Round-tripped in a browser: a dungeon with a name, a magenta wall
upload, a skeleton and a chest exported to 38 KB, was wrecked back to
the demo map, and came back whole — name, wallCustom true with pixel
[255,0,170], monskeleton frames 8, both items on their squares. Also
driven: replace-not-merge (a bundle with only a green floor left an
imported-over blue ceiling back at custom:false), a bare map (art
untouched), and a JSON file that is not a dungeon (refused by name).
That round trip found an unrelated bug. Built-in art is an <img> with
no data URL — a third kind of texture source this file had never had —
so any redraw of the tile rows after the shipped PNG landed threw
"t.source.toDataURL is not a function". It surfaced as an import
reporting failure after it had actually succeeded; changing a monster's
frame count would have hit it too.
Two existing tests located code by its old indentation and could not
see inside an at-rule. Both fixed rather than worked around: the
scoped-CSS parser now descends into @media (but not @keyframes, whose
names are global anyway), verified by planting a leak inside the block
and confirming it is reported — the first attempt at that fix pushed
the wrong depth and silently stopped looking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLSprites/Monsters/skeleton1.png is now the built-in skeleton, replacing the drawn placeholder. The drawing stays as the fallback for when the file cannot be fetched — from file://, where a cross-origin image throws inside texImage2D, or simply missing. Its path resolves against document.currentScript.src rather than the document, because the game loads the module from the repo root and the Builder from inside Modules/Dungeons/. Two bugs the file exposed that the drawing never would have. The loader was a loop inside startCrawler, and the Builder does not call startCrawler — it keeps its own copy of the texture-restore sequence. So the PNG was never requested there and the drawn skeleton stayed on screen with no error. Exactly the failure this module exists to prevent. Caught by watching the network rather than the picture: zero requests for the file. It is a function now, exported, called from both hosts. And the rescaler rounded DOWN to the next power of two, which is the obvious reading and is wrong. The sheet is 1024x254 — 0.8% off 256 — and flooring took it to 128, a two-to-one vertical squash from a rounding choice. Rounding in log space sends 254 to 256, 500 to 512 and 900 to 1024, so the worst case is a slight upscale instead of half the art discarded. It also moved into the module beside the GL upload: the Builder's upload path is not the only way art arrives, and a built-in file or a save restored in the game were skipping it entirely. Verified in a browser: 200 for the PNG, the slot holds an IMG, and skeletons render clean at one and two squares with no mip shimmer and no bleed from neighbouring frames. 12 of test_dungeon_monsters.js's checks fail against the previous sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The first half of step 5, taken alone: a monster stands on a square and animates. It does not move, block, notice the party or fight, and the GM is not told it is there. A monster is an ordinary ITEM. Placement, serialisation, saving, click-picking and drawing already work for items, and a monster is one that is tall and has no lid — 1.10 units against a 1.15 wall, so its skull sits just under the ceiling, on a 1:2 quad matching a 1:2 frame. So this is a `monster` flag and a palette heading, not a second system; the Builder's Items and Monsters groups are two headings over one list. That is also the cost: an item is visible to everything that reads items, including the GM's dossier, which is the one place it must not be. DUNGEON_CONTRACT says NOTHING LIVES HERE YET, and handing the GM a skeleton one square ahead in the same breath is a contradiction whose resolution is a fight the engine cannot run. Filtered out of the in-front line, the off-to-one-side line, the standing-over clause and the scenery block — four places, because filtering three leaks it through the fourth. That filter is the seam for when monsters are real. The built-in skeleton is drawn rather than shipped, like every other default tile. Transparent, hard-edged and flat, since it goes down the same lit alpha-cut path an uploaded sheet does. Drawn to a seven-and-a-half-head canon after a first pass by eye came out cartoonish. Uploads are now rescaled to power-of-two. A generated sheet is almost never one — 2015x500 is an ordinary export — and without mipmaps a monster three squares off shimmers with no clue why. Safe for a strip: frames are equal fractions of the width, so uniform scaling leaves every boundary where it was. Through a cleared canvas so transparency survives, and it reports what it did. Verified in a browser: the skeleton slot dressed in a four-frame red/green/blue/white strip showed exactly one hue at a time (107648 px, cycling, 0/24 samples showing two); the frames:1 control showed all three at once at a quarter each (26912 px), unchanging in all 24. Colour rather than brightness — an earlier brightness metric was measuring torch flicker, caught because its control had the larger spread. tests/test_dungeon_monsters.js; 21 of 27 checks fail on revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The item quad hardcoded its UVs to 0..1, so an animated sprite rendered as its whole strip squashed onto one billboard. It now takes the frame's slice. frames:1 — every slot's default and every dungeon already saved — returns the whole image and does no arithmetic, so nothing that exists today changes. The flame could not simply be copied. It is drawn by a second program: additive and unlit, which is why a black backdrop is free there. A floor sprite goes through the lit, alpha-cut path, so it needs real transparency, hard edges and evenly-painted art (torchlight multiplies the texel, so baked-in shading darkens twice). Nor could its phase. A sconce carries its own, because sconces are placed one at a time; an item is identified only by the square it stands on, so the phase is hashed from the square and from the offset that separates several loose items sharing one. Derived rather than random, so walking away and back resumes the beat instead of restarting it. Without it three skeletons breathe in unison. strip changed from a boolean to a count. It did not mean "may be a strip" — it meant "this slot is the flame", with the default frame count hardcoded to the flame's 8, so marking a chest strippable would have sliced its single-image art into eighths. It is now the number of frames the slot's own built-in art has: 8 for the flame, 1 for the sprites. FLAME_FRAMES reads from the slot instead of being declared beside it. Builder: the frames box now appears on every strip-capable slot, the thumbnail crops to frame zero at true aspect instead of squashing the strip into a swatch, and a fresh upload resets to a still rather than inheriting the previous count. Verified in a browser — a four-frame red/green/blue/white strip in the crawl view, canvas sampled over time. frames:4 showed exactly one frame at a time (8556 px, cycling); frames:1, the control, showed the whole strip with red at exactly a quarter (2150 px, unchanging). tests/test_sprite_strips.js; 25 of its 29 checks fail on revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Follows the Rooms region filter fix. Rendering all of them side by side showed the defect was never local: an untouched <select> keeps the native control, so Chromium paints its own widget over the box and the author background never reaches the painted surface. The ones that looked right only did so because they carried color-scheme: dark, which coaxes the native chrome dark rather than replacing it — and then broke the other way, since the open dropdown takes its chrome from the same property and so opened dark on the light palette. One rule at the foot of the stylesheet now owns it: appearance:none, an arrow drawn from two gradients (a data-URI cannot read var(--text-dim), so it could not follow the palette or the hover), and a color-scheme that follows data-theme. Placed last so it lands on top of the per-control rules without any of them being reordered. The four per-control color-scheme pins are gone. Scope note: the selector is select:not([multiple]), so it covers every dropdown in the app rather than the six named. The defect is in the element default, and a dropdown left out would be the odd one now. [multiple] is excluded because a multi-select is a list box — no slab to override, no arrow to replace; #regions-stitch, #ability-ed-skills and #sound-add-type were never part of this. The sweep also turned up var(--raised, #241d16) in four rules. That variable is not defined anywhere — it is --bg-raised — so all four fell through to the hardcoded dark brown in every theme, which on the light palette left the Weather year selects dark-on-dark and unreadable. Checked every select in the live DOM afterwards: none still native, none squeezed by the added right padding. The one 30px hit is a select with no options yet; populated it measures 124px. tests/test_select_theming.js replaces the narrower test_rooms_region_select_style.js; 6 of its 12 checks fail on revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The select sits immediately beside the name-filter input and did not match it: an untouched <select> keeps the native control, so Chromium paints its own mid-grey slab with a black arrow over the box and the author background never reaches the painted surface. Measured in a browser — computed backgroundColor read as --bg-panel in both themes while the pixels did not, which is why the existing declarations looked correct and were not. appearance: none drops the native look, the same treatment input[type=checkbox] already gets at the top of the sheet. That also drops the arrow, so the wrapper draws one: a CSS triangle rather than a background-image, because a data-URI cannot read var(--text-dim) and so could not follow the palette or the hover. color-scheme was pinned to dark, which is only half a theme — the open dropdown is still the OS's to paint and takes its chrome from there, so the light palette opened a dark popup. It now follows data-theme. New tests/test_rooms_region_select_style.js; 7 of its 8 checks fail against the old rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Found reading a second failing GM handoff. Asked to open a door below ground, the GM answered "The door itself answers only to your own hand at the controls — walk into it or work the switch yourself": a keyboard inside the fiction, in the narrator's voice. The contract asked for exactly that. It wanted the refusal delivered "plainly and in character", then handed the GM two props no character can hold — the movement keys and the on-screen pad. There is no way to obey both, so the model split the difference. It now names the voice instead: one short sentence spoken as the game and not as the world, with the bad line quoted back as a counter-example. The player was never short of the information anyway — the bar under the view already reads "WASD · Q E turn · Space search". Regression checks in test_dungeon_pseudo_location.js; both fail against the old wording. Design doc records this and what the same handoff says about the exit-narration bug: the transition note in it is fresh, not stale, because descending and climbing straight back out leaves no GM turn between them — so the engine's account of the crossing and the player's next command land on the same turn, and that command was "go up", the same words as the climb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported from play: climb out into the Wine Cellar, type "up", and the GM narrates coming up out of the DUNGEON rather than out of the cellar. The natural guess — the crossing was left off the log — is wrong, and the handoff proves it. The DUNGEON TRANSITION note was delivered on that very turn, and the system prompt said Wine Cellar throughout with no BELOW GROUND and no Dungeon contract. A second attempt did not reproduce it, which is the tell: a correct prompt losing, intermittently, to the weight of the history behind it. The cause is an asymmetry. Descending works because the dossier re-asserts BELOW GROUND and the whole contract on EVERY turn. Climbing out had no standing statement at all — sendToLLM merges pending notes into a COPY of the user turn and clears them, deliberately, so they never pollute the stored transcript. One sentence, spent once, carrying the whole crossing against a dozen dungeon turns still in conversationHistory. So the crossing becomes a line in the dossier too, where truth lives and which is rebuilt every turn: the party climbed out into THIS room, that climb is DONE and has already been narrated, and an exit taken from here leads where this room's own list says rather than out of the dungeon. That last clause is the reported turn specifically. It holds for two turns and is spent AFTER the turn's prompt is built — decaying first would spend it on the turn that asked for it — and it is keyed to the room they climbed into, so it vanishes the moment they walk on. By then the GM's own replies are about the room. The one-shot note gains the same "already narrated" clause, since it is the re-narration rather than a wrong location that was the symptom. Both failure shapes confirmed to fail when reverted: dropping the line from the room block, and decaying before the prompt is built rather than after. Suite 410/419; the nine are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The world builder asked for weather that looked right and did nothing: conditions and day types had no "effects" in the schema, so a forged world's skies were scenery and the DM had to author every mechanical consequence by hand afterwards. Conditions may now carry the status they impose, and a day type may override its condition's with the REPLACE semantics the engine already implements -- omit the key to inherit, which the directive states so the GM does not return an empty array meaning "none" and get "inherit". The DEFAULT IS NONE, and deliberately so. Weather in most worlds is atmosphere, and a game where every sky costs a stat is harsher than most settings want. An effect is earned by the world's own rules, theme or premise, or by a region described as that hostile -- not by a sky having been described vividly. Where one is authored it may be a boon as readily as an affliction. Races could not answer the weather at all, for a reason worth recording: a weather effect applies to whoever is standing in it and normalizeWeather Effect has no appliesTo and no race gate, so "the ash blinds everyone but the ash-born" is not expressible from the weather side. It has to be a racial ability whose condition names the sky -- and the schema never asked races for abilities, so every generated world's peoples were mechanically inert, weather aside. Races are now asked for 1-3 abilities, with a rule pointing at this world's own skies where a people is suited or vulnerable to one. That is what makes the chain a chain: the region's description, the climate bound to it, the conditions that climate brings, and the peoples who live under them, instead of four unrelated lists. The directive also requires the region's description and its climate to agree -- salt fog and sunken coast should not be bound to a climate of hard sun. Verified the model keeps every field the schema now asks for, since a schema asking for something the normalizers drop is worse than not asking: the GM spends tokens on it and the world arrives without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reported from play: drop a ring, click its sprite, press Loot, nothing
happens.
The button was wired to lootRoomItem, which below ground looked only at
the square the PARTY was standing on. But you click a ring by LOOKING at
it, and looking at it almost always means it is a pace or two away — so
takeFromDungeonFloor searched the party's own square, found nothing, and
returned in silence. Reproduced before changing anything: on the square
underfoot it worked, one square ahead it did nothing, two ahead it did
nothing.
An item is picked up from where it LIES, so the square travels with the
popup. showDungeonFloorItemDetail already knew which square the sprite
came from and was throwing it away; it now passes it through as
{ lootFloor: key }, and lootDungeonFloorItem takes from that square.
lootRoomItem's below-ground branch delegates to the same function with
the party's own key, so there is one implementation rather than two sets
of rules about where treasure goes.
With reach, mirroring the chests: the square underfoot or one step off
it, same floor. Further than that is a thing you can SEE, and seeing it
is not holding it. Out of reach the popup says "out of reach — walk to it
to pick it up" instead of showing no button — a missing button reads as
"this cannot be picked up", and the truth is "not from here".
Also found while reproducing, and NOT fixed: a sprite on the party's OWN
square cannot be clicked at all — it sits at or below the bottom edge of
a first-person frame, which is true of a chest underfoot as well. The
sidebar row is the way to reach that one, and it works. Worth a look if
it grates in play; it needs a camera or placement change rather than a
wiring one.
test_loot_button gains the new assertions and its old one updated to the
delegation; confirmed to fail with the original wiring restored. Suite
409/418, the nine pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA GM-forged world arrived with no regions and no weather catalogs. It
still played -- both have working defaults -- so nothing failed; it was
quietly a world with no map and Earth's four seasons, and the DM had to
notice and build them by hand. That is the shape of this bug: an omission
that looks like success.
The directive now asks for ONE region holding every opening room, with
each room's "region" set to its name, and for the world's own three
weather catalogs -- the conditions that exist here at all, the climates a
place can have, and the seasonal patterns the year turns through. The
region names one of those climates and the world names a defaultClimate,
so geography and sky arrive bound together rather than as two things to
reconcile later.
The band and wind vocabularies are interpolated from the engine's own
constants rather than retyped into the prompt, so renaming one in code
cannot leave the GM authoring values the engine will not recognise.
The GM is told not to invent coordinates -- the same contract the Regions
tab's own generator states -- so the ingest runs buildRegionsFromList over
whatever comes back, which draws the polygon, label and coastline. That
helper was dropping an authored `climate` on the floor; it carries it
through now, or every region would have silently landed on the default.
validateGeneratedWorld gained the notes for all of it: no region, more
than one, rooms filed nowhere or into a region that was never authored, a
region or default naming an undefined climate, a missing catalog, and a
day type built on a condition this world does not define. That last one
matters most -- it resolves to whatever condition happens to be first, so
the sky comes out WRONG rather than absent, which is the harder kind to
notice. The validator takes either the GM's array or the laid-out { list }
shape, since it also runs over JSON pasted into the editor by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomCo-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QkfixpCzES9ksWheTQ9JWg
The sprite question had a third answer, better than either of the two I had been weighing. A ring dropped on the flagstones cannot have authored art, because the GM may invent it thirty seconds from now — but every item already carries a GLYPH, and a glyph is something a canvas can draw at any size. Which is exactly what the crawler already does for every wall and floor it has. So the sprite is generated: no art to produce, no file to ship, and it covers an item that did not exist when the dungeon was built. Measured before building, since the whole idea rests on it: emoji rasterise IN COLOUR on a plain canvas — a dozen to three dozen distinct colours per glyph at a quarter to a half of the box. The one thing needing care is the font. ⚔ is a colour sprite under an emoji stack and a flat monochrome text glyph under plain serif, so the stack is named. From there it is the existing billboard path unchanged: one lit, cut-out quad each, taking the torchlight and sorting against the walls as a chest does. The crawler holds a glyph and a key and nothing else about an item — the host hands over the whole set with setLoose and gets a click back by key, so no game concept enters a module with two hosts. Sized by looking at it, twice. At 0.42 units a ring one pace away filled a third of the frame and read as furniture; bones are 0.72 wide and a chest 0.64, and a dropped ring should be nothing like either. 0.22 reads as something you could pocket. Several on a square sit on a small ring about its centre, capped at four. This closes the bug the design doc had been carrying. placeItems resolves through currentRoom(), and descending never changes player.currentRoomId — so "// create a chest here" below ground landed in the entrance room, silently, waiting upstairs when the party climbed out. There was no "here" to put it. Now there is: a store keyed by dungeon and square, in the save beside the chests, differing only in arity — a square holds one chest and any number of loose items. Which gives lootRoomItem a second kind of floor to take something off, so the half after a pickup — trove or pack, the story line, the note to the GM — was factored into finishLootedItem rather than written twice. And the Loot button, withheld wholesale below ground last week because nothing down there was takeable, is now offered for exactly the things that are: loose items, decided by identity rather than name, since a chest's contents can share a name with something on the floor. Verified by reading PIXELS out of the WebGL canvas, which is the only thing that can tell "the store is wired up" from "a ring appears on the stone": the brightest pixel in front of the party goes 30 → 165 when one is dropped and back when it is taken. Two of my own measurement errors on the way — readPixels hands back a bottom-up buffer, so a band written top-down samples the ceiling; and getContext ignores its options once a context exists, so preserveDrawingBuffer has to be forced before the page runs or every read is black. Also checked: placement, the sidebar, the popup, looting, clicking the sprite in the view, several on one square, a save round trip, and an icon that is an image URL falling back rather than drawing the letter "h". New assertions confirmed to fail when their fixes are reverted. Suite 409/418; the nine are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The level never appeared in play. Two mistakes in one line, and the second hid the first. frame.tile describes the ITEM the party is standing on, and is null on every square without one. Reading the floor from tile.lv therefore worked underfoot of a chest and nowhere else -- in a corridor the header fell back to the bare dungeon name, which is exactly what it did. The crawler already publishes frame.level, and publishes it ONE-BASED for this precise purpose: "One-based, like the HUD and the level picker -- the host is quoting this to a player." So the +1 was wrong too, and would have reported a floor too deep on the one square where the old read worked. The reason this shipped is worth recording: the browser check stubbed Crawler.frame() with the shape I assumed, so it confirmed my arithmetic against my own guess and never touched the module. Verified this time by mounting the real crawler -- frame.level is 1 and frame.tile is null on a plain square -- and the tests now assert against crawler.js's source rather than a stub, so the shape cannot drift without saying so. frame.levelCount sits right beside level and is still deliberately unused: the header shows the current floor only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Local clone was shallow and missing history; unshallowed and re-ran tools/gen-progress-report.js against origin/main to bring the report up to date (1854 commits across 32 days, through 2026-07-31). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y67TAZ4v2ojQy6UBbCyco4
"Sewers: Level 1". The crawler counts levels from 0 and every surface that shows one to a human adds 1, so dungeonHeaderLabel owns that +1 rather than leaving it to be re-derived and eventually missed. The CURRENT level only, never "Level 2 of 6". The Builder shows a total because its author is meant to see the whole map; a player is not. How deep a dungeon runs is something they find out by going down, and a header stating it up front hands that over before they take a step. Pinned by a test, because "of N" is the obvious next thing for someone to add. A crawler that is absent or not yet mounted yields the bare dungeon name. It does not know which floor the party is on, and "Level 1" is exactly the guess a reader would trust. The floor keeps up with the party via onLevelChanged, which the crawler has always offered and defaulted to a no-op -- the game simply had no use for it until the header started naming a level. No module change was needed to start listening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The header names where the party is, and below ground it was naming the room they descended from. currentRoom() stays pinned to that room for the whole crawl -- deliberately, since it is where they climb back out -- so the app claimed they were standing in the tavern for as long as they were underground. Same class of mistake as the pseudo-location suppressions this sits beside: not merely a stale answer, a confident one. Entering now refreshes the header immediately rather than waiting for the next turn's incidental redraw, which would have left the old room name up until the party's first move. Leaving already refreshed, and does so after openCrawlDungeon is cleared, so it reverts on its own. The crawl bar above the canvas is hidden while an alternative is explored. Hidden rather than removed: enterDungeon still writes the dungeon name and the keyboard-owner hint into it, so restoring it is a style change and not a rebuild. Hiding Leave strands nobody. The crawler's onExit already calls closeDungeonCrawl when the party climbs the exit stairs, which is the intended way out -- the button was the convenience, not the mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking an item where it is NAMED — a contents link in the dungeon's GM overlay, or an indented row in the sidebar's Items block — opened its popup with no Loot on it. The only route into a chest was to open the CONTAINER's popup and use the row there, which is not where the player was looking. The gap was never dungeon-specific: those popups had no Loot above ground either. The overlay is just where it became obvious. It is a different move from the floor-item Loot, so it is a different button: lootContainedItem rather than lootRoomItem, because a container may lie in a room, be carried in the pack, or stand on a dungeon square, and none of those is a room's item list. lootContainedItem already resolved dungeon chests through findContainerByName, so this is a button onto a path that worked and had no door. Gated on the container being OPEN, not merely seen. contentsSeen survives a lid coming back down — the player still remembers what was inside — and remembering what is in a box is not reaching into it. The popup closes afterwards. lootContainedItem refreshes a popup showing the CONTAINER, which is right when the clicked row lives in that container's own popup; here the popup on screen is the item's own and the item has just left, so there is nothing for it to describe. Same as lootRoomItem closing a floor item's popup once it is in the pack. One adjacent hole closed while here: the overlay shows the tail of the same transcript, so a "You notice:" line naming a ROOM floor item can still be on screen a turn or two into a crawl. Its Loot resolved that item from a room several floors up and would have pulled it into the pack through the stone. Now gated on not being below ground. Verified end to end in a browser rather than by the button's existence: a real click moves the real item into the pack, out of the container, the popup shuts, treasure goes to the trove instead, the sidebar row disappears, a closed container offers nothing, and the same button works on a room container above ground. Each new assertion was confirmed to fail when its fix is reverted. test_notice_loot_button asserted the old `fromRoom` condition and was updated to the reachability one it became. Suite 408/417; the nine are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Phases 1 and 2 shrank NEW saves: art generated from then on lives on the vault and the save holds a reference. A save that already carried tens of megabytes of base64 got none of that. This is the explicit action that moves it -- decision Q2, always a named button, never a load-time or save-time hook, because a background upload of tens of megabytes during play is the kind of surprise that erodes trust in the whole feature. It needed the first write path from browser to vault disk. POST /vault/media is deliberately narrow: the access token gates it, the body is RAW bytes rather than JSON (base64 inflates by a third and would collide with the 12 MB express.json ceiling -- the 8.5 MB sound in the measured save encodes to ~11.3 MB), the media type comes from Content-Type and must be one the store already serves, and the NAME is the sha256 the store computes from those bytes. The client supplies no filename, no path and no hash, so there is nothing in the request to traverse with. The walk is over live objects, not a serialized snapshot. Encounter and ambient timers keep firing and the story keeps growing; rewriting a whole serialized save would clobber whatever arrived mid-run. In place, a message that lands during the operation is simply not converted and a later run picks it up. Art is found by FIELD NAME, plus data URIs sitting in a src= attribute inside story HTML. Requiring src= rather than "looks like HTML" is what keeps a prompt or a description that merely quotes a data URI out of the rewrite -- that is prose the author wrote, not a picture. Each distinct picture uploads once however many places reference it. The same icon is reachable from the catalogue, the pack and the floor, which is ~10 MB of duplicates in the measured save; the server would dedup them anyway, but not sending them three times is the point. The world pauses while it runs: reanchorClock(0) freezes the clock, encounter timers stop, and handleSend refuses a turn. That last guard is at the turn entry rather than left to the modal, because a field that already had focus can still submit on Enter. Building it surfaced a latent bug in reanchorClock: `Number(scale) || GAME_TIME_SCALE` swallows a scale of ZERO, since 0 is falsy, so asking to freeze time silently ran the clock at full speed. Measured before the fix (scale 24 during the walk) and after (scale 0, and zero in-world drift across 360 ms of real uploads). Combat's own re-anchor was never affected -- it passes a non-zero scale -- but the trap was there for anyone else. Failure restores everything. An upload error leaves the save exactly as it was, and the clock, the timers and the turn guard are released in a finally so a throw cannot strand the game paused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Both reports were one bug. Clicking an Items-block row below ground, or a contents link in the GM overlay, opened the popup BEHIND the 3D view. #view-story is position:relative with z-index:auto, which creates no stacking context, so the detail-popup family at 9 was competing directly with #dungeon-crawl at 30 in #app's own context — and losing. Measured rather than reasoned: elementFromPoint over the sidebar popup answered #dungeon-crawl-bar, and over the story popup answered the WebGL canvas itself. The family moves to 45: above the crawl view (30) and the combat bar (40), still under the top-bar menus (60), which are opened later than whatever popup was already up. Nothing else lives between 9 and 45, so that is the whole behavioural change. The three position:fixed members re-declare the same value and move with it, or a faction, region or spell popup drops behind the popup that spawned it. The test that let this through is the part worth keeping. It asserted the popup OPENED and carried the right text — both true, and both useless. A popup can be open, fully populated and completely invisible, so "did it open" is not the property that matters and "what is the topmost element at its own coordinates" is. The checks now go through elementFromPoint, sampled across the popup's face rather than at its centre, since a half-covered popup is a real outcome too. The repo test compares the DECLARED z-indexes against each other rather than hardcoding 45, so it fails if either side moves; confirmed to fail both on the original value and on the family moving without its three fixed members. Suite 408/417; the nine are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two of the five were not what they looked like from outside, so each was measured before anything was changed. SELECT ON A CHEST (reported as not working; it was, sometimes). pick() always names a NEAREST edge, so on a chest standing in a room with a door, every click in the half of the tile facing that door took the door and the chest could not be selected at all. "A face is the finer target, so a face always wins" was the wrong rule. On a tile carrying a chest a face must now be CLAIMED rather than merely nearest — within the outer quarter, the same band the wall and door brushes are aimed with. The middle is the chest, the edge is still the door, and a tile with no chest is unchanged. The hover ghost reads the same rule, or it would light the door while the click would take the chest. THE ITEMS BLOCK listed chests and nothing else, so a party standing on a pile of bones was told their square held nothing while the bones were on screen in front of them. It now lists what is on the square: scenery underfoot first, then the chests within reach. Scenery needs no store — no lid, no contents, so no state for the save to carry — it is derived from the map and memoised so the row and the popup it opens are one object. Clicking resolves against the same pool the block drew from. Also dropped the Loot button below ground. lootRoomItem moves an item out of a ROOM's item list and nothing down here is in one, so the button appeared, moved nothing, and closed the popup. A chest is looted through its contents rows, which have their own Loot and move the real item. THE OVERLAY'S LINKS did nothing, and the cause is worth naming: the overlay is a THIRD view of one transcript, and a view onto shared content inherits the content and not the behaviour. The delegated story-link handler was bound to #narrative and the pinned-room head; nobody had added the overlay. The same shape as the sidebar resolving clicks against a pool it no longer drew from — twice in one change. It also needed the one exception to the panel being click-through. That is deliberate (a click on the canvas works a chest), but a link you cannot click is worse than no link, so the LINKS alone take pointer events: a few dozen dead pixels where a link sits rather than the whole corner. Hover on the lines was already working and still does. THE POPUP is now bounded by the window instead of by its contents, as a flex column with a scrolling body and a 16px margin at the foot. The load-bearing line is min-height: 0 on that body — without it a flex column grows to fit and the max-height does nothing at all. Verified at 900, 760, 620 and 520px. REMOVE CHEST sits last under a rule, takes the chest and everything in it off the tile, closes the panel, and asks first only when there is something to lose. Undo brings it back either way; the confirm is a courtesy against a stray click on a full chest, not a gate. Four existing tests asserted the behaviour these deliberately changed and were updated rather than relaxed — the Select ordering became the band rule run over real edgeDists, and the overlay's "nothing re-enables pointer events" became "the links alone do, and nothing else". New assertions were confirmed to fail when the fixes are reverted. Checked in both real hosts end to end. Suite 408/417; the nine are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Uncaught ReferenceError: refreshItemIcons is not defined
at img.onload (crawler.js)
refreshItemIcons() is the Builder's repaint of its item palette, so an
uploaded chest shows up there and not only in the dungeon. The shared
crawler module called it BY NAME from inside an Image.onload. In the
Builder it works; in the game there is no palette and it threw.
Where it sat is why it lasted this long. That line is reached only by a
dungeon whose author has actually replaced some tile art, only
asynchronously, and once per replaced slot — and the throw lands AFTER
the upload, so the art still appeared. A red console and a working screen
is close to the worst shape a bug can take: nothing to notice and nothing
to bisect.
Fixed as a hook rather than a typeof guard. A guard would leave the
module still naming a Builder function, which is exactly what the
extraction removed. onTextureReady(id) states the fact — a custom tile
has decoded — and each host decides whether it cares: the game does not,
the Builder repaints. It defaults to a no-op like every other hook, so a
host that passes none is unaffected.
The test is the general form, not that one name:
tests/test_crawler_no_backrefs.js asserts that NOTHING the module calls
is a top-level name only dungeon-builder.html declares. Measured across
the whole file, this was the only real one — the other apparent hit was
persist() inside a comment explaining why play mode makes it a no-op, so
the scan strips comments before looking. Confirmed to fail both on the
shipped bug restored and on a fresh violation it had never seen.
test_dungeon_builder_tex_persist lifts that restore loop and supplies its
collaborators by name, so it now supplies `hooks` — and takes the chance
to assert the signal itself: one call per decoded slot, and none at all
for art that failed to decode or that belongs to a mount the party has
already left, since sending a host off to repaint with art being dropped
would be the same bug in the other direction.
Also verified in both real hosts: the game descends and refreshes with
custom art and throws nothing, and the Builder's palette button still
repaints with the uploaded sprite (checked by reading its pixels).
Suite 407/416; the nine failures are pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA measured playthrough exported at 441 MB. Banner references and a bounded weather cache already took that to ~62 MB with no server involved; what is left is the art itself, carried as base64 data: URIs inside the world, the player and the story. The vault is already holding those bytes -- it is the thing that calls the provider and hands the result straight back inline. So this is one seam, not a change spread through the app: write the bytes to disk, return a URL. Every media field in the client already holds "a URL or a data URI" because Pollinations returns URLs today, so nothing downstream had to learn a new shape. PHASE 1 -- the store. Files are named by the sha256 of their CONTENT, not by what they depict. That is what collapses the duplication the save cannot otherwise shed: an item icon is legitimately reachable from the catalogue, the player's pack and a room floor, which is ~10 MB of the same images in one measured save. Content addressing also makes writes idempotent and lets the read route mark files immutable, since a name can never describe different bytes. It also makes the read route safe by construction rather than by filtering. A stored name is exactly 64 hex characters plus an allow-listed extension, and the hash is computed from the bytes here -- nothing a client sends is ever used to build a path, so traversal is unrepresentable rather than merely rejected. Every way the store can fail degrades to the data URI the client would have received anyway: store off, provider returned a URL, unsupported media type, or a write that throws. A generation the player has already paid for must not be lost to a disk problem. PHASE 2 -- portable export, shipped alongside, because until it exists an export from a vault-mode save is silently non-portable: the file only renders against the one vault holding its bytes. All four export paths (game, world, character, objects) rehydrate first. That works on the serialized JSON text rather than walking the object graph -- not a shortcut but the more complete choice. References also live inside story HTML, where a walk would never look, and a base64 data: URI contains no character JSON must escape. A reference that cannot be fetched is left in place and reported rather than failing the export: a half-portable file the author is told about beats no file at all. Direct mode is untouched. No vault, no store, data URIs throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Both are defined by what they do NOT do. FLOOR turns rock into floor and inherits whatever faces are already there. Every other piece decides its tile's four faces for you — right when drawing a corridor, wrong when the tile already carries the doors you meant, because a re-stamp goes through setEdge and setEdge drops the lock on any face it changes. Re-stamping a finished square silently unlocked its door; Floor writes nothing, so there is nothing to drop. Coming out of bedrock it does close one thing: a face claiming to open into rock that is still rock. A solid cell's faces are whatever the file says, and carrying that one across would be a hole in the map. Inherit means the walls, not a hole — an opening with a real neighbour behind it is kept. `open: 'keep'` is the second string 'auto' had to itself, so both readers that map over piece.open needed guarding: stampPiece, and the palette icon, which would have thrown on .map of a string. The hover ghost needed more than a guard — it previews the faces a stamp would leave, and for Floor those are the tile's own. Showing a piece's walls there would read as a stamp about to close a doorway, which is the one thing this brush never does. SELECT now picks a chest, after the editable face and before the stair. A chest has a lock, a trap and contents — more to say than anything else on the map — and reaching its editor by re-picking the item brush was never obvious. The order is the load-bearing part: a face has to keep winning, or a door on a chest's own square becomes unreachable. Bones are not a container and still select nothing; the test is a lid, not an item. That left a discoverability gap, since a face line drawn over a chest reads as "nothing here" while Select would in fact open its editor. So the ghost outlines the whole square when the square is what it would take, which fixes the same silence for stairs. tests/test_dungeon_editor_tools.js runs stampPiece for real over a small grid with the shipped setEdge, so the lock-clearing behaviour under test is the real one. Both defects were confirmed to fail when reverted: Floor resetting the faces, and Select taking the chest ahead of the face. Checked in the real Builder too, driving applyTool through the editor canvas. Suite 406/415; the nine are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The lines sit half-transparent over a lit 3D scene, with a fade over the older ones. That is right for glancing at and poor for going back to a sentence, so hovering one darkens its panel, lights its rule and brings the text up to full strength. Hit-tested from JS rather than done with :hover, and that is the whole design of it. #dungeon-crawl-gm is pointer-events: none deliberately — it lies over the bottom-left of the canvas, and a click on the canvas is how a chest or a wall switch is worked. Taking pointer events to get :hover would make that corner of the view dead to the mouse: a chest behind a line would stop opening, and the failure would be silent, because nothing about a highlight suggests it swallowed a click. So the pointer is tracked on #dungeon-crawl-body, which is BEHIND the overlay and keeps every click, and the line under it is found by rectangle. Four lines at most, so a mousemove costs four getBoundingClientRect calls. The listeners go on with the focus pair in enterDungeon and come off with them in closeDungeonCrawl, which also forgets the pointer so the next descent does not open pre-highlighted. renderCrawlOverlay replaces the lines wholesale, so it re-runs the hit test afterwards — otherwise the highlight stays behind on a line that has since moved up. Tested in test_dungeon_two_inputs beside the existing overlay assertions: the hit test runs for real over fake rects, the :hover route is asserted ABSENT, and nothing in the overlay block re-enables pointer events. The re-render assertion was confirmed to fail when reverted. Also checked in a real browser, where the load-bearing half is that elementFromPoint over a highlighted line still answers with the canvas, and a click through one still opens the chest behind it. Suite 405/414; the nine are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The place all of yesterday's container data gets authored. A chest tile was the one selectable thing in the editor with nothing to edit; now selecting one — or placing one, which selects it, so the panel is up rather than waiting to be found — opens four sections: the chest, inside, lock, trap. Bones get nothing: the test is the lid, not the item. It writes the game's container spec verbatim — method, pickDC, keyName, button, detectDC, disarmDC, damage, contents — straight into normalizeContainer. The two apps share no runtime and are joined only by the bytes in the saved map, so a shape of our own would need translating on the other side, and the translation is where two vocabularies drift. The five lock methods are cross-checked against the game's own CONTAINER_LOCK_METHODS in the test rather than by eye. Contents needed the one thing the Builder does not have. It takes an exit-stair room id on trust because it has no world to check against, and contents cannot work that way: "type the item's catalog id" is not an editor and a typed id resolves to nothing. So the game publishes a digest of its item catalog under the world's scope on the way in — the same shared-storage route the map travels, not postMessage, which would only reach a Builder listening at that instant — and the picker reads it back by the scope already in its query string. A digest because a catalog entry carries an image URL and a generation prompt: megabytes across a world, useless to a select box. Entries are stored as refs; the same item twice stacks; an id the world no longer has is shown AS its id so the author can see which one broke. Standalone from file:// there is no digest and the picker says so. A chest nobody configured writes no `ch` key at all, and a spec that says nothing is tidied back to null — otherwise ticking Trapped and unticking it would leave empty blobs in every map forever. Lifting the chest takes its lock, trap and contents with it, so a later chest on that square cannot inherit a stranger's loot. Renamed the Builder's catalog cache to dbItemCatalog: as `catalogItems` it collided with the game's own catalogItems(), which test_dungeon_builder_scoped caught. That is the collision class the scoping work was done for — a missed reference reads the wrong object rather than throwing. Two things the screenshot changed, neither visible from the diff: Inside moved to second, because it is the section an author reaches for every time and the panel scrolls, so whatever is last is off screen; and the scroll track is styled, because the trap fields sat below a silent clip with nothing on screen saying there was more. Verified in the real Builder end to end — real clicks on the real editor canvas, driving the real controls, then checking the serialised cell against the shape the game reads. Suite 405/414; the nine failures are pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Half one of step 5's loot. The game already had contents, capacity, four
lock methods, traps with detect/disarm/spring and containerChanges to
drive all of it; a dungeon chest declined every bit of it and said "It is
empty", because it was. This is joinery, not new mechanics.
Two stores, on the line §10 already draws. The MAP holds what the author
put in a chest — lock, trap, contents — in the cell as `chest`, so it
travels with the dungeon. The SAVE holds what has HAPPENED to it, keyed
by dungeon and square. A chest is materialised from the first into the
second on mount, and thereafter the live object is the truth.
The missing seam was a hook, but not the one predicted. onOpen would have
been enough to report an opening and not enough to stop one — a lock has
to be able to say no. So onChest(c, r, opening, level) is asked BEFORE
the lid moves and its answer is obeyed: { allow: false, msg } refuses,
{ msg } replaces the line said. It is the second hook about the party
rather than the map, and the only one the crawler obeys. A host with no
opinion sees no change, which is what keeps the Builder's preview free.
DUNGEON_CONTRACT went the other way, which is the measure of the change:
the clause suspending rules 5a and 5b is deleted, and the rules apply in
full. What replaces it is the one genuine difference — the lid is the
player's, worked by hand between turns, with the trap already sprung and
the contents already revealed by the time the GM hears about it. So the
GM must not also emit "open"; everything else stays its own, named rather
than implied: the pick, the search, the disarm, take and put.
Three joins that are silent when wrong:
- Materialising is additive. Re-seeding a chest the party emptied would
make every dungeon an infinite treasury, and would read as generosity
rather than breakage. A square the author has since cleared is pruned,
or a stale container answers by name forever.
- findContainerByName is narrowed below ground to the chests in reach,
and skips the world-wide sweep entirely. "Wooden Chest" is the
commonest container name in the game, and the current room down there
is the one they descended from.
- Reach is now enforced. itemAtPoint picks by line of sight at any
distance — harmless for scenery, not harmless when opening a trapped
casket from across a hall would defeat the trap.
gmContainerNote moves to file scope so the dungeon square and a room's
floor share one formatter; rule 5a calls that line the single source of
truth, and two formatters would be two truths. The sidebar's Items block,
blank below ground, now lists the reachable chests — which is where Open,
the contents rows and Disarm live. Lids are reconciled from
updateSidebar, since three paths can open a chest and only one moves a
lid.
Tests: tests/test_dungeon_chests.js, which runs toggleChest, the verdict,
materialisation and the lookup for real over lifted source; both key
behaviours were confirmed to fail when reverted. test_dungeon_pseudo_
location's chest and contract blocks now assert the new shape, and prove
the dungeon square uses the shipped gmContainerNote rather than a copy.
Verified end to end in a real browser as well, since the middle of the
chain is WebGL: a click one square ahead opens the container, contents
reach the story, the dossier states them exactly, the locked one refuses
by hand and yields to a pick with the lid following, the trapped one
costs 5 hp, and a save round trip leaves the emptied chest empty and the
sprung trap spent. Suite 404/413 — the nine failures are pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLItem and entity popups put a Generate button in the glyph placeholder when there is no picture yet. The spell popup showed a bare glyph, so the one place you were actually looking at the spell was the one place you could not paint it -- the trip was out to Editor > Magic > Spells and back. generateSpellPopupPortrait mirrors generateItemPopupPortrait rather than inventing a second flow: status messages render into the placeholder itself, and a popup closed or re-pointed at another spell mid-paint is left alone instead of having a late image dropped into it. It never blocks on a missing prompt. The GM authors one when reachable; without an API key spellImageFallbackPrompt composes one from the spell's name, school and description, so the button paints something rather than sitting there doing nothing -- the same rule the item popup already follows. A failure restores the button with a hint instead of leaving a stalled status line. A painted spell goes through refreshSpellViews, so the loadout chips, the Spellbook tab and the editor card pick it up too, not just the popup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Player's Handbook and the Dungeon Master's Guide each had their own toolbar button, two icons deep in a row that was already seven wide and growing. They are one kind of thing -- documents you open -- so they now sit behind a single bookshelf button in the same place, mirroring the export dropdown beside it: same markup, same styling, same click-outside and Escape handling. The DM gate moves with the guide rather than the button. The Library button is always visible because the Handbook is for everyone; the Dungeon Master's Guide keeps its .dm-only class as a menu ITEM, so applyDMVisibility still owns exactly what it owned before and a plain player sees a Library holding one book. Putting the gate on the button would have hidden the Handbook from the players it is written for, so a test asserts the wrapper carries no dm-only class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Every editor tab could already export its own kind, one file at a time. Moving a handful of rooms plus the NPCs in them plus their items to another world meant three files and three trips. Export Objects... in the Export/Import menu opens a checkbox list and writes the chosen kinds to one file holding just those objects -- no character, no game save, no world settings. The list is Object.keys(EDITOR_IO), which is exactly the set of tabs carrying an Import/Export row. That is deliberate and load-bearing: a type gains a checkbox the moment it gains those buttons, and a hand-kept list would have silently omitted whatever was added last. A test reads the Export buttons out of the markup and asserts the two sets match, so they cannot drift. The bundle is keyed by TYPE, not by the adapter's JSON key. Items, flora, magic items and spellbooks all serialize under "items", so a key-shaped bundle would fold four kinds into one map and hand the flora tab the magic items coming back in. Keyed by type they stay apart, and each slice returns through the adapter that wrote it. Import Objects... reads the whole bundle in one go. Each tab's own Import also now recognises a bundle and lifts only its own slice, so a mixed file is useful from either direction, and plain single-type exports still import exactly as before. An unknown kind is skipped and named rather than failing the file -- a bundle from a world with types this one lacks still delivers everything this one understands. A count of zero gets its cause named. A type with nothing in it says the world has none; a type whose tab filter happens to match nothing says the filter hid them. Reporting the first when the second is true would send an author hunting for content they already have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Four claims had gone stale as the code moved past them: - §15 said a returning party lands on their saved square. It does not — every descent arrives at the dungeon's start square and only a resumed session restores the position. That was the fix for the "facing the opposite direction on the stairs" report; the bullet still described the behaviour it replaced. - §15 and §16 both said layouts live in localStorage. Step 2 moved the payload to IndexedDB and left only the card-sized index behind. The portability point still stands — per-machine either way — so only the location detail was wrong. - §16's "should play state ever persist?" is answered by having built it, the same way deriving the entrance answered "one exit or many?". The split falls out of who passes the pair: the Builder calls neither half, so a preview still starts clean. And a new section for what is left of step 5, because "grid cell contents" reads as one job and measuring says it is two. Loot is three seams from working: the game already has containers with contents, locks and traps, the dungeon declines all of it through an explicit clause in DUNGEON_CONTRACT, and the one genuinely missing piece is a hook — toggleChest reports onMapChanged, so the host learns the map changed but not that a lid went up, which is the event loot needs. Monsters are not blocked on rendering: billboards, picking and line-of-sight all ship with the item layer. They are blocked on a fight having nowhere to happen — #combat-bar's z-index 40 paints across the foot of the crawl view at 30, and nothing guards descending mid-fight. That is a decision, not a diff, so loot goes first. Doc only; suite unchanged at 403/412. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
main added a defensive accessor: the crawler is a separate script and can simply be absent, so every read goes through crawlerApi() and a missing crawler reads as "nothing has been drawn" instead of throwing mid-render. This branch added a great many new crawler reads while that landed, so the merge is mostly about honouring that invariant across them rather than about the three textual conflicts. The conflicts, all in the dungeon code, all combined rather than chosen between: - dungeonExitRooms: kept main's missing-crawler guard AND this branch's trim/drop of blank exit-room ids. Orthogonal — one is about the crawler being absent, the other about what an index may contain. The trim matters more now than when it was written, since this list decides which rooms are dungeon ENTRANCES. - enterDungeon's gate: kept main's comment explaining that dungeonHasExit already covers a missing crawler, with this branch's `return false` — enterDungeon now reports whether it got in, because descendFromRoom needs to know. - deleteDungeonBuild: main's guard, then this branch's play-state and entrance-index invalidation. Beyond the conflicts, the reads this branch added were still going straight at `Crawler`. The three that run whether or not a dungeon is open are now routed through crawlerApi() too — partyIsBelow and dungeonPromptFrame (asked on every sidebar render, every story line and every prompt build), the revealExit route, and the play-state capture that rides every save. The rest sit inside enterDungeon past the dungeonHasExit gate, which main's own comment reasons is unreachable without a crawler. Verified rather than assumed: - origin/main alone: 397/406, nine failures. Merged: 403/412, the SAME nine. Zero new failures, and the six tests this branch adds all pass on top. main's fix also repairs test_world_uid_roundtrip, which had been failing here since the maps moved to IndexedDB. - All four browser checks pass on the merged tree. - And a new one for main's actual invariant: with crawler.js blocked outright, all eighteen dungeon-aware paths this branch touches survive, the party is never below ground, no dungeon reports a way out, a Descend cannot be taken, every card reads "not drawn yet", the Dungeons tab renders — with no page errors. Two test-side updates: one assertion named `Crawler.reveal` and now names the accessor, and two browser scripts still expected the pre-reset re-entry position from before descending was made to arrive at the dungeon's start square. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Observed: the GM narrates "to the west you see…" where "in front of you" is what
it means. That is the dossier's fault, not the GM's — every direction it was
handed was a bare compass word, so compass words were what came back.
A first-person view has no map on it and no compass rose. "To the west" is a fact
about the model, and at the moment a player hears it they have to stop and work
out which way they were facing — which is exactly why it lands as FAR AWAY rather
than as ON YOUR LEFT.
Every direction in the dungeon dossier now leads with the bearing relative to the
party's facing, with the compass word after it in brackets:
ahead (north) → an open way through
behind them (north) → a LOCKED door — it will not budge …
to their right (west) → plain dressed stone
a secret passage in the wall to their left (east)
pile of bones, 3 squares away to their right (east)
Both, deliberately: the relative bearing is what the GM narrates, and the compass
word has to stay because revealExit takes north/east/south/west and confusing the
two would grant the wrong wall. The contract says which is which, spells out the
phrasing in second person ("In front of you…", "To your left…"), and reserves
compass words for a player who used one first.
The same helper is used for faces, for concealed faces, and for items in view, so
turning in place re-bases all of them together — which is the thing a compass-only
dossier could never express.
Two punctuation fixes fell out of reading the result: the face phrase now closes
before what follows it (it read "an open way through In front of them"), and a
phrase already ending in a parenthetical is not given a second full stop.
Eight existing assertions matched the bare-compass format and were updated; two
scratchpad browser checks still expected the pre-reset re-entry position and were
corrected to verify what actually persists (the door state) rather than where the
party lands. 400/410, the ten failures pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLPoint every month of a climate at a one-day-type "Always Snow" and the card still offers eight condition weights, seven of which are never read -- weatherPickDayType only consults a weight for a condition some day type in that pattern is built on. Typing 10 into Rain there contradicts nothing; it is simply inert. The card gave no sign of that. Worse, and the reason this is more than tidying: Arid zeroes snow, and under Always Snow it snows all 240 sampled days. Zeroing every day type of a pattern drives the weighted total to zero, and the pick re-runs unbiased so the sky still resolves -- so the veto is overridden by the very pattern it was vetoing. The hint stated "a 0 means that sky never comes here", which in exactly this case is false. The fallback stays. A region with no resolvable sky is worse than one with a surprising sky, and honouring the veto would mean defining what "no weather" renders as. So the job is to say what happens, not change it: - Weights for conditions no day type in the climate's year uses are struck through and dimmed, with a tooltip and a count. Still editable, because the year can change tomorrow and hiding them would only move the confusion somewhere harder to find. - A pattern whose every day type this climate zeroes raises a warning naming it and the months it covers, saying the weather does happen here, and pointing at the two real fixes. - The hint states the rule and its exception together. weatherClimateReach mirrors the pick's own arithmetic rather than restating it, so the warning cannot drift from what the engine does. No behaviour changes. An ordinary four-season world strikes through nothing and warns about nothing -- the seasons between them use every condition -- so this is quiet until a year is authored that earns it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two things from testing. A CHEST ONE SQUARE AHEAD was invisible to the GM. Standing in front of it and typing "look" reported no chest; the GM learned of one only when the party stepped ONTO its square. Crawler.frame() described the party's own tile and nothing else, while the renderer was drawing item billboards all the way down the corridor — so the dossier said less than the player was looking at. The frame now also reports what is IN VIEW, using the crawler's own hasSightTo — the same line of sight the renderer uses, so a chest behind a shut door or round a corner is correctly not there. Each carries its bearing, its distance in squares, whether it is straight ahead, and its lid state. Nearest first, capped at six so a hall of bones cannot crowd out the rest of the dossier. In the dossier, anything straight ahead joins the FACING line, because that is what an unqualified "look" is about. Anything off to one side gets its own line, flagged as needing to be walked to before it can be touched. The party's own square stays separately reported and is never double-listed. CLIMBING OUT NOW FOLDS THE EPISODE. The visit is over, so it becomes one line with its count and the world the party came back to reads unbroken; after that it is theirs to open and close, and their choice is what gets saved. The way out and the arrival stay outside the episode, so folding still cannot hide how they got back. An empty visit — descend, turn straight round — gets no fold control at all, since "0 lines hidden" is a button that does nothing. That guard exposed a second bug it would otherwise have caused: the head is written BEFORE any line is under it, so at that moment the count is zero and the control renders as nothing. A path that could only REPLACE the control would then never have shown one however long the episode grew. applyCrawlFold now adds it when missing. Caught by the browser check, which is the only place that ordering is visible. Tests: the frame's in-view rules driven against the real hasSightTo (ahead vs aside, behind a shut door, opened again, standing on it), the dossier's two lines, and the auto-fold wiring including the ordering that makes it work. 400/410, the ten failures pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The lines stay in the story, which was the right call for a reason beyond the
design saying so: messageLog and conversationHistory are separate stores, so
suppressing the story lines would leave a chapter with a hole in it while the GM
went on remembering every word — the mirror of the bug just fixed, pointing the
other way. And the crawler's mechanical chatter ("the door swings open", every
step and turn) was never in the log to begin with; it stays transient in the view.
So what lands is only what the player typed and the GM answered.
What was missing was any way to READ it as an episode. Every dungeon line was an
ordinary player/narrator/system entry, indistinguishable from one above ground —
so a visit looked like a dozen lines that had lost their setting, and there was no
hook to fold them behind. Measured: a six-exchange visit leaves fifteen lines.
Now each line carries the episode it belongs to, the descend line is its head, and
the head folds the rest away behind a count ("8 lines" / "8 lines hidden"). The
tint is a left edge and a breath of warmth, not a box — enough that a visit reads
as one thing when scrolling past it.
Three things make it safe:
- The COUNT is derived, never stored. The episode is still growing while the party
is down there, so a stored count would be wrong for most of its life. The head
is refreshed on each append; verified counting 2 -> 4 -> 6 -> 8 mid-crawl.
- FOLDING IS A VIEW STATE. The entries are untouched and the story-book reads
messageLog rather than the DOM, so a folded episode is still in the book. The
fold is saved, so the player's choice survives a reload and a re-render.
- The episode CLOSES BEFORE THE WAY OUT IS WRITTEN. "You climb out of ..." and the
room arrived in belong to the world, so folding a visit can never hide how the
party got back.
The restore needed care: it rebuilds messageLog entries from scratch, so the
episode stamps had to be carried across explicitly or a restored episode would
come back as loose untinted lines.
Three existing tests asserted the exact spelling of the two lines in addMsg that
this refactors (the className literal, the push-then-cap pair, and the distance
between reading the pinned state and re-narrowing). All three protect properties
that still hold; updated to assert the behaviour instead — one of them now runs
the shared helper rather than regexing for a literal.
Verified in a real browser: the episode groups, folds, unfolds, survives a
windowed re-render and a save round-trip, a second visit is its own episode, and
the way out stays visible when the first is folded. Screenshots confirmed the head
reads correctly — the first attempt put the head in a flex row, which promoted its
<strong> to a flex item and rendered "You descend into Sewers ." with the period
adrift. 400/410, the ten failures pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLDecision K: keep both, as a coarse/fine pair. Once a climate could name its own pattern per month, the condition weights looked like a second way to say the same thing, and moving day-type odds onto the pattern card (where a day-type's chance already lives) would have retired them. They are not the same thing. chance belongs to the PATTERN and is shared by every climate using it; weights are the only per-climate say over which of a shared regime's days actually land. Measured against the seeded content, neutralising Arid's weights puts 14 of 28 winter days under Snowbound -- snow in the desert. Deleting them is only safe alongside a per-climate pattern set to express what they expressed, and the seeded world has no desert-winter to point at, so the deletion would land before its replacement. So the risk here is legibility, not correctness: two adjacent controls that look like alternatives invite exactly the confusion that "condition" meaning two things once did. The card now names the relationship instead of leaving it to be inferred -- the Year picks which regime a month runs, the weights tilt which of its day types this climate gets -- and the reasoning is recorded in the design doc so the question does not get reopened from the same wrong premise. No behaviour changes; the wording and the decision entry are the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reported again, with a real handoff, and the dossier was already correct: BELOW
GROUND, the Sewers' description, "FACING RIGHT NOW … a shut door, unlocked". The
GM still described the wine cellar.
The handoff's MESSAGES block shows why. The conversation ran:
USER "go down" → ASSISTANT descends the party to the cellar (moveToRoom:
wine_cellar) → USER "look" → ASSISTANT describes the cellar at length →
USER "look"
The descent into the Sewers happened BETWEEN those last two turns, and nothing in
conversationHistory records it — because the CRAWLER moved the party, not the GM.
addMsg("You descend into …") goes to the story; the history never hears about it.
So the system prompt said BELOW GROUND while the message stream said "I just
described the cellar to you", one line above the question being asked. The stream
won. No amount of improving the dossier could have changed that: the contradiction
lived in the stream, so the correction has to go there too. (I checked the prompt
for the cellar first and it contained none of it — the previous fix was right about
what the dossier lacked, and insufficient.)
It goes through queueGmNote, which already exists for exactly this shape: an
out-of-band change the GM never saw, injected into the turn actually sent without
being stored in the transcript. Both legs announce themselves. The note NAMES the
room being left, because the vague version does not stop the GM re-describing it —
that room is the most concrete prose in its context. A second crossing replaces
the first rather than stacking, so leaving and descending again cannot send two
notes that disagree.
One note is enough. Once the GM has answered a single turn below ground, its own
reply is the newest thing in the history and the room above stops being the
freshest prose in it.
Verified by reconstructing the reported handoff in a real browser — the same four
turns, ending on the GM's own cellar description — then descending and building
the payload the next "look" would send. It carries the crossing; leaving queues
the mirror; a re-descent replaces rather than stacks. 399/409, ten pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLTwelve dropdowns is a lot of blank page for a DM who knows what a climate is but not yet what its calendar looks like. Suggest asks the GM to fill them in. Deliberately not a free-text box like the pattern editor's Apply. Every input the request needs is already on the card -- the description, the base band, the season swing, the gustiness, and which skies the climate never gets -- and the calendar supplies the months with their world defaults, so the button asks a question that has only one shape. Months this climate already overrides are disclosed too, so a second press revises rather than starts over. Sparse answers are asked for explicitly: return "" wherever the world default is already right, because a month left blank keeps following the calendar if the DM later re-binds it, and one pinned to the same value by coincidence does not. A season swing of 0 is called out as meaning the same pattern in all twelve. Nothing coming back is trusted. A slot naming a pattern this world does not define is dropped rather than stored -- it would resolve to the calendar anyway, and sitting in the data it would merely look authored -- and a short array leaves the missing months inherited. Either case reports in the attention colour, because a result the DM should read is not a clean success even when some months landed. The result line is held in module state rather than written into the card, since applying a year re-renders every card and a message poked into the DOM would go with it. Editing a month by hand clears it, a stale "mapped 5 of 12" being worse than none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reported: standing in a dungeon facing a locked door, "look" made the GM narrate the Wine Cellar — the entrance room, not the square. Measured first, and the dossier was NOT leaking that room: a real below-ground prompt contains no "Wine Cellar", no "wine_cellar", none of its description. (The two words that did appear were innocent — "healing draughts" in a quest reward, and "The Rusty Flagon" inside rule 3c's own worked example.) What it was missing is two things a room supplies and a grid reference does not: - AUTHORED PROSE. A room hands the GM a name, a description and an atmosphere line. A square was handing over coordinates and a list of four faces. Asked an open question with nothing to describe, the GM reached for the most vivid description in its context — the cellar, a few lines up the transcript. That is the dossier being thin, not the GM being wrong. The dungeon's own description, detailed description and lore — the trio its card already edits — now stand where the room's prose did. Lore follows the convention item lore already uses: locked lore is named to the GM as unearned, with how the party earns it. - WHAT THEY ARE LOOKING AT. Crawler.frame() has always flagged which face is `ahead`; the section never said so, so the locked door the player was facing sat buried in a list called "The other sides of this square". It now gets its own line before the exit list, stating that an unqualified "look", "examine", "search" or "open" means THIS. A concealed face reads as plain stone to the party and is named to the GM, since that is the face a search would test — the one place the two must differ. A dungeon with nothing authored says so rather than omitting the line: a blank is an invitation to invent a place. The contract also names the failure now. "A SQUARE IS NOT A ROOM" was too abstract to cover it, so there is a rule saying the entrance room is still sitting in the transcript, is behind them, and must not be re-described as where they stand — and pointing at the three lines that answer a bare "look". Verified in a real browser by dumping the prompt and turning in place: the facing line follows the party through a locked door, an unfound secret (stone to them, named to the GM) and an open way. 399/409, the ten failures pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two bugs found by playing it, both specific to leaving a dungeon and going back — which nothing did before the way in/out landed. WALLS VANISHED ON THE SECOND DESCENT while the torches went on animating. A WebGL object is only valid in the context that made it, and each mount builds a new context. `batches` caches one vertex buffer per texture slot and only creates it `if (!batches[k])`, so on a remount every wall, floor and ceiling batch was writing into a buffer belonging to a context that no longer existed — silently, since bufferData writes into nothing and drawArrays draws nothing with no error raised. The flames kept working because flameBuf is created inside initGL, which does run again, and that asymmetry is what pointed at the cache rather than at the map or the textures. itemBuf and switchBuf were stale the same way, so chests and wall switches were invisible too. unmount now drops everything the context owned, and the test for it is structural rather than a list of today's three caches: it finds every module-scope name assigned a `gl.create*` result anywhere in the file and requires unmount to clear it, so the next cache somebody adds fails the test instead of quietly breaking the second descent. Verified against real WebGL by reading pixels in-frame across three descents, and confirmed the check catches it: with the fix reverted, visit 1 draws 98.8% of the frame and visit 2 drops to 0.6% — which is the torch flame, exactly what was reported. (Pixels have to be read inside a requestAnimationFrame callback, not by drawImage-ing the canvas afterwards: the context has no preserveDrawingBuffer, so a later read is black however much was drawn. My first attempt at this check "failed" on visit 1 too, which is how I know.) LANDING ON THE DOORSTEP FACING OUT. Restoring the party's square along with the doors looked right and plays wrong. A party who leaves by the stairs is standing on the exit stair when the state is captured, facing the way they walked out of it — so the next descent put them back there, facing the wrong way. Reported as "facing the opposite direction on the stairs grid". Descending is an ARRIVAL and belongs at the dungeon's own start square, which the author placed for that. Resuming a session is NOT an arrival — nobody went anywhere, the tab was closed — so there the saved square is right and moving them to the entrance would be the surprise. So the position is an opt-in on restorePlay, and only the resume path asks for it. What the party did to the place comes back either way, which is the part that matters. Also guards a stale texture decode: tile art loads off an Image, and a load finishing after the party had left one dungeon for another would upload the first one's art over the second's. Mounts are generation-stamped and a late decode is dropped. test_dungeon_builder_tex_persist covers it now, and still holds the original claim-synchronously fix it was written for — the guard is on the upload, not the claim. 399/409, the ten failures pre-existing and unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The month a weather pattern ran under was a world-global scalar: every region turned through the same regime in the same month, whatever climate it stood in. A climate could only subtract from the regime it was handed — never choose a different one — so a region whose sky never changes all year had no way to say so without dragging the rest of the world with it. The binding is now a function of (month x climate). A climate carries a year: twelve slots, month to pattern, blank meaning "whatever the world calendar says". The Months binding stays as the world default, so every existing world resolves unchanged and a new one needs no authoring; a climate fills in only the months where it parts company. Endless winter is twelve slots, a climate that differs in high summer alone is one. The editor renders the resolved answer rather than an empty box -- an inherited month reads "world default: Wintery" in dimmed italic, because silent inheritance down a five-hop lookup is how a world ends up behaving differently than it was authored with nothing on screen to say so. The second half is why one pattern can be shared at all. A day-type's tempBand and wind are authored absolutely, and the pattern-driven resolve read them as-is: baseTempBand, seasonAmp and gustiness were consulted only by the pre-pattern resolve, which no world carrying patterns ever reached. Three editable fields that moved nothing, and the symptom was a desert frigid for forty straight days of winter and an alpine summit exactly as warm as the lowland field in summer. The climate now routes and then modifies -- the authored band is read as a deviation from the temperate baseline and re-applied to this climate's own base, scaled by its season swing, with gustiness nudging the wind. A climate sitting on the defaults is an identity. That is the property that makes this safe for a world already in play, so it is pinned by a test directly rather than left to the numbers happening to work out. Patterns gained a Duplicate button for the remaining case -- a climate that wants its own take on a season, where the kinds of day genuinely differ and no amount of band-bending will do. The pre-pattern resolve and its weatherPickCondition are gone. weatherPatternForMonth now always finds a pattern (its terminal fallback reaches into the built-ins), so the only way past it is a world whose every pattern has had its last day-type deleted; that hands back a plain calm day bent by the climate rather than keeping a second, divergent engine alive to serve it. Condition weights stay on the climate for now. Removing them is only safe alongside per-climate patterns to replace what they express, and measured against the seeded content it is not yet: neutralising Arid's weights puts 14 of 28 winter days under Snowbound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The way in works but the built-in world had nowhere to use it: every room either has no business opening onto a dungeon or already has a `down` of its own (barrow_entrance descends to barrow_chamber, so the collision rule rightly refuses it). So there was no way to try the feature without authoring a room first. Adds "Wine Cellar", down from the Inn's Common Room — a proper interior of the inn like the upper landing and the private room, with time-of-day prose and the WineCellar.png banner that was already sitting in Images/ unused. Nothing about the room is dungeon-specific, which is the point. There is no flag and no dungeon id on it: a room becomes an entrance purely by being named by some dungeon's exit stair, and the game derives the Descend from that. Linking one is done entirely in the Builder, by typing `wine_cellar` into a stairs-up room field. What IS load-bearing is an absence: the cellar has no `down` exit of its own. A room whose `down` is taken keeps it and gets no Descend, so adding one here would silently turn the cellar back into an ordinary room. Tested, with the reason next to the assertion, because that is an easy thing to break while editing prose. The descriptions hint at the possibility without promising it — a draught, older stonework at the back — so the room reads honestly whether or not a dungeon is linked, and never implies an exit the engine is not offering (rule 3a). The world version is deliberately NOT bumped: adding a room does not invalidate existing saves, and bumping would stop every one of them resuming. Saves carry their own copy of the world, so the cellar appears in NEW games only. Verified in Chromium end to end: the room exists and connects both ways, is indoors, shows the same weather treatment as the inn's other interiors, and every stored exit in the 14-room world still resolves. Then with a dungeon linked — the Descend appears in the story chips, the sidebar and the GM's exits list; clicking it opens the crawl; walking out arrives back in the cellar; and going down again returns to the same square with the door they opened still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Step 5's list opened with loot and monsters, and both were the wrong place to
start. Until this, no player could reach a dungeon at all: Enter lived on a
dungeon card on the DM-only Editor tab, so everything §14 built was unreachable
in ordinary play — and a dungeon forgot every door the moment you left it.
Authoring treasure would have meant hiding it somewhere nobody could get to
that reset itself on the way out.
THE WAY IN, derived rather than flagged. §14 planned one own prop on the room —
the id of the dungeon it opens, stored the way `interior` is. Built, that is a
second copy of a link the dungeon already holds: an exit stair carries the id
of the room it climbs into, and dungeonExitRooms() already reads it. Two writers
on one relationship drift — retarget the stair and the old room goes on claiming
to open a dungeon that no longer comes out there. So the room side inverts that
index instead. Nothing is authored, nothing is migrated, nothing can go stale,
because there is one fact and the dungeon owns it. §14's own symmetry paragraph
already argued for this; it just didn't notice the prop was the redundant half.
It also answers §16's "one exit or many?" for free: two stairs into two rooms
are two entrances, because both are the same fact read the same way.
The derived `down` is never stored — a stored exit would persist into the save
and strand a phantom Descend. Three readers see it (the GM's exit list, the
sidebar badges, the story chips); the other twelve room.exits readers do not and
needed no teaching, because a derived exit has no destination and the
connectivity walk, map, region walks and flee picker all already guard on the
destination room existing.
Collision, decided: the authored cellar wins. A room with its own `down` keeps
it and offers no Descend, because shadowing an authored exit would make the
cellar unreachable with nothing saying why. The dungeon's card reports the clash
instead ("its `down` is taken", "no such room") — the only place both sides of
the relationship are visible at once.
ARRIVING FOR REAL. closeDungeonCrawl used to name a room without putting the
party in it. It now runs the same bookkeeping a GM-driven move does
(despawn-on-exit, ambient presence) and then the room's own arrival. An exit
stair's room wins; Leave falls back to the room they descended from; a stair
naming a room this world lacks leaves them where they were and logs it rather
than announcing an arrival nowhere.
PLAY STATE. Crawler.playState()/restorePlay() — the five sets, the per-level
fog, and where the party stands. In the game's own save, not a store of its own:
§14 called this "IndexedDB's problem", and measured it isn't. A fully walked
20x20x3 dungeon is about a thousand short strings, single-digit kilobytes, and
it is play state like player.currentRoomId, not the megabytes of art that drove
the map out of localStorage. Keeping it beside the party buys one writer and one
restore; captured in buildGameSnapshot rather than on every door, since that
write is already debounced and turn-paced.
This closes step 4's one deliberate seam. Step 4 saved which dungeon the party
was in but not where, because a position without the state around it could
strand them behind a door they had opened. The position now travels with the
doors it has to agree with.
Two bugs the round trip found, both unreachable until now — the Builder mounts
once and never unmounts, and until step 4 made onExit actually fire the game had
no way out to come back from:
- glReady survived an unmount. Set by initGL and never cleared, so after a
dungeon closed the flag said the context was live while the canvas was null:
`if (!glReady) return` was no guard at all and renderFrame read .width off
nothing. It also made Crawler.painted report a painted frame for a dead
context — and that flag is what a host holds its "descending" veil on.
- The render loop could be unmounted from inside itself. updateAnim settles a
step, a settled step can land on an exit stair, that calls onExit, and the
game's answer is to close the dungeon — tearing the module down mid-frame with
three draw calls still to come.
Also hardened dungeonExitRooms to trim and drop blanks. buildIndex already does,
so no real index carries one, but this list now decides which rooms are
entrances rather than only whether Enter is offered, so it should be right for
any index it is handed.
Tests: two new (the entrance derived over the REAL buildWorld() rooms in the
repo's DOM mock, including the never-stored negative and the arrival leg driven
through closeDungeonCrawl; the play-state round trip against the real crawler,
plus both lifecycle bugs pinned). Verified end to end in Chromium as well:
booting a real session, filing a map, clicking Descend, walking onto the exit
stair, arriving, and returning to find the dungeon as it was left — no page
errors. 399/409, the ten failures pre-existing and unrelated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA region's climate decided which weather it got, but nothing in the UI could set it — you had to hand-edit the world JSON. Add a select to the region detail panel, under the description. Blank means "follow the world default", and the option names that default (World default — Temperate) because the distinction matters: a blank region follows a later change to the default, a pinned one does not. A climate the world no longer defines is kept as a "no longer defined" option rather than silently re-filed under the default — the author deleted a climate, and the region should say so rather than quietly change weather. The stored value is the slugged climate id, matching what normalizeRegions and weatherClimateForRegion already read, and setting it re-renders the Climates tab so its "Used by" note stays true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The crawler is a separate script (Modules/Dungeons/crawler.js), so it can simply not be there — this HTML copied somewhere without its Modules folder beside it, a load that failed, a harness that runs only the inline script. Two of its readers were unguarded and are called while a card builds its HTML, so an absent crawler threw inside renderDungeons() and took the whole Dungeons tab down with it, not just one card's read-out. Every read goes through a crawlerApi() accessor now, and a missing crawler reads as "nothing has been drawn": cards render, each says "not drawn yet", and no dungeon reports an exit — which also makes the crawl mount below unreachable without one, so it needs no guard of its own. deleteDungeonBuild claims nothing was erased rather than swallowing the failure, and the BroadcastChannel listener declines to open rather than relying on a TypeError to skip itself. With crawler.js blocked outright, the Dungeons tab now renders its cards with no page errors; before, the same run threw ReferenceError at buildDungeonCard. This also fixes tests/test_world_uid_roundtrip.js, which was failing on main for this reason since the maps moved to IndexedDB. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A Scorching day-type based on Heat stopped inheriting Heat's effect. The day carried `effects: []`, which in stored data is a deliberate "this day imposes none" and correctly overrides its condition — but it had not been authored that way. It arrived from a spec. Handing a model a schema with an `effects` field gets that field echoed back on every day it writes, empty arrays included, however plainly the instruction says to omit it. After the fact the noise is indistinguishable from the intention, and the costs are lopsided: a false "none" silently mutes a condition's effects on that day and looks exactly like a day that simply defers. So on the GM path an empty list is dropped and the day defers. The DM can still say "impose none" in the day-type editor, where emptying the list is an explicit act with its own message and its own way back. An IMPORT is not filtered — that is usually a world returning from an export, where the empty list IS the authoring, and dropping it would destroy it. The GM's schema no longer advertises [] either, since it is ignored there. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The party could walk a dungeon but not be anywhere. The crawler drew a first-person view over the Story tab and the command prompt was switched off while they were below, because two live input surfaces could not both own the keyboard. This gives a dungeon square a location the GM can describe, and gives the prompt back. A dungeon square is a PSEUDO-LOCATION: not a story location equal to a room — rooms carry music, occupants, descriptions and exits, and minting four hundred of them per dungeon would be a second world model — but a structured frame for the GM's dossier. What the party is facing is the descriptive content, and the GM answers against that. Built in five steps, each verifiable before the next: 1. Crawler.frame(). The crawler had no read side at all: nine exports and every one a command. It now answers with the square as flat data — the four faces by compass direction, what each is, whether it is passable, a door's open/locked state, an unfound secret marked GM-eyes-only, what stands on the tile, the level, the way out. A snapshot, not a handle on `party` and `grid`, so reshaping the model is not a breaking change to the game. 2. The location section lifted out of buildSystemPrompt() behind one variable. Its pieces were interpolated at seven points, so a frame could only ever have been added beside the room's — which answers "who is here?" with the tavern's occupants. The room case comes out byte-identical; a test holds it. 3. The dungeon frame, and the rules with it. DUNGEON_CONTRACT is injected after ## Rules, naming the rules it replaces (3/3a/3c movement, 3b secrets, 5a/5b containers, 11b camp) — later and more specific, the shape COMBAT_CONTRACT already set. The World Map goes (nothing on it is reachable), item lore narrows to the inventory, the faction dossier empties. moveToRoom is refused in code, not only in the prompt: the existing world.rooms[…] guard stops a square id but would teleport the party out from under a live crawler. revealExit routes to Crawler.reveal(dir). 4. Focus as the line between the two keyboards. The crawler's guard was negative — bail if the target is an INPUT — which a sidebar button satisfies, so clicking one and pressing W walked the party. It is now positive where the host makes the view focusable, and the Builder (which has no tabindex) keeps the old rule untouched. mount() blurred pad clicks to <body>, which under the positive rule would have killed the keyboard every time the pad was used; focus goes back to the view instead. Movement is held for the length of a GM turn, since the dossier was built from the square the party was standing on when it was sent. A visible ring and a word in the bar say who has the keys, because a stricter rule with no visible state is indistinguishable from broken keys. 5. The GM overlay: bottom-left, over the dungeon, click-through so canvas picking still works. A VIEW onto the tail of messageLog, not a second inbox — one transcript, one history, one save, or the GM forgets the dungeon the moment the party climbs out. A sibling of the crawl view, never a child, since unmount() empties its container. Found while building, none of it in the review: - hooks.onExit was NEVER CALLED. leaveDungeon() still postMessaged to window.opener and window.parent, and once the crawler is mounted natively there is no opener and parent === window. Walking out of an exit stair did nothing but say a line. Documented, defaulted, and wired up by the host from both ends, which is why it looked connected. - Re-enabling the prompt re-armed more than the exit chips. The sidebar stays in view beside the dungeon and was listing the entrance room's floor items and occupants; "look around" re-printed that room, banner and all. - Movement out of a dungeon has more doors than the GM's: //goto, a character import, a new game, a world draft and logout each left a live WebGL loop over a party that had moved, and logout would have carried inDungeon into the next save. All now end the crawl; the session changes do it without narrating "You leave the Dark Crypt" into another party's story. - A bare direction is answered by the engine, not the GM — the shape reconcileRestIntent already uses, moved ahead of the call. Deliberately narrow: "look north" and "search the north wall" are real turns. The location survives a reload (inDungeon is in the snapshot), because conversationHistory and messageLog already did — without it a restore brings back a saved conversation about a crypt and a party not in one. The SQUARE deliberately does not: play state still resets on a fresh mount, so restoring deep inside could strand a party behind a door they had opened. They come back at the start square and the resume line says so. Nothing in a dungeon can yet be picked up, fought, or persisted between visits, and the contract states that to the GM rather than hoping — given a dark crypt and no instruction, a GM populates it. Tests: three new (the frame against the real canPass/isLocked/switchShown, the section and every suppression, the key rule driven for both host shapes), plus the exit hook now asserted to be called. Verified in Chromium as well as by source: the overlay renders and filters, the ring follows real focus, a pad click returns focus to the view, and a click through the panel lands on #gl. 397/407 — the ten failures are pre-existing and unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The previous regeneration commit hadn't yet been counted since it was the one creating the report. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwCtba9g3F5iYqfszX1SAg
Step 4 was one line of plan ("build the pseudo-location frame and re-enable
the prompt"). Measured against text_adventure.html and crawler.js, the design
holds but almost none of the seams it needs exist — so §14 now carries the
breakdown instead of discovering it mid-implementation.
The load-bearing findings:
- The crawler has no READ side at all. window.Crawler is nine exports and
every one is a command; party, level, grid, cell(), openDoors and
foundSecrets are module-private. The frame needs one new export, and a
snapshot rather than a handle so the game does not get a pointer into the
model that steps 1-3 spent their effort decoupling.
- onMapChanged is not the move signal (it fires on map changes, not steps).
onStatusChanged is, and is simply named for the HUD.
- The "room block" is not a block: eight consts across ~500 lines,
interpolated at six points, with ## Rules referring back to them by name.
- Replacing the data is the smaller half. Rules 3/3a/3c (movement),
3b (revealExit writes room.hiddenExits, not foundSecrets), 5a/5b
(containers a dungeon chest cannot satisfy) and the whole ## World Map
are wrong below ground. moveToRoom needs refusing: its existing guard
stops a square id but teleports the party out from under a live crawler.
- Re-enabling the prompt re-arms the exit chips, whose only guard is
input.disabled — one-click movement links for a room the party has left.
- Three focus-model details: mount() already blurs to <body> (which the
positive rule would read as dead keys), the listener is on window, and
isBlocked() is already the seam — including for movement during a turn.
- The GM overlay cannot mount inside #dungeon-crawl-view; unmount() empties
it. Sibling, and rendered from the messageLog tail.
- "One transcript, one save" has a second half: openCrawlDungeon is in no
snapshot field while conversationHistory is, so step 4 owns a minimum of
location persistence or a restore that says the view is closed.
Also corrected two measured claims: the buildSystemPrompt room shape is wider
than six fields (two methods and three whole-object consumers, so a plain
literal throws on the first call, not on a missing field), and the combat bar
paints OVER the crawl view at z-index 40 rather than under it.
And the game's own comments about the dungeon view still described the iframe
and postMessage bridge that step 3 removed. Updated, since step 4 lands there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe previous report was generated from a shallow checkout and only covered a fraction of the project's git log.
Only Patterns had a screen. The two catalogs beneath it were reachable through exported JSON and nothing else — so a DM could SEE a condition's effects on a chip but had no way to author one, and could not touch a climate's weights at all. Left to right in the order a world is authored. CONDITIONS is the catalog of skies: name, glyph, whether it falls as precipitation, how it shifts the temperature band, the prose the GM narrates from, and the effects it imposes on every day of that weather — the missing editor that started this. CLIMATES is base band, season swing, gustiness, and the per-condition weights that decide which skies a region actually gets, which is what makes a moor feel like a moor. PATTERNS is unchanged, moved. Both new tabs carry the filter / add / remove / import / export / collapse controls the rest of the editor uses, and both are wired into EDITOR_IO so Export and Import work on them. Each card names what leans on it — which patterns and climates use a condition, which regions use a climate — so removing one is an informed decision rather than a surprise, and the last of either can never be removed, because the resolver has to be able to fall back to something. A climate's weight grid is built from the conditions in the CATALOG rather than the weights already stored, so a condition added today is weightable immediately and a removed one stops being offered. And the split settles a word. "Condition" meant both a catalog entry and a pattern's day-type, and the pattern card called its rows "Conditions" — which is exactly why adding effects there looked like it would change Rain itself. A pattern's rows are "day types" throughout now: the card, its add button, the dialog title, the remove confirmation, the filter placeholder and the GM directive. Also fixes a latent crash in the weight editor: it guarded on `CSS && CSS.escape`, which throws a ReferenceError rather than short-circuiting wherever CSS is not declared. It is a typeof check now. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
"GM responses go to the overlay instead of the Story tab" splits into two designs and only one works, so §14 now says which. If the overlay held its own messages, a dungeon conversation would live nowhere else and vanish on the way out. It has to be a view onto the tail of the SAME transcript: messages still go through addMsg into the narrative, and the panel shows the last few of them over the dungeon rather than behind it. The reason this is load-bearing rather than tidy: conversationHistory is saved and restored with the game and is what goes back to the GM as context, so a dungeon outside it means the GM forgets the dungeon the moment the party climbs out — mid-session. Twenty places read the narrative DOM, the story-book novelisation among them, so a dungeon that left no trace would be a hole in the chapter. Two consequences recorded. The panel needs no scrollback, because the history IS the Story tab — a few recent lines and a fade is the whole requirement. And it forces a small taxonomy: the crawler's own say() line is transient and not in the transcript today, so mechanical feedback stays overlay-only while anything the GM says goes into the record. Better written down than decided message by message while building. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
§14's step 2. A dungeon is mostly ART — fourteen tiles at 1024² measured 2.19 MB
as base64 — against about five megabytes of localStorage for everything, the
same wall the game's own save hit before it moved. So the map and its art go to
IndexedDB.
The plan did not foresee the shape this takes. IndexedDB is asynchronous and two
of the readers are not: a dungeon card asks how many levels a dungeon has and
whether it has a way out WHILE it is building its HTML. Making card rendering
async to answer that would be a large change for a small question.
So the record splits by what each half is for. A card-sized INDEX — { meta,
exits }, under a hundred bytes — stays in localStorage and answers those two
questions synchronously. The PAYLOAD — map, art, frames — goes to IndexedDB.
The index is derived from the payload and written with it, so there is one
writer, it cannot meaningfully drift, and it is regenerable. A pre-split record
migrates the first time it is read: payload to IndexedDB, index left behind.
Measured: 1.05 MB of tile art now stores against 127 bytes of localStorage, and
survives a reload with every tile intact.
Everything falls back to localStorage when IndexedDB is missing — private mode,
some file:// contexts — which is exactly what the app did before, quota and all.
Verified separately that IndexedDB IS available from file:// and shared across
pages there, so the standalone editor keeps working.
The storage event went with the move, since IndexedDB raises none; the store
announces its own writes on a BroadcastChannel instead, which was verified
delivered across windows and across a frame in both deployments. The storage
event stays wired as a second path — it costs nothing and covers a browser
without BroadcastChannel.
One fault caught on the way: serialiseMap/deserialiseMap had been left behind in
the Builder during the extraction, so the game could load a dungeon's bytes and
then fail to turn them into a map — silently falling back to the demo. They are
model code and now live in the module. The exit test is stronger for it: it
drives the game's side through the real buildIndex, so the index builder and the
Builder's own exitStairs must now agree rather than merely resembling each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfeTwo things about the same control. The chip link carried a dotted underline at rest, which made a row of chips read as a row of underlined words. It is transparent until the pointer is on the chip — transparent rather than absent, so revealing it cannot shift the text — and keyboard focus reveals it too. The hover target is the whole CHIP, not just the name, so the stat pill lights it as well. And the day-type editor's effect rows lose their dedicated edit button: the chip's name is the link there too, exactly as on the pattern card, so the thing you want to change is the thing you click. Removal keeps a button of its own — it is destructive and should not share a target with "let me look at this". Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each chip in a Conditions row now carries its name as a link into the effect editor, so a DM reading a pattern can fix an effect where they noticed it rather than opening the day-type first. Dotted underline rather than a link colour — the chip's green or red is already carrying the effect's polarity and should not be painted over. The name alone is the anchor; the stat pill and the timer stay plain, and the click stops propagating so it cannot also open the day-type editor behind it. Two entry points, because a chip must edit exactly what it depicts. An authored chip belongs to that DAY. An inherited chip belongs to the CONDITION, and editing it changes every day of that weather — so its tooltip says that, and the editor's own title repeats it, before anything is saved. Neither ever changes which layer a day-type is on: editing an inherited effect edits the condition, it does not quietly turn that day into one that overrides. Verified by clicking both in a browser, including a day-type labelled with an apostrophe and double quotes, since the handler is an inline attribute. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
§14's step 3. The iframe is gone: text_adventure.html loads crawler.js like any other script and mounts it straight into the Story tab's overlay. The postMessage bridge goes with it — walking out of an exit stair is now a hook call, not a message across an origin boundary — and so does the ?mode=play URL the game was building to talk to itself. The first effect is not a feature, it is speed. A cold dungeon now paints in about 2 seconds where the iframe took ~14, because it is no longer a second whole document booting inside the first. The "Descending…" veil stays, and still waits for a PAINTED frame rather than merely a ready context, but it has much less to cover. Two things the module gained to make a second host possible. Crawler.start(saved) brings a dungeon up — WebGL, the default tiles, whatever art was uploaded over them, then the map — taking the stored object rather than reading storage itself, which is what will let the IndexedDB move happen without touching any of this. And Crawler.painted, because glReady and "there is something on screen" are not the same thing and only the host cares about the difference. The containment tests follow the code: the id check now covers crawler.js, since its markup is injected into the GAME's document and its ids are the ones that most need to be free. Zero collisions on names, ids and CSS. Verified end to end in the game: mounts, paints, the party walks with the keyboard, the host's own buttons keep their styling, and leaving unmounts — render loop stopped, listeners dropped, container emptied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
§14's step 1. The renderer, the map model, movement and the crawl view move out of dungeon-builder.html into Modules/Dungeons/crawler.js and crawler.css, so the crawler that draws the Builder's preview can be the same code that plays a dungeon in the game. dungeon-builder.html drops from 5,000 lines to 2,350 and is now the editor and nothing else. A CLASSIC script, deliberately: verified earlier that <script src> executes from file:// while an ES module is blocked by CORS, and both hosts must open from disk with no build step. Its top-level names are therefore global — a real cost, paid knowingly. They collide with nothing in the game (measured), and the scoped test now holds crawler.js against text_adventure.html at zero so a future collision is caught rather than discovered. The module owns its own view. Crawler.mount(container, opts) injects the markup, finds its canvases, registers its listeners and starts the loop; unmount tears all of that down. Nothing is looked up until mount, so loading the file does no DOM work at all. The stylesheet travels with it, resolved from the script's own src, and every rule is scoped to .tlr-crawler — the same containment discipline the Builder's sheet follows, and now enforced for both. What the crawler cannot know goes through hooks, and there are only five: onMapChanged, onLevelChanged, onSaveNeeded, onStatusChanged, and the isBlocked / isEditing / isVisible predicates. The ~37 back-references measured earlier collapsed into those because most were false positives — x.restore() is the canvas context, not the editor's restore. Every hook defaults to a no-op, so a host wanting a viewer passes none. Three things went the other way, to where they belonged. stampingStair is a model flag that had been declared editor-side. The editor's keyboard shortcuts came out of the crawler's key handler, which had been serving both — the crawler now takes movement keys and nothing else. And play mode no longer reaches for #db-root: the module records the flag, the host hides its own chrome, because a module that names a host's element by id breaks in the other host. Verified by comparing the rendered result: every box and computed style in both tabs, before and after, identical except the paper doll that was already gone. Two real faults were caught that way — initGL ran before the canvas existed (mount now happens first in boot), and three canvas listeners were still registering at load against a null glCanvas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
It was a layout study: thirteen empty slots ringing a body silhouette, copied from the game's Character › Equipment screen, answering the question of where equipment would sit IF the crawler ever had a party. The game now has an Equipment sidebar block that answers it properly — same figure, same coordinates, but reading the game's own tables and showing real gear — and the sidebar is a SIBLING of the story view, so it sits beside the crawl overlay rather than behind it. With both on screen the placeholder was simply a second, emptier doll next to the real one. Three things go with it. The copied EQUIP_SLOTS and EQUIP_SLOT_ICONS. The drift test written to hold those copies byte-identical to the game's, which has no subject left. And fitCrawlColumn, which existed only to shrink the doll when a short window could not fit three blocks — measured afterwards at 1280x840, 900x640 and 820x520, the remaining two blocks fit with room to spare, so the code that made room is genuinely dead rather than merely unused. The collision surface against text_adventure.html is now zero on every axis, so test_dungeon_builder_scoped drops its allowance for the doll tables and asserts a flat zero instead — plus a new check that no copy of the game's equipment tables comes back, since re-adding one is the exact drift that file exists to catch. This is the first piece of §14's step 1: the paper doll is the one part of the crawler that the extraction makes redundant rather than portable, so it goes before the rest moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The Conditions list is where a DM reads a whole pattern at a glance, so each day-type now says what it DOES there, without having to be opened. Each row draws the effects in force for that day, resolved exactly as play resolves them: its own authored list, or — labelled "from <condition>" — the ones it inherits while it authors none. The label matters because an inherited chip and an authored one are otherwise identical, and they mean different things to someone about to edit that day. A day that deliberately imposes none draws no chips, which its row summary already says in words. Same chips as everywhere else: the markup a live condition wears in play, green for a boon, red for an affliction, muted for neither. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
While a day-type defers, its condition's effects ARE what applies — but the panel only named the condition and left the DM to go and look it up. It lists them now, as chips. Not a new chip style: the very markup a live condition wears on the Character sheet and in the sidebar, so an authored effect is read in the colours it will be felt in. Green for a boon or benefit, red for an affliction, and the stat summary and duration ride inside the chip as they do in play. A bare phrase is graded from its own words through the existing polarity lists, so "warmed" reads green and "poisoned" red without either being authored. Adds one class for the case those two colours leave out: a status that is genuinely neither — "marked", "glowing" — read muted rather than borrowing the affliction red. The Character sheet gave everything not-positive the red; green for positive and red for negative means neither should claim a neutral. The authored rows wear the same chip, so a day's own effects and the ones it would inherit read alike. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A condition's effects are the standing rule for every day of that weather. A pattern's day-type is "this specific day", so effects authored there REPLACE the condition's rather than adding to them — which is what makes the awkward case sayable: a rain day that does not drench. Merging would leave it unauthorable. Replace needs three states, not two, and the design turns on keeping them apart: no `effects` key means this day has no opinion and its condition's apply; an explicit [] means it deliberately imposes none, overriding the condition; a list means exactly those instead. So normalization never invents the key — emitting `effects: []` for every day-type would silently mute every condition's effects on every world that already has patterns. Authored in the day-type popup's new Effects section, which states which of the three it is in: with nothing pinned down it says so and names what the GM will weigh instead (the description, where the player is, what they are doing, their race, class and gear), so an empty section reads as a decision rather than an oversight. Emptying the list flips the message to "this day deliberately carries no effects", with a control to hand it back to the condition. Effects are authored through a small editor carrying the STATUS fields only — label, stat deltas, minutes, grade. Deliberately not the item-effect editor: that one is trigger-bound (onEquipped / onUse / onHit) and a weather effect has no trigger to author. The sky is the trigger, and whether it lands on this player here is the GM's call. The dossier now names the layer it is reading, so the GM can tell a deliberate one-day authoring from the standing rule for that weather. The GM's own pattern-authoring schema gained the field too, with the replace rule spelled out. Design doc and README updated: Decision J's authoring half has shipped; the engine still applies none, which is what the decision intended. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The full Character › Equipment screen is a TAB, so opening it costs whatever the Story tab was showing — and in a dungeon that is the dungeon. Checking what is in your hands should not close the corridor you are standing in. This is the same figure, small enough to live in the sidebar, between Inventory and Magic. It reads the game's OWN tables rather than copying them: equipSlotsForPlayer, EQUIP_SLOT_ICONS, EQUIP_BODY_IMG, equippedItemById, itemIconHTML. So a class that trades its shield for a spellbook is reflected here with nothing kept in step, and there is exactly one definition of each table in the file. That is also the point of the exercise — it is what lets the Dungeon Builder delete its placeholder copy and retire the drift test written to police it. Deliberately READ-ONLY. Every route that changes gear stays in the full screen, so there is one writer; the block's only interaction is clicking through to it. It uses its own eqm- class prefix rather than reusing .equip-slot, which carries drop-hover and compatible/incompatible states from the drag system that a read-only view must never pick up — but the COORDINATES are the shared table's own percentages, so the two dolls superimpose exactly. Nothing else was needed to make it a first-class block: the ☰ visibility menu builds itself from the live sections, so it appears there automatically, and SIDEBAR_SECTION_ORDER gives it a default slot after Inventory. The refresh call in updateSidebar is wrapped, because updateSidebar is central and a throw there would take the whole sidebar down over a decorative block. Verified in place beside a running dungeon: visible, to the right of the crawl overlay rather than behind it, tracking the loadout, with the Story tab still active. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
I first wrote this up as a removal, on the reasoning that integration gives the party a real Character › Equipment screen so a placeholder doll is redundant. That was wrong, and the correction matters: reaching that screen means tabbing off the STORY tab, which is where the dungeon is. Walking a corridor and wanting to check what is in your hands should not cost you the view. So the panel moves rather than goes. The crawler drops it; the main app gains an equipment sidebar block — a mini view of what is currently worn, beside the dungeon rather than behind it. Verified that this works: the sidebar is a SIBLING of the story view, not inside it, so it stays fully visible under the crawl overlay alongside portrait, wealth and inventory. And it earns its place outside dungeons too; a glanceable equipment view is worth having in a tavern. Adding a block is an established operation rather than a new mechanism. SIDEBAR_SECTION_ORDER lists them, each has per-block visibility in the menu and can be dragged into any order, and the registry's own comment anticipates exactly this: "a newly-added block appears in a sensible place rather than at the very end." The duplication dies either way, which is the real prize. A sidebar block reads the game's own EQUIP_SLOTS and EQUIP_SLOT_ICONS, so the Builder's copies go, the drift test written to hold them byte-identical retires, and the last two entries in §14's collision table clear — zero across the board. Recorded in §8, in §14's table and order of work, and as a roadmap item in §15. Also fixes a pre-existing bug this doc carried: it used the .phase-tag class for roadmap chips without ever defining the rule, which every sibling design doc does carry, so the chips rendered as plain text. Copied the siblings' rule in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
§14 had the inside of a dungeon planned and the door into it left vague. This settles it, and the answer is the boring one: the entrance is a first-class room, outside the dungeon, named as its own place — "Dark Crypt Entrance", "Ominous Stairs Down". Described, visited, banner-painted, weathered, able to hold occupants and items and a locked gate. Structurally it is the pattern the world model already has, an inn's common room listing the rooms upstairs through interiors; an entrance is that relationship with a different inside. Because it is plainly its own place with its own name, the ownership rule states itself: the dungeon owns the dungeon's name, the entrance owns the entrance's name. Neither copies the other, so neither can go stale. What marks it is one own prop on the room, stored the way `interior` is, from which the game DERIVES a `down` exit labelled Descend that opens the crawl overlay. Three measured reasons this is nearly free: `down` is already first-class and paired with `up` in DM_OPPOSITE_DIR, so the return leg keeps working and Descend is a label rather than a new direction; the connectivity walk already guards `if (to && w.rooms[to])`, so an exit with no room target is skipped by the map and every other traversal WITHOUT an exception written anywhere — which is the whole reason the entrance can be ordinary; and the GM sees room.exits, so a derived Descend reaches the dossier and can be narrated. Two things recorded because they would bite if left implicit: derive the exit at read time rather than storing it, or clearing the flag strands a phantom Descend; and decide what happens when an entrance also has a real `down` to a cellar. The symmetry is free either way — the exit stair's `room` field already names a room id, so it names the entrance, and one door serves both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The section asked "how do we merge two apps" and the answer turned out to be don't. Extract the one piece both need — the crawler — as a shared classic script, leave the editor a separate app, and let the game render the crawler natively over the Story tab. Retitled from "what it would take" to "the plan", because it now is one. That shape dissolves most of what the old section worried about, and the reason is worth stating plainly: every one of those problems came from putting the EDITOR in the game's code space. A 60,000-line file, a mouse-only editor inside a touch-capable app, the Builder's service worker outliving its own absorption, Ctrl+Z hijacking the command box, a 400 ms autosave inside a game that writes session snapshots, and a detached editor that could not open cold because a detached window still restores a session to clear the login overlay. Sharing only the crawler removes all of them at once. The detach mechanism is still described, as the road not taken. The three questions the old section left open are answered. A dungeon square becomes a PSEUDO-LOCATION: not a room, but a structured frame for the GM's dossier — what the party faces is what the GM is told. This fits far better than expected. buildSystemPrompt uses the room in only about eleven places, and the mapping is natural: exits are the passable faces, items obey the existing "a closed container's contents stay concealed" rule, and secrets land on the GM-eyes-only convention the prompt already has for traps. Three rules make it work, all decisions rather than code: the frame replaces the room block rather than augmenting it, the GM does not own movement, and narration happens on player input rather than per grid step. The map moves to IndexedDB, which is what the game's own save already did for this exact reason — 2.19 MB measured for one dungeon's art. Verified that this does not cost the editor its standalone life: IndexedDB works from file:// and is shared across pages there. What it costs is the storage event, which is what refreshes a card's Layout line live; BroadcastChannel replaces it, verified across windows and across an iframe in both deployments. The GM speaks in a transparent panel over the dungeon, bottom-left, and the command prompt comes back. The two input surfaces are separated by FOCUS: WASD only while the dungeon view holds it. That is stricter than what the crawler does today — a window-level listener that merely skips text fields, which would let a click on a sidebar button leave W walking the party — and nothing in the crawler is focusable yet. Recorded with its real cost: a strict focus rule needs a visible focus state, or it just reads as dead keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
A "Still unaddressed" subsection in §14. Everything already there is about getting the code into one file; this is what would remain broken once it was. Split by confidence — seven items verified against the source, two reasoned but unchecked — because a design doc that does not say which is which invites the guesses to be trusted like the measurements. The one that matters most is not a mechanic. The party's location is a ROOM ID, and 43 places depend on it; rooms carry music, ambient, occupants and descriptions, and describeRoom(currentRoom()) is what feeds the GM. While the party is below, currentRoom still names the room they left — wrong occupants in the sidebar, wrong music playing, wrong place described. The frame hides that today because the crawler is visibly separate. Integrated it becomes a lie the app tells itself, and the question it forces is whether a dungeon square is a first-class location at all. Sharpest of the rest: only a DM can currently reach the crawler, because Enter lives on a card inside a tab whose DOM is torn out for plain players — the player-facing half is behind a DM-only door. A detached Builder could not open cold, since a detached window still restores a session to clear the login overlay, which would end drawing a dungeon before a world exists. And the Builder keeps maps plus base64 tile art in localStorage — 2.19 MB measured for one dungeon — which is precisely what drove the game's own save to IndexedDB. The verdict is rewritten rather than softened. The merge is tractable and steps 1 and 2 took the mechanical risk out of it; what is not tractable is what the merge would be FOR. Moving files first would only mean arriving at the location question with a bigger file to change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
A condition's authored effects reached the chip tooltip but never the GM, so a DM
could author one the GM was never told about — visible to the player, invisible
to the thing that would apply it.
They go in the dossier now, worded the same way the rest of the game words a
status ("drenched (DEX -2), CON -1"), and framed as the DM's INTENT rather than a
verdict. The GM is told it may fold them together with anything else the day
warrants, temper them, or leave one out when the situation negates it — sheltered
indoors first among those, since that is the common case. That keeps Decision J's
split intact: the DM says what the weather is meant to do, the GM decides whether
it lands here, and the engine only owns how a status behaves once it exists.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomSteps 1 and 2 of the migration path in Designs §14, worth doing whether or not the rest ever happens. Nothing about the app changes; what changes is that it can no longer reach out of itself. `player` is `party` now — a character in one file and a camera in the other was the one genuinely dangerous collision, because a missed reference would read the wrong object rather than throw. `party` is also the word the comments already used throughout, so the code has stopped disagreeing with its own prose. resetPlayer followed it, and activeTab became builderTab. 132 references. The CSS turned out to need more than the doc promised. "Prefix the generic class names" would have left the stylesheet styling bare `button`, `input` and `kbd` — enough to restyle every control in the game. So all 153 rules are scoped under a single #db-root element wrapping the app, with exactly one deliberate exception: the standalone page opts into `body.db-page` for its own margin, and a host embedding the app simply does not set it. The nine colliding element ids are prefixed as well, since scoping CSS does nothing about two elements sharing an id and getElementById silently returning the first. The collision surface against text_adventure.html is now zero element ids, zero escaping CSS, and two top-level names — the paper doll's tables, which a merge is meant to collapse into one rather than rename. Verified by measuring the rendered result rather than reading the diff: every box and computed style across both tabs, before and after, identical. That is how three silent no-ops were caught, all the same shape — a string replacement that matched nothing because the scoper had removed a space, and, worst, `:root` scoped to `#db-root :root`, which matches nothing at all. Every colour variable quietly became invalid; the app still booted and the layout collapsed. Every replacement asserts on its match count now. A new test proves containment directly, by pasting the stylesheet into a page styled to clash and checking the host survives it. Another records the doll tables as the one pair of names allowed to stay. Also settles the read/write question §14 had left open, per the actual design: detached tabs are read-only because they all write the same world data, and the Builder is not in that position — it owns its store completely and takes only read-only copies of anything from the world. So a detached Builder writes freely because nothing else writes what it writes, and the in-window crawler never has the problem at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
A new §14 on removing the frame, and honest about it rather than encouraging. Starts with what the wall actually blocks, concretely: combat, loot, the GM's voice, character state, saves, the world clock, audio, and the paper doll that is already drawn and cannot reach the gear sitting in the other document. Some of those can be pushed across one message at a time — the exit already is — but the list does not converge, and combat is not a message, it is most of the game. Then the detach pattern, because the instinct behind the question is right and worth stating precisely: a detached tab is THE SAME DOCUMENT re-run with ?detach=, with its DOM torn out of the main window. Nothing is duplicated because there is only ever one copy. Applying that here inverts today's relationship — the crawler would live in the game, and the standalone Builder would become the spawned thing. One wrinkle recorded: tab viewers are deliberately read-only mirrors, and a crawler must write, so its precedent is IS_DETACHED_EDITOR rather than DETACHABLE_TABS. The cost is measured rather than guessed, which changed the conclusion. Of the Builder's 233 top-level names only FOUR collide, and two of those are the paper doll's deliberate copy, which merging would delete. Nine element ids collide and all are chrome that play mode already hides. Eighteen CSS variables are shared and every one is identical in value — not a collision at all, the same palette twice. The game has no WebGL context anywhere. Verified separately, because the recommendation leans on it: a classic <script src> executes from file:// while an ES module is blocked, so a shared crawler file needs no build step. The verdict is that the frame is not a wrong turn to undo. It is a working preview, and it should stay until there is a concrete reason to cross the seam, because everything worth integrating for is new design rather than relocation. Two of the five migration steps — renaming `player`, scoping the CSS — are worth doing whether or not the rest ever happens. Also caught up three things the doc had fallen behind on and that this section cites: the Enter flow and play mode were undocumented, the paper doll and the shared crawl column were undocumented, and §5 still described the iron lock plate that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The chart drew floors, walls, doors, torches and switches, but a flight of stairs looked like ordinary floor — so the one feature that moves a party between levels was the one thing they could not find again on their own map. Drawn in the editor's own colours, blue down and green up, with the chevron pointing the way the steps run under the same OPP[stairDir] rotation. Reading the two maps should not mean learning two schemes. One chevron where the editor draws three: a minimap cell is a handful of pixels and three would be a smudge. The tile wash is the same hue but carried harder, since here it sits on the lighter floor tone rather than the editor's near-black. Two placement rules, both load-bearing. The glyph goes inside the `seen` guard, so a stair the party has not charted stays hidden — the minimap is what they have found, not what the author drew. And it goes before the wall pass, so an edge line stays crisp over it instead of being cut through by the chevron. The palette now lives in two canvas routines that must agree, so a test pins them: retinting the editor alone would leave the chart quietly showing the old colours, which reads as a different kind of stair rather than the same one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Facing a wall, Space said "No door here." and did nothing else. It now searches. That second half matters more than the convenience: a secret passage and a hidden switch are both drawn as plain wall, so a dungeon only gives up what an author hid in it to a party that searches. Making them reach for a second key to do the thing the dungeon is built around meant most walls never got touched. A door ahead is still opened, locked or shut or already open, with the same words as before. Everything else — plain wall, torch wall, secret, switch, and an open doorway that has nothing to find — goes to searchAhead, which already had a sensible answer for each. The pad's OPEN button is deliberately NOT routed through this. A control labelled OPEN should open doors and say so when there is none; only the key is contextual. The two now run different actions, which is the point, so a test pins the split — pointing them back at one action is the obvious later "simplification". F still searches explicitly, and both copies of the key hint stop claiming Space only opens doors, since that hint is the only place the crawler documents itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Leaving a dungeon read "You climb out of Dark Crypt, into bell_tower_market." An exit stair stores a room ID, because that is what identifies a room, but narration wants the room's name. Resolved through world.rooms at the moment of narration rather than stored, so renaming a room does not strand the note. Three ways it can fail to resolve, all falling back to the id: the id is typed by hand in the Builder, which has no list of this world's rooms to offer and so can easily name one that does not exist; a room may carry no name; and no world may be loaded at all, which is the state the app boots in. In each case the id is all there is, and saying it beats saying nothing — it also tells the author which id failed to match. The Builder's own transient message still shows the id. It has no room list to resolve against, and for an author testing the wiring the id is the useful thing to see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
A dungeon card gains an Enter button, left of Edit, which opens the party's view of the dungeon over the Story tab. The narration is simply behind it. That view IS the Dungeon Builder's Game tab. The Builder is embedded in an iframe with ?mode=play, which strips it to the crawl view. Embedding rather than porting was the whole architectural question, and the measurements settled it: the frame is same-origin over http, and although every file:// URL is its own opaque origin, the three things this needs work in BOTH — localStorage is shared (so the frame finds the map the game wrote), postMessage crosses either way, and WebGL renders inside the frame. So nothing reaches into the frame's document; everything goes by message. The result is one crawler instead of two, which is the difference between a few dozen lines here and a second WebGL renderer that would drift from the first. Play mode is READ-ONLY. Walking around is not authoring, and a player's session has no business rewriting what the author drew, so persist() is a no-op there — verified by scribbling over the grid and confirming the stored bytes are identical. Enter is offered only once the dungeon can be left again: a way in with no way out is a trap. A disabled button says WHICH half is missing — nothing drawn yet, or stairs up with no room set. Leaving works two ways, by the Leave button or by walking out of an exit stair; the stair announces the room it climbs into but does NOT move the party, since travelling there for real has to answer to save state and whatever a room does on arrival. That is a separate, deliberate step. While the party is below, the command prompt is disabled and says so, so there is one input surface at a time. Two things found on the way. The Builder disagreed with ITSELF about a room id of nothing but spaces: leaveDungeon trimmed before deciding it had somewhere to go, but exitStairs did not, so it called a dungeon exitable that would then refuse to carry anyone — and the game, which trims, would not offer Enter at all. Both now trim, and tests/test_dungeon_exit.js drives the Builder's predicate and the game's off the same maps to keep them agreeing. Second, a cold frame is a black rectangle for as long as WebGL takes to warm up, so the Builder now reports a PAINTED first frame and the host holds a "Descending…" veil until it arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Adds a centered copyright notice on its own rule-separated line beneath the existing footer row, in 10px monospace at reduced opacity so it reads as fine print rather than competing with the tagline above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eik
Adds a studio link opposite Contact, pointing at braveyouworlds.com and opening in a new tab like its counterpart. Dropping it straight in as a second absolutely-positioned chip collided with Contact on a narrow phone — at 320px the two overlapped mid-word. So the pair now sits in one flex bar spanning the hero's top, spaced apart rather than each pinned to its own corner, and their shared styling moved out of inline attributes into a .corner-link class. That buys a real :hover rule instead of the style-hover attribute, and a media query that tightens the type and padding below 520px so both fit a small screen with room to spare. The bar itself is click-through, so it never steals a press from the hero beneath it. Checked at 1440, 390 and 320px: balanced on desktop, clear of the eyebrow and title on both phone widths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eik
Adds a Contact link in the hero's top-right corner, pointing at the Jira Service Desk customer portal. Styled as a small monospace chip in the page's own gold idiom, matching the scroll cue and the day/night labels rather than introducing a new treatment, and sat above the background layers so it reads against the artwork. Opens in a new tab so the landing page isn't lost on the way to raising a ticket. Checked at desktop and phone widths — it clears the eyebrow row and the title at both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eik
Hovering the current-weather chip in the story said what the sky looks like. It
now also says what it is doing to you.
A weather condition record has always carried an `effects` array — reserved when
the weather engine was built, round-tripping through save and export ever since,
but never read by anything. A world that authored one had no way to see it.
The shape was never pinned down, so the reader accepts the three the rest of the
app already speaks: a bare stat adjustment ({ stat, delta } → "DEX -2"), a full
status entry ({ label, effects } → "drenched (DEX -2)"), and a plain phrase. It
reuses statusEffectSummary for the stat part, so weather effects are worded
exactly like every other status in the game. Anything it cannot read renders as
nothing — an "[object Object]" in a tooltip is worse than silence — and a
condition with no effects gets the tooltip it always had, with no empty
"Effects:" line.
The header's weather readout shares the text. It is the same sky, so the two
must never describe it differently.
The effects go on their own line, which needed white-space: pre-line on the
tooltip body. That only changes text that carries a newline, and nothing else in
the app does.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomGenerating a picture for an item left its Compendium card showing the old one. Beings have propagateEntityPortrait, which OVERWRITES the discovered snapshot on the grounds that painting a portrait is deliberate. Items had no equivalent — only the fill-when-blank backfill. The card takes a copy of the picture when the item is discovered, so from the first picture onward every later one had nowhere to land. The lookup was wrong too, and a magic weapon is exactly the case that exposes it. An item's category is computed from its CURRENT type and enchantment, but the card was filed when the item was discovered: an enchanted weapon resolves to Magic today while its card may sit under Items — catalogued before it was known to be magic, or simply filed there by the GM, which chooses the category itself. Searching only the computed category finds nothing at all. So propagateItemPortrait overwrites, and searches every category an item can be filed under. It matches the apparent name as well, for something catalogued while unidentified, and updates every card for the item rather than the first — the GM can catalogue one thing twice. Uploading a picture takes the same path; it is no less deliberate than painting one. The popup's regenerate button also refreshed only the popup, so the Compendium behind it — often the surface the popup was opened from — kept the old picture on screen. It refreshes every surface now, as the popup's Generate button already did. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Unchecking Pin Room left the story part-way up rather than at the newest message. renderNarrativeWindow scrolls once, synchronously, at a moment when the images it has just mounted still measure zero height — so the scroll targets a container that is about to grow. Turning the setting OFF is the worst case for that. The window widens from the current room alone to the last NARRATIVE_WINDOW messages, and every in-story room title and banner that the pinned head had been suppressing becomes visible again. All of that height arrives after the scroll, and it puts the view far enough from the foot that the per-image re-scroll gives up too — that one only chases the bottom from within 200px of it, deliberately, so a late load cannot yank a reader who is looking at older text. So the toggle now pins through the settle with scrollNarrativeToBottomSoon, the helper the restore path already uses for exactly this. It yields the moment the reader scrolls up, so holding the foot for a settle window costs nothing. Measured in a browser on a container with the same shape: a single synchronous scroll finished 1400px short of the bottom and stayed there; pinning through the settle finished at 0. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The lock is built into the door, part of whatever art the door carries, so a locked door and an unlocked one now render identically. There was an iron plate at handle height, with a boss on it. It had no texture of its own: it borrowed the wall's stone tinted almost to black, which read as ironwork only for as long as the wall happened to be dark. Against uploaded art it was plainly a rectangle of masonry stuck on a wooden door. That it looked fine until now was the texture-persistence bug hiding it — uploaded art was being wiped on every reload, so the plate kept drawing with the default dark wall. Fixing persistence is what made it visible. The plate geometry, its two batches and their tint pass are gone; uTint stays, since the switch button is its real user. Nothing about the lock MECHANIC changed. That a door is locked is still told in the places that can say it without painting on the art: the party is told when they try it, the chart draws a locked door in its own colour, and the editor marks it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Uploaded tiles kept vanishing: they showed on screen, then a refresh or two later the plain drawn defaults were back and storage held no art at all. Closing the window and relaunching brought them back — until it happened again. persist() saves whichever slots say `custom` at the instant it runs. Restoring art means decoding a data URL, which is asynchronous, and the `custom` claim was being made in img.onload — so there was a window right after boot where storage was full of art and every slot read as default. A save landing in that window wrote an empty `tex` and destroyed the art permanently. Measured at one frame for a small tile, and far longer for megabytes of real uploads. It was being hit for real rather than theoretically: the host posts the dungeon name the moment the Builder window opens, and that path calls persist(). Hence the pattern — a fresh launch reads intact storage and looks fine, the name arrives and empties it, and the next reload comes up bare. The art is now claimed synchronously, before the load handler is even attached, so a save in the gap rewrites exactly what it just read. Only `source` waits for the decode, since only the GL upload needs pixels. A side effect is that the sidebar thumbnails are right immediately, since they already preferred dataUrl. Two smaller silences alongside it. Art that fails to decode now releases its slot instead of holding a claim that can never be honoured — otherwise one bad entry keeps a tile permanently blank. And running out of storage room says so rather than dropping every custom tile without a word, which is what made this look like the upload had simply never happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The doll is a LAYOUT PLACEHOLDER and nothing more: the Builder has no party — its `player` is a camera — so all thirteen slots draw empty and inert, with no drag targets and no click handlers. The point is to settle where equipment sits before the crawler moves into the main window and has real gear to show. The slot ring and glyphs are copied verbatim from the game's EQUIP_SLOTS / EQUIP_SLOT_ICONS rather than shared, because this file must still open on its own from file:// with no build step. A copy drifts, so a test lifts both tables from source and compares them position by position — it caught two glyphs I had truncated while copying. The three blocks of right-hand furniture are now literally one column: chart, doll and pad are children of #crawl-ui, all taking their width and margin from it, so they share an edge by construction instead of by three coinciding numbers. The minimap matches its backing store to that width each frame, so a wider column paints a crisper chart rather than scaling 150 pixels up. A short window no longer makes them collide. The column is anchored top and bottom, so fitCrawlColumn can see it overflow; the doll is the part that gives, narrowing proportionally (width drives height, so the ring stays on the body) and folding away below the point where the slots stop being legible. Opening it by hand overrides that floor — asking to see it in a 640px window shrinks it rather than snapping shut under the click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The four switches that change what the story SHOWS — Show Weather Imagery, Interior Weather Banners, Art Style Weathering, Banner Videos — were sitting in Story among the reading preferences, where they read like prose settings rather than the image generations they each cost. They get their own Imagery section, directly after Story. Pin Room goes the other way: it decides what the Story tab shows, so it belongs with Story rather than with the app-chrome switches in Interface. Markup only — every setting keeps its id, its key and its onchange. Adds tests/test_settings_sections.js, which walks the panel and maps each switch to the section title above it. A setting's section is decided by nothing but where its markup sits, so this is the only thing that can catch one drifting into a neighbour. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Logging in or refreshing now ends the story with where the player is standing — title, banner, description for the time of day, and the lines listing what is there — every time, with Pin Room on or off. It used to be written only when the scene looked like it was missing from the mounted story. Three separate mechanisms hide it: the story mounts a trailing window, the message log is capped, and Pin Room moves the mount floor. Deciding which one had applied was the whole bug, and it is gone — there is no decision left to get wrong. The one thing it still will not do is stack a second copy on an identical one. If the story already ENDS with this room's scene — a refresh moments after arriving — that entry is replaced rather than appended to, which also refreshes its banner and weather chip to the current sky. The write also moved AHEAD of the mount, so fewer steps have to succeed before the scene is in the log: it goes in first, the window mounts ending on it, and the pin floor derives from it. Also unpins test_pin_room's restore check from the wording of a code comment — it asserts the ordering now. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
It came out because the current-weather readout sits on the same row and carries a full-colour emoji, and two coloured marks side by side clashed. So this one is a line drawing in the label's own colour at 72% — quiet enough that the weather glyph stays the only coloured thing in the header, and the icon reads as part of "AFTERNOON" rather than competing with it. Six shapes, told apart by gross form because they are read at 13px: a sun on the horizon rising or setting (the chevron says which), a sun behind cloud, a sun high with rays, a crescent over the horizon, and a crescent alone in the sky. The emoji table stays where it was — the Editor's banner slots, the art-review rows and the Rooms time strip all want the colour version. The header reads from a separate drawn set, and only repaints when the time of day actually turns, since the tick runs every second. Also unpins a character-distance regex in test_weather_ui: it measured 1600 characters from renderClockDisplay's name to the weather lookup, so adding lines to the same function broke it with nothing changed. It reads the function body now. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Three things, all on the admin page; the key API itself was correct throughout.
A configured card's status badge was `class="state set"` and the Set button is
`class="set"`. The badge comes first in the card, so `card.querySelector('.set')`
returned the BADGE — the click handler bound to a status pill, and the button did
nothing. It bit on every replace, and on every re-enter after the master key
changed: the first Set of an unreadable card lands, the card re-renders as
configured, and from then on the button is dead. The badge is `.state.ok` now,
and the handlers ask for `button.set` / `button.remove` so the two cannot collide
again.
Remove was gated on `configured` alone, so the one state where you most want to
clear an entry — present but undecryptable — was the one that could not. It is
enabled for stale too, and the field now says "Re-enter key…" there.
And the outcome of a write is reported after the list is rebuilt rather than
before, so "Saved." survives the re-render instead of flashing and vanishing —
which is what made a working Set look like a broken one.
The binding loop is also scoped to `#cards`: the Access panel renders `.card`
too, its cards carry no Set/Remove, and it sits above #cards in the document, so
an unscoped query threw on the first access card and left every key card
unbound. A guard skips odd cards rather than taking the rest down.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe handbook had fallen behind the game on several player-facing systems, and in two places said the opposite of what the game now does. Three new chapters, all drawn from the Field Guide: - Ch. 7 "What You Wear & Wield". The Equipment subtab was named in Chapter Five and then never explained anywhere. It covers the eleven body slots, the class slots a world adds on top (a Mage's Spellbook, where a field spellbook always settles), the rule that an item fits exactly the slots it names — a dagger being both Weapon and Sidearm while a two-handed sword is weapon-only — and why it matters: equipped armour sets your AC base and the equipped weapon supplies your damage dice, neither of which carried gear does. - Ch. 10 "Abilities". The book had one bullet for a whole gameplay lane. It now has the distinction the system is built on: traits you *have* rather than *have learned*, situational by consequence, owned by their source so they cannot be levelled — with a table separating skill from ability from status effect. - Ch. 11 "Saving Throws". Previously a passing clause in the combat chapter. Saves are always the player's to roll, the engine does the whole calculation off the live sheet, and ability bonuses fold in automatically — worth its own chapter, and it follows Abilities because that is where those bonuses come from. Corrections to what was already there: - Combat said a weapon's Damage stat was "set to feed the numbers directly in a later update". It feeds them now; the Player Damage Rolls setting (off by default) decides whether the player throws them. - The Journal listed three working subtabs. Legends is the fourth — one dated world-voice entry per resolved arc, carrying its outcome, renown, and clickable spoils. - The login model list was missing Claude Opus 5, and asserted a key is always required; on a realm that holds the key for you the field is absent and the model choice is bounded by its keeper. - Skills gained the class-gated book refusal (the book is not consumed). - Settings gained the two roll toggles the book referenced but never listed. Chapters renumbered 7-20 to fit the insertions, with the contents page, roman numerals, and the two in-text chapter cross-references updated to match. Quick Reference gains saving-throw and equipment-slot entries. Verified by rendering: all 20 chapter heads in order, no page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eik
The button that opens the full inventory was a ▦ glyph. It is now a drawn cog, in the same house style as the toolbar icons. Beside it, to its left, sits a shirt that jumps to Character › Equipment. The pair reads as one idea from the Inventory block: gear you carry on the right, gear you wear on the left. The shirt is the same glyph the paper doll already draws in its shirt slot, so the sidebar and the Equipment tab agree on what a worn item looks like. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A save carries its generated art inline, as base64 data: URIs. The 441 MB playthrough we just shrank is the grounding: banner references and a bounded weather cache took it to ~62 MB with no server involved, and what is left is media that has a stable identity and no reason to be duplicated — item icons stored three times over (catalog, pack, floor) at 18.3 + 5.7 + 4.0 MB, and 13 MB of sound across three files. Writes up storing those bytes in the vault, content-addressed by sha256, and carrying references in the save instead. The addressing dedups for free, makes writes idempotent and lets URLs be cached forever. Portability is the decision that shapes everything else — an exported save is one self-contained file today — so export rehydrates references back to inline bytes; Direct mode is unchanged. Proposed only: 7 open decisions, 4 phases, and a recommendation to target sounds and item art first rather than weathered banners, which are ephemeral. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The weathered-banner cache is a CACHE, and was not being treated as one. Each entry holds a full weathered image - a few megabytes - and there was one per room ever weathered, kept for the life of the world, in memory AND in every save. Three rooms came to 9.4 MB; a forty-room realm would have carried well over a hundred, and it only ever grew. It is now bounded to the three most recently used rooms (enough that stepping between neighbouring rooms still hits), and it is no longer persisted at all. A restore starts empty rather than rehydrating a save full of images, so an old save's copies are discarded instead of being pulled back into memory to be thrown away the moment the sky moves. Nothing is really lost. On resume the room the player is standing in re-weathers to the CURRENT sky, which is the one they should be looking at; a saved entry would as often as not be stale by then - the weather drifts, the hour turns - and be replaced by a fresh render anyway. A stored scene that referenced a weathered sky falls back to that room's art for the hour it depicted, which is the same fallback it already had once the cache rotated. Cost: one re-weather on resume when the sky and hour happen to be unchanged, which used to come free from the cache. test_weather_imagery asserted the old contract directly - that the snapshot persists the cache and the restore rehydrates it - and now asserts the new one. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Regenerated from the full git history (1754 commits, 30 days active) to pick up everything landed since the last snapshot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcmUYhnky2EyAApNcJVztK
The hero offered only the Field Guide, so the two book-length references had no way in from the front page. Adding them to the existing button row made four buttons that wrapped 3-and-1 at desktop width, orphaning the Dungeon Master's Guide on a line of its own. Split the row instead: Play Now stands alone as the primary call to action, with the three reading references centered beneath it. That reads as one action and three references rather than four peers, and it balances at every width — one row of three at desktop and tablet, cleanly stacked on a phone. The secondary buttons drop a point of type and a little padding so all three fit one line inside the hero's 860px column. Links use bare filenames to match how the Field Guide is already linked; the books are served alongside it rather than from Handbook/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eik
A story message is HTML, and a room banner is written into it as <img src="...">. When that src is a base64 data: URI - which every weather re-skin produces - the whole image is copied into the message and saved with it for ever. A player's 441 MB save was 377 MB of exactly this: roughly 110 weathered PNGs frozen into the log. The weathered CACHE keeps one per room, overwritten; the MESSAGES kept every historical one, because each new room message gave the next re-skin a fresh target to bake into. A long stay in one room made it worse, and so did "look" writing a room message each time. A stored banner now keeps only what IDENTIFIES its image - the room, the hour it depicted, and the sky it was weathered under - and the pixels are resolved back when the story is mounted. Stored lean, mounted whole: nothing on screen changes. addMsg stores the reference; the live node it mounts still has the image patchLiveRoomBanner records WHICH sky the scene shows, instead of baking in the PNG messageNodeHTML resolves references back to real images at mount time What is lost is the exact historical weathered image once its cache entry is gone; the scene still shows that room at that hour. Those pixels were always regenerable and were never worth a megabyte each. A migration pass runs on load and frees the bytes from any story written before this, so an existing save shrinks on its own rather than only new play staying lean - the reported save should fall from 441 MB to roughly 60 MB. The strip/resolve regex is attribute-order tolerant on purpose: the reference marker is added ahead of `class`, so a pattern insisting on class-first would match the original markup and then never match its own output again. It did exactly that on the first run. test_weather_imagery asserted the old behaviour directly - that the weathered data URI is baked into the log - and now asserts the reference contract instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The first run of this tool on a real save credited 377 MB - 85% of the file - to "messageLog[].html" as plain text. It is not text. A story message is HTML, and a room banner is written into it as <img src="data:image/jpeg;base64,...">, so the bytes sit INSIDE a string that does not itself begin with "data:". Counting only whole-value data URIs missed where the weight actually was, which is the one thing this tool exists to find. It now scans strings for embedded base64, attributes those bytes to their field and mime type, and reports them in their own section with a copy count - which is the number that matters here, since the same banner is stored once per message that shows it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A save carries its generated art INLINE as base64 data: URIs, so a world with a
lot of painted rooms reaches hundreds of megabytes with nothing actually wrong.
Knowing that is not the same as knowing WHICH fields hold it.
tools/analyze-save.js walks a save (or a world export) and reports the bytes by
field path, the inline media totalled by mime type, and the heaviest individual
values. Id-keyed maps collapse to {} so rooms aggregate into one line per banner
slot rather than one line per room - otherwise the top of the table is a single
room's six slots and the shape of the problem is invisible.
Read-only; it changes nothing about how saves are written.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomSettings > Story gains "Disable Auto-Save". Off by default. When on, the game stops writing itself to browser storage. The line it draws is between automatic and asked-for. Everything automatic stands down: the per-turn saves, the story checkpoint, the post-restore checkpoint, and - the one that actually matters - the page-leave handlers that fire on refresh and tab close. Leaving those in would have made the setting close to meaningless, since a refresh is exactly when an unwanted write lands. saveGameStateNow() takes an `auto` flag for those callers; a save the player asked for passes nothing and always writes. Two saves are therefore still honoured: the toolbar's Save Game button, which is the escape hatch the whole setting depends on, and logging out, which is an explicit end of session where silently discarding the game would be a nasty surprise. The draft editor is exempt entirely - it authors a world rather than playing one, so its writes are the document, not an auto-save. Turning the setting ON takes one final save first, so there is a clean point to fall back to rather than losing whatever was already unwritten, and says plainly in the story what now saves and what is at stake. Turning it back off checkpoints at once. It also cancels any write already queued, so a save scheduled a moment before the toggle cannot still land after it. test_detach_tabs and test_save_debounce_and_cap pinned the old saveGameStateNow() signature; updated to match. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two fixes, plus a durability one found on the way. "look" re-describes the room the player is already standing in, but it ran the full arrival path - so every "look" counted another room entered, and the first one another place visited. describeRoom now takes arrival:false for a look: same scene, no tally, and none of the other arrival side effects either. The missing description after a refresh was the trailing mount window, not the message cap. The story mounts only the last NARRATIVE_WINDOW messages, so a room held for a long conversation leaves its scene above that window - present in the log, reachable only by scrolling up. Pinned, the pinned head still paints from the room itself, which is why it read as a title and banner over a blank story; unpinned, it was conversation with no scene in sight. The earlier fix for this was gated on "Pin Room", which was wrong - the window applies either way - so it is now ungated and both modes get the scene back. The save cooldown also deferred STORY appends by up to twenty seconds. A refresh inside that window loses them: an async IndexedDB write started as the page tears down cannot be relied on to finish, which is why the leave handlers exist at all. Appending to the story now schedules a cooldown-exempt save, still debounced so a multi-message turn writes once. Urgency is sticky across the pending window, so an ordinary save arriving behind one cannot push the story write back out to the full cooldown. The cooldown still governs everything else, which is what it was added for. test_look_command and test_save_debounce_and_cap pinned the exact source lines both changes touch; updated to match, and test_look_command now also covers the arrival distinction itself. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Settings > Story gains "Art Style Weathering". Off by default. When on, the world's Art Style is prepended to the image-to-image EDIT prompt used to re-weather a room banner. It is off by default because a re-weather edits a banner that already carries the world's look, so the instruction is deliberately style-free - restating the style invites the model to repaint the scene rather than re-sky it. The setting exists for worlds where the weathered banner drifts off-style anyway. EXTERIOR rooms only, as asked, and the reason is the same one: an interior re-weather is the narrow "change only what the window reveals, leave the room untouched" instruction, and leading that with a full scene style is the surest way to get the room repainted. Interiors stay style-free whatever the setting. Styling goes through withWorldArtStyle - the same helper every other generated image uses - so a style-less world still gets the game default, and editing a world's Art Style moves its weathering along with everything else. The setting is part of the weathered-banner cache identity, or toggling it would keep showing the banner weathered under the old prompt until the sky happened to change. A cache record written before the field counts as unstyled, so existing saves do not re-generate a banner they already have. test_weather_imagery pinned the exact source line the clause was built on; it now checks the clause selection and the new wrapper separately. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
"Pin Room" shows the current room alone: the title and banner sit static above the story, and the story below is mounted from that room's entry message - which is what carries the description and the lines listing what is in the room. On login that entry is not always there to mount. The story mounts only a trailing window, and renderNarrativeWindow takes the LATER of the pin floor and the window start, so a stay longer than the window pushes the entry above it. A longer session drops it entirely, since the message log is capped. Either way the pinned head still paints - it renders live from the room, not from the log - which is why this reads as a title and a banner over a blank story. The scene is now re-emitted on resume when it is not in the mounted window, and only then: logging in with the room block already on screen leaves the story exactly as it was rather than stacking a second copy. roomSceneHTML() is split out of describeRoom so the scene can be written without the arrival side effects. Entering a room is a game event - it counts toward rooms entered and places visited, moves NPCs onto their routines, restarts the room's music and runs compendium discovery. Re-showing a room on login is not, and a test holds that line. With Pin Room off nothing changes: the full scrollback already mounts, so the description is where it always was. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
§11 had already been rewritten by the sessions that did the work; what was left stale was everything around it that still described a map as keyed by the dungeon id alone. The lede, the status badge and §1's "joined by the dungeon's id and nothing else" now say what is actually true: a world scope and a dungeon id, the id keeping two dungeons apart and the scope keeping two worlds apart. The roadmap and the open decision it belongs to are the substantive change. "Where does a dungeon's layout live?" is now half-answered, and the doc says which half: ownership is settled — a map is filed under its world's uid, follows that world through rename, re-save and re-import, and is swept once nothing stands in the world any more — while location is not, since the map is still in localStorage and does not travel with an exported world. Hand that file to someone and they get a dungeon with no rooms in it. README index row updated to match.
Deleting a world from the library swept its whole map namespace - but removing a world from the library does not remove the playthroughs in it. Those games still walk its dungeons, so the sweep emptied every dungeon in a game still being played. A world with saved games is now left entirely alone. The debt is settled at the other end instead: deleting a saved game sweeps its world's maps only once nothing refers to that world any more - no library entry, no draft, no remaining save, and not the world currently loaded. Answering "is a playthrough still standing here?" has to be cheap, because it is asked mid-delete and a save snapshot can run to hundreds of megabytes. The saved-games index now records each save's world uid, so it is a lookup rather than a read of every snapshot. Index entries written before that field fall back to the world NAME - which is exactly the question being asked of them (is a save on the world being deleted?) and needs nothing opened to answer. Both deletion paths pass the saved-game list they have already computed rather than re-reading it, so a best-effort index write that fails cannot silently undo the decision made a line earlier. Every uncertainty resolves toward keeping maps: an unreadable store, an unanswerable question, or a world that might still be wanted all leave them in place. An orphaned map costs a little space; a swept one empties a dungeon someone is standing in. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A world's uid is what its Dungeon Builder maps are filed under, so losing it anywhere in the save/export/reload chain orphans them: the dungeons still show on their cards, but every one reads as never drawn. The chain already held - every export goes through serializeWorld(), which carries uid, and rebuildWorldFromSnapshot restores it rather than minting over it - but nothing proved it, and it is exactly the kind of field a later edit to either function drops without anything noticing. This pins it end to end: the game snapshot, the JSON a file export writes, the rebuild on load, and repeated save/load cycles (so the uid cannot drift a little each time). It also covers the world-export envelope, the `new World(data)` path some loads take instead of reInstance, and a save older than the field - which is given a uid AND must write it back out, since a uid minted fresh on every load would file that world's maps somewhere new each time. Test only; no behaviour changed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Scoping maps by world uid stopped an imported world from inheriting an incumbent's rooms, but created the inverse: a world file carries the uid of the world it was exported FROM. Import it back as a second copy - a variant under a new name, the usual way to fork a world - and two worlds answer to the same uid, sharing one set of maps. Editing the fork's dungeon would rewrite the original's, silently and in both directions. That is worse than the collision the uid fixed, because it corrupts rather than merely confuses. claimWorldUid() settles this: a world with no uid gets one, and a world whose uid is already claimed by a DIFFERENT world gets a fresh one. Re-importing a world over itself keeps its uid - that is what lets its maps come back with it. When a fresh uid is minted, the maps under the old scope are COPIED into the new one. A fork should look like what was imported - dungeons already drawn - rather than starting blank, while being independent from the next edit onward. An existing map in the target is never overwritten. Applied at every door a second copy can come through: both file imports (login and the World Editor) and both store writes (the saved-worlds library and the drafts), so a fork created by Save-As is covered as well as one created by import. Best-effort - if the stores can't be read, the import proceeds as given rather than being refused. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Saving a world under a name already taken REPLACES a different world with this one - the saved-worlds library is keyed by name. With maps filed by name, the incoming world inherited the outgoing one's rooms: the same collision as the import case, arriving by another door. Name-keying cannot fix this, because it cannot tell re-saving your own world (must keep its maps) from evicting someone else's (must not inherit them). So a world now carries a uid, minted once and carried through save, restore and export - the same thing entities already do, and for the same reason: "distinct from name, which can repeat". Maps are scoped by it. World data too old to have one falls back to its name, as before. Two bugs fall out at once. Renaming a world used to strand its maps; now the key follows the uid, so they follow the rename. And saving over a taken name clears the evicted world's maps, which would otherwise sit unreachable forever - but only when both worlds carry a uid, since without one there is no telling an eviction from an ordinary re-save, and an orphaned map is a far smaller harm than deleting rooms someone is still using. Migration now walks older key shapes newest-first - name-scoped, then the bare id - adopting the first found and clearing the rest, so a map moves at most once however far behind it was. The cross-check test grew cases for the uid, the name fallback, and the two collision properties the uid exists to guarantee: a renamed world keeps its maps, and two worlds sharing a name do not share them. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A map was keyed by dungeon id alone, and a dungeon id is only unique WITHIN a world - addDungeon hands out "dungeon", "dungeon_2", ... in every world it makes. So importing a world whose ids happened to match an existing one's had its dungeons silently inherit the incumbent's rooms. The key now carries the world: "<prefix><world>.<id>". Worlds are identified by name, as they are in every other store here (the library, the drafts, the saved-games index), which also means renaming a world starts a fresh set of maps - the same thing renaming does to its library entry. Both sides derive the key independently - the game builds it, the Builder rebuilds it from the URL - so a disagreement would mean each writes a key the other can't read. The id keeps the Builder's existing rule unchanged so old maps still resolve; the world scope drops the dot from that set, making the first dot after the prefix an unambiguous separator (otherwise world "a.b" + id "c" and world "a" + id "b.c" collide, which is the very thing being fixed). tests/test_dungeon_builder_key.js lifts BOTH implementations out of their source files and drives them off the same inputs - unicode, odd characters, the 80-char caps, dotted names - to prove they agree. Maps drawn before the scope existed are moved into the current world the first time its Dungeons tab renders (always before a Builder can open, since Edit lives on a card), and the old key is cleared so no second world adopts it. A map already filed under the new key is never overwritten. Deleting a world is now a clean sweep of its own namespace, which also catches maps left by dungeons removed from the world earlier - walking its dungeon list never could. Legacy keys still go through the id-in-use guard, since those carry no world. Also fixes the test's localStorage stub, which had no length/key(i) and so silently exercised nothing in the sweep. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Deleting a world left every map drawn for its dungeons behind, keyed by ids nothing referenced any more. Sweeping those ids blindly would have been worse than the leak. A dungeon id is only unique WITHIN a world - addDungeon hands out "dungeon", "dungeon_2", ... in every world - so two worlds sharing an id is likely, not hypothetical, and erasing a deleted world's ids would take a live world's rooms with them. So dungeonIdsInUse() gathers the ids still claimed by the saved-worlds library, the drafts store and the loaded session (skipping the world being deleted, whose ids are the point), and deleteWorldDungeonBuilds() erases only what nothing else wants. A shared id keeps its map. confirmDeleteWorld reads the dungeon list from BOTH copies before either is removed - the library envelope and the working draft, which can hold dungeons the saved envelope never saw - and erases afterwards, once the world is gone and its own ids no longer read as in use. Best-effort throughout: a map that cannot be erased is a stale key, not a broken delete. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A dungeon's Builder map lives OUTSIDE the world save, keyed by the dungeon's id,
so removing the card left the map orphaned — and a later dungeon that happened
to take the same id would silently inherit someone else's rooms.
The confirm dialog now says what else is being destroyed before asking. When a
map exists it says how much of one ("2 levels · 137 tiles · 20x20 · built
Jul 28, 2026"), so the weight of the answer is visible rather than implied; when
none has been drawn it still warns that a map would go too.
Confirming deletes both. deleteDungeonBuild(id) closes any Builder window open
on that dungeon FIRST — it autosaves on a debounce, so erasing the key under a
live editor would just see the map written straight back a second later — then
removes the stored map and forgets the window. It is the only place that knows
where a map is kept, so a future move of that storage has one thing to follow.
Note: the Builder stores maps in localStorage under
"lostrealms.dungeon-builder.map.<id>", not IndexedDB. The app's IndexedDB
(tlr_store/kv) holds save blobs and the crypto key and carries no dungeon data.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomTwo spots still described shipped Journal/Character subtabs as unbuilt. - text_adventure.html: the JOURNAL_SUBTABS comment called Faction, Profession, Legend and Tasks "placeholders for now". Three of those four have render functions and real views; only Profession is still a placeholder. Comment only — no behaviour change. - guide.html: "Your character" introduced the Equipment subtab as "a placeholder for now". It is built, and it is load-bearing — equipped armour sets your AC base and the equipped weapon supplies the damage dice, neither of which carried gear does. So the Equipment tab now gets the write-up it was missing rather than another apology: a new "What you wear & wield" section covering the eleven body slots, the class slots a world can add on top (a Mage's Spellbook, where a field spellbook always lands), the rule that an item fits exactly the slots it declares — which is what lets a dagger be both Weapon and Sidearm while a two-handed sword is weapon-only — and why any of it matters in a fight. Linked from the Character section, the table of contents, and the colophon, whose caveat about Equipment being undocumented is now retired. Test suite: 376/385, with the same 9 failures present on the branch point (test_ambient_comment, test_char_quick_popup, test_editor_io, test_enable_respawns, test_entity_ambient, test_factions_editor, test_room_ambient, test_room_title_link, test_world_digest) — pre-existing and unrelated to this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eik
guide.html still described the game as "a single-file browser app with no server of its own" and carried an On-the-roadmap note promising that a future version *will* move keys out of the browser. That server exists — it hosts the game, holds every provider key encrypted at rest, and has an admin page behind its own sign-in. A player reading the security section was being told the opposite of what they were playing on. - Reframe "API key security" around the two ways to run. Direct mode is unchanged and still the default, and everything about on-device encryption now says so explicitly. Vault mode is described by what the player actually sees: no key field, no API Keys button, an Admin button instead, and a "Logged in as … / Sign out" line when it sits behind a sign-in. - Replace the roadmap note with what shipped, including the point that matters most — on a vault-hosted realm the browser-side exposures in that section do not apply, because there is no key in the browser. - Login screen: note that the Claude API Key field is absent under a vault, that the model picker is then bounded by what the operator permits, and add the missing Claude Opus 5 to the model list. - Overview and colophon: drop "single-file", which stopped being true once the game grew the Dungeons module and the server. While in there, two more stale spots: - The Journal called Legends a placeholder. It ships — one dated world-voice entry per resolved arc, with its outcome, the renown earned, and clickable spoil chips. Four subtabs work now, not three. - The colophon listed Legends, Tasks and Equipment as pending seams. Only Professions still is; Equipment is built but genuinely undocumented here, and now says so rather than implying coverage that doesn't exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eik
Expand landing page with character, combat, and world features
The landing page had drifted from the game it describes. Its four
highlight cards led with implementation ("One HTML file", "Ships a
manifest and service worker"), and both the closing CTA and the footer
sold a single-file browser app driven by your own API key — which stopped
being the whole story once the Server Vault shipped and stopped being
literally true once the game grew CDN deps and the Dungeons module.
Meanwhile whole shipped systems went unmentioned: the character sheet,
skills, spells, abilities, rest & fatigue, and combat. Two of the images
already sitting in images/ were staged for exactly those sections and
never used.
So: drop the plumbing talk and spend the space on gameplay.
- Two new sections. "A character, not an avatar" covers attributes,
class, levels, book-learned skills, field spellbooks, inherent knacks,
and the fatigue/encumbrance that presses on the same sheet — using the
previously unused images/character.png. "The dice are yours to throw"
covers rounds, initiative, to-hit against armour, and saving throws
made with the player's own dice under a turn clock.
- Two highlight cards swapped for systems: unidentified things (a pale
root stays a pale root) and the first-person dungeon crawl.
- The day/night section now names the six times of day, the realm
calendar, and the region weather that shipped since; the phase caption
reads with the game's own labels instead of invented ones.
- CTA and footer reworded away from keys and "single-file text
adventure".
- "no menus to click" softened to "no dialogue trees" — combat does ask
the player to roll.
- Fix the Field Guide link: guide.html, not uploads/guide.html.
Also refresh two design docs that had gone stale:
- Designs/README.md was missing rows for abilities.html and
branching-quests.html; all 21 docs are now indexed.
- Designs/living-world.html §09 still listed weather & seasons as an open
growth idea though Weather Phase 1 shipped, and listed hunger & fatigue
as open though the fatigue half shipped with Rest & Fatigue. Both
entries now carry their real status and link the docs that took them
up; the header chip and footer summary follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skzd3u6NmrCBNaJDE22eikRegenerated from the full git history (1725 commits, 29 days active, 120,740 lines across 483 files) to pick up everything landed since the last snapshot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB1GTufg9oN4Nw5R1tQ79U
Written to the same shape as its siblings — stylesheet lifted verbatim
from world-image-baker.html, the tooling eyebrow, status badges, a
numbered contents list and matching section furniture — and indexed in
the README alongside the rest.
Fifteen sections covering what was built over this run: the cell
structure everything derives from and the mirroring that keeps a wall's
two sides in agreement, the brushes and the selection popup, the
lock-id model that wires switches to doors and secret walls, levels and
the exit stair, the item billboards, and the renderer.
Two things it records deliberately, because they are the parts that are
easy to get wrong twice:
- the split between the authored map and the play state a
walk-through leaves on it, which every mechanism follows;
- the host contract — the query string in, the two message types, and
the storage event the card reads back over. Including that the
arrival half is specified but not wired, and that polling for the
window to close does not work because a background page's timers
are throttled.
It closes on what is next and five open decisions, the first being
where a dungeon's layout ought to live: per-key localStorage suits an
editor and does not travel with an exported world.Stairs up on the top level no longer conjure a floor above themselves.
They are the dungeon's exit to the world instead, and carry the id of
the room in the main game the party climbs out into — a Room field on
the tile, saved with the map like anything else. Stairs up on any lower
level still pair with the floor above exactly as before.
Selecting one needed the Select tool to reach a tile and not just a
wall face: it takes an editable face when there is one and the square
itself when the square is a stair, and the popup titles itself
Selected tile rather than Selected face for those. A stair that is not
an exit says what it is and which level it joins, and offers no field.
The Builder cannot walk the party out — it has no world to walk them
into — so arriving on an exit says where the stair goes and posts
{ type: 'dungeon-builder:exit', dungeonId, room } to whichever window
opened it. Nothing listens for that yet; the game side is the half that
owns the arrival.
When there is no way out — no stairs up on level 1, or one that has no
room — the editor sidebar carries a standing note saying so, amber
rather than red because nothing is broken, it is unfinished. It counts
partly-done too: one exit named and another blank says which. The game
tab says it once as well, since a party can walk a whole dungeon
without ever meeting the top level, and the sidebar is not somewhere
they would look.removeDungeon called appConfirm with an onConfirm callback. appConfirm has no such option — it RESOLVES a promise — so the callback was silently dropped: the dialog opened, Remove did nothing, and the card stayed. Awaited now, the way every other remover in the file does it. Removing also clears the id from the collapse set so a deleted dungeon leaves nothing behind. The test passed because its appConfirm stub was shaped like the invented API rather than the real one, so it verified my mistake instead of the behaviour. It now returns a promise like the real function, and covers cancel (the dungeon stays), confirm (it goes), and removing an id that is already gone. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Coin is carried as three independent pools (1 gold = 10 silver = 100 copper),
and each was clamped separately when a delta was applied. So a bill in a
denomination the player wasn't holding was silently waived: a 450-copper magic
dagger sold to a character with 15 gold and no copper hit
Math.max(0, 0 - 450) = 0, and cost them nothing.
spendCopper(total) pays a copper price against total wealth instead. Coins go
smallest-first so the purse is disturbed as little as possible - loose copper,
then silver, and a gold piece broken only when nothing smaller covers the rest -
with the overpayment returned as change so the total is exact. It is
all-or-nothing: a purse that cannot cover the price spends nothing and reports
how far short it fell.
New "costCopper" field on stateChanges: one price, in the same unit every item's
value is already in, that the engine settles on its own. The GM no longer has to
inspect the player's coin or decide which pieces change hands, and is told the
charge may be refused. The *Delta fields stay for narrative awards - handed five
silver, the player sees five silver - but a NEGATIVE delta is now converted to
copper and folded into the same charge, so the old shape is correct too rather
than merely tolerated. Awards are credited before charges, so coin earned in a
turn can pay for something bought in the same breath. A refusal says so in the
story rather than passing quietly.
Values also read as money now. They are still STORED in copper, but a chest
worth 12000 of them read as a number rather than a price, so item cards, the
character sheet's inventory lists and the item popup roll the figure up into the
largest denominations that fit ("4 gold, 5 silver"). The GM prompt still states
values in raw copper, which is the unit it has to answer in.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe concept app leaves Concepts/ for Modules/Dungeons/, and takes the file name it has been called since the rename: dungeon-builder.html, with its manifest, service worker and icon alongside. The manifest id and start_url move with it; the service worker's scope follows the new directory and still never touches the app at the root. It now saves per dungeon. `?id=` names one, and the map is filed under lostrealms.dungeon-builder.map.<id>, so a Builder can be open on each dungeon in the world without either overwriting the other. With no id it keeps the single key it has always used and runs standalone as before. Ids arrive from the game but end up in a storage key, so anything odd in one is squeezed out first. Alongside the map it now writes a small summary — levels, floor tiles, grid, when — so the game can say what is in a dungeon without parsing every cell of it. On the Editor › Dungeons card, an Edit button opens the Builder in its own window, keyed by the dungeon's id and headed with its name, both on the query string. Pressing it again raises the window already up rather than opening a rival onto the same dungeon, and renaming the dungeon posts the new name through to a Builder that is already open. A Layout row in the card's Details reads back what has been drawn. That read-out follows the drawing live, by listening for the storage event the Builder's saves raise. Polling for the window to close was the obvious approach and does not work: once the Builder has focus this page is in the background, where the browser throttles timers to a crawl — measured, not assumed.
The popup borrowed the quick-character popup's .cq-head / .cq-title / .cq-body class names, but those rules were scoped to #char-quick-popup alone — so it got none of them. That is both reported problems at once: no title styling, and no body padding, which is what left the item icons sitting against the popup's borders. The three rules are now shared by the two ids, so the pair cannot drift apart again. The pack is also grouped by item type — Weapons, Armor, Consumables, Flora, Fauna, Books, Tools, Treasure, Materials — using the section headers the quick popup already has, so a full inventory reads as a few short shelves instead of one undifferentiated wall of tiles. Sections appear in a fixed order and only when non-empty; a type the table does not claim falls into a trailing "Other" group, so a GM-invented type is never silently dropped from the list. Grouping is by type alone. Enchantment is an orthogonal property, so a magic dagger shelves under Weapons with the other daggers rather than being pulled into a Magic section — the same call the item model makes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor > Dungeons tab was a shell over an empty list. It now edits a real
world object: world.dungeons is an array of
{ id, name, type: 'dungeon', location, description, detailedDescription,
portrait, portraitPrompt, ignoreArtStyle, lore, loreKey, loreUnlocked }
`type` is a declaration rather than a choice, so it is forced to 'dungeon' on
normalize (an import claiming otherwise is corrected) and shown read-only on the
card.
The card is built as the race card's twin, from the same shared helpers, so the
two tabs behave identically: the media column with Generate/Upload/Regenerate,
buildDescEditFields for Description + Detailed Description, wrapInPromptsSection
around the Portrait prompt with the art-style override and the prompt-suggest,
and buildDmLoreSectionHTML for the DM-only Lore section. 'dungeons' is routed
through compendiumTypeContext and applyCompendiumLoreField (shaped as a place,
so its image is wide rather than portrait-shaped) so the shared prompt-suggest
and lore editor write straight through to world.dungeons.
Dungeons are authored by hand: "+ Add" drops a blank card at the top of the
list and each card can be deleted. There is no GM authoring contract for
dungeons yet, so the GM request bar says so and points at "+ Add".
Also normalized on the way in everywhere it matters - the World constructor,
the reInstance load path (which bypasses the constructor, so it must be
explicit or Dungeons-tab edits are lost on reload), and JSON import, which now
keys an id-less entry by a slug of its name so export/re-import updates in
place instead of duplicating.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAn expand icon sits left of the Inventory block's info icon and opens a tall panel in the story view's upper right holding every carried item as a tile. Clicking a tile opens that item's detail to the popup's LEFT. It is deliberately the quick-character popup's machinery rather than a parallel one: the same tile grid, the same left-hand detail pairing, the same #app-level placement so it floats over the main panel and survives a tab switch. The two should read as one idea. Where they differ is scope — the quick popup is a glance at a few tiles, this is the whole pack — so this one takes an explicit height and fills the panel instead of scrolling a grid through a short box. The header button stops propagation, or opening the popup would also collapse the block it was opened from. Below 900px there is no room for three panels abreast, so the detail overlays the inventory popup rather than being pushed off-screen. Also fixes a straggler from the magic-as-a-property change: the quick-character popup's Magic section still tested `type === 'magic'` and so would have listed nothing at all. It reads through isMagicItem now, like everywhere else. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The 500ms debounce only ever coalesced a BURST — several calls inside half a second, which is what the post-load storm was. It did nothing for a stream of INDEPENDENT events a couple of seconds apart: a GM brief lands, a banner is re-weathered, the clock ticks over. Each cleared the window and wrote the whole snapshot again, which is the save-every-two-seconds in the log. A save now waits the ordinary debounce OR the remainder of a 20s cooldown, whichever is longer, and everything happening inside that window folds into one trailing write. The delay is anchored to the last WRITE rather than to "now", which is what stops a steady stream of events starving the save by pushing its deadline forward for ever: each successive call schedules earlier, never later, so the trailing write always lands. The anchor is stamped when a write starts AND when it finishes, so a snapshot slow enough to take seconds buys a full cooldown of quiet afterwards rather than only the remainder. This trades durability for responsiveness, as asked: up to a cooldown of play can be lost to an ungraceful closure. Every graceful exit — logout, the manual Save, the page-leave handlers — already goes through saveGameStateNow(), which cancels the debounce and ignores the cooldown, so the loss window applies only to a hard kill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Puts one instance of that catalog entry on the floor of the room the player is standing in — the counterpart to authoring the thing on the card, with no GM turn and no inventory juggling in between. All three tabs share buildItemCard, so one button serves them all, under its own Actions heading rather than pretending to be another field. It stacks onto a same-named floor item unless either is a container, which is exactly how a player's own drop behaves: containers are unique, each holding its own contents, and merging two would silently destroy one set. One judgement call worth naming. A gated item the DM places is placed deliberately, so the DISCOVERY gate is cleared — it is there to be found. The IDENTIFICATION gate is left alone: finding a thing does not tell you what it is, and that is the entire point of an unidentified item. Dropping the Barrowking's Signet should put a plain iron ring on the floor, not spoil it. Feedback lands in the output box of the tab the item belongs to, via a small editorKindForItem router — a Drop on the Flora tab reports on the Flora tab rather than somewhere the DM is not looking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two halves, and the missing one was not the directive. The item contracts never mentioned detailedDescription at all, though the entity, flora, race and faction contracts all ask for it. Every item-authoring schema now requires it: 2-4 sentences of the closer look a character gets by picking the thing up - materials, workmanship, wear, weight in the hand, marks and smell - with the constraint that it is OPENLY VISIBLE, so it holds no secret. What is hidden belongs in "lore", and a thing behind an identity gate must read here as its apparent self, or the field would leak the very nature the gate hides. But applyItemSpec - the path the GM box and every "+ Add" write through - did not copy the field, so a GM that supplied one had it discarded anyway. makeItem keeps it, and that only covers items built inline. The same shape of bug as the abilities/effects one, in the same function, found by checking rather than by assuming the directive would be enough. test_item_subtypes_directives asserted "subtypes" directly followed "description" in one schema; the description fields now sit between them. Its actual claim - that kinds are authored under "subtypes" - is unchanged and still asserted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A magic dagger is still a dagger. Making enchantment its own item TYPE meant it stopped being anything else, and that was not merely untidy: the weapon and armour fields are gated on the type, so a type:"magic" sword could not carry `damage` at all and a type:"magic" ring could not carry `acBonus`. The GM would author them and the write path would drop them. `magic` is now an orthogonal boolean and `type` stays the thing's natural kind, which is what lets those fields apply. isMagicItem is the single reader, and it still recognises the legacy shape, so saved worlds keep working before any migration touches them. migrateItemMagicFlag rewrites type:"magic" into magic:true plus a natural type inferred from how the thing is worn, then its subtypes - deliberately NOT its name, because "Ring of Armor" is a ring, and reading the name for "armor" would hand it a base AC instead of the bonus it actually carries. It defaults to misc, since a wrong guess changes which fields the item may keep. itemCompendiumCategory and itemHasIdentityGate now take the item rather than its type string (both still accept the string). Plants and animals keep their own tabs and their own identifying skills - an enchanted herb is still flora, found and studied as flora - and magic is the classification for enchanted things that have no tab of their own. Same rule for identification: the natural kind wins where it has a rule, and Arcana answers for everything else. Cards state it plainly: a Magic row beside Type, and a badge on the card head so a collapsed card reads at a glance. Every authoring contract drops "magic" from the type list and gains the flag, with the rule spelled out. Five tests moved with the change: four asserted the old call shape or the old seed data, one was my own expectation that enchantment outranks flora. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
There are two item write paths, and only one of them carried the new fields. makeItem handles items built INLINE - class loadouts, room floor items, loot - and that is the one that got `abilities`/`effects` support. applyItemSpec is what requestItemEdit writes through, so the GM request box and every "+ Add" button went down the other path, where both arrays were silently discarded: the dagger was created with its damage and identity gates intact and nothing to render. Both fields now follow applyItemSpec's existing consumeEffect pattern - set when the spec offers usable entries, cleared when it explicitly offers an empty array, and left alone when the spec omits the field, so a later edit that touches only the description cannot wipe an authored grant. Also, per the point that a magic item's effect is often a standing enchantment rather than a countdown: an onEquipped effect with durationMinutes 0 or omitted now means "for as long as it is worn". It is applied with no expiry at all and ends when the item comes off. Deliberately restricted to onEquipped, since onUse and onHit have no natural end - 0 there still falls back to the finite default. Reading it back says "while worn" rather than a countdown, in the card chip, the summary line and the GM dossier alike. consumeEffect stays what it always was: the eat-or-drink status, gated to plant/consumable/potion/food. `effects` is the separate, type-unrestricted field for what an item DOES at a moment. The reported GM response is now a test fixture verbatim, through applyItemSpec to the rendered card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The effect twin of the Ability editor: openEffectEditor authors ONE triggered effect and hands the normalized record back through a callback, the same context-agnostic shape openAbilityEditor uses. The form leads with the two things an effect cannot omit - when it fires and how long it lasts - rather than burying them among optional refinements, and choosing onHit moves the target to "the one struck" without taking the choice away. A live preview shows the NORMALIZER's verdict, not the raw form, so a capped duration or a dropped 100% chance is visible before saving rather than discovered after. Chips on the card gained edit and remove controls, and the Effects section is now always rendered so "+ Effect" is reachable on an item that has none yet - which is the whole point, and matches Grants always offering "+ Ability". Chip indices walk the item's OWN effects array rather than the normalized copy, so a malformed entry cannot shift what the controls address. Removing the last effect drops the field instead of leaving an empty array. The consumeEffect chip stays read-only and its tooltip now says where it is authored. test_room_flora_fauna asserted the old intent - no Effects heading on an item with nothing authored - and is updated to the new one, scoping its chip assertions to the Effects section, since Discovery further down the card uses the same class. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The item contract offered ac/acBonus/damage/damageBonus/identity gates but never
mentioned "abilities", so a GM-authored magic item could not carry one - the
field existed on the model and in the card editor, and nothing ever filled it.
It is now documented, alongside a new field for the thing abilities cannot
express.
The two are deliberately different, and the contract says so in as many words:
an ABILITY is a behavioural grant, true while the item is worn, with no moment of
its own - it tips what the character can DO. An EFFECT is a conditional status
that lands at a NAMED MOMENT, on somebody, and wears off. Every effect must carry
a trigger and a duration; one without both is not a rule, so normalizeItemEffect
refuses an effect with no recognised trigger outright rather than guessing when it
fires. Duration is capped, so an item effect is never effectively permanent.
target is self or target - the bearer, or whoever was struck - defaulting by
trigger, so the magic sword case reads { onHit, target, poisoned, 5 }.
Application splits along what the engine actually knows. onEquipped is applied
and removed by the engine itself, because equipping is an action it performs;
those statuses are tagged with the item that produced them, so unequipping
removes exactly those and leaves an identically-named status from elsewhere
alone. onUse and onHit are moments only the GM can recognise, so they reach it
through a new per-turn dossier block naming the status, the target, the duration
and the channel to apply it through.
Item cards fold the triggered effects into the existing consumeEffect "Effects"
section as chips led by the trigger, rather than opening a second heading.
Not included: a hand-editor for effects. Grants have one; effects are authored
through the GM box or "+ Add" for now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA new Items section in the editor stands an iron chest, a wooden chest or a pile of bones on a floor tile. Click the same tile with the same item to take it away; anything but floor refuses. The item rides in the map like everything else, so it saves, exports, undoes and redoes. In the game each is an upright sprite standing on its tile, turned about the vertical axis to face the party — a cylindrical billboard, so a chest never tips over as you look down at it, and all three read from down a hallway. Cut out rather than blended, so there is nothing to sort: walls hide them through the depth buffer exactly as they hide everything else. They do not block the way; you walk onto the tile and the sprite is there with you. Chests carry two sprites. Clicking one in the Game view raises or lowers its lid, with a short wooden groan pitched down as it opens and up as it shuts. Which chests are open is play, not map, so it resets with the dungeon — the same rule doors and secrets already follow. Bones have no lid and never offer a pointer. All five sprites are procedural and uploadable like every other tile, drawn on transparency so an uploaded PNG only needs an alpha channel. Picking walks the map for line of sight rather than trusting distance alone: on screen a wall hides a chest through the depth buffer, but the mouse cannot consult that, and without the walk you could open a chest through a wall.
Header, window title, banner comment, manifest name and short_name, the service worker's own header and the icon's aria-label. The app was already titled Dungeon Builder in the <title> tag while calling itself Dungeon Master everywhere else; now they agree. The host message channel takes the new name too — dungeon-builder:name — and keeps answering to dungeon-master:name, so a host written against the first name is not broken by the rename. Left alone on purpose: the file names, the manifest id and start_url, and the localStorage key. The id is what makes an installed copy the same app rather than a second one, and the key is what makes an already-saved dungeon still load; neither is a name anyone reads.
A name joins the map model, saved with it and so exported, undone and
redone like anything else. It sits in the header's centre slot ahead of
the level readout, and doubles as the window title. Blank falls back to
a default rather than leaving the header empty, and a long one is
clamped and ellipsised so nothing the host sends can push the header
about.
The window that launched this one has the last word, two ways in so it
can use whichever suits how it opened us:
a query string dungeon-master.html?name=The%20Sunken%20Keep
a message win.postMessage({ type:'dungeon-master:name',
name:'The Sunken Keep' }, '*')
The query string is read once, before the map loads, so it beats the
name the map was saved with. A message may arrive whenever the host
likes and takes over from then on; it is accepted only from the opener
or the containing frame, and only in the shape above. Origin is not
checked, because the concept runs from file:// where every origin reads
as null — a real deployment should check it, and the code says so.
Note that the host's name wins at load, not on every deserialise: undo
and redo go through the same path, and a rename has to be undoable like
any other edit.
The editor gains a Dungeon group with the name field, on the same terms
as the properties popup's fields — the map changes as you type, the
undo step is taken when you leave it.The handler's key gate tolerated an empty apiKey when a vault was present, but every GM contract it calls checks `apiKey` for itself - so the click got past the friendly gate only to be refused by the contract, with a message about a key the player is not supposed to have. Tolerating the empty key was the wrong half of the fix. In Vault mode the server owns the Claude key and the app carries a sentinel so its `!apiKey` gates pass; this handler now ADOPTS that sentinel, as the rest of the app already does at three other sites. It re-runs vault detection first, since loadApiKeysFromStorage clobbers the sentinel detectVaultMode set and a detached editor window may never have run detection at all - the same failure the draft-load path documents. Direct mode is unchanged: still no key, still "Start a game first". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fauna, Magic Items, Spellbooks, Spells, Factions, Rooms and Encounters join the shared driver, which now covers all fourteen editor tabs. Each gets its own angle list and closing guidance, and several of those closings carry the constraint that tab actually needs: fauna must not be hostile, magic items get an evocative name and the identity gates, a spellbook must teach a spell that already exists, a new room must connect by exits to a room that does - a room the player cannot reach is not worth having. Adding seven at once made the duplication worth collapsing. The five near- identical map reorders become one mapWithKeysFirst primitive, and the three copies of the hoist-list pair become a makeCardHoist factory - so factions and rooms are one line each, and spells got their hoist for free. The three hoists stay independent instances rather than one shared list. Encounters are the one shape neither covered: an array, matched by name, so a name shared by two encounters moves both. requestSpellEdit and requestFactionEdit now return createdIds alongside the names they already returned, as the earlier contracts did - placement needs the id. requestRoomEdit already returned room ids, and the entity contract already returned uids. Tests grow to 307 assertions: every tab's button and placement, per-kind request cases for all fourteen contracts, the reorder primitive's edge cases, and that the three hoist lists do not leak into one another. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Both join the shared driver, bringing it to seven tabs. Each gets its own angle list and closing guidance: races lean on peoples and their histories, items on ordinary gear and what it is for - with an explicit "not a magic item", since that tab is the Magic Items tab's neighbour and the GM would otherwise drift. Items needed nothing new - it is item-backed like Flora, so requestItemEdit and moveCatalogItemsToFront already covered it. Races are a map keyed by id, so worldRaces() renders in key order and moveWorldRacesToFront reorders it the same way classes are handled; it leaves a malformed array-shaped world.races alone rather than silently rebuilding it as a map. requestRaceEdit already tracked createdIds internally for its portrait pass, so it now returns them - a race's created array carries names, and placement needs the id. The test grows to cover all seven tabs, including a completeness check that every registered kind has all ten registry fields and its own angle list, so a future tab cannot be half-wired and fail only when someone clicks the button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Both get a "+ Add" button beside Export, driven by the same shared handler as Flora, Classes and Skills: it composes a GM instruction from the world's theme & premise, tone and prologue, plus a random angle and the current roster so repeated clicks diverge. NPCs and monsters get separate angle lists - what makes an interesting person to meet is not what makes an interesting thing to fight - and separate closing guidance, both insisting on a real room id so the new being lands somewhere the player can actually reach it. Placing them at the top needed the hoist approach rather than a reorder. Every entity lives in the `entities` array of the room that defines it, and the editor list is that traversal, so putting one first would mean shuffling rooms or their contents - real game data, and visible in play. Instead the editor keeps a display-only list of uids rendered first in the NPCs and Monsters tabs. Keyed by uid, not name, because entity names are deliberately not unique: a world can hold several "Town Guard", and hoisting one must not disturb the others. requestEntityEdit now also returns addedUids alongside the names it already returned, since names cannot identify which being was just created. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Both get a "+ Add" button beside Export that asks the GM to invent one new entry from the world's theme & premise, tone and prologue, plus a random angle and the current roster so repeated clicks diverge. Each tab has its own angle list - "something a herbalist would prize" is no use when inventing a character class - and its own closing guidance about what fields matter. The Flora implementation is generalised rather than copied: one driver reads a per-tab entry saying what to call the thing, which GM contract to use, and how to place the result. The progress, refusal, no-op, error and button-disable handling is identical across the three and now exists once. Placing the new entry at the top differs per tab, which is the substance here. Flora and Classes render in key order, so their data is reordered - classes get moveWorldClassesToFront, mirroring the catalog version. Skills cannot be treated that way: allSkills() sorts alphabetically and the character sheet depends on that, so the DM editor keeps a display-only hoist list instead, leaving the player-facing views alone. requestSkillEdit now also returns createdIds, since its created/updated arrays carry names and the hoist needs ids. tests/test_flora_add.js becomes test_editor_add_random.js, covering all three tabs: button placement, the instruction's grounding, per-tab angle lists, top-of-list placement by each of the three mechanisms, and the failure paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Selected face leaves the sidebar and becomes a panel in the upper right of the editor, up only while an editable face is selected. It sits with the map it belongs to rather than at the bottom of a column the map never scrolls with, and it carries its own close button; Escape and a click off the map still let go. Because the panel is now the whole of the selection UI, a click that finds nothing editable clears the selection instead of raising an empty box, and the empty-state text goes with it. A dedicated Select tool joins the wall brushes: it picks a door, secret wall or switch to edit and changes nothing. Lock and Visible still select as they did — they just flip one property on the way — so editing an already-locked door no longer means unlocking it to reach its ID. Select is click-only like the other two, and its hover preview marks the target face solid where there is something to edit and dashed where there is not. The Wall & Door brush and Stairs descriptions become collapsible Notes sections, closed by default. Between that and the panel moving out, the sidebar shows the brush palette, party, stairs and tile graphics at once instead of pushing them past a screen of prose.
Regenerated from full (unshallowed) git history: 1689 commits across 29 days, June 30 - July 28. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKSFsszNjv9HSn6y1nUiEq
A checkbox above the player list that sets the list ASIDE rather than deleting it: while it is on, any authenticated user may play, and unticking it restores exactly the list that was there. That is the difference from simply emptying the list, which is the only way this could be done before and loses the roster. cfg.anyoneCanJoin is seeded from VAULT_ANYONE_CAN_JOIN and persisted to the access file like the two lists, so the admin-page toggle survives a restart. isPlayerUser consults it after the identity checks, not before: it opens the gate to authenticated users, and is not a way past signing in, an unverified address, or a request with no user at all. The flag reads only affirmative spellings. A bare truthiness test would make VAULT_ANYONE_CAN_JOIN=0 mean "on", which is the wrong direction to be wrong in for a setting that opens the game to everyone. The API likewise takes a real boolean and rejects the string "true". Its own endpoint rather than a mode of the list save, since it is a different kind of change and would otherwise have to be special-cased inside the list validation. No lockout guard is needed: it only ever widens who may play and never touches admin access. In the UI the list stays visible and editable while the toggle is on, only dimmed - the point is to hold a roster in reserve, so it has to remain workable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The previous update ran against a shallow checkout, which only saw the last ~175 commits and undercounted commits/days-active. Unshallowed and regenerated from the complete git history (1687 commits, 29 days, June 30 - July 28). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4PUTBaJy2yG2sJBeX55nt
A secret wall can now carry a lock ID, the same field a locked door
carries. Press a switch wired to that ID and the wall grinds aside where
it stands — the party hears it go, and can walk through. Doors and
secret walls can share one ID, so a single button can free both.
What they hear is pitched to how far off it was: the sound is a block of
masonry sliding, synthesised as brown noise swept down through a closing
resonant low-pass with a slow scrape riding on it, at a volume falling
off with distance to the nearest wall that moved. On the party's own
floor the message says so — beside them, nearby, or far off for one a
level away.
Which walls have gone is runtime state, as it is for a wall found by
searching: the map still says 'secret', so reloading seals it back up.
Also:
- Clicking off the map in the editor lets go of the selected face. The
listener sits on the canvas area rather than the canvas, so the
margin around the map counts as outside — which is what it looks
like. Clicking in the sidebar deliberately keeps the selection,
since that is where the fields being typed into live.
- The Lock tool now reaches secret walls: there is no state to flip,
so a click there just selects, and the panel offers Lock ID.
- A secret wall wired to a lock carries the same amber bar on the
editor map that a locked door does.
- One lazily-made audio context is shared by the click and the grind,
rather than the click owning it; whichever plays first makes it.A lock now carries an ID, and a switch carries the ID of the lock it opens. Press that switch in the game and every lock of that name is thrown, wherever in the dungeon it is: the doors become openable, lose their iron plates, and the party still has to walk over and open them. Both are per-edge text fields, mirrored onto the neighbouring cell like the edge itself and saved with the map. Which locks have been thrown is runtime state, as it is for secret walls and hidden switches — the authored map is untouched, so reloading bars the doors again. They need typing rather than clicking, so the editor gains a Selected face group. The Lock and Visible tools now also select the face they act on, and the panel edits it: Locked plus Lock ID for a door, Visible plus Opens lock for a switch. Both text fields complete from the lock ids already in use anywhere in the dungeon, the checkboxes flip the same state the map click does without having to click the map again, and a caret on the map marks which of the four faces the fields belong to. Field edits join the undo history once the field is left, rather than one step per keystroke. Locked-door rendering now asks isLocked rather than the raw flag, so a door whose lock a switch has thrown stops drawing as barred — in the 3D view, on the editor map, and on the minimap alike. The Locked checkbox still shows the authored flag, since that is what an author is editing.
A switch face now carries a "hidden" flag alongside the lock flag on doors — held on the edge, mirrored onto the neighbouring cell, and saved with the map, so it survives reload, undo and redo. A hidden switch is not built into the frame at all: it does not render, and there is nothing to click, so the wall is indistinguishable from plain masonry. Search that wall and the party turns it up — "Your fingers find a hidden button, flush with the stone" — after which it draws, clicks and presses like any other. Whether it has been found is runtime state, exactly as it is for secret walls; the authored flag on the map is untouched, so reloading puts it back out of sight. In the editor, Visible joins Lock as a click-only toggle in the wall brush row: click a switch to hide it, click again to show it. Anything that is not a switch refuses and says so. On the map a visible switch keeps its solid pip while a hidden one is drawn hollow, dashed until it has been found — the same language secret walls already use — and the in-game minimap shows nothing at all until the party finds it. Also brings foundSecrets into the level bookkeeping that only openDoors had: inserting or removing a level shifts every level-qualified discovery key, not just the doors, and clearing or replacing a map forgets all three sets through one clearDiscovered().
A door now carries a locked flag, held on the edge alongside the door itself and mirrored onto the neighbouring cell the same way, so both sides of one doorway always agree. It rides along in the map format, so it survives save, reload, undo and redo like any other edit. In the editor a new Lock entry sits with the wall brushes. It is not a brush: click a door with it and that door locks, click again and it unlocks. It is click-only rather than drag-paintable, since dragging a toggle across a door would flip it on and off as the cursor wandered. Anything that is not a door refuses, and says so. Locked doors draw with a bar across them on the editor map and in amber on the minimap. In game a locked door will not open — Open, Space and the Close/Open toggle all report it, and searching it says the lock is sound. Locking a door that stands open shuts it first, so a locked door you can walk through is not a state you can reach. Locked doors wear an iron plate with a raised boss at handle height, built from the wall texture and tinted down in two passes so the boss reads against the plate even head-on. Dimming the door alone looked like nothing more than a door standing in shadow. Locks are kept honest at every point an edge is written: setEdge drops the lock whenever the face changes to something else, the carve loops now go through setEdge instead of writing edges behind its back, and the bulk paths that rebuild a level's edges rebuild its locks too. A fresh door can never inherit a lock left behind by an older one.
make-cert.ps1 generates the same server.key + server.cert pair, with the same subjectAltName, extensions and 825-day default, so a Windows box needs no shell. Two paths. If openssl is on PATH it is invoked with the same arguments as the shell script. If not, the certificate is built with .NET's own CertificateRequest API, so a machine with no OpenSSL still works; the PEM is written through File.WriteAllText with a BOM-less encoding, because Set-Content on Windows PowerShell emits a BOM and Node's TLS parser rejects it outright. That fallback needs PowerShell 7 - Windows PowerShell 5.1 runs on .NET Framework, which can create the certificate but cannot export the private key as PKCS#8. The script detects that by probing for the method and prints the two ways out rather than failing obscurely. The file is deliberately pure ASCII: Windows PowerShell 5.1 reads .ps1 as ANSI unless the file carries a UTF-8 BOM, and a BOM ahead of the shebang would break it on Unix. Verified under PowerShell 7.4.6: both branches, host arguments typed as IP or DNS by shape, the overwrite refusal exiting non-zero, and the resulting .NET-built pair actually serving the vault over HTTPS to a verifying client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The server could already serve HTTPS from VAULT_TLS_KEY + VAULT_TLS_CERT. Three things made using it locally harder than it needed to be. Auto-detection: with neither env var set, a server.key + server.cert pair beside server.js is now picked up, so dropping the two files in is all it takes. Half a pair never silently becomes plain http — the missing half is named at startup — and an unreadable PEM reports which file and where it was looked for instead of a bare ENOENT stack. Setting one env var without the other is now fatal: that is a typo, not a choice. VAULT_TLS_AUTO=0 disables the detection, which is what the tests use so their results do not depend on a developer's local certificate. make-cert.sh generates the pair with a subjectAltName. Without one no browser will accept a certificate however it is named, and the cert previously in the tree had CN=localhost only. server.cert is now untracked and *.cert joins *.key and *.crt in .gitignore — a tracked certificate whose key is ignored is no use to anyone cloning. The desktop app hard-refuses certificate errors, which meant it could not talk to a local HTTPS vault at all. TLR_ALLOW_INSECURE_CERT=1 now accepts an unverified certificate for the vault's own host only, applied at session level so subresources are covered too; a sign-in provider's certificate must still verify, since a failure there is misconfiguration or interception rather than a dev setup. Off unless explicitly set, and logged loudly when on. Adds Electron/test/test_tls.js: a real headless launch against an HTTPS vault using the generated pair, asserting the page actually rendered rather than just that a URL was attempted. It skips with a printed note when no certificate is on disk, since both files are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The button was reading as a feature of the room rather than something you have to notice. It is now about two-thirds the size, stands half as far off the wall, and its gasket is a thin seam rather than a frame; the gasket, face and side tints all move closer to the surrounding masonry, so it takes a look to find and no longer draws the eye. The texture patches shrink to match, so the button is still cut at the same grain as the wall it sits in rather than a magnified crop of it. Picking gets its own margin — the pick quad is about half again the drawn face — so the smaller button stays comfortable to click without the drawn geometry growing.
A sixth edge brush. A switch wall is ordinary masonry — it blocks movement and sight like any other wall — but carries a small block standing off its face, textured from the middle of the very same wall tile so it reads as part of the stonework rather than a UI element. Clicking it in the Game view drives it into the wall and lets it back out, with a click synthesised on the fly (a bandpassed noise burst over a short square-wave pitch drop), so the file stays standalone. Picking projects each button's face with the matrix the last frame drew with, so what you click is what you see; the cursor turns to a pointer over one. Presses are keyed by the shared edge rather than the face, so a wall between two open cells shows a button on both sides and it is one mechanism. The button is drawn in three ranges of one dynamic buffer, each with its own tint via a new uTint uniform: a dark gasket lying on the wall plane, the lit face, and the shaded sides. Without the gasket the button vanishes into the masonry from a couple of paces back — seen head-on none of its sides show, so it has no silhouette at all. Also: the editor map and the in-game minimap draw switch edges in teal with a square pip, searching a switch wall reports the button, and presses on buttons that have been edited away are pruned on rebuild.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4PUTBaJy2yG2sJBeX55nt
Two fixes. Launching sent the app into the system browser. The origin allow-list was the vault plus auth0.com, so a vault configured with a CUSTOM Auth0 domain — or any federated provider behind it — failed the test on the very first navigation and was handed to the default browser; with the main window still waiting on ready-to-show, nothing of the app appeared at all. A redirect is the server driving the flow, not the user choosing to leave, so https redirects are now followed and their origin trusted for the rest of that sign-in, and forgotten again on returning to the vault. TLR_ALLOWED_ORIGINS (plus TLR_AUTH0_DOMAIN and the server's own AUTH0_ISSUER_BASE_URL) name extra in-app origins outright. Every hand-off to the browser is now logged, so this is visible next time. The window content is now a bundled landing page with a Start button; Start opens the game in a second window and the launcher steps aside, returning when the game closes. Being local, it paints immediately and reports a bad vault URL instead of leaving a blank window. It has its own preload exposing four calls (config/start/minimize/quit); the remote game page keeps the empty shared one, so none of that is reachable from vault-served content. Both windows use the frameless 1282x772 shape. The launcher draws its own title strip and controls; for the game window the wrapper makes the game's #header the drag handle at load time, so a frameless window is still movable without baking Electron-only CSS into text_adventure.html. Adds test_launch.js: a real headless boot of the shipping main.js against a stub vault, covering the launcher, Start, the redirect path that caused this, and the return-to-launcher teardown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking a tab or a pad control left focus on that button. The browser shows no ring for a pointer click, but the first movement key afterwards flips it into keyboard modality and it rings whatever was last clicked — so a tab lit up while the party walked, until something else was clicked. A pointer-driven click now hands focus back to the document. Keyboard activation is left alone: it reports detail === 0, so tabbing to a control and pressing Enter keeps focus where the user put it, and tab order still works. Movement is unaffected either way, since the key handler listens on the window rather than on any focused element. Focus rings themselves are now gold and offset rather than the browser's white default, so when one is shown — genuinely, from the keyboard — it belongs to the rest of the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Open the main window with the requested shape: frameless with square corners at a content size of 1282x772 (useContentSize, so that is the page and not the page plus a title bar), still resizable, menu bar auto-hidden. sandbox and devTools are turned off in the shared web preferences rather than on the main window alone — the game's detached windows script against their opener on the same origin, and a mixed sandbox setting between opener and popup is exactly the asymmetry that breaks that. contextIsolation and nodeIntegration: false are unchanged, so the page still has no path to the shell. The View menu loses its devtools item, which would otherwise be a dead entry. Frameless means no OS close/minimize buttons and no title bar to drag. The menu is never drawn but its accelerators still fire, so the window stays closable and reloadable from the keyboard; the README notes the -webkit-app-region rule the served page would need to become draggable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Flora tab could only grow by typing an instruction into the GM box. Add a "+ Add" button beside Export that composes the instruction itself from what makes this world its own — theme & premise, tone and prologue — plus a random angle and the current roster, so repeated clicks diverge instead of returning variations on the same plant. It reuses the existing item-edit GM contract (requestItemEdit), so validation, the flora "type": "plant" guidance and the discovery/identification gates all apply unchanged. The one new mechanic is placement: the editor lists the catalog in insertion order, so a created entry would land at the bottom of a long list — moveCatalogItemsToFront rebuilds ITEM_CATALOG with the new ids first, putting the new card at the top where it can be found and edited. Progress, failures and no-op replies report into the tab's own output box, and the button is disabled for the duration so a slow GM can't be double-fired. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A Secret brush paints a wall face that is masonry in every visible respect — same texture, blocks movement, blocks sight down a corridor — until the party searches it, at which point it goes and leaves the way it was hiding. Search sits with the other actions on the pad and on F. It reports what it finds either way: plain stone says so, a door says it is plainly a door, so an empty search never reads as a bug. Found passages stay open for the session, keyed per level like door state. The brush carves the rock behind the face, as Open does. A secret wall with nothing behind it would be a dud, and there is no way to tell from looking, so it is not a map the editor should let you build by accident. On the editor map secret walls are dashed rather than solid, and switch to a finer dash once found, so the map shows both what is there and what the party has turned up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Report was stale (1,596 commits / 28 days from a prior history); now reflects this branch's actual git log: 168 commits across 5 days.
A thin shell in Electron/ that loads the game from a vault over the network. It ships no game code — the vault serves text_adventure.html and holds the keys, exactly as for a browser — so updating the game does not mean rebuilding the app. The vault is configured with TLR_VAULT_URL (VAULT_URL accepted as an alias, a --url= argument wins over both), defaulting to the vault own default port so a local server works with no configuration. A malformed or non-http URL is reported on a styled page rather than silently falling back, as is an unreachable vault. Because the window loads REMOTE content it is deliberately locked down: the renderer is context-isolated, sandboxed and without Node; the preload exposes nothing; navigation, redirects and popups are allow-listed to the vault origin plus Auth0 (so the OIDC login completes in-window) with everything else handed to the system browser; all permission requests are denied; and certificate errors are not click-through. The game genuinely needs popups — the detached Editor and tab viewers, the Field Guide, the DM Guide, the Handbook — so those open as real windows at the requested size and are hardened in turn. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The lockout guard only protected whoever was signed in, so two admins could still remove each other. Add a permanent OWNER admin, named by VAULT_OWNER_EMAIL or defaulting to the first address the environment seeded. The owner is read from the ENVIRONMENT only, never from the editable store, so the trust order is shell access > admin-page access: an admin can add and remove other admins, but only whoever controls the server can hand over ownership. - The owner always counts as an admin, even if absent from the list entirely. - A stored list that omits the owner is repaired on load (forced back to the front), so a hand-edited access file cannot lock the deployment out either. - Removing the owner through the API is refused for everyone, including a dev/loopback session; the UI marks that row "owner" and disables its Remove. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The map showed where the party starts but not where they are. A pale disc with a facing wedge now marks their square, set against the start marker's gold arrow so the two read apart even when the party is standing on the start square. Position comes from the interpolated transform rather than the grid coordinates, so it sits correctly mid-step rather than snapping a square early. The start marker returned early when it belonged to another level, which would have skipped anything drawn after it; that is a conditional now, so the party is marked on whichever level is shown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Taking a stair already re-pointed the map data, so the editor drew the right floor — but its Levels readout and up/down buttons were left as they were at boot, still claiming "Level 1 of 1" after levels had been added and walked between. The refresh now hangs off useLevel, which every level change funnels through — stairs, the editor's own controls, undo, import — rather than being repeated at each call site where one would eventually be missed. The map is redrawn there too when the editor is the visible tab. Note this makes the readout honest rather than changing any behaviour: undoing a level deletion already left the party re-seated at the start level, because their square does not exist on the restored floor. Checked against the previous build — the active level was identical, only the label disagreed with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Arriving on a stair, the party now turns to face the alcove's open side, so the flight is behind them and they are looking out into the level rather than at the steps they just used. The facing is read from the arrival cell's own opening rather than derived from the direction carried in. That matters when the far end landed in a room that was already dug: those keep their existing walls instead of being recut as an alcove, so their opening need not be the reverse of the approach, and a rule based on the incoming facing would point the party at a wall. Yaw is turned by the short way round so it stays continuous with the facing rather than jumping. Applies to climbing as well, which lands the same way for the same reason. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The up end was a wall face painted with the edge brush; it is now the same three-walled, rotatable alcove as the down end, just running the other way. The brush is gone and both ends live in the Stairs group. A pair sits on one square of two levels, and the lower alcove opens the opposite way, so a descent and the climb back run in a straight line and you always come out facing the way you were already going. Laying any other tile over either end removes both. Half a stair — steps that arrive in bedrock, or a landing with nothing above it — is no longer a state the editor can produce, whether it is reached by stamping over one, deleting a level, or regenerating one. An unexpected benefit: a rising flight sits ABOVE floor level, so it does not hit the occlusion that hides the descending one. Nothing intervenes between viewer and steps, and the climb reads from right down a corridor. Both still end in the matching arch on the wall the steps run into. Saves written while the up end was a wall face load cleanly; those faces come back as plain wall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
An opened door used to render nothing, leaving a bare hole in the wall. It now draws an Open door tile — stone jambs and lintel with the leaf swung back against one side — and the shader discards texels below an alpha threshold, so the opening is a genuine hole you see and walk through rather than a blended surface that would need sorting. Cutting out beats blending here: no draw-order dependency, and it still writes depth where the frame is solid. Opening and closing are now separate actions rather than one toggle, so a door can be shut deliberately: OPEN and CLOSE buttons, Space and C. Each says when there is nothing to do instead of silently flipping the wrong way. Space still bump-opens as before. The control cluster moves from bottom centre to the bottom right corner, where it no longer sits over the corridor ahead, with open and close on their own full-width row beneath the movement keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The two ends are now different shapes rather than mirror images. Down is a three-walled tile, rotated like a piece (right-click or R) to choose which side is open. It stamps the same dead-end shape the piece tool does, auto-carve included, and a real flight of steps runs from that opening to the far wall, so the direction of travel is built rather than implied. Entering the tile descends. Up is a wall face, like a door: an arched mouth with steps climbing away into it. It blocks walking and sight as any wall does; walking into it climbs. Placing an alcove writes it on the square below, on the same side as the opening, so you come back up still facing the way you came — and the reverse. Removing either end removes the other, including when a level is regenerated or deleted. A finding worth recording: steps sunk below floor level are close to invisible in this camera. The floor between viewer and stairwell is an opaque plane at y=0, so from a corridor the flight is occluded, and by the time you are near enough to see past it the steps have dropped below the frustum — measured at 33% of the flight's vertices on screen from the adjacent square. The floor geometry is still there and correct, but what actually announces a way down is an arch on the wall at the foot of the flight, matching the one you climb. Stair materials also carry a small ambient floor, since a dead-end alcove catches no torch. Fixes a scope bug this introduced: the stair-clearing loops in the random generator were brace-less, so the added edge sweep sat outside them and referenced c and r out of scope, throwing on every generate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Two editable lists — VAULT_ADMIN_EMAILS (who may administer) and VAULT_PLAYER_EMAILS (who may play). The environment seeds them at boot; edits made on the page are written to data/vault-access.json and applied to both the live config and process.env, so they take effect immediately AND survive a restart (the stored list wins over the env on later starts). Emails are normalized on save — trimmed, lowercased, de-duplicated — and accept either an array or a pasted comma/space/semicolon-separated string. Malformed entries are rejected rather than silently stored. Lockout guard: with Auth0 on, the admin list is what grants access to this very page, so it may not be emptied and the signed-in admin may not remove their own address. Without those rules one save could permanently lock every operator out of /admin with no in-app way back. The UI disables that row's Remove button so the rule is visible rather than only discovered on failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The admin page was one long scroll, which will not hold as more is added. Give it four top-level sections, with the existing provider keys / providers / usage UI moved into a "Providers" inner tab under Settings. Players, Worlds and Users are placeholders for now. The tab switcher is shared by the top-level and inner tablists (a button names its panel via aria-controls), so adding a section is markup-only. The selected top-level section is remembered, so a save that re-renders the page comes back where you were. The h1 drops "— Provider Keys", which no longer describes the whole page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A small readout in the top-right of the map area gives the column and row the cursor is over, styled like the game's own HUD panel. It appears only while the cursor is on the map, and clears on leaving the canvas or switching tabs, so it never reports a stale square. It reads the existing hover state rather than tracking the mouse separately, so it stays correct during a drag and costs nothing extra — that state already updates only when the hovered cell changes. The tool panel width moved into a custom property, which the readout offsets against to sit clear of the panel instead of hard-coding 288px in two places. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The current-weather readout on the same row already carries a glyph, so the sun/moon icon next to "Afternoon" was a second, redundant one. Removes the element, the code that set it, and its now-unused CSS rule. TIME_OF_DAY_ICONS stays — the Editor's banner slots still use it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The header was a single flex row with the brand, some pill buttons and a hint jammed together. It now follows text_adventure.html: a three-column grid with the world title left, live status centred and controls right, and the ⚔ logo prefixing the title exactly as the game writes it. The "Concept" pill reuses the completion-chip styling that sits beside the world name there. Game and Editor move out of the header into their own bar below it, as .panel-tab buttons — uppercase Cinzel, dim until selected, with the gold underline and glow the game gives its active tab. The centre slot stands in for the game's location readout, showing which level the party is on and updating as they take stairs or as levels are added. Every computed value was diffed against the running game: header grid, title font, size, weight, colour, tracking and glow, and the tab font, size, transform, tracking and padding — thirteen properties, no differences. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Adds a manifest, a service worker and an icon beside the concept, plus an Install button in its header that appears only once the browser signals the install criteria are met — so it never offers something that cannot happen. The concept gets its own manifest rather than reusing the game's: that one starts at text_adventure.html, so installing from here would have launched the wrong app. Its worker registers from Concepts/, giving it the scope /Concepts/ and a separate cache, so it cannot disturb the root app's. The worker mirrors the game's: network-first, so an edited file is always fresh online, with the cache serving only when the network is absent. Registration is skipped unless the page is on http or https. Opened from disk as file:// neither the worker nor installation can work, and guarding it keeps that case silent rather than throwing — the concept still runs fully from a file, it just cannot be installed from there. Verified against Chrome's own checks over http: the manifest parses with no errors, Page.getInstallabilityErrors returns empty in a persistent profile, the worker activates and controls the page, and a reload while offline still serves the app and boots it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Older occurrences of a room's banner kept playing their clip, so a long story could hold several animating banners for the same place. When a newer banner for a room lands in the story, demote every earlier video banner for that room back to the still image it would have shown with "Banner Videos" off. The demotion resolves the image for the hour THAT scene depicts, not the current one, so an old dawn scene reverts to its dawn art. The video markup now carries data-banner-time to make that possible. Both the live DOM and the persisted messageLog are rewritten, so it survives a reload, and a story saved before this rule is healed once on restore. A banner with no still to fall back to is left animating rather than blanked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The story entity-link handler is delegated on #narrative, and the pinned head sits outside it — so the pinned title link (data-story-place) never routed to goToPlace. Bind the same handler to the pinned head, as was already done for the banner width toggle. This also covers any other story link the pinned head renders (item / NPC / container), not just the title. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Generates a dungeon on the level you are on, leaving the other levels alone. Rooms are placed without overlap and then joined in sequence by L-shaped corridors, so the result is connected by construction rather than by hoping and checking. Walls fall out of adjacency the same way the demo map derives them; doors go where a corridor meets a room, and torches are thinned out across the walls. Regenerating clears any stairs on that level along with their partners on the neighbouring levels, and drops the doors opened there, since neither refers to anything afterwards. The party is re-seated in the first room when the start sits on the level being replaced. An empty level generates straight away. One that already holds a map asks first, through a dialog in the app's own palette rather than the browser's — Escape or the backdrop cancels, Enter confirms, and the game ignores the keyboard while it is up. The three existing window.confirm calls (clear, restore demo, delete level) now use the same dialog, since one native prompt beside a themed one would look like an oversight. Checked across twelve generated maps: every floor cell reachable from the start, no start buried in rock, and no open or door edge leading into bedrock. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The dungeon is now a stack of levels, index 0 at the top, so "below" is
always level + 1. `grid` and `seen` are live references into the active
level rather than copies, which let every function that already worked on
a single map keep working untouched.
Two new tiles, stairs down and stairs up. Placing either end always writes
the matching end on the joined level at the same square, carving a landing
out of bedrock and creating the level if it does not exist — a stair that
leads nowhere is not a reachable state. Clearing one clears its partner,
and turning a stair cell to bedrock takes the pair with it. Stepping onto a
stair carries the party to the joined level, landing on the same square.
Editor gains a Levels group (walk up/down, add below, delete) and a Stairs
group. Deleting a level first clears the stairs on its neighbours that
pointed into it, so no dangling ends survive the renumbering.
Things that had to become level-aware rather than global:
- fog of war, so charting one floor does not reveal another
- door state, whose keys are now level-qualified; identical coordinates
on two floors previously shared one entry
- the party start, which now records which level it sits on
- insert/remove, which shift every stored level index, door key included
Stairs render as a shaft — sunk through the floor going down, punched
through the ceiling going up — rather than a floor decal, which was
illegible at the grazing angles a first-person crawler views floors from.
Saves move to v2 with a levels array; v1 single-level maps still load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfeKeeps the header readout unobtrusive until looked at. Keyboard focus matches hover, since the chip is tabbable (role="button") and would otherwise stay faint when focused; the change is transitioned alongside the existing colour/border. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Editor teardown: detaching the Editor now removes its view from the base window (the largest static subtree in the app) and hides its tab, like the detachable main tabs; closing the popup rebuilds and re-binds it. applyDMVisibility and restoreEditorDOM both refuse to resurrect an Editor that is currently handed off, and switchTab redirects away from an absent Editor view. A blocked popup leaves the Editor in place rather than hiding it with nowhere to go. Rev guard: the detached Editor's save is a read-modify-write (it keeps its own world but splices the main window's play-state), and IndexedDB gives no cross-window transaction — so a save landing in the gap between its read and its write could silently lose one side. Every snapshot now carries a monotonic `rev`; windows record the revisions they load, sync, and write, so they never write behind what they have seen. After writing, the Editor verifies its revision landed — if another window superseded it, it re-merges its world edits onto the newer play-state and writes again (bounded retries, logged rather than silent). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
It was absolutely positioned in the Story tab's top-right corner. Wrap the world name and the chip in a flex group that occupies the header's first grid column, so the chip sits just right of the world name and is visible from every tab. Its tab-switch refresh is no longer gated to the Story tab for the same reason. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The editor sidebar used the browser's default scrollbar, which sat oddly against the dark gilded panel. It now carries the same rules the game gives its own scrolling panels — thin, transparent track, a --border thumb with a 2px radius — declared both as the standard scrollbar-width / scrollbar-color pair and as ::-webkit-scrollbar pseudo-elements, matching how text_adventure.html writes them. The map canvas wrapper is themed alongside it, so the two scrollable regions of the editor agree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The pinned head sat outside .msg-room, so it inherited none of the story's room-title styling. Share those rules between .msg-room and #story-pinned-room (title, time of day, region pill, weather pill, title link) so pinned and unpinned look identical — including flow-root on the title, which contains the right-floated weather pill. The pinned title now also carries the time of day in the story's own shape (name : time of day, then the region pill, then the weather pill), and repaints when the hour turns over. Clicking the banner toggles full width in the pinned head too: the delegated toggle was bound only to #narrative, so it never fired there. A re-render (weather re-skin, banner regen, hour change) now preserves that expanded state instead of silently resetting it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each tile row gets an icon button beside Upload that saves that tile to disk as dungeon-<slot>.<ext>, so the generated art can be pulled out and edited, or a custom tile recovered later. Uploaded art is written back from the data URL it arrived as, byte for byte, rather than being re-encoded through a canvas. Procedural tiles have no source file, so those are rendered out as PNG. The link is fed from a Blob rather than a data: href, which is unreliable to download once a tile runs to megabytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Settings › Interface › Pin Room (off by default). When on, the Story tab shows ONLY the current room: its title and banner are held STATIC in a pinned head above the story, while that room's own text — description, You notice, Present, Exits, the GM brief, and whatever happens while you are there — scrolls beneath. Entering a new room repaints the pinned head and resets the scroll to that room. The full history still lives in messageLog: pinning only changes what is MOUNTED (via a pin floor the window/reveal both respect), so unchecking the setting restores the whole scrollback. The pin floor is re-derived from the story after a restore, and rides the messageLog cap so indices stay in step. The pinned head is rendered fresh from the live room, and repaints when the banner is re-weathered, regenerated, or swapped to video. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Restoring a saved game ("Continue Your Journey" / boot resume) and the save that
runs before logout both take a noticeable moment, and the only feedback was a
subtle status-bar shimmer — so the app looked frozen. Add a blocking overlay with
a spinner and an indeterminate progress bar that explains the wait.
The covered work is largely SYNCHRONOUS (parsing a large snapshot, rebuilding the
world, serializing the story), which blocks the main thread — so withAppBusy
yields for an actual paint (two rAFs, with a timeout fallback for hidden tabs)
before running the work, or the modal would never appear. The modal is
reference-counted and comes down even if the work throws.
A detached tab viewer re-runs restoreGameState on every main-window save to
mirror it, so it is exempt (the modal would flash constantly).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe flame's lower corners broke through the sides of the cup. Measuring the sprite against the cup profile: at the depth the flame quad sits, the cup is 0.0754 half-wide, but the sprite's ink reached 0.0861, and 0.0981 once the sway was at full swing — over by 0.023 a side. Narrowing the whole flame enough to fix that would have cost about a third of its width, so instead the atlas now pinches only the foot: full width is reached a quarter of the way up, above the cup rim, leaving the silhouette that actually shows unchanged. Width is trimmed slightly (0.20 -> 0.18) and the sway eased (0.012 -> 0.008) to go with it. Widest ink below the rim is now 0.0563 including sway, against 0.0754 of cup — clearance on both sides rather than overflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Right-clicking one of these tabs offers "Detach", opening it as a live, READ-ONLY mirror window (?detach=<tab>) like the Editor and Guide. While a tab is detached its view is REMOVED from the main window's DOM (freeing its markup and images) and its tab button hidden; closing the popup rebuilds the view from a pristine template, re-binds its delegated listeners, and re-renders it. The main window stays authoritative: - a viewer never writes the save (saveGameState/saveGameStateNow bail out), and - no detached window runs encounter/ambient timers, so there are no duplicate GM calls or behind-the-back world mutations. A viewer live-syncs the WHOLE session (play-state included, unlike the editor's world-only sync) whenever the main window saves. Story is excluded (it is the main window). Logs is excluded: the game log is session-only and never persisted, so a viewer could not mirror it. switchTab is now null-safe and redirects away from a detached tab, since any view may legitimately be absent from the DOM. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Open brush refused any wall whose far side was bedrock, and said so through say(), which renders into the game overlay — hidden while the Editor tab is up. So it declined silently. That made it inert on a freshly loaded map: loadAscii only ever writes a wall where rock sits behind it, so on the demo dungeon there was not a single edge the brush would accept. Open now carves the bedrock behind the wall before opening the edge, the same auto-carve the piece stamps already do, so it cuts new passages instead of only unsealing pre-existing ones. The map border is still refused, since there is nothing beyond it. The editor grew its own message slot, so warnings from either view are seen on the tab that raised them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The cup met the masonry on a narrow 0.06-wide neck and flared to 0.136 at its lip, so its visual mass hung out in the corridor on a thin stalk and read as floating even though the back face was exactly on the wall plane. Its sides are now near-parallel (0.072 back, 0.080 front) so it meets the stone across its full width, and the back plane is pushed 0.035 BEHIND the wall face rather than sitting on it, so no joint can open up at any angle or any depth precision. Verified: cup vertices now span -0.035 to +0.082 about the wall face. Burying the back means the cup pokes a little way through into the cell behind, which is accepted for now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The flame sprite ended in a hard horizontal edge where its quad stopped. A small tapered iron cup now stands proud of the wall at the flame base: being real lit geometry drawn in the opaque pass, it writes depth and so occludes the bottom of the sprite from every angle, hiding that boundary instead of covering it with more glow. It also gives the flat painted stem a three-dimensional thing to terminate in. Five quads per torch — front, both sides, underside and rim — bolted to the wall face and protruding 0.078, with its own dark-iron material. Winding is derived per face from the desired outward normal rather than reasoned out by hand, so each face culls correctly on all four wall orientations. The cup's texture is deliberately near-black. It sits directly under the sconce light, and a mid-tone iron blew out into a pale block bolted to the stone rather than reading as the same metal as the painted bracket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Loading a saved game ran several post-restore helpers that each called saveGameState(), firing back-to-back full-snapshot writes — each serializing the entire banner-heavy story log — which froze and sometimes OOM-crashed the tab. - saveGameState() is now DEBOUNCED on the REAL clock (never the game clock): a burst coalesces into ONE write. saveGameStateNow() forces an immediate, awaitable write for the page-leave handlers, the manual Save button, logout, and the post-restore checkpoint. Writes are chained so they never overlap, and the pending promise is captured before scheduling so a sync-firing timer is safe. - messageLog is capped (MESSAGE_LOG_CAP = 500): the oldest entries are dropped beyond the cap so the retained history — serialized into every save with embedded data-URI banners — cannot grow without bound. The mounted DOM window and its start index shift together, so upward paging stays consistent. Story paging already existed (the trailing 60-message window reveals older batches on scroll-up); the cap just bounds how far back it retains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Measuring the sprite rather than eyeballing it found the real cause of the fire looking detached: the atlas baked in a soft halo reaching across 95% of each tile, centred well above the flame base. Blended additively that became a glowing ball hovering near the wall, and it dominated the much smaller flame inside it. The halo is now a close, dim bloom hugging the fire — the sconce already casts real light, so the sprite does not need to fake any. The flame is smaller (0.30 x 0.37 -> 0.20 x 0.27 world units, against a 1.15 wall) and its base moved up to 0.775 so the lit part starts just under the bracket cup at 0.793 instead of above the arm. Clearance from the masonry is halved to 0.015, still far beyond the depth precision at these distances. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Camera-facing was the wrong call for a wall sconce. Because the sprite sat 0.16 off the masonry and pivoted to meet the viewer, looking down a corridor showed the fire hovering in mid-air, detached from the stem mounted on the wall. Each flame quad now lies in its own wall's plane, a hair (0.03) in front of the face, and stays put as the camera moves. Sconces record which wall they hang on so the quad can be built along that wall's tangent, and the sway now travels side to side along the wall rather than across the cell. The tradeoff is deliberate: viewed edge-on the flame foreshortens to a sliver, exactly as a flat decal on a wall should. It reads as mounted from every angle, which the floating sprite never did. Additive blending, the unlit flame shader, the animated sprite strip and the per-sconce phase are all unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The flame was baked into the wall tile, which meant it lay in the wall's plane — going edge-on and nearly vanishing when viewed from the side — and, being ordinary geometry, it was multiplied by scene lighting, so a fire in a dark corridor rendered darker than one in a lit room. Fire is now its own pass: a camera-facing billboard per sconce, drawn after the opaque geometry through a second, minimal shader with no lighting or fog maths, blended additively with depth-writes off. Walls still occlude flames; flames no longer occlude each other. Additive blending also means a black backdrop contributes nothing, so flame art needs no alpha channel. The billboard pivots only about Y, so flames stay upright rather than tipping to face the camera. Each sconce carries a stable phase, so the strip frame, sway, breathing and brightness are all out of step between torches instead of pulsing as one. The default flame is a generated 8-frame strip in a power-of-two atlas. A "Torch flame" tile slot accepts a replacement — a horizontal strip with its frame count set, or a single still, which still flickers and sways. The wall tile keeps only the ironwork, a scorch mark and cup embers. Two bugs found while doing this: Sconces sat on the OPPOSITE side of the cell from the wall they belong to, because the offset was subtracted rather than added. Barely visible as a light; obvious once a flame is drawn there. The editor's torch pip had the same inverted offset. uploadTexture always asked for REPEAT wrapping and mipmaps, which WebGL 1 only permits on power-of-two textures — any other size sampled as solid black. Non-power-of-two uploads now clamp and skip mipmaps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The banner re-weather change-detector (currentRoomWeatherKey) and the weathered- banner cache keyed only on room + weather CONDITION + base image, so when the time of day advanced (afternoon → dusk → midnight) with the same weather and same base art, the sky was left frozen. Thread the current time-of-day bucket through the key, the gen dedup key, and the cache identity (persisted + restored), so the sky is re-painted for the new hour even when the weather itself is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The World Editor is the largest static DOM subtree in the app and a plain player can never open it. On login as a non-DM, drop #editor-view entirely (its nodes are GC'd) and keep only a pristine HTML template to rebuild from if a DM later takes over the window (log out → log in as DM, no page reload). The editor's five delegated listeners (entity/class/room card toggles, editor-map select, room-popup links) were load-time IIFEs; they are now named functions that bindEditorDomListeners re-attaches to the rebuilt DOM. Never runs in a detached editor window, whose whole purpose is the editor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The rotate gesture now steers the wall & door brush as well as the stamped piece. Rotation is applied as an offset to the wall face nearest the cursor, so 0° stays exactly today's "point at the face you want" and each right-click (or R, or the button) turns the brush 90° from there — letting an awkward face be painted without chasing it with the cursor. The hover ghost draws the bar, and the sconce pip for torches, on the rotated face, and adds a tick from the cell centre toward that face whenever the brush is turned, so the offset is never invisible. Because one rotation now drives both tools, rotatePiece is renamed rotateTool and the button reads "Rotate tool". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
A player utility on the "/" meta channel: parses NdM(+/-K), plays the dice-roll video for the first die, then reveals the per-die breakdown + total in the story and flashes the total in the centre overlay. Not a game turn and never relayed to the GM. showDiceResultOverlay gains an optional label so the multi-dice total is captioned by the spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a History group to the editor panel with Undo and Redo buttons that disable themselves when their stack is empty, plus Ctrl+Z to undo and Ctrl+Y or Ctrl+Shift+Z to redo (Cmd works too). Starting a fresh edit discards the redo branch, and the stack is capped at 80 entries. History holds whole-map snapshots rather than inverse operations — the grid is small enough that this is simpler and cannot drift out of step with the edit that produced it. Two things the naive version gets wrong, handled here: A drag is one undo step, not one per cell. A stroke records its pre-state the first time it actually changes something and stays quiet for the rest of the drag, resetting on mouse-up. A click that changes nothing costs no undo step. applyTool compares state before and after, so re-stamping an identical cell, or painting an edge that already has that value, leaves the stack alone. Painting now applies once per cell entered instead of once per mousemove. Clear, Restore demo and Import are undoable as well, and restoring a state that buries the party under bedrock re-seats them at the start. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
The weather-pattern editor removal prompts used the browser default confirm() dialog, which clashes with the app's style. Add a generic Promise-based appConfirm() styled to match the app (reusing the modal-* classes and the danger/ghost button tones), and route every native dialog through it: - removeWeatherPattern / removeWeatherDayType (Editor › World › Weather) - the world-chunk overwrite prompt (Editor › World › Regions merge) appConfirm degrades to window.confirm only when the modal DOM is absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The "/" Field Guide lookup and "/hint" gated on `!apiKey` alone, so a Vault-mode player (server owns the key; the app carries no personal key) was wrongly told to "Set your Claude API key to ask a question" even though the GM is reachable. Use the app's canonical `!apiKey && !isVaultMode()` gate, matching gmFetch's vault branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each pattern's Conditions section already removed day-types, but the bare muted-gray ✕ wrapped below a long day-type summary and read as noise. Give it a right-anchored, labelled '✕ Remove' pill so removal is obvious. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Rotating no longer means reaching for the R key or the palette button while the cursor is already over the target cell. Right-clicking the map turns the active piece 90° in place and repaints the hover ghost, so the orientation can be dialled in and stamped without moving the mouse. Mouse-down is now filtered by button: only the left button paints, so the right button rotates without also stamping, and middle-click is ignored. The context menu stays suppressed over the canvas. The R key and the Rotate button now share the same helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
New "Player Damage Rolls" checkbox (Settings › Dice, off by default).
When on, the GM asks the PLAYER to roll their weapon's multi-dice damage
(e.g. 2d6+5) from the dice bag instead of rolling it itself; the engine
rolls every die, sums + adds the flat bonus, shows the result, and relays
the exact total back to the GM to apply.
- rollDiceSpec(spec, firstDie): pure roller — rolls each die of a damage
spec (the die the player clicked counts as roll #1), returns the full
{ dice[], sum, flat, total } breakdown. (Weapons already carry a
multi-dice `damage` attribute — 2d6+5 — so no model change needed.)
- pendingDamageRoll + setDamageRollAwaiting / handleDamageDiceRoll /
submitDamageRoll: mirror the skill-roll bridge — a "Damage Roll Called
For" card, the dice bag opens, rolling the spec's die resolves the whole
spec, and "[DAMAGE ROLL — <label> (<spec>): rolled […] = <sum> + <flat>
→ total <T>.]" is relayed to the GM. Routed in rollDie before combat, so
it works mid-fight; consumed once.
- GM contract: a "damageRollRequest" field + rule, advertised ONLY when
the setting is on (like the auto-roll-off skillRollRequest). Parsed in
applyStateChanges behind the same gate. The GM still rolls ENEMY damage
itself.
Adds tests/test_player_damage_rolls.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomHovering a grid cell now ghosts what the active tool would do, so the result is visible before committing to a click. Piece tools tint the target cell, draw the walls the piece would leave standing (honouring the current rotation), and outline in dashes any bedrock neighbour the stamp would auto-carve. Bedrock previews as a crossed-out cell. The edge brush highlights the exact wall face in that brush's colour, showing the sconce position for torches and dimming when the target is not floor. The start tool ghosts the party arrow, already turned to the facing a click would set. The ghost follows a drag as well, and clears when the cursor leaves the canvas. It is drawn on top of the map each repaint and never mutates map state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
A standalone single-page proof of concept in the house palette (the
:root vars from text_adventure.html, Cinzel/Fira Code), with two tabs:
GAME First-person, grid-stepped movement and 90-degree turns, rendered
in real 3D with a carried torch plus wall-sconce point lights,
distance fog and an emissive term so flames glow. Doors open on
bump or Space. Minimap charts sight-lines down each opening.
EDITOR 2D top-down map editor. Stamp bedrock, dead ends, hallways,
corners (2-way), T-junctions (3-way) and crossroads (4-way) with
rotation, auto-carving the neighbours an opening points into.
A per-face brush paints plain wall, wall-with-torch, door or
open. Start position and facing are placeable. Wall, torch, door,
floor and ceiling tiles can each be replaced by an uploaded 2D
image, which the game projects onto geometry in 3D. Maps export
and import as JSON; map and tiles persist in localStorage.
Rendering is hand-rolled WebGL rather than a CDN 3D library, so the file
is genuinely standalone and runs from file:// with no network.
The map is one grid of cells, each owning four edges that are mirrored
with the neighbouring cell, so geometry, collision and lighting all
derive from a single structure.
Verified in headless Chromium: geometry builds, movement and turning
step correctly, doors block until opened then allow passage, tile upload
reaches the 3D walls, and map plus tiles survive a reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfeInvert the Interface tooltip toggle to a positive "Show Tooltips"
checkbox that is checked by default (tooltips shown). It's presented as
the inverse of the stored disableTooltips flag — the checkbox writes
!checked and restores as !getSetting('disableTooltips', false) — so the
underlying suppression logic and any existing saved preference are
unchanged; only the label and polarity of the control flip.
Updates test_disable_tooltips for the new label + inverted wiring (which
also fixes its prior staleness from the earlier Hide-Tooltips rename).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe AI Providers section (its title + the "Providers" button that opens the AI Generation panel of image/icon/gallery/sound/video provider + model pickers) is a Dungeon Master concern. Wrap it in a .dm-only container so applyDMVisibility hides it for a plain player and reveals it for the DM, consistent with the other DM-only UI. Adds tests/test_ai_providers_dm.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On restore, the current room's banner is often re-weathered (the time of day / base image moved while away) but the base weather CONDITION is unchanged — so reflectWeatherRepaintInStory's "only brief a genuine condition change" gate skipped the GM brief, leaving a weathered banner with no accompanying description. Arm a one-shot catch-up: restoreGameState sets _weatherRestoreBrief (only when weather imagery is on), and the first reflectWeatherRepaintInStory after resuming emits a weather-condition brief for the current sky even when the condition string is unchanged — the player has been away, so this is their first look at it. The flag is consumed after one brief, and a genuine room entry cancels it (that entry shows its own scene). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Drag a sidebar block by its header to move it up or down; the top-to- bottom order persists in localStorage (tlr_sidebar_order) and is restored on load. The two sticky settings still win: Sticky Portrait pins the Portrait block to the very top and Sticky Exits pins the Exits block to the very bottom (their headers become non-draggable while pinned), just as before. - computeSidebarOrder(): pure order resolver — saved order ∩ present blocks, unsaved blocks appended in their default slot, then the sticky overrides (portrait→front, exits→end). - applySidebarOrder(): reorders the live DOM to the effective order and locks/unlocks drag handles; called on boot, after a drag settles, and when either sticky setting toggles. - initSidebarDrag(): delegated HTML5 drag-and-drop on the headers with a live follow-the-cursor reorder; collapse-on-click is unaffected (a click is not a drag). Sticky guards keep a block from crossing a pinned portrait/exits mid-drag. - CSS: grab/grabbing cursor on draggable headers, dim the dragged block. Adds tests/test_sidebar_reorder.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New "Show Logs" checkbox reveals/hides the Logs tab. Its default is
role-based: ON when the player is the DM, OFF for a plain player; an
explicit toggle (stored) then wins for either role. The Logs tab is no
longer a hard `dm-only` element — a player can now opt in, and a DM can
opt out.
- applyShowLogsSetting(): show/hide #tab-logs per getSetting('showLogs',
isDM); hide-while-active falls back to Story. Called on the checkbox
change and from applyDMVisibility (login/restore/DM-toggle), so the
default follows the current role.
- syncSettingsControls reflects the checkbox to the saved value (role
default).
Adds tests/test_show_logs.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe GM brief generator (requestTimeOfDayBrief) is shared by hour-turn briefs and sky-change briefs, so every log line read "Time-of-day / weather brief". Add a `kind` argument: the two weather beats (emitWeatherConditionBrief, maybeEmitWeatherChangeBrief) pass 'weather', and the generator tags its log lines "Weather-condition brief" vs the default "Time-of-day brief" — for the handoff, raw response, result, empty, discarded, and skipped lines. Behavior is unchanged; only the Logs-tab labels differ. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New Settings › Gameplay checkbox "Banner Videos" (off by default). When on, a room whose banner has a generated video (room.bannerVideo) plays that clip — muted, looping — in place of the still banner image in the story. A room with no video keeps its still banner. - renderRoomBanner: when the setting is on and the room has a video, return the video markup (checked before the weathering/still path). - bannerVideoMarkup: a <video> inside the same .room-banner clickable container (width toggle still works), tagged data-banner-video. - onBannerVideosToggled / refreshCurrentRoomBannerElement: flipping the setting live-swaps the current room's newest banner between video and still in both the DOM and the persisted messageLog (survives reload). - CSS: .room-banner video shares the image's cover sizing. Tests in test_weather_imagery cover the on/off render, the no-video fallback, the markup, and the settings wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the time-of-day room-title banners (the reprint on a time change and the weather-change beat), the region chip sat between the room name and the " : <time of day>" text. Move it after the time so the order reads name : time · region · weather. The plain room-entry title (no time text) is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The weathered-banner img2img prompt took its time-of-day from shownBannerTimeLabel(room), which returned the PINNED banner slot's hour for every clock time. So a room whose banner is pinned to "afternoon" art produced a re-paint prompt that said AFTERNOON even at midnight. Feed the actual current clock time of day (timeOfDayLabel at currentGameDate) into the edit clause instead, so the banner is weathered and lit to NOW — midnight reads as midnight regardless of which hour the pinned art depicts. Remove the now-unused shownBannerTimeLabel helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MDrebyXFM6sRraP2SEyueL
Increment 2 — engine pick. resolveWeather no longer hard-codes the season from floor(month/3); it reads the month's bound weather pattern (world.calendar.months[m].pattern → world.weatherPatterns[…]) and picks ONE day-type per in-world day: a deterministic, seeded, climate-biased weighted pick over the pattern's day-types. The picked day-type's authored WeatherState (condition, label, glyph, intensity, tempBand, wind, description) drives the sky; the base condition stays the structured key. Night still dims the band; a light per-cell noise ripples intensity only, within the day-type's envelope. Falls back to the legacy climate resolve when a month has no usable pattern. Increment 3 — downstream touchpoints: - Present chip / header / sidebar: already read st.label/st.glyph, so the authored skin now shows (reads as itself — "Frozen fog", not "Fog"). - Forecast strip: shows the base condition glyph (new st.conditionGlyph) so bizarre days aren't spoiled ahead. - img2img (fresh scene + re-paint): fold the day-type description in as the colour descriptor (a "rains frogs" day paints frogs). - GM dossier: names the world's month + weather pattern and the day's authored character, and tells the GM to adjudicate effects/damage from that description via the status model (beyond the bare condition). New state fields: conditionGlyph, patternId, patternName, and description now carries the authored day-type text. Helpers weatherPatternForMonth + weatherPickDayType added; WEATHER_DAY_MS added. test_weather rewritten for the pattern-driven model (climate now tilts which day-type is picked within the pattern; a DM day-type surfaces a bizarre condition), plus coverage for the new fields, per-day stability, and the forecast/img2img/dossier wiring. The default world's feel is preserved; the exact deterministic sequence shifts (design §07). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On each room card, add a "Weather" checkbox on the Artwork row, just left of "Visited". For an INTERIOR room it gates whether that room's banner is weathered (window-only) — a per-room opt-out within the global Interior Weather Banners setting. It is editable only when the room is interior; for an exterior room it is read-only and shown checked, since exteriors always weather (subject to Show Weather Imagery + Dynamic Banners). - Room model: new weatherBanner flag (default true), round-trips via the Room constructor like dynamicBanners. - weatherArtAppliesTo: an indoor room now needs the global setting AND its own weatherBanner flag; exteriors ignore the flag. - setRoomWeatherBanner persists + re-arms the detector and repaints/ reverts the live banner if it's the current room. - setRoomInterior live-syncs the Weather checkbox's editable/checked state when a room's interior flag is toggled (no full re-render). Tests: test_weather_imagery covers the per-room opt-out for interiors, the exterior "always weathers" case, and the card wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The setting only reached top-level interiors (interior:true) — a buried inner room (interiorOf, e.g. a private room inside an inn) was still hard- excluded, so with Interior Weather Banners on nothing was submitted when its weather changed. Gate ALL indoor rooms (flagged interior OR any room nested via interiorOf) behind the setting: off (default) → no interior weathering; on → window-only weathering for every interior, including private rooms and cellars. Exteriors are unaffected. Update test_weather_imagery: the buried-inner-room block now checks both setting states (not weathered off, window-only on), and the static gate assertion tracks the new indoors condition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Interior (window-only) banner weathering is erratic right now — the prompt reads well in the logs but the image AIs paint interiors poorly. Rather than weathering top-level interiors unconditionally, gate them behind a new "Interior Weather Banners" setting (Settings › Gameplay), default OFF, so interiors keep their plain pinned/time-of-day banner until the models improve. Turning it on restores the window-only weathering. - weatherArtAppliesTo: a buried inner room (interiorOf) is still never weathered; a flagged top-level interior is weathered only when the new setting is on; exteriors are unaffected (they follow Show Weather Imagery as before). - Settings: new checkbox wired to the interiorWeatherBanners setting and restored on open; toggling it re-runs onShowWeatherImageryToggled so the current banner reverts/repaints immediately. Tests: test_weather_imagery now covers both states of the setting for a top-level interior, and asserts the buried-inner-room exclusion holds regardless. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Claude (GM) provider already had an admin allow-list ceiling that the app's model pickers filter to. Nano Banana (Gemini) had no equivalent: the admin card offered no model control, and the app always listed both Nano Banana and Nano Banana Pro regardless. Server: - vault-core: add a NANOBANANA_MODELS catalog (flash → "Nano Banana", pro → "Nano Banana Pro", ids matching the client registry) plus allowedNanoModelIds() and a config.nanoModels ceiling, mirroring the GM model ceiling. buildClientConfig now carries nanoModels. - admin: /api/keys returns the nano catalog + current ceiling; a new POST /api/settings/nano-models validates + persists the ceiling. - server: seed the settings default + hand the ceiling to /vault/config. Admin page: - Generalize the "Allowed models" card section so it renders on both the Claude card and the Nano Banana card, each POSTing to its own endpoint (carried in a data-endpoint attribute on the checkbox group). App: - Capture config.nanoModels into vaultNanoModels; add nanoModelCeiling() and nanoModelOptionIds(); build every Nano Banana model dropdown (Image / Icon / Map / Gallery / Weather AI) from the filtered list, and resolve the saved model within the ceiling so the model actually sent is never outside the admin's allow-list. Tests: server admin + vault-core cover the nano ceiling end-to-end; client test_gm_model_ceiling covers the filtered option list + resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
TASK 1 added Opus 5 to the client's MODEL_CHOICES, but the server's GM_MODELS catalog (vault-core.js) was never updated. In Vault mode the client filters every model menu to the admin's allow-list ceiling (config.gmModels), which is derived from GM_MODELS — so Opus 5 was filtered out of the in-game AI Generation panel even though the client knew the model, and the admin card had no checkbox to permit it. - Add claude-opus-5 to GM_MODELS so it appears on the admin "Allowed models" card, can be permitted into the ceiling, flows through to the client's gameplay/world-gen menus, and passes the proxy's model check. - Add a pricing row (Opus tier, 5/25) so usage/cost tracking doesn't silently read $0 for Opus 5 calls. - Update test_admin: default ceiling is now 4 sanctioned models, and the catalog includes Opus 5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Starting a new game while a previous game was still in memory (logout → New Game, or restarting without a full page reload) left the weather change-detector baselines (_lastWeatherBriefKey / _lastRoomWeatherKey) holding the PRIOR game's sky. The new game's first clock tick runs before the opening room is drawn; with a stale baseline it read a spurious "condition change" and emitted a full weather-change scene (title + banner + GM brief) stacked above the real room render — so the starting area appeared twice. initGameClock already nulls lastKnownTimeLabel for exactly this reason; extend it to null the two weather baselines as well, so a fresh game's first tick merely re-baselines instead of firing a beat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The brief length clause under "Short GM Briefs" asked for "40 words or fewer", which the GM often still answered as two or three short sentences. Tighten it to demand a single sentence explicitly (ONE SENTENCE ONLY — no second sentence) so short briefs are actually short. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two related fidelity fixes for GM turn/brief prompts: - Brief fidelity: state the weather as the EXACT and ONLY condition in play, add an explicit no-invented-precipitation instruction, and note when the player is indoors (sensing weather through windows/draughts, not the open sky) — stops briefs drifting to snow/rain when the actual condition is something else. - Interior/exterior via roomIsIndoors: the weather dossier shelter note, the "Current Room" INDOORS/OUTDOORS line, and the engine camp check now use roomIsIndoors(room) instead of room.interiorOf alone, so a top-level interior (interior:true, no parent) is correctly treated as indoors — the GM is told INDOORS and camp degrades to sleep there. Extends test_rest_camp (top-level interior degrades camp) and test_weather_imagery (exact/only, no-invented-precipitation, indoors sensing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
paintWeatheredBanner's submit log named the room + weather label but not the actual edit instruction sent to the image AI. Fold the exact prompt into that log line as collapsible detail, so the story-log stays tidy while the full weather + time-of-day instruction is one click away — useful for diagnosing a banner that comes back weathered differently than the sky reads. tests/test_weather_imagery.js: assert the submit logs the prompt collapsibly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
First increment of the calendar design doc's Phase 1 — the world's year and its
months become world-owned and editable (companion to the already-built Weather
tab). Cosmetic over the Gregorian substrate (decisions A/B): 12 months, 7
weekdays, clock math untouched.
- Data model: world.calendar = { months:[{name, pattern}], weekdays[7], year,
yearLabel }. normalizeCalendar seeds from REALM_MONTHS/REALM_WEEKDAYS/437 with
each month bound to its season pattern (0–2 winter … 9–11 autumn), so existing
worlds read exactly as before. Wired through the World constructor, serializeWorld,
and rebuildWorldFromSnapshot (the reload/cross-window restore path).
- Header + GM date now read world.calendar: realmMonthName / realmWeekdayName /
realmYearLabel, and realmDisplayYear (world year + Gregorian delta off the 437
epoch — a pure display offset; the clock stays raw UTC). renderClockDisplay and
currentRealmDateString use them, so a renamed month/year shows in the header and
the GM's date at once.
- Editor › World › Calendar tab (between Regions and Weather): renderCalendar
builds the year + label, 12 month rows (name + a weather-pattern dropdown), and
7 weekday fields; setCalendarField / setCalendarMonth / setCalendarWeekday
persist and refresh the clock. switchWorldInnerTab dispatches to it.
The month→pattern binding is authored here but not yet consumed by the resolve
engine — that's the next increment (wire resolveWeather to the bound pattern).
tests/test_calendar.js: model seeding/tolerance/round-trip, the header readers +
display-year offset, and the tab/switcher/renderer/handler wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe weathered banner was only ever applied to the LIVE DOM (patchLiveRoomBanner
set img.src) and cached in memory. The messageLog entry kept the plain base URL,
so an app refresh/reload re-rendered the base image, and the in-memory cache was
empty — the current room would even re-generate the weathering from scratch.
- patchLiveRoomBanner now also bakes the shown src into that room's most-recent
banner entry in messageLog (newest banner only, mirroring the DOM rule so an
earlier scene is never re-skinned) and saves. So the weathered image (and, on a
revert, the base) persists FOR THAT STORY ENTRY and renders straight from the
log on reload.
- buildGameSnapshot persists weatheredBannerCache (roomId → {baseUrl, condition,
url}); restore rehydrates it, so the current room shows its saved weathering
immediately and isn't needlessly re-generated. The detached editor splices this
play-state like messageLog so a world-only edit can't clobber it.
tests/test_weather_imagery.js: assert the newest banner entry is rewritten in the
persisted log (earlier + other-room entries untouched), and the snapshot
persists/rehydrates the cache.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomTwo Editor › World fixes: 1. Default the World inner tab to Profile, not Chunks — the Profile panel/tab is now active on load, activeWorldInnerTab defaults to 'profile'. 2. Weather patterns (and all weather data) vanished on reload/cross-window sync. restoreGameState rebuilds the world with rebuildWorldFromSnapshot, which reInstances the world field-by-field and BYPASSES the World constructor — and it never copied any weather field, so weatherPatterns/weatherConditions/ weatherClimates/weatherEnabled/defaultClimate were silently dropped every reload (weather still "worked" because the resolve helpers fall back to built-ins, but authored patterns were lost). Restore them explicitly, mirroring the constructor's defaults + normalizers — the same fix the races/sounds fields already needed on this path. tests: cover the restore round-trip through rebuildWorldFromSnapshot (authored patterns, a rename, and the conditions/climates catalogs all survive); update the world-inner-tabs test for the new Profile default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Root cause of the intermittent "brief shows on reload but not live": brief element ids were `tod-brief-<n>` off a counter that RESETS to 0 each page load, while a continued/restored story still carries its old tod-brief-<n> ids in the mounted window. A new brief could therefore mint an id that already exists in the DOM, so fillTimeOfDayBrief's getElementById patched the OLD restored node instead of the new placeholder — the live view never updated, but the message log did, so it appeared only after a reload re-rendered the story. - Salt brief ids per page load: nextBriefId() → `tod-brief-s<base36-wallclock>-<n>` (via _briefIdSalt = 's' + Date.now().toString(36)). The salt differs every load, so a fresh brief id can never collide with a restored legacy id, and getElementById fills the correct new placeholder live. All three emit sites (time-of-day beat, weather-change beat, in-place condition brief) use it. - Log the brief request + JSON response to the Logs tab (category "gm"), plus every skip/empty/failure path (busy-skip, no key, parse failure, failed call, player-left, empty result) so a missing brief is diagnosable. tests/test_weather_imagery.js: assert salted, unique, non-colliding brief ids and the request/response/skip logging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each Editor › Rooms card now has a "Dynamic Banners" checkbox (default ON, so existing behavior is unchanged). When on, the room's story banner tracks the weather — re-painted under the current sky as it changes, as today. When off, the room keeps its pinned image (if pinned) or its configured time-of-day image (the carousel slot) and is never weather-modified. - Room model: dynamicBanners own prop, default true (false only when explicitly stored), round-trips through save/reload. - weatherArtAppliesTo gates on dynamicBanners !== false, so a room opted out gets no fresh-art weather clause, no img2img re-paint, no cached weathered banner, and no weather change-key — its banner falls back to the plain base. - buildRoomCard renders the checkbox beside Interior/Visited; setRoomDynamicBanners persists it, re-arms the weather detector, and — when turned off for the player's current room — reverts the live banner to its base immediately. tests/test_weather_imagery.js: default-on applies weather art; off opts out of the scene-prompt clause, the cached weathered banner, and the change key; plus model default + card checkbox + handler wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A weather re-paint only shows the model the existing scene, so it had no cue for the hour and could re-light a midnight banner as afternoon. The edit clause now names the time of day (dawn/morning/afternoon/evening/dusk/midnight) so the AI paints the sky's brightness/darkness and shadows for the correct hour, not just the weather. - timeOfDayEditBit(label): a short "The scene is at MIDNIGHT — paint the sky, the level of daylight or darkness, and the shadows to match midnight." clause. - weatherEditPromptClause / weatherEditInteriorPromptClause take a `label` and fold it in (falling back to the old isNight hint when no label is given). - shownBannerTimeLabel(room): the time of day the shown banner depicts — a pinned slot that has art wins for every hour (mirrors getBannerImageFor), else the current time of day. refreshCurrentRoomBannerWeather feeds it into the clause so the re-paint keeps the banner's own hour. tests/test_weather_imagery.js: assert the time of day appears in both edit clauses, shownBannerTimeLabel resolves the pin/current label, and the re-paint threads it through. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On login the sky can have advanced while logged out: the clock tick re-skins the current room's banner to the new condition (Heat) in place, but its weather chip stayed on the old sky (Fog) and no GM brief described the change. During normal play a condition change prints a whole new scene beat, so this only bit the in-place re-skin path (load, or toggling Weather Imagery on). - patchLiveRoomTitleChip(roomId, st): the chip counterpart to patchLiveRoomBanner — rewrites the weather chip on the SAME latest scene whose banner was just re-skinned (matched by that room's most-recent banner-bearing title, in the DOM and the persisted log). Room-scoped and forced, so it only touches the one scene the banner update touched — never an earlier occurrence. Returns the condition the chip previously recorded. - reflectWeatherRepaintInStory(room, st): called right after each successful patchLiveRoomBanner in refreshCurrentRoomBannerWeather. Updates the chip (issue A), and when the chip had recorded a DIFFERENT condition — a real change no scene beat announced — appends a GM weather brief for the new sky (issue B) via emitWeatherConditionBrief, baselining the brief key so the tick doesn't then fire a second, redundant beat. During play the beat already updated the chip, so prev matches and both stay quiet (no double brief). tests/test_weather_imagery.js: cover chip-follows-banner (per room), the prior-condition return value, the brief on a genuine change, and no brief on a same-condition re-skin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Update the room-creation GM directive so every new room carries an explicit "interior" boolean — true for indoor rooms, false for open-air ones — instead of only nudging it for a building's main indoor space. It now states the rule plainly: any room given an "interiorOf" is ALWAYS "interior": true; a building's own main room entered from outside (inn common room, shop floor, temple nave, great hall) is "interior": true even without an interiorOf; open-air places (streets, yards, gardens, fields, a cave/barrow mouth open to the sky) are "interior": false. Also enforce the invariant in the Room constructor: a room with interiorOf whose "interior" flag wasn't given explicitly resolves to true (rather than leaving the null "auto"), so the stored data matches the rule the GM authors to. roomIsIndoors already treated these as indoors, so behavior is unchanged; the data is now explicit. tests/test_weather_imagery.js: assert the interiorOf⇒interior:true invariant and the directive's explicit-flag wording; update two assertions for the reworded brief (TASK 4) and interior (TASK 5) directives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New Settings ▸ Gameplay checkbox "Short GM Briefs" (shortGmBriefs, off by default). When checked: - buildSystemPrompt appends a SHORT MODE hard cap so the GM's story "narration" (the msg-narrator text) is 40 words or fewer, overriding the verbose/brief verbosity guideline; all structured JSON fields are untouched. - requestTimeOfDayBrief (which backs both the time-of-day and the weather-change briefs) swaps its "1–3 short sentences" instruction for "40 WORDS OR FEWER". The checkbox writes the setting via setSetting and is restored to its saved value when the Settings panel opens (alongside the other checkboxes). tests/test_short_gm_briefs.js: prove the setting drives the system prompt on/off, the brief directive is length-gated, and the checkbox is wired + restored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Both region <select>s on the Rooms tab (the top-of-tab filter
.rooms-region-select and the per-room .rr-room-region) styled only the closed
control, leaving the native <option> list to fall back to the OS light palette
(white background, black text) — the browser-default look. Add the same
option { background/color } theming the app's other selects use
(.settings-select, #regions-stitch), and color-scheme: dark so the control
chrome (arrow/background) also honors the dark theme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe GM time-of-day / weather brief lands ASYNC: its room message starts as a one-line "…" placeholder (often too short to reach under the open dice bag, so applyDiceMessageNarrowing skips it), then grows to 1–3 sentences that run beneath the bag with no re-narrow. fillTimeOfDayBrief now re-applies the story-message narrowing right after injecting the text — idempotent, and a no-op when the bag is closed — so the brief pulls clear of the bag like every other story message. tests/test_weather_imagery.js: assert the re-narrow call, widen the fill regex span for the added lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add claude-opus-5 ("Claude Opus 5") to the gameplay login picker
(MODEL_CHOICES), the shared MODEL_LABELS, and the World Builder generation
picker (WORLD_GEN_MODEL_IDS), plus the static #we-model options. Both pickers
rebuild their options from these constants, so the new model appears in each.
Existing defaults are unchanged (gameplay still defaults to the first entry,
world-gen still to Opus 4.8).
tests/test_model_select.js: assert MODEL_CHOICES offers claude-opus-5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomWhen the sky's condition changed, the clock tick rewrote the LATEST story room-title's weather chip to the new sky (updateLatestRoomTitleWeatherChip) and THEN printed a fresh scene beat for the change — so the previous scene lost its original chip and the story showed the new weather twice instead of the passage from one sky to the next. Now the in-place chip refresh only touches the latest title when that title still depicts the CURRENT condition (the sky merely drifted in temperature/wind). When the condition itself changed, the latest title is the record of the earlier sky at this time of day — it's left untouched, and the freshly-printed scene beat (weather-change or time-of-day brief) carries the new condition. Earlier occurrences of the same time of day keep their original chips, so the story reads clear → rain rather than rain → rain. - weatherTitleChipMarkup tags each chip with data-weather-cond (the condition it depicts); a legacy chip without it falls back to the old update-in-place. - updateLatestRoomTitleWeatherChip gates the DOM + persisted-log rewrite on the latest chip's recorded condition matching the current one. tests/test_weather_imagery.js: cover the gate — a latest title under a different condition keeps its chip (DOM + log), a same-condition title is refreshed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A building's own main room (an inn common room, shop floor, hall) is a structure ROOT — it HOLDS interiors but has no interiorOf of its own, so roomIsIndoors() (which only tested interiorOf) classified it as EXTERIOR and weathered its whole banner. The Rusty Flagon — Common Room is exactly this case: indoors, with windows, wrongly getting a full-scene weather repaint. - Room model: add an explicit tri-state `interior` flag (true = indoors even without an interiorOf parent; false = force outdoors; null = auto/legacy). It's an own property, so it round-trips through save/reload like interiorOf. - roomIsIndoors(): the explicit flag wins, else fall back to the interiorOf heuristic. This is the indicator a parent/root interior room previously lacked. - Weather art strategy is now three-way: exterior → whole scene weathered (unchanged); top-level INTERIOR (indoors, no interiorOf) → WINDOW-ONLY — the prompt confines the change to what's visible through windows/openings and no-ops when nothing outside is in view, leaving the interior untouched; buried inner room (interiorOf) → never weathered (unchanged). New interior clauses for both the fresh-scene injection and the img2img repaint. - Default world: flag inn_common_room "interior": true. - DM Rooms card: an "Interior" toggle (top-level rooms only) + setRoomInterior, which live-updates the header chip and resyncs the banner/sidebar; the "interior" header chip now reflects effective indoors state. - GM world-authoring directive: document setting "interior": true on a building's own indoor main room (distinct from interiorOf for nested rooms). tests/test_weather_imagery.js: cover the top-level-interior path (still weathered, but window-only), the two interior prompt clauses, and the model/default-world/DM-toggle/GM-contract wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor › World › Weather tab reused the shared toolbar/view/edit markup but none of the shared styling actually applied to it, so its margins/spacing were off and the GM input box was unthemed: - The panel wasn't a positioning context, so the absolute corner toolbar anchored to the wrong ancestor (like #world-inner-factions, the panel now sets position: relative). - The #weatherpat-toolbar / -view / -edit / -edit-input IDs were in none of the shared editor style groups, so the toolbar overlap padding, view scroll area, edit-box border/background, and the GM input theming (background, border, focus ring) were all missing. Added a dedicated block mirroring the shared editor styling (the same idiom the Magic/Skills block uses). - Moved "+ New pattern" out of an extra row between the view and the GM box (which no other tab has) into the toolbar as a "+ New" tool-group, so the panel body is toolbar → view → GM box exactly like Factions/Skills. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The GM-authored brief below a room banner wasn't appearing: - Time-of-day brief bug: requestTimeOfDayBrief capped the GM at maxTokens 500. The GM answers with its FULL JSON object (every schema field, most empty) plus the narration, which overran 500 tokens — so extractJsonObject/JSON.parse failed and the brief silently came back empty (the placeholder cleared to nothing). Raised to 1500, matching the working ambient calls. - Weather change previously never printed a brief at all — it only patched the banner/chip in place. Now a change of the sky's CONDITION (clear -> rain), when there's no time-of-day change, prints its own scene beat: a fresh room title (with the new weather chip) + the current banner + a GM brief below it — the same treatment a time-of-day change gets. Condition-level (minor temp/wind drift stays a quiet in-place patch), one at a time, never in combat, and never on a tick a time-of-day scene already printed. Room entry baselines the sky so moving between regions isn't mistaken for a weather change. tests/test_timeskip_room.js: assert the 1500-token budget and cover the weather-change beat (baseline, condition-change beat, temp/wind-drift no-op, and the tick + room-entry wiring). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Implement the world-editable weather-pattern catalog and its editor, per
Designs/calendar.html:
Data model
- world.weatherPatterns: a named seasonal regime = { id, name, description,
days[] } where each day-type is a small authored WeatherState (base condition
+ label/glyph/intensity/tempBand/wind/description) plus a relative chance.
- WEATHER_PATTERNS built-ins seed the four seasons; normalizeWeatherPatterns /
normalizeWeatherDayType are tolerant and reseed from the built-ins. Round-trips
through the World constructor and serializeWorld like the other catalogs.
- Day-type bands use the shipped engine vocabularies (intensity
light/moderate/heavy, WEATHER_TEMP_BANDS, WEATHER_WINDS) so they wire straight
into the resolve engine in a later pass.
Editor tab (Editor › World › Weather)
- Standard editor chrome: a name/condition filter, Import/Export, Collapse all /
Expand all (via the generic EDITOR_IO adapter + persisted collapse state), and
a GM authoring box (create/edit/remove patterns by prompt).
- One card per pattern with click-editable name + description and a "Conditions"
section listing the pattern's day-types (chip + summary) with add/edit/remove.
- A WeatherState (day-type) popup editor mirroring the Ability editor: a
"start from…" preset dropdown, base-condition/label/glyph/intensity/tempBand/
wind/description/chance, and Use.
tests/test_weather_patterns.js covers the model round-trip, normalize clamping,
create/edit/remove specs + import, the card + Conditions rendering, the popup
form round-trip, and the tab's static wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomSpec the Editor › World › Weather tab to match every other editor tab: - Top toolbar: name filter, Import / Export, Collapse all / Expand all, and a GM request box pinned at the bottom to create / edit / remove weather patterns by prompt. - Each weather pattern is a first-class card (like items/rooms/entities) with click-to-edit inline fields (name, description). - The card holds a "Conditions" section listing the pattern's day-types, with add / edit / remove via the WeatherState (day-type) popup editor. - Add a note disambiguating the two senses of "condition" (the plural Conditions list of day-types vs. each day-type's single base condition field). - Update the mock and the §08 touchpoints row to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold the revised weather-pattern model into the calendar doc and reconcile it with the weather doc: - calendar.html: a day-type is a full WeatherState (base `condition` + authored label/glyph/intensity/tempBand/wind) plus a `chance`, authored in an ability-editor-style popup; the forecast strip shows the base `condition`. Reconcile field values to the shipped WeatherState vocabulary (intensity none/light/steady/severe, wind calm/breeze/rising/gale) and fix the leftover `type` -> `condition` in the resolve pseudocode. - Promote the free text to a first-class `description` field on the shared WeatherState (renamed from the day-type-only `flavor`), used across both docs. It is the DM/GM's words for the sky: it drives narration and the img2img weathering, and is what the GM reads to adjudicate status effects and damage beyond the bare condition (a "rain" of frogs still strikes you). Add the field to the weather doc's WeatherState model and to §08 (status effects) as its authoring home. - Add reciprocal cross-references between the two docs at every seam (season -> weather pattern, value-noise -> per-day weighted pick, forecast display, the img2img colour descriptor, status effects). Fix the weather doc footer, which still said "not yet built" while the header says Phase 1 shipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold the DM's answers into Designs/calendar.html and mark all eight decisions
confirmed:
- A: keep the Gregorian calendar for Phase 1 (cosmetic before virtual).
- B: fixed 12 months / 7 weekdays in Phase 1.
- C: patterns are a separate object from climates.
- D: patterns & month bindings fully free.
- E: chance as relative weights (engine normalises).
- F: GM-adjudicated, per-playthrough-variable effects (already confirmed).
- G: every day-type maps onto ONE known structured condition (drives label /
glyph / sidebar / header / forecast); the free text becomes a `flavor` colour
descriptor threaded into GM narration + the img2img weathering prompt — so
"rains frogs" is still rain, "acid-green fog" is still fog. Day-type shape is
now { condition, chance, flavor }; updated the §03 model, §05 resolve, §06
Weather tab, §07 defaults, and the §08 touchpoint table to match.
- H: defer; future weather-override hierarchy room → region → calendar (the
always-desert region with its one oasis room). Folded into Phase 3.
Status/footer updated to "decisions settled — Phase 1 ready." Design doc only;
no engine code changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe story room-title now carries a current-weather pill (glyph + condition) at its right end, after the time of day — mirroring the region pill. It rides both the room-entry title and the time-of-day-change title. When the sky changes with no new title printed, updateLatestRoomTitleWeatherChip refreshes the chip on the MOST RECENT title only (live DOM + persisted log, so it survives a reload) — never an earlier title, mirroring patchLiveRoomBanner. Hooked into the same tick-side detector that refreshes the sidebar Weather block. Tests: test_weather_imagery.js (chip markup, empty when weather off, newest-only in-place update); test_room_title_region_chip.js updated for the appended chip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A deliberate wait/rest that jumped time still reprinted the room's default description via the fromTimeSkip branch — the exact "repeated default description" the brief was meant to replace. Key the choice purely on whether the prose actually changed: prose differs → full authored scene; prose unchanged (including after a wait/rest skip) → title + banner + GM brief. test_timeskip_room.js updated: an unchanged-prose jump now shows the title+banner+brief (no repeated description/Exits), plus a new case verifying a genuinely time-varying room still reprints its authored prose + detail lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On a passive time-of-day change where the room's authored prose is UNCHANGED, stop reprinting the (identical) baked-in description. Instead print the title (with the new time) + the current banner, then slot in a fresh 1–3 sentence GM "brief" describing the room at this time of day and weather — generated async and filled in under the banner. - requestTimeOfDayBrief(): a lightweight GM call (JSON narration only, ≤500 tokens) grounded in the room, the new time-of-day label, and the current weather; one at a time, key-gated, fire-and-forget. - fillTimeOfDayBrief(): slots the text into the live banner message AND the persisted message-log entry (so it survives a reload), then saves; an empty result clears the placeholder. - A changed-prose hour, or a deliberate time skip (rest/wait), still prints the full authored scene + detail lines (re-orienting the player) — unchanged. Tests: test_weather_imagery.js (brief wiring + fillTimeOfDayBrief DOM/log update); test_timeskip_room.js (natural unchanged-prose change now shows the title+banner+brief placeholder, not the repeated description or Exits). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two coupled fixes for the banner/time mismatch: a "The Village Square : Afternoon" scene's banner turned into a midnight one when the weather changed hours later. 1) On EVERY time-of-day change, reprint the whole scene — title : new time, the current banner (its weathered version for the current sky, else the plain time-of-day image), and the current description — as a fresh entry. Replaces the old "reprint only if the description changed, else echo 'It is now …'". Each time of day now gets its own correctly-labelled banner in view. 2) patchLiveRoomBanner now re-skins only the MOST RECENT banner, never an earlier scene's banner scrolled up. So an in-place weather update can't retroactively change a past "…: Afternoon" scene to the current sky. Tests: test_weather_imagery.js (always-reprint; patch touches only the newest banner, earlier ones untouched); test_timeskip_room.js updated — a natural time-of-day change now reprints the full scene instead of a terse note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Regenerate Web/Reports/progress-report.html from git log; the prior snapshot didn't include the commit that created it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcFAHt8SSpXoPwgmppqHpC
Regenerate Web/Reports/progress-report.html from git log to pick up the commits made since the last snapshot (July 24th -> July 26th). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C8jkn2CjAo8XWcZQhXGoxB
A time-of-day transition that reprints the scene ("The Village Square :
Evening") only re-showed the banner when the banner IMAGE URL differed from the
prior hour. With dynamic weathered banners the base URL often stays the same
while only the sky changes, so the banner was skipped and the current
(weathered) image scrolled off with the previous scene.
Show the banner on every such reprint when the room has one; renderRoomBanner
already resolves the weathered banner for the current sky (else the plain
time-of-day image), so the current banner rides along with the new description
and stays in view. Only fires on a genuine scene reprint (description changed
or a rest/wait time-skip), not on every clock tick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomWith Auth0 on, the catch-all static route gated EVERY path behind requirePlayer,
including /manifest.webmanifest. The browser fetches the PWA manifest (and its
icons) WITHOUT credentials, so the session cookie isn't sent, requirePlayer
302-redirects it to Auth0's cross-origin /authorize, and the manifest fetch —
which can't follow a cross-origin redirect — surfaces as a CORS error ("No
Access-Control-Allow-Origin"). The state param decodes to
{"returnTo":"/manifest.webmanifest"}, confirming it.
Serve a small allow-list of public branding assets (manifest, icon.svg,
favicon, apple-touch-icon, robots.txt) directly, BEFORE the player gate, so
they return the file (200) instead of a login redirect. They carry no game or
user data. Everything else — the app html, /vault/config — stays gated.
core.isPublicAsset() (case-insensitive, query-tolerant) drives the allow-list;
covered in test_vault_core.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom"could not read the source banner to weather" happens when the shown banner is a cross-origin URL: the browser can't read its pixels (CORS blocks both fetch and a tainted canvas), so there are no inline bytes to send the Weather AI. It surfaced after a reload, when the banner is loaded from a URL rather than a freshly-generated inline data URI. Let the vault fetch the source server-side (no CORS there): - server: fetchImageAsDataUri(url) — http(s) only, image/* only, size-capped, and SSRF-guarded (isPrivateImageHost blocks localhost / loopback / private / link-local / CGNAT / IPv6 ULA). runDescriptor now resolves a URL initImage into inline bytes before prepending it (data URIs behave exactly as before). - client: when imageToDataUri can't inline the source, paintWeatheredBanner hands the plain URL to the vault instead of erroring; it only fails when the source isn't a fetchable URL at all. Tests: server URL-fetch + SSRF-refusal + unit checks (test_providers.js); client fallback wiring (test_weather_imagery.js). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Routing the Weatherlore link through the Skills-tab tree popup left it easy to
miss (a small corner popup inside another tab). Instead, open the skill detail
in #sidebar-entity-popup — the SAME popup, in the same top-right story-view
location, that the Occupants block opens for an NPC — so it's always visible
without leaving the game.
Factor skillTreePopupHTML's body into a shared skillDetailBodyHTML(sk, {closeButton})
so the tree popup and the framed story popup reuse one renderer (the story
popup supplies its own ✕, so it passes no close button). openWeatherloreSkill
now calls showEntityPopup('sidebar-entity-popup', skillDetailBodyHTML(sk)).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomClicking the Weather block's "Weatherlore" link navigated to Character › Skills › Tree and opened the skill popup, but on a narrow/mobile viewport the popup was invisible: the skill-tree graph makes the tree body wider than the screen, and the popup's absolute right:10px anchored to that overflowing body put it entirely off-screen (measured x≈491 on a 430px viewport). Anchor the popup to the viewport instead: position:fixed, with a JS helper (positionSkillTreePopup) that pins it to the top-right of the VISIBLE tree area clamped on-screen each time it opens. Desktop looks identical (same top-right corner); narrow screens now keep it fully visible. Applies to every skill popup (node-title clicks too), not just the Weather link. Extended test_weather_ui.js: the popup is fixed, openSkillTreePopup positions it, and it receives explicit on-screen coordinates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
In the locked Weather block ("Learn Weatherlore to read the weather to come"),
Weatherlore is now a link. openWeatherloreSkill() jumps to Character › Skills ›
Tree and opens the Weatherlore detail popup — the same card the skill tree's
node titles open. The link stops click propagation so the tree's outside-click
dismiss handler doesn't immediately close the popup, and it's keyboard
accessible (role=button + Enter/Space). Safe no-op in a world without the skill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomClicking a room banner in the Story tab used to swap the <img> src to the room's predefined time-of-day gif on enlarge, and back to the preset static on shrink. When the shown banner was a live weathered image, enlarging replaced it with the gif and shrinking reverted to the plain default — losing the weather. Make the toggle change ONLY the width (the .expanded class already scales it via CSS) and leave whatever image is currently displayed in place, so a weathered banner stays weathered at both sizes (and a plain one stays plain). New test tests/test_story_banner_enlarge.js drives the delegated click handler and asserts the weathered src survives enlarge → shrink. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The header weather text repaints every clock tick (renderClockDisplay), but the sidebar Weather block only rebuilt on a full updateSidebar (room change / action). So as the deterministic weather ticked over between renders, the block drifted out of sync — e.g. the header reading Fog while the block still said Clear. Add a change-detector (currentWeatherBlockKey = region · condition · temp · wind · indoors) and maybeRefreshWeatherBlock(), called from the same clock tick that repaints the header. It re-renders the block only when the signature changes; updateWeatherBlock syncs the key on every render so refreshes from any caller count as up-to-date. Cheap no-op when nothing changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The 🎬 emoji rendered in full color, unlike the sibling portrait buttons (upload/regenerate glyphs and the enlarge SVG, all inheriting the app color). Replace it with a stroke video-camera SVG using stroke="currentColor" and the same sizing/attrs as the enlarge button, so it picks up the themed color and hover states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A wide room banner, scaled so its longest side hit the 1024px cap, could end up under ~300px tall — and Runware rejects frame images whose height is outside [300, 6000] (400 invalidHeight). Teach compressImageForInit two optional bounds: minDim floors the SHORTER side (upscaling a too-short banner) and hardMax caps the LONGER side. The three video callers (vault + both direct providers) now pass minDim:340, hardMax:6000 so a banner of any aspect ratio lands inside Runware's frame-size range. Image-to-image callers pass neither, so their cap-only behavior is unchanged. New test tests/test_video_init_compress.js drives the scaling math with an Image/canvas stub (wide banner floored, tiny banner upscaled, normal untouched, no-minDim path preserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a fourth icon (🎬) under the Character-sheet portrait, beside upload / regenerate / enlarge. It opens a lightbox (reusing the shared image/video lightbox overlay and sizing) showing the current portrait with a centered Generate / Re-Generate pill. Generate feeds the portrait to the selected Video AI (image-to-video, same pipeline as room banners) and swaps the still image for the returned clip in place; the clip persists on player.portraitVideo and round-trips with the save. The button only appears when a portrait exists. New test tests/test_character_video.js covers the field default, the gated button wiring, the lightbox open/generate/regenerate/failure/close flow, and that generation runs through generateVideoWithProvider. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Runware AIR identifiers use the vendor:name@version form — LTX works as lightricks:ltx@2.3-fast, but our Seedance id bytedance:seedance-2-0-fast had no @version segment, so Runware rejected it (400 invalidModel). Match the LTX pattern: bytedance:seedance@2.0-fast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Extend DEFAULT_VIDEO_PROMPT (server + client) with "Do not add music to the video." so generated clips stay silent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Visited checkbox previously rode the Video label row. Move it to the "Artwork" field row instead, right-aligned to the card's far edge: the Artwork value cell becomes a space-between flex row (artwork text left, Visited toggle right), and the Video label is back to a plain "Video". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Runware's videoInference rejects an empty positivePrompt (400 invalidPositivePrompt — must be 2–10000 chars). The design keeps the banner image as the real driver, but the prompt field can't be blank, so default it to a static-camera "just animate it" instruction that tells the model to breathe the existing scene rather than re-invent it: "Animate this image. Leave the camera position static in its current position. Do not add or remove anything from the scene or change the coloring." Applied on both the server (vault path — DEFAULT_VIDEO_PROMPT threaded through runVideo for both Runware and Higgsfield) and the client direct-mode calls, used whenever no explicit prompt is passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The prior header layout parked the Visited toggle at the right edge of the Banner column — i.e. the seam against the Video column — so it read as attached to the VIDEO label / preview. Equalize the Banner and Video columns to identical width (flex 1 1 0, matched min-widths, and drop the banner's 460px cap in this row so it can't be narrower than the video), and move the Visited toggle to the far-right of the Video label row. It now sits above the top-right corner of the video preview, clear of both the VIDEO label and the box. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Runware's videoInference task rejects a top-level `frameImages` parameter (400 unsupportedParameter). The image-to-video source must be nested under `inputs.frameImages` as first-frame objects, and width/height are not supported alongside it (dimensions are inherited from the source image). Fix both the server executor (vault path — the one that runs for vault users) and the client's direct-mode runwareVideoGenerate, plus the debug log line. Update the server and client tests to assert the nested shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The "BANNER" label sat above the row while the "Video" title sat inside the video column, pushing the video box a title-height below the banner image. Moved both labels into one two-column header (matching the media columns' flex) so "Banner" and "Video" share a line and the banner image + video box align at the top; the media row now uses align-items:flex-start. Visited toggle still rides the Banner label. Tests updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Runware's videoInference `model` is the AIR id, not the friendly name. Keep the display labels but send the correct ids: LTX 2.3 Fast → lightricks:ltx@2.3-fast, Seedance 2.0 Fast → bytedance:seedance-2-0-fast (and the server-side fallback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The /v1/image2video/dop endpoint rejects any model but 'dop-lite' / 'dop-preview' / 'dop-turbo' (a 422 on 'seedance-2.0'). "Soul Cinema" / "Seedance" aren't valid there — DoP is Higgsfield's Director-of-Photography model with three quality tiers, and Seedance is available via the Runware provider instead. - HIGGSFIELD_VIDEO_MODELS → dop_turbo (fast, default) / dop_preview (best) / dop_lite, each sending the API-accepted slug. A stored obsolete key (soul_cinema/ seedance) self-heals to the default. - Updated test_higgsfield_video / test_vault_video for the DoP tiers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fixes two vault-mode bugs: only Runware showed in the Video AI list, and generating errored with a stale "no Runware key — API Keys dialog" (video was never routed through the vault, so the server-side key was never used). - Dropdown: the server catalog merge kept only the server's slots for a shared provider, dropping Higgsfield's client-only VIDEO slot (the server knows Higgsfield only as an image provider). VIDEO is client-only — it has no server descriptor; it routes to /vault/video — so the merge now preserves the video slot for client video providers while still honoring admin slot-restriction overrides for descriptor slots. - Vault routing: generateVideoWithProvider now POSTs to a new /vault/video in vault mode (key stays server-side); direct mode still calls the provider REST. The client resolves the model KEY to its underlying slug and sends the source image inline (downscaled). - Server: providers.runVideo runs both image-to-video APIs server-side — Higgsfield DoP (compound-header auth, job-sets poll) and Runware videoInference (Bearer auth, getResponse poll) — host-pinned, with a clear "no key — add it on the admin page" 503 and upstream-error surfacing + a one-line failure log. New /vault/video route. - tests: server/test/test_run_video.js (both providers, key-missing, upstream error); tests/test_vault_video.js (slot union + vault routing); updated test_provider_catalog for the preserved video slot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a Video placeholder to the RIGHT of the banner in each Editor › Rooms card: - buildRoomVideoBlock: titled "Video". Empty → a placeholder with a centered Generate button (disabled until the room has a banner image to animate); with a generated clip → an inline muted/looping <video> that opens a large lightbox on click, plus regenerate/remove actions. - roomVideoGenerate feeds the CURRENTLY-shown banner (pinned slot or the carousel's time-of-day slot — roomShownBannerUrl) as the image-to-video source to the selected Video AI; no prompt is sent (the image directs the animation). The returned clip is stored on room.bannerVideo and swapped into the card in place. - Both providers implemented: runwareVideoGenerate (POST api.runware.ai/v1, a videoInference task with frameImages, async getResponse polling → mp4) and the existing higgsfieldVideoGenerate; generateVideoWithProvider dispatches to the selected one. Sources are downscaled/JPEG-compressed before sending. - Clicking the clip opens #room-video-modal — the same centered lightbox overlay (and sizing class, .img-lightbox-img) used when a Rooms-card banner is clicked, playing a <video> (openRoomVideoModal / closeRoomVideoModal). - room.bannerVideo persists through serialize/rebuild. - tests/test_room_video.js (25 checks). Runware request shape per the official Runware video-inference API; verify field names against your account if a call errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds Higgsfield as a second Video AI provider alongside Runware, with a real
image-to-video generate function:
- HIGGSFIELD_VIDEO_MODELS { soul_cinema, seedance } — each carries its endpoint +
model slug (adjustable in one line). VIDEO_PROVIDERS.higgsfield wired with a
generate fn; it auto-appears in the Video AI provider dropdown (built from the
catalog).
- The Video AI model box is now provider-aware: switching Runware↔Higgsfield swaps
the model list, each stored under its own setting key (videoModel /
higgsfieldVideoModel) so choices don't clobber each other. getVideoModel/
setVideoModel/videoModelLabel/refreshVideoModelRow generalized via
videoModelsFor / videoModelSettingKey / videoModelDefaultFor.
- higgsfieldVideoGenerate(prompt, {initImage, model}): POST /v1/image2video/dop
with { params: { model, prompt, input_images:[{type:'image_url',image_url}] } },
same hf-api-key/hf-secret auth and job-set polling as the image path; returns the
mp4 URL (browser-renderable in <video>). Endpoint + request shape confirmed
against the official Higgsfield SDK; the two model slugs are best-effort and
isolated in the registry.
- tests/test_higgsfield_video.js (28 checks); updated test_provider_catalog for the
new video-slot provider.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomEnrich the GM-discretion callout: a day-type resolves into potentially MULTIPLE summed status effects whose severity the GM weighs against where the player is, their condition (wounds, race, class), and — key — their EQUIPMENT (metal armour conducts cold, a torch/cloak wards it, sodden boots invite worse, a torch gutters in a gale). The same snowy day barely touches a northerner by a fire but stacks chilled+slowed+exhausted on a wounded traveller in steel plate with no flame — making weather a strategic layer players build for and around (gear, light, shelter, timing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
- Fold in the confirmed steer: the engine's PICK of a day-type is deterministic, but the EFFECTS (kind + severity) are GM-adjudicated in context (indoors vs out, the narrative beat, player status, race/class) and are MEANT to vary across playthroughs — weather is like that. Added as a callout in §03 and as a confirmed decision F. - Add decision G (under study): how the plain-text day-type relates to the built STRUCTURED weather (conditions/climates that drive the glyph, sidebar/header, and img2img art). Records the likely happy medium — the text mostly tells the GM which structured pattern/condition applies, returning a fully-authored one only when the day fits none — to be settled after a closer read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reworks the calendar proposal around the refined model:
- Weather pattern = a named object holding a WEIGHTED SET OF DAY-TYPES. Each
day-type is PLAIN TEXT the DM/GM writes describing that kind of day, with a
relative chance (+ optional glyph/label for the header). The engine only PICKS
which day-type is in force (deterministic, seeded); the GM EXECUTES the text —
narrates it and applies any status effects to the player/environment. This folds
in weather's deferred race/class-gated status effects naturally.
- Two new Editor > World tabs: Weather (author patterns + their day-types + the
region climates that bias the pick) and Calendar (months, weekdays, current
year; assign each month a pattern — one pattern can cover many months).
- world.calendar (months[{name,pattern}], weekdays, year) + world.weatherPatterns,
seeded from today's constants + the four seasons; resolve = weightedPick over the
month's pattern's days[]; region climate still biases. Gregorian substrate
untouched in Phase 1; virtual calendar deferred to Phase 2.
- Three layers named (condition=what / climate=where / pattern=when); engine
touchpoints, editor mockups, and 7 open decisions with recommendations.
Discussion draft — decisions open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomDesigns/calendar.html — a proposal to make the calendar world-owned, prompted by
the question of whether weather ties to the months (it does — season = floor(month/3))
and whether a generated world can have its own calendar (it can't today; REALM_MONTHS
/ REALM_WEEKDAYS are hard-coded globals).
Proposes world.calendar { months, weekdays, seasons, monthSeasons, epoch?, moon? }
seeded from today's constants (nothing regresses), with the weather season lookup
reading world-defined seasons + their tempSwing/conditionBias instead of a fixed
quarter-slice. Separates a low-risk Phase 1 (cosmetic rename + re-season over the
Gregorian Date substrate) from a deferred Phase 2 virtual calendar (arbitrary
month/week/day structure). Six open decisions with recommendations; engine
touchpoints and authoring (GM at world-gen + a DM Calendar editor) laid out.
Indexed in Designs/README.md as a companion to weather.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAdds a compact glyph + label (e.g. "☁️ Cloudy") to the header bar, just left of the digital clock / time-of-day indicator. Updated every clock tick in renderClockDisplay from the player's region weather at the displayed instant; hidden when the world has weather disabled or none resolves (e.g. pre-play). Styled to match the time-of-day indicator. Independent of the Show Weather Imagery setting — it's just text. tests/test_weather_ui.js: header element sits left of the clock, renderClockDisplay sets the glyph/label and shows it, and it hides when weather is disabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The vault relayed a provider's non-OK status to the browser but logged nothing and discarded the error body — so a Gemini 429 surfaced in the browser while the server console stayed silent and the reason (quota / rate-limit) was lost. - providers.js runDescriptor: on a non-OK upstream response, read the error BODY and include a trimmed snippet in the returned error (so a 429 says WHY), with any key= in the echo redacted. Guarded for stub responses without .text(). - server.js /vault/generate: console.warn a one-liner on failure — provider/model/status/reason. - server.js /vault/gm: same one-liner for any upstream error status (e.g. a Claude 429/529), with the reason pulled from the error body. - test_providers.js: assert the upstream body is captured in the message and key= is redacted. The vault itself does NOT rate-limit; these statuses come straight from the upstream provider. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
paintWeatheredBanner now takes the weather label and folds both the room name and the weather into every log line — so a failure (e.g. a 429 rate-limit from Nano Banana) reads `Weathering the room banner via Nano Banana failed for "The Village Square" (Heavy rain, windy) — …` instead of naming only the room. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The weathered-banner request sent the room's banner inline as the init image; a
built-in banner is a multi-MB PNG whose base64 exceeded the vault's request-body
limit, so Nano Banana image-to-image failed with a 413 "request entity too large".
- Add compressImageForInit(src, {maxDim, quality}): loads the image, downscales the
longest side to maxDim (default 1024) and re-encodes as JPEG via canvas; falls
back to the original source if it can't be decoded / the canvas is tainted.
- paintWeatheredBanner now compresses the source banner (1024px, q0.82) before
sending on both the vault and direct paths. The weathered output is a fresh
render, so the small quality hit on the SOURCE is invisible.
- Raise the vault server's JSON body limit 4mb → 12mb so an inline source image
(weathered banners, portrait-gallery variations) never trips a 413. (Requires a
server restart to take effect; the client-side compression already fixes the
common case without one.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomBehind a new "Show Weather Imagery" setting (off by default), exterior-only: - Fresh scene art: room-banner prompts (story on-demand + editor per-time) have the current sky woven in via withWeatherInScenePrompt, so a rainy moor is painted rainy. Off/interior/blank-prompt → unchanged. - Weather-change re-paint: when the sky changes for the current room, the SHOWN banner (pinned image or current time-of-day slot) is re-painted under the new weather via image-to-image (paintWeatheredBanner), swapped into the live banner in place. Detected on the clock tick (maybeRefreshWeatherBanner) keyed by room|condition|base; cached per (room, base, condition). Always weathers FROM the stored base — never a prior weathered result — so quality never compounds. - New "Weather AI" provider slot (image-to-image only, preset Nano Banana), mirroring Gallery AI: getWeatherProvider/setWeatherProvider, the weather slot in GENERATION_SLOTS/SLOT_SELECT/providerForKind/nanoModelForKind, a settings block + Nano Banana model row, synced on Settings open. - EXTERIOR-ONLY: an indoor room (interiorOf) has no visible sky, so its banner is never weathered and its fresh art gets no weather clause (weatherArtAppliesTo). - renderRoomBanner prefers a cached weathered image when one matches. tests/test_weather_imagery.js (31 checks). Widened three proximity-window assertions in test_story_banner_gen / test_art_style_override to accommodate the one added weather-injection line (the calls are otherwise unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Weather is now a first-party engine feature (its own native "Weather" block), so
the browser-extension PoC is generalised into a neutral, reusable template per the
design follow-up:
- Rename Extensions/Weather → Extensions/SampleBlock (weather.css → block.css).
- The mod now injects a generic "Field Notes" block with placeholder content and a
Refresh button that cycles canned entries. It uses a DISTINCT data-section key
("fieldnotes") so it never collides with the built-in Weather block.
- content.js is reshaped as a fork-me template: the block key, id, title, info, and
content source are grouped at the top with guidance on which keys are reserved.
Styles renamed to the .sample-mod-* namespace.
- README rewritten as a mod-template guide (what to change to build your own block),
keeping the "how it rides the sidebar conventions without engine changes" table.
- Designs: mark weather.html Phase 1 built & shipped (engine core + sidebar block +
Weatherlore forecast + GM dossier); update the Designs index row to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom- Weather sidebar block (data-section="weather", standard toggle/menu convention): a big glyph + condition readout and a temp/wind line for the current region. Muffled when the player is sheltered indoors (room.interiorOf) — the sky reads as "Outside: …" with a note that they feel it only as sound and draught. - Forecast strip (next ~4 cells, hours-ahead chips) gated by the Weatherlore skill (Decision E): without it the block invites the player to learn it. Layout mirrors the approved Weather PoC mod. - Location plumbing: weatherRegionIdForRoom (room.region name → region id), weatherForRoom / currentWeather, roomIsIndoors (interiorOf), weatherForecast. - GM weather dossier in buildSystemPrompt: the engine OWNS the weather — the GM weaves it in and lets NPCs remark on it (follow-up A), but never invents or contradicts it; uncanny weather may serve a quest/curse beat only when the world/quest establishes it (follow-up C). Notes indoor shelter and whether the player can forecast. Drops out when weather is disabled. - tests/test_weather_ui.js (25 checks): block markup, outdoors vs indoors render, forecast lock/unlock by Weatherlore, disabled state, dossier presence + rules. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds the first-party weather system's engine core per Designs/weather.html: - Deterministic resolveWeather(regionId, gameMs): the sky over a region at any game instant is a pure function of (world seed, region id, ~3.5h time cell) via value-noise. No stored state — same instant always resolves to the identical WeatherState, so save/reload never changes the weather and two viewers of one region always agree. Condition holds across a cell; temp/wind/intensity drift smoothly between cells. Season (from the game month) and day/night couple in. - World-editable catalogs: WEATHER_CONDITIONS (clear/cloudy/fog/rain/storm/snow/ wind/heat) and WEATHER_CLIMATES (temperate/moor/alpine/arid/coastal/blighted), seeded into world.weatherConditions / world.weatherClimates and freely extensible — a DM can invent bizarre weather (e.g. ashfall) just by adding a record with its own weights. A Phase-2 status-effect hook is schema-reserved but inert. - World fields: weatherEnabled (default on), defaultClimate; region.climate selects a climate (blank/unknown → world default). All round-trip through serializeWorld / new World. - Weatherlore (WIS) added to the base skill set — gates the forecast strip in a later increment. - tests/test_weather.js (35 checks): determinism, cell stability, cross-region divergence, climate bias, disable toggle, custom weather, serialize round-trip, tolerant normalizers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Region generation is grounded in the running world via buildSystemPrompt(), but
that prompt — and a few other authoring prompts — hard-coded proper nouns from
the DEFAULT world as examples. So even for a brand-new, unrelated world the model
saw "Ashfen Moor" in its context and reproduced it as a region name.
Genericized every built-in name baked into prompt-building code (all sent to the
model), so nothing but the loaded world's own data reaches a generation prompt:
- buildSystemPrompt CON rule: "trekking the Ashfen Moor" → "a long trek across
harsh, punishing terrain" (this one fed the region generator).
- World-map prompt example: "if the Ashfen Moor lies north of the Village
Square…" → a generic relative-position example.
- Region-map prompt example: "if the Market Row lies north of the Village
Square…" → generic.
- NPC-task authoring example: Aldric/Innkeeper/"Ashfen Moors" → a village smith,
the innkeeper, "a nearby ruin".
- Lore-key example: "Earn the Gatekeeper's trust" → "Earn a wary guardian's
trust".
(Left untouched: a lowercase generic "gatekeeper" dialogue-quoting example, and a
developer code comment — neither is world lore nor, for the comment, sent to the model.)
tests/test_prompt_no_builtin_leak.js: guards that "Ashfen" never appears anywhere
below the WORLD_DATA literal (i.e. in engine/prompt code), the specific leak
phrases are gone, the generic replacements are present, and region gen still
grounds itself in buildSystemPrompt. Full suite 343/351 (only the 8 pre-existing
baseline failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomOn the Editor › Rooms card, a room pinned to a time-of-day slot shows that slot's image for every hour in-game — but the card's banner carousel still defaulted to the CURRENT time of day (roomBannerTimeIndex → currentBannerTimeIndex), so the DM saw a different image than the game actually shows, and the prev/next arrows let it cycle away. Now, when a room is pinned, buildRoomBannerBlock LOCKS the card to the pinned slot: it shows the pinned image, keeps the Pinned checkbox checked, drops the prev/next cycle arrows, and labels the footer "📌 pinned · every hour" instead of the "n / 6" time count. Un-checking Pinned restores the normal per-time carousel. (Classes named room-banner-locked / room-banner-count-locked to avoid colliding with the room-banner-pin row.) tests/test_room_banner_pin.js: reworked the "viewing a different slot while pinned" case to assert the lock — pinned shows the pinned image regardless of the carousel index, checkbox stays checked, arrows are gone, footer reads "pinned · every hour"; and un-pinned the carousel cycles again. Full suite 342/350 (only the 8 pre-existing baseline failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Tree tab is now a pan/zoom surface like the Map tab — drag to pan, scroll
to zoom, and +/−/reset controls in the upper-left corner.
- Wrapped the graph in a .map-viewport (id skilltree-viewport) with the graph
div (#skilltree-view) as the transformed group; the panel now clips instead
of native-scrolling.
- Registered a `skilltree` view in MAP_VIEWS (its own scale/pan state, ids),
so it reuses applyMapTransform / mapZoomView / setupMapPanZoom /
mapZoomKey / mapResetKey verbatim — same drag + wheel + button controls the
maps use. renderSkillTree binds the pan/zoom (idempotent) and re-applies the
transform each render so scale/pan persist.
- CSS: #skilltree-view transforms from the top-left; the .skilltree-controls
sit top-left (clear of the top-right detail popup).
The detail popup stays anchored to the non-scrolling .skl-inner-body, so it
isn't transformed or clipped by the pan/zoom.
tests/test_skill_tree.js: the transform is applied on render, zoom-in/out scale
the group, reset restores scale 1, and the viewport + controls + MAP_VIEWS
registration are present. Verified with a Playwright screenshot (zoomed-out
graph + corner controls). Full suite 342/350 (only the 8 baseline failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom- Node titles are now clickable links that open a skill DETAIL POPUP pinned to
the upper-right of the Tree tab (anchored to the non-scrolling
.skl-inner-body, so it stays put as the graph scrolls; ✕ / outside-click /
tab-switch dismiss it). It lays out every pertinent field — attribute (with
the live modifier if held), class gate, base DC, point cost, training time,
prerequisites (met/unmet), unlocks, the capstone onAcquire payoff, the
discover-only flag — plus a mastery block (tier/level, proficiency,
xp-to-next) when the character already holds the skill. An open popup
survives a tree re-render (e.g. after buying).
- Fixed the stale skill-points badge on level-up: awardXp now calls
refreshSkillViews unconditionally (previously only inside the
progression-milestone branch), so the badge + tree affordability update the
instant the character levels — no tab toggle needed.
tests/test_skill_tree.js: popup open/fields/close, and the level-up badge
refresh while the Tree tab is open. Verified the popup layout with a Playwright
screenshot. Full suite 342/350 (only the 8 pre-existing baseline failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomFollow-up on the duplicate-skill diagnosis: the real cause is a built-in skill
gated to a class the world lacks (Lockpicking → Rogue), so reusing it as-is
would make a world class learn it OFF-class — which is why the GM minted the
"Pick Lock" synonym instead. The fix is to reuse the skill and WIDEN its gate.
- applySkillSpec gains an additive `addClasses` field: it unions the named
class(es) into the skill's `classes` without dropping the ones it already
serves (an explicit `classes` array still replaces, for full DM control).
Shared by both the class-authoring and DM skill-editor apply paths.
- The class-authoring contract now tells the GM: to give an existing skill
to your new class, add { "id": "<existing_id>", "addClasses": ["<Class>"] }
to widen its gate at full effect — never a reworded synonym. A genuinely
different take (a distinct mechanic/scope, e.g. opening only arcane-warded
locks) may still be a new skill.
- The DM skill-editor contract documents addClasses the same way.
tests/test_skill_acquisition.js: addClasses unions without dropping or
duplicating; an explicit classes array still replaces; contract source checks.
Full suite 342/350 (only the 8 pre-existing baseline failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThree fixes from feedback on the Skills › Tree tab.
- Scrollbars: theme the .skl-inner-panel scrollbars to match the rest of
the app (thin, transparent track, var(--border) thumb, gold-dim on hover),
on both axes since the tree scrolls horizontally and vertically.
- Descriptions: node text no longer overflows/cuts off. Removed the flex
stretch that defeated -webkit-line-clamp; descriptions now clamp to 3
lines with an ellipsis and the foot (cost + Learn) pins to the bottom of
the fixed-height node.
- Duplicate skills (same skill, different title): two-layer guard.
* Deterministic: normalizeSkills collapses records whose names share a
canonical key (skillNameKey — "Lockpicking" / "lock-picking"), keeping
the first (built-ins seed first, so they win).
* Authoring: the class-creation and skill-editor GM contracts now firmly
forbid minting a differently-worded synonym of an existing skill — scan
the EXISTING SKILLS roster and reuse its id ("Pick Lock" must reuse
"lockpicking"), only creating a new id for a genuinely distinct
proficiency. (Semantic synonyms can't be caught deterministically, so
they're headed off at authoring time.)
Verified the clamp + layout with a Playwright screenshot. tests: added
same-name dedup + contract source checks to test_skill_acquisition.js. Full
suite 342/350 (only the 8 pre-existing baseline failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomRework the §07 skill tree per feedback — it wasn't a tree (single column, no
visible tier-2, no visible paths), and the inner tabs sat at the top.
- Inner tabs moved to the BOTTOM, adopting the Editor › World inner-tab
pattern (.skl-inner / -body / -panel / -tabs). The skill-points badge
rides in the bottom bar. switchSkillsInner targets the new ids.
- The Tree is now an actual GRAPH. renderSkillTree lays skills out in tier
columns left→right and positions each node absolutely; a dependent is
placed near the vertical mean of its prerequisites, spread ROW_STRIDE
apart so paths stay legible. An SVG layer draws a curved cubic edge from
each prerequisite's right edge to the dependent's left edge, carrying
data-from/data-to and highlighting (green) once the prerequisite is held.
Nodes are compact/fixed-size (prereqs are shown by the edges, not chips):
head + clamped description + a foot with cost + Learn.
- Fixed the "no visible tier-2" cause: normalizeSkills now backfills the
built-in tier-2 base skills (field_medicine, survivalist, dirty_fighting)
into default-seeded worlds and older saves, alongside the existing
spellcasting/inherent backfill — so an existing game shows the advanced
nodes, not just new games.
- CSS uses the real theme vars (--bg-raised, --green, --text-dim …).
Verified the layout with a Playwright screenshot of a standalone render:
Tier 1 / Tier 2 columns, a green Herbalism → Field Medicine edge, positioned
nodes. tests/test_skill_tree.js reworked (26 checks): graph headers, the SVG
edge layer, absolute node positions, prereq→dependent edges (and met
highlight), the two-prereq capstone's dual edges, buy-from-tree, hidden
filter, and the bottom-tab wiring. Updated test_character_subtabs.js for the
new panel id. Full suite 342/350 (only the 8 pre-existing baseline failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomRefactor Character › Skills into two inner tabs and add the build-planning
skill tree, the first surface for the §07 acquisition system.
- Inner tabs: "Learned" (the existing known-skill cards, unchanged) and
"Tree" (new). A skill-points badge in the tab bar shows the purse.
switchSkillsInner drives them; switchCharacterTab and refreshSkillViews
route Skills through the active inner view and keep it live.
- The skill tree (renderSkillTree / skillTreeNodeHTML): every world skill,
grouped into columns by acquisition tier. Each node shows its state —
known (gold) · available (prereqs met) · locked (prereqs unmet) ·
found-only — with met/unmet prerequisite chips and a minLevel chip. A
buyable, affordable, eligible node offers a Learn button that buys
straight from the tree via acquireSkillWithPoints (learnSkillFromTree),
running the strict-capstone payoff. Hidden ("discover-only") skills are
omitted until known (the flag is honored now; the reveal flow is next
phase).
- Base catalog: three tier-2 skills with prerequisites so the tree has
advanced nodes to visualize — Field Medicine (⟵ Herbalism), Survivalist
(⟵ Tracking + Herbalism, a capstone that levels its prereqs on acquire),
and Dirty Fighting (⟵ Stealth). WORLD_DATA seeds from SKILL_CATALOG, so
new games get them.
- CSS for the inner tabs, the points badge, and the tiered tree nodes.
tests/test_skill_tree.js (21 checks): the tier-2 catalog additions, the tree
grouping + node states + prereq chips, the hidden-until-known filter, buying
from the tree (points spent), and the inner-tab switch. Updated
test_character_subtabs.js for the new placeholder location. Full suite
342/350 (only the 8 pre-existing baseline failures).
Design doc + README: mark the skill-tree UI built; discover-only reveal,
diegetic unlock wiring, respec, and the DM/GM authoring surface remain next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomFirst build increment of the §07 design (engine core only — no UI, per plan).
Adds a second, world-scale-independent acquisition channel on top of the
shipped inherent/book/off-class routes.
Data model (normalizeSkills — all fields optional; absent = tier-1, freely
learnable as today, so nothing existing changes):
- tier (power rung, ≠ mastery level), pointCost (defaults to tier; null =
found-only), trainingHours (tier 1 instant; tier 2+ default (tier−1)×24),
prerequisites { skills[], minLevel }, unlocks[] (diegetic doors),
onAcquire { levelPrerequisites, grants }, hidden (discover-only).
Player economy:
- player.skillPoints — +1 per level in awardXp, initialized in the Player
constructor, backfilled to 0 on restore for old saves.
Acquisition mechanics (helpers after forgetSkill):
- skillAcqTier / skillPointCost / skillIsBuyable / skillIsHidden /
skillTrainingHours / skillPoints / skillPrereqsMet.
- canAcquireSkillWithPoints — strict gate (known / hard-gate / found-only /
prereqs / cost); points NEVER waive prerequisites or a found-only gate.
- raiseSkillLevel + applySkillOnAcquire — the strict-capstone payoff: level
up each prerequisite (to 'max' or by N) and grant the new abilities via the
existing applyPlayerAbilityChanges.
- acquireSkillWithPoints — verify, spend, pay training time (advance the
clock for tier 2+), learn, run the payoff; refunds on a late failure.
tests/test_skill_acquisition.js — 22 checks: normalization defaults + explicit
shapes, +1 point/level, found-only/prereqs/cost gating (each spends nothing),
and the Harvest Sight capstone buying through (cost deducted, both prereqs
leveled to mastery, new ability granted, clock advanced). Full suite 341/349
(only the 8 pre-existing baseline failures).
Design doc + README: mark §07 engine core built & tested; UI skill tree,
discover-only reveal, diegetic unlock wiring, once-per-world respec, and the
DM/GM authoring surface are the next phase.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomResolve the open questions from review and reshape the capstone model to the
stricter, cleaner form.
Decisions locked:
1. Economy — 1 point/level; pointCost = tier; both linear on purpose
(keeps buy-now-vs-save tension; joint/decoupled growth would dissolve
the investment gates or muddle the math).
2. Training — tier 1 instant; tier 2+ pays trainingHours, rising with tier.
3. No waiving — points can't bypass prerequisites (you pay for them anyway)
or found-only unlocks; forces path commitment, rewards planning.
4. Respec — no menu respec, but one diegetic out per world (NPC/potion/
ritual/altar): a dead build needs an escape, but reaching it is a feat.
5. Strict capstone (single model) — a capstone NEVER re-implements its
prerequisites. It requires them, then (a) levels them up on acquisition
(potentially to max) and (b) grants a genuinely new ability/mechanic.
This closes the diluted-duplicate trap by construction, so the
convergent-ingredient archetype is dropped.
Harvest Sight reworked to the concrete spec: requires Weatherlore + Herbalism;
on acquire levels both, grants +1 WIS (permanent) and a kinder harvest (lesser
debuff / greater buff from consumed flora), and unlocks GM flora-fate
foretelling ("the storm will wipe out the Honey Blossom by the river — pluck
now or lose them"). Extends the acquisition-policy shape with onAcquire
{ levelPrerequisites, grants } and fixes pointCost = tier. Adds a "point
economy, fixed" callout. Flags only the magnitudes (how far prereqs level,
how big the grants) for playtest.
Updates the status chip, build order, footer, and README index.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomDraft the forward-looking skill-acquisition sub-system that grew out of the
weather/Weatherlore → Harvest Sight discussion. New section 07 (rev. 4),
design-only; the shipped Phases 1–2 are untouched and stand on their own.
Covers:
- The bind: world scale bounds diegetic skill supply, build space is
unbounded → diegetic-only acquisition soft-locks builds.
- Two channels split by role: diegetic finding (signature, place-bound)
+ level-up SKILL POINTS spent and *trained* (trainingHours via the
existing timeSkipHours plumbing, so point-buy stays grounded, not a menu).
- Two clean currencies: points buy breadth (which skills), per-skill xp
buys depth (mastery) — they never cross.
- Prerequisites & tiers: capstone-synthesis vs convergent-ingredient
archetypes, the diluted-duplicate anti-pattern, and the three levers a
capstone needs (emergent / efficiency-subsumption / feed-forward), with
Harvest Sight worked both ways (Weatherlore+Herbalism vs Cooking+Intuition).
- Acquisition-policy data shape on the catalog record: tier, prerequisites
(floor), pointCost (null = found-only), trainingHours, unlocks (doors that
waive cost) — absent fields = today's freely-learnable behaviour.
- World-first authoring: the GM ties skills/tiers/prereqs to narrative;
signature skills found-only, base skills the buyable floor; per-skill DM dial.
- Five open questions deferred to playtest (award/cost curve, mandatory
training-time, points waiving prereqs, respec, capstone magnitudes).
Renumbers the following sections (07→08 … 10→11), bumps rev 3→4, updates the
status chips, build order, footer, and the README index. No numeric §-refs
pointed at the moved sections, so cross-links are intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomTwo refinements from discussion:
- In-world root for Weatherlore: in a world this close to the soil,
reading the coming days IS survival — so the lore grew up around the
harvest and the herd (farmers, shepherds, fishers), not scholarly
meteorology. Gives the skill its description and feel (the set of the
clouds, a ring round the moon, the wind's turn).
- Harvest Sight is NOT a rename of Weatherlore. Weatherlore stays
focused (it reads the sky, nothing more) so the forecast has one clean
gate. A broader "Harvest Sight" skill — bundling sky-reading with soil/
crop/foraging/almanac knowledge born of the same agrarian necessity —
is a distinct, richer idea, parked as follow-up E for another day, with
Weatherlore shaped not to preclude it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomReview resolved every open decision. Flip the Decisions section from "Open" to "Locked" (house-style done tags, green cards), reword each note from "Recommend" to "Decided", and act on E. Decisions as locked: A determinism · B coarse bands · C ~3–4h ticks · D light season/time coupling · E earned forecast via a new skill · F bounded weatherEvent · G world.defaultClimate · H defer elevation · I Phase-1 editable catalog · J Phase-2 race/class status effects. E — forecasting skill: add Weatherlore (WIS) to WORLD_DATA.skills beside Herbalism/Tracking, gating the sidebar forecast strip (untrained sees only "now"; a sky-reader glimpses ahead, more with proficiency). World-editable like every skill, so the name is provisional — "Harvest Sight" or a broad "Survival" umbrella are trivial renames. Ships in Phase 1. Threaded through the block (06), authoring (09), phasing (11), the status chip, the footer, and the README index. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold in three review notes before the doc's first read:
- Bizarre, DM-conceived weather. Add "authored, not hard-coded" as a
core principle: conditions AND climates are a world-editable catalog
(like skills/spells/races), presets as seed content. A custom
condition (ashfall, whispering-mist) and a custom climate (a weighted
table over that catalog, optionally season-ignoring) are authored
records; the deterministic core stays condition-agnostic, so a
barrow-sky out of time is as reproducible as a temperate one.
New Decision I recommends building the catalog in from Phase 1.
- Weather carries status effects (later phase). New "Weather-borne
status, gated by race & class" subsection + effects-table row: a
condition confers appliesTo-gated buffs/debuffs that fold through
effectiveStat and expire on leaving the weather — reusing the shipped
abilities + timed-status plumbing, no new engine. New Decision J,
Phase 2, with the authoring hook schema-reserved in Phase 1.
- Helps some, hurts others: frigid cold biting a desert race, blighted
fog empowering the undead-kin, heat sapping an armoured knight.
Updates phasing, decision count (8 → 10), footer, and the README index.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA design doc to get the discussion rolling on an engine-owned weather
system, segueing from the Weather mod PoC. Design-only; nothing built.
The proposal, in the house style:
- Engine-owned + deterministic: a region's sky is a pure function of
(worldSeed, regionId, season, coarse game-clock tick) via seeded
value-noise for inertia — zero save state, reproducible, testable.
Mirrors the seeded/authoritative habits of combat and rest/fatigue.
- Region-scoped: one new authored field, region.climate, keyed off a
central CLIMATE_TABLE (temperate/moor/alpine/arid/coastal/blighted…),
with a world default and world.weatherEnabled toggle.
- Felt outdoors, muffled indoors via the existing interiorOf flag.
- A native sidebar block reusing the mod's render, with real state.
- GM narrates, never invents: a compact GM-eyes-only weather dossier
(the established dossier pattern) tells the GM the sky and forbids it
from authoring one; a bounded, opt-in weatherEvent escape hatch is
left as an open decision.
- Cosmetic Phase 1 first; encounter/skill/rest effects, forecasting,
and elevation deferred to later phases.
- 8 open decisions with recommendations, plus wilder follow-ups
(NPC remarks, weather in art, uncanny curse-weather, and the mod's
graduation into the engine).
Also indexes the doc in Designs/README.md next to living-world.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA side experiment to research whether the engine is flexible enough for an
external mod to "just work" with no engine changes. Answer: yes, for this case.
The extension is a Manifest V3 content script that injects a new "Weather"
sidebar block with a placeholder forecast. It rides the engine's existing
sidebar conventions rather than modifying anything:
- updateSidebar() writes into inner element IDs and never rebuilds #sidebar,
so an appended block survives every refresh.
- buildSidebarBlockMenu() scans live [data-section] blocks when the ☰ menu
opens, so the block auto-registers in the show/hide menu.
- The header reuses the page's inline toggleSidebarSection('weather') (main
world) for native collapse; the Refresh control uses addEventListener in the
extension's isolated world for the mod's own logic.
- It reads the engine's own localStorage keys (tlr_sidebar_blocks,
tlr_sidebar_collapsed) — content scripts share the page origin's storage —
so show/hide + collapse state round-trip across reloads.
Files: manifest.json, content.js, weather.css, README.md (with load-unpacked
instructions). No changes to text_adventure.html or the engine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomWhen a player types an out-of-scope / meta / engine-modification request
into the action box, the GM sometimes breaks frame and replies in plain
prose ("I can't do that — that's a request to change how the engine
works…") with no turn JSON. Previously this surfaced as a red
"System error: Failed to parse game master response." with no useful
guidance.
Now a reply that contains no JSON object at all is recognised as a
conversational decline: sendToLLM throws a flagged error carrying a short,
in-world nudge ("The Game Master didn't act on that … try rephrasing it as
an in-world action"), and gmSubmit renders it as a plain story notice
rather than a system error. The full GM prose is still available in the
collapsible GM log, so nothing is lost and the story board isn't spammed.
A reply that DOES contain a brace but is malformed stays a genuine parse
error, and a truncated (max_tokens) reply keeps its "too long" message.
Also roll back the user turn on any parse failure: nothing usable came
back, so leaving the pushed user turn in conversationHistory would stack a
second consecutive user turn on the next submit — which the API rejects
(roles must alternate). This fixes a latent issue on the retry paths too.
tests/test_gm_decline.js covers the gmDeclined path, the history rollback,
the malformed-JSON distinction, and the gmSubmit rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAn NPC who was central to a quest the player has since RESOLVED can now offer an
after-the-fact callback in conversation ("Thanks again for finding my pendant"),
without any new turn mechanic — the GM just gains the awareness.
resolvedQuestGratitudeDossier() assembles, for the living PEOPLE present in the
room, which completed quests (questIsComplete) each took part in — using the same
beat.npcs the Compendium cross-references, flagging the most-tagged person as the
thread's face — plus how it ended (the legend's outcome, or a terminal beat's).
buildSystemPrompt injects it (empty on ordinary turns, so it's free) with a
directive to acknowledge it in character: SPARINGLY (a callback, not a broken
record; fades with repetition), coloured by the outcome (warmth for a fair/
triumphant end; grief or coolness for an ill one), referencing the DEED only —
never "quest"/"legend"/"journal" or any mechanic — and only when the NPC has a
natural reason to speak.
Responsive by design: it surfaces when the player talks to / is greeted by the
NPC, not as a forced interjection.
tests/test_npc_gratitude.js: the dossier names the present central NPC + deed +
outcome, omits absent NPCs, stays empty when the quest is unfinished or nobody
involved is present, carries the tasteful/no-meta directive, and is wired into
the prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA legend spoil that opens a room/item popup used #legend-entity-popup, which is position:absolute — but its subview #journal-sub-legend had no positioning context, so the popup anchored to the app instead of the tab and floated in the app's upper-right. Give #journal-sub-legend position:relative (mirroring #editor-sub-quests for the Quests tab) so the popup sits in the Legends tab's upper-right like other tab-confined popups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two gaps behind "I completed a quest in the Editor and Legends stayed empty": 1. recordLegend only fired from the live-play questUpdate path — never from the Editor's beat toggle. dmSetBeatUnlocked now records a legend when a DM unlock completes the quest (questIsComplete). There's no live GM turn there, so it lands as a TEMPLATED entry; recordLegend dedupes, so re-toggling won't duplicate. 2. There was no way to get the GM-authored (world-voice) prose for such an entry. Add a DM-only per-legend button — "✨ Enrich with GM" (templated) / "Regenerate with GM" (already authored) — wired to enrichLegendWithGM, which asks the GM to write the retelling + outcome + spoils for the resolved quest (from its unlocked beats) and updates the legend in place. A new `authored` flag on the legend distinguishes GM prose from the templated fallback and drives the button label. tests/test_legends.js: the authored flag (GM vs template + round-trip), the DM-completion recording, the Enrich/Regenerate button, and enrichLegendWithGM rewriting the story/spoils and flipping authored true (mocked GM). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When Issuer.discover() fails at the network layer (a firewall clamp, DNS blip,
wrong AUTH0_ISSUER_BASE_URL, or an Auth0 outage), express-openid-connect rejects
the /admin/login request and Express dumped a 500 + AggregateError stack to the
console and browser. Add an error-handling middleware that recognises OIDC
connectivity failures and responds with a clear 503 page ("Can't reach the
sign-in provider" — likely a firewall/VPN/proxy or a bad issuer URL, with the
configured issuer shown and a Retry link) plus a one-line console warning. Every
other error falls through to Express's default handler, unchanged.
core.isOidcConnectivityError (matches the discovery message + network/TLS error
codes across an AggregateError and its wrapped RequestErrors) and
core.oidcUnreachablePage (themed, HTML-escaped) are pure + unit-tested; server.js
wires the middleware after the routes.
Tests: detection + page in test_vault_core; error-handler wiring in
test_server_integration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomrequestQuestEdit returns the WHOLE quest (every beat with its text, trigger, rewards), and a BRANCHING quest adds beats plus after/branchGroup/terminal/ outcome fields and forked prose. The old 3500-token cap truncated those mid-JSON, so the parse failed with a cryptic "GM returned invalid JSON" — and because output length varies around the ceiling, it looked intermittent (fails twice, succeeds once). - Bump max_tokens to 16000 (the largest single-object editor; still well under the model's limit). - When the response stops on max_tokens, return a clear "the answer was too long and got cut off — try again or split the edit" instead of "invalid JSON". tests/test_quest_edit_tokens.js drives requestQuestEdit with a mocked GM: asserts the 16000 budget, the truncation → "cut off" message, and that a complete response still parses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The vault serves admin.html / the game HTML fresh from disk, but its routes and in-memory modules are fixed at process start. After a `git pull` without a restart, the page and process disagree (e.g. a renamed API route 404s, returning HTML that the page fails to JSON.parse). The server now detects this and tells the UI. Detection (server): at boot, hash the server-side source modules (SERVER_SOURCE_FILES via computeBuildSignature); re-hash live (throttled to 3s) and compare — if the on-disk source changed since boot, the process is stale. Client assets are excluded (a reload picks those up without a restart). Exposed as `serverStale` on /vault/config and the admin GET /api/keys. Banner (UI): - Admin page: a gold "Server restart needed" banner above the key cards when serverStale is true. - Game: a slim fixed top banner (#server-stale-banner, toggled by reflectServerStale) shown when the vault reports it; cleared on the next load after a restart. Tests: computeBuildSignature stability + change-on-edit and buildClientConfig surfacing (test_vault_core); serverStale=false on a fresh server (test_admin); client detectVaultMode capture + banner toggle + wiring (test_server_stale_banner). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Regenerate Web/Reports/progress-report.html from git history via tools/gen-progress-report.js: 924 commits across 20 active days, now including the branching-quests suite (engine core, GM contract, DM renderer, Legends), the GM-model allow-list ceiling, the Vault draft-editor key fix, the login↔admin cross-links, the Guide-tab detach, and the racial-ability editor hyperlink. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the Races tab, a race's ability name is now a gold hyperlink that opens the shared Ability editor for that ability (openRaceAbilityEditor), keyboard- accessible (role=button, Enter/Space). This replaces the separate ✎ pencil button — the name itself is the affordance now — while the ✕ remove control stays. No behavior change to the editor it opens. tests/test_race_abilities.js: assert the name is a hyperlink wired to openRaceAbilityEditor; loosened the name-match patterns for the added link attributes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Detach (mirrors the Editor tab): right-clicking the Guide tab now offers "Detach", opening the same file at ?detach=guide in a second window scoped to just the Guide — a read-only second view of the same session (e.g. a walkthrough on another monitor). Adds IS_DETACHED_GUIDE + a shared IS_DETACHED flag, the body.detach-guide chrome-hiding CSS with its own header, setupDetachedGuideView (resumes the session and renders the Guide), the guide-context-menu + wiring, and detachGuide(). The "don't disturb the main window" boot guards (resume-note, key prompt, resumable-cache, login cues, file-origin note, inactivity logout, character-setup resume) now key off IS_DETACHED so the Guide window behaves like the Editor window; editor behavior is unchanged. Enrich button: the Guide's "Enrich with GM" / "Regenerate walkthrough" button is now right-aligned (margin-left:auto, with the gen/status text on the left) and uses the dark ghost button variety (regions-btn-ghost) instead of the solid gold. tests/test_guide_detach.js covers the context menu, detachGuide, the button markup/ordering, and the detached-view wiring; verified in a real browser (?detach=guide applies the chrome and boots cleanly). Updated test_gm_seam.js for the broadened IS_DETACHED guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Login screen: add an "Admin" button just left of "Import Game" that opens the Server Vault admin portal (/admin). It's hidden by default and revealed only in Vault mode (reflectVaultModeInLogin), since the portal exists only when the game is served by the vault — a Direct-mode / file:// page shows no dead link. Admin portal: add a top-right "Play" link back to the game (/). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The draft/World-Builder editor window (?detach=editor&draft=…) loads its Claude key via loadDraftIntoEditor's `apiKey = await loadAndMigrateSecret(SAVED_API_KEY)`. In Vault mode there is no client-side key — the vault holds it — so that read returns '' and clobbers the sentinel detectVaultMode had set. loadDraftIntoEditor never re-adopted it (unlike restoreGameState, which already does at the same spot for the in-game detached editor), so `apiKey` ended up empty and every editor GM box's `if (!apiKey || !world)` guard fired the misleading "Start a game first — the GM edits … your API key." Re-adopt the vault sentinel right after the clobbering storage read (await ensureVaultDetected(); if (isVaultMode() && !apiKey) apiKey = SENTINEL), mirroring the restoreGameState fix. Verified end-to-end against the real vault server: the draft editor's Quests GM box now passes its key gate. tests/test_world_draft.js gains a Vault-mode section asserting loadDraftIntoEditor re-adopts the sentinel (fails without the fix). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Journal › Legends becomes a dated chronicle: each resolved quest arc recorded
with the ending reached and the spoils it won, told in world voice — the
player-facing payoff the whole branching system points toward, and the promised
home for the once-hidden fame record.
Model + persistence:
- player.legends holds normalized entries { id, date, questId, questTitle,
outcome, story, spoils:[{kind,label,ref}] } (normalizeLegend/normalizeLegends);
restore backfills older saves.
Recording:
- recordLegend fires when a quest completes (a terminal beat unlocks, in the
questUpdate handler). It uses the GM's authored legendEntry (world-voice story
+ outcome + spoils) when supplied, else a templated retelling with spoils
derived from the terminal beat's rewards. A fame chip (the current renown
label) is appended, giving the hidden fameValue a player-facing home. A quest
is chronicled exactly once.
GM contract:
- New "legendEntry" response field + field note + rule 10c: on the turn a quest
ends, write the retelling in world voice, tag the outcome, list spoils with
refs where they resolve — and, for a branched quest, carry the faint
roads-not-taken echo in-fiction only, never as meta.
UI:
- renderLegends draws the dated timeline (reusing the journal-entry pattern);
each entry shows its outcome chip, story, and a row of spoil chips. Item/magic
and place spoils are clickable, opening the item/room popup in a new Legends
popup slot (companion-registered like the Quests tab). switchJournalTab wires
the Legends subtab; CSS for the spoil chips.
Designs/branching-quests.html: Legends marked built — the branching-quests
design is now fully implemented.
tests/test_legends.js covers normalization, GM-authored vs templated recording,
dedup, derived spoils, the fame chip, render + clickable resolution, the
save/restore round-trip, and the GM-contract wiring. Updated
test_journal_subtabs.js (Legends is now a live container, not a placeholder).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomGive the DM a real visualization of a branched quest, per the design-doc mock. The Quests-tab timeline is one vertical column today; a branched thread is a graph, so it now renders as an indented vertical tree — no sideways scroll. renderQuests now chooses per quest: - Linear quest (no branch fields) → the existing flat timeline, byte-for-byte unchanged (shared through the new questBeatCardHTML helper). - Branched quest → renderQuestBranchTree: entry/merge beats form the trunk, a beat with a single `after` nests under that parent, and beats sharing a branchGroup nest under a collapsible "Fork · <group>" header (toggleQuestFork — pure CSS toggle, no re-render). Merges (≥2 `after`) render at the trunk, labelled "after: A or B"; a branch that feeds a merge shows a "↳ rejoins <merge>" hint. Each beat card gains branch scaffolding (empty for a linear beat): an outcome/terminal chip (ill/fair/triumphant), and — for a foreclosed beat — the .foreclosed dim+strike plus a DM-only "never shown to the player" note, with its status badge reading "Foreclosed". Progress is branch-aware: a terminal beat unlocked ⇒ Complete; all beats unlocked-or-foreclosed ⇒ Resolved; else the discovered count. CSS: outcome chips, collapsible fork header + caret, indented .quest-branch- children (dashed rail), foreclosed dim/strike, merge/rejoin notes. Designs/branching-quests.html: marked the engine core, GM contract, and DM renderer as built; Legends timeline remains pending. tests/test_quest_branch_render.js drives renderQuests over the giant's-idol DAG (fork nesting, chips, merge/rejoin labels, foreclosed styling, branch-aware progress) and confirms a linear quest stays flat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The vault previously stamped one admin-chosen GM model onto every proxied call, overriding the client and hiding both in-app model pickers in vault mode. But world-gen and gameplay want different models — a thorough model (Fable) for building a world, a fast one (Opus/Sonnet) for turn-by-turn play — so collapsing them to one server-fixed model was wrong. Now the client chooses the model per context and the vault enforces only an allow-list CEILING: it honors the client's requested model when the admin permits it, else substitutes a deterministic in-ceiling fallback. The API key stays fully custodial; only the model becomes the client's choice. Server: - GM_MODELS gains Fable 5 (so it can be permitted for world-gen); add allowedGmModelIds (reads the ceiling; migrates a pre-ceiling single-model setting to a one-item ceiling) and pickGmModel (honor-or-fallback). - /vault/gm honors the client model within the ceiling instead of overriding it. - /vault/config exposes the ceiling as gmModels so the client can filter its pickers. - Admin API: settings now stores an allowed-models array (POST /api/settings/models, validated, ≥1 required); the keys response returns allowedModels. - Admin UI: the single Model <select> becomes an "Allowed models" checkbox group. Client: - Capture config.gmModels as the ceiling; gameplayModelChoices / worldGenModelIds filter the two menus to it (falling back to the ceiling itself if it excludes every listed model, so play always has a runnable model). - getSelectedModel / worldGenModel never return a model outside the ceiling; the World Builder select is now built dynamically from the filtered set. - Both pickers stay VISIBLE in vault mode (the model is a client choice again): the World Builder model field is no longer force-hidden, and the AI-Settings Claude model row shows filtered options instead of the "managed by the vault" note. Tests: rewrote test_admin's model block for the ceiling (honor within, fallback outside, reject unknown/empty); new tests/test_gm_model_ceiling.js covers the client filtering, visibility, and wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Give the GM everything it needs to run AND author branched quests, on top of the engine core. Sees the shape (questSummary, GM eyes-only): - Each beat now prints its branch structure when it departs from linear: after edges, branchGroup (with the exclusivity note), and ENDING + outcome for terminal beats. A foreclosed beat is flagged "FORECLOSED … NEVER unlock it." - A branched quest's header carries a [BRANCHED QUEST] reminder of the hidden-switch contract. Linear quests print exactly as before (no scaffolding). Runs the branch (system prompt rules): - Rule 10-branch: the switch is hidden (never a menu), turning points carry weight, only eligible beats unlock, unlocking a branch beat is irreversible, never unlock a FORECLOSED beat, and a better ending must have cost more. - Rule 10b + the "forecloseBranch" field note: close a branch the player let slip (walked past / never noticed / deadline expired), sparingly, silently. Authors the branch (requestQuestEdit / applyQuestEdit): - The quest-edit directive documents after / branchGroup / terminal+outcome as an opt-in fork/turning-point/endings feature, with DAG-sanity guidance and the pay-more rule. - The current-quests handoff surfaces existing branch fields so an edit preserves them; applyQuestEdit passes the src arg so authored branch fields are normalized onto the new beats, and carries runtime foreclosure state across a re-author. tests/test_quest_branching_gm.js covers the dossier rendering (branch shape, foreclosed warning, linear back-compat), the prompt rules + field note, and the authoring wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Turn quest beats into a small DAG so a quest can branch, merge, and end more
than one way — the deterministic engine layer beneath the design doc.
QuestBeat/Quest model:
- QuestBeat gains after[] (prerequisite edges), branchGroup (mutually-
exclusive siblings), terminal + outcome (endings), and foreclosed (a
permanently-closed choice). normalizeBeatBranchFields coerces/defaults them
on construction and on reQuestObj rehydration, so legacy linear quests are
unchanged.
- Quest.completed and .nextLockedBeat now route through the engine helpers and
skip foreclosed beats.
Engine helpers (pure, operate on plain quest-shaped objects):
- questBeatEligible — entry beats always; others need a prerequisite unlocked.
- foreclosureAfterUnlock — unlocking a beat forecloses its still-locked
branchGroup siblings, then cascades.
- cascadeQuestForeclosure — merge-aware fixpoint: a beat forecloses only once
ALL of its predecessors are foreclosed (redemption merges survive while any
path lives).
- forecloseQuestBeats — explicit missed-chance / expiry foreclosure by id or
title, then cascade.
- questIsComplete / questIsResolved — terminal-beat-aware completion.
State-change wiring:
- questUpdate refuses to unlock a foreclosed beat and runs foreclosure after a
successful unlock; completion now uses questIsComplete(quest).
- New forecloseBranch GM state-change ("forecloseBranch": null in the response
shape) lets the GM close a branch on inaction.
- dmSetBeatUnlocked clears foreclosure when the DM force-unlocks a beat.
tests/test_quest_branching.js exercises the giant's-idol DAG end to end
(normalization, eligibility, exclusivity, merge-aware cascade, redemption,
missed-chance, linear back-compat, serialization round-trip, source wiring).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomRefine branching decision 2: the GM box already provides authoring INPUT on the Quests tab; the real gap is VISUALIZATION of a branched thread in what is today a single vertical column. Resolve it with an indented vertical tree read view that extends renderQuests — branches nest under their fork, each tagged by outcome + foreclosure state, merges labeled, forks collapsible — staying column-oriented with NO horizontal pan (that's what separates it from the deferred side-by-side graph editor). Adds a 'How the DM sees a branched quest' subsection with a giant's-idol mock, and notes the progress/completion label and foreclosed-beat (DM-only, never shown to the player) consequences. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold the five open-question answers into branching-quests.html: - Foreclosure PROPAGATES through the engine (full merge-aware cascade), not just direct siblings. - Author via the GM box first; the collapsible-tree Quests tab and a dedicated graph editor are noted as later, optional richer UIs. - Legends is a dated timeline pairing GM-authored world-voice prose with structured, clickable spoils (rewards/treasure/fame/boons → popups) — both authored and templated, mirroring the Tasks subtab. Added the entry shape to the Legends section. - Missed-chance forecloseBranch + time-triggered foreclosure, the timed kind used sparingly with generous in-game durations. - Save-scumming left to the existing single-save design; no special machinery. Header chips + footer note the decisions as resolved. Still design-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a 'Model' dropdown to the right of the World Name field in the World Builder, selecting the Claude model used to forge the world — Fable 5, Opus 4.8 (default), and Sonnet 5. It has its own picker (independent of the per-turn login model, and the only place Fable 5 is offered) with its own persisted choice (tlr_worldgen_model). requestNewWorld now generates with worldGenModel() instead of the general getSelectedModel(). Hidden in Vault mode, where the model is fixed on the vault admin page (mirrors the login model row). Updates test_worldconcept.js to anchor on the Tone row now that a second .we-row exists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New Designs/branching-quests.html capturing the branching questline design: - Two orthogonal layers — story BRANCHES (turning points) over logistical PATHS (build-agnostic ways past obstacles); the accessibility rule governs paths, branching governs branches; outcome tracks choices, never class. - Beats become a small DAG (after edges + branchGroup + terminal + outcome); deterministic, merge-aware foreclosure (engine-enforced) so redemption/ merges survive — 'foreclosure closes the choice, not the fate'. - The hidden switch: natural-language triggers unchanged; GM contract nuances (telegraph the weight / hide the specifics; never resolve ambiguity). - Tiered rewards (better ending always pays more) + generalized effects (status/ability/alignment/fame — the curse consequence); redemption costs. - Legends reconceived as a world-voiced chronicle that seeds replay curiosity without exposing a meta. - A worked giant's-idol example, five settled decisions, and open build questions. Cross-linked from Quests & Journal growth-idea B. Design only; not built. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Wire the multi-path accessibility principle into world building. Define a shared CRITICAL_PATH_ACCESSIBILITY_GUIDE directive and inject it into all three authoring surfaces: - new-world generation (requestNewWorld rules), - region expansion (requestWorldExpansion rules), - quest editing (requestQuestEdit, with an add-the-fallback framing). The directive: a quest's critical path (a required beat that can't unlock without passing a skill/class/race gate) must always have a universal fallback that is the COSTLIEST way through (time/attrition, resources, risk, stiffer odds, or a worse outcome) — never a free skeleton key, never dominating a specialist path, always survivable. Racial passives waive/cheapen a cost rather than being the sole key. Must-identify items need a teachable skill or a reachable identifier (sage/service/scroll). Cross-region backtracking is allowed. Branching-safe: only SOME valid conclusion need stay reachable, so build-gated alternative outcomes remain exempt (like side content). Adds tests/test_critical_path_accessibility.js (defined once, injected 3x, key principles present). Full suite 330/338 (8 pre-existing baseline failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold the quest-accessibility analysis into Designs/quests-and-journal.html as a new section 07 (renumbering 'Where it could grow' to 08): no legal build may be hard-locked out of a quest's critical path. Captures the tiered model (specialist/racial shortcuts + a universal, costliest fallback), the cost currencies (time/attrition, resources, risk, stiffer odds, worse outcome) mapped to existing engine levers, the racial-passive 'waives a cost' tie-in, the guardrails (survivable; no fallback may dominate a specialist; not a universal skeleton key; make it concrete; side content exempt), and the identification-specific accommodation. Marked as a proposed GM authoring directive, not yet wired into the generation prompts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
- Seed a worked example in the built-in world: the Barrowking's Signet, a magic ring in the Barrow Chamber that is unidentified (reads as 'a plain iron signet ring'), identified by Arcana, DECEPTIVE (a near-miss reads it as the false 'Ring of the Warding Hand' with a +2 AC boon) and its TRUE payload a -2 cursed band, and one-shot (identifyRetryable:false) — so a hasty arcane study leaves the player confidently wrong until an external source corrects them. Exercises the whole Phase-2 mechanic in a fitting spot. - test_item_identification.js asserts the example ships and threads its gates. - Designs/hidden-and-unidentified-things.html: flip Phase 2 status to built (badge, section header + shipped callout, footer). Full suite 329/337 (8 pre-existing baseline failures); app boots clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Teach the GM to run identification for any gated item, not just flora: - floraNoteForGM -> itemGateNoteForGM: the per-turn room/inventory tags now cover plants, magic items, and contraptions, naming each item's REAL identifying skill (Herbalism/Arcana/Machinery) as a fact so the model never infers it from class. Adds [SENSED] and [MISIDENTIFIED] (GM-only) cues so the GM refers to a sensed/mis-read item correctly and never leaks the truth. - Field spec: generalize revealItem / identifyItem / identifyFlora away from plant-only wording (identifyItem also corrects a false belief) and document the new senseItem verb (a detect-magic KIND reveal -> the sensed tier). - Authoring: the DM item-edit handoff gains a generic IDENTITY GATES directive (seen/apparentName/identifySkill/identifyDC/revealCondition/falseName+ falsePayload/identifyRetryable) + a magic-tab pointer; 'contraption' added to the item type enum. Tests: generalize the flora prompt-tag assertions; add GM-wiring checks to test_item_identification.js (a hidden magic item is tagged UNIDENTIFIED by its apparent name, names Arcana, offers senseItem; schema+spec declare senseItem). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add per-item control over whether the player's OWN skill may retry an
identification:
- identifyRetryable:false ⇒ a single player-skill attempt, EVER. Once tried
(identifyTryDay set), itemIdentifyReadiness returns 'spent' on every later
day — no self-identification path remains. The default (absent) keeps the
once-per-in-world-day cooldown (flora behaviour).
- The retry flag never gates an EXTERNAL reveal: identifyItem (NPC/scroll/
narration) still completes or corrects a spent one-shot item.
- The identify manifest and the identifyFlora handler surface the 'spent'
state ('Needs another means'); the DM editor's Discovery read-out shows
'One-shot skill attempt' and a 'Deceptive' marker.
Extends test_item_identification.js (retriable cooldown vs later-day ready;
one-shot spent forever; external reveal ungated by retry).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA deceptive item (one with an authored falseName) can be read WRONG: - resolveItemIdentify: on a deceptive item a near-miss produces the 'misidentified' outcome (a confident wrong read) instead of 'sensed'; a comfortable pass / nat 20 cuts through to the truth. Non-deceptive items never mis-ID. Skill margin gates accuracy (pass=truth, near-miss=wrong, big miss=nothing). - The identify manifest shows a mis-identification as a success under the FALSE name — the player is fooled. - The item popup renders from a tier-sourced payload: the true item once identified, the authored falsePayload (false name/description/AC/effect) when misidentified — shown as if genuine, never leaking the truth — and nothing while unknown/sensed. Fixes a true-name leak through the portrait button's onclick/title + image alt (now use the shown name). - identifyItem also matches on the falseName, so an NPC/scroll can correct a false belief the player currently holds. Extends test_item_identification.js (misidentified read, false-payload popup, see-through pass, non-deceptive never mis-IDs, external correction). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add the unknown -> sensed -> identified ladder (with a misidentified branch reserved for Inc 3): - identificationTier(it) accessor (viewer-aware: DM/non-gated => identified); itemIdentified() now means FULLY identified, so 'sensed'/'misidentified' withhold the true name/payload. displayItemName is tier-aware. - resolveItemIdentify: a near-miss within SENSE_BAND of the DC on a magic item or contraption yields the 'sensed' tier (you can tell WHAT KIND it is); flora keeps unknown <-> identified. The readiness gate lets a sensed item be re-attempted (only true/absent counts as 'known'). - itemPayloadHiddenFromPlayer(it): the player-facing item popup withholds the mechanical payload (AC bonus, weapon dmg, granted abilities) AND the true name/description until identified; a DM/expert sees all. - senseItem GM verb + schema field: a detect-magic glance raises an unknown magic item/contraption to sensed. The manifest and the DM editor's Discovery read-out render the sensed tier. Extends test_item_identification.js (tier ladder, sensed via near-miss + senseItem, payload hiding). Flora suites still green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Widen the Phase-1 flora discovery/identification machinery to a type-neutral item system (plants + magic + a new non-magical 'contraption' type): - itemHasIdentityGate(type) replaces the plant-only isPlantType gate at the ~8 identity-gate sites; add IDENTIFY_SKILL_BY_TYPE + identifyingSkillFor() (plant→herbalism, magic→arcana, contraption→machinery, item override wins). - Rename the helpers flora*→item*: floraIdentified→itemIdentified, floraApparentName→itemApparentName, knowsHerbalism→knowsItemLore(it), floraIdentify*→itemIdentify*, resolveFloraIdentify→resolveItemIdentify, attemptFloraHerbalismIdentify→attemptItemSkillIdentify, the dice-bag continuation, and recatalogIdentifiedFlora→recatalogIdentifiedItem. The identify check now uses each item's own skill (title/icon/stat follow it). - Add Arcana + Machinery to the default-world SKILL_CATALOG; contraptions catalogue under the Items bucket. - Thread the new authoring fields (identifySkill, falseName, falsePayload, identifyRetryable) + a forward-compatible normalizeRevealedTier through makeItem / catalogItemShape / applyItemSpec / reItemObj. revealCondition sharing with the concealed mechanism is preserved. - Generalize the revealItem / identifyItem / identifyFlora state-change handlers and the item-detail Discovery read-out to any gated type. Flora behaviour is unchanged (test_flora_* updated to the new names and still green); adds test_item_identification.js for the magic/contraption paths. GM prompt tags stay flora-specific until Inc 5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reverses the earlier 'no false names' call and adds a new attribute: - Deliberate mis-identification (decision 5, reversed): a deceptive item authors a falseName (+ optional false payload). A near-miss skill attempt yields a confident WRONG read (the player believes the false name as though identified); a bad miss yields nothing; a pass yields the truth. Skill margin gates accuracy. Adds a 'misidentified' branch to the revealed tier ladder (unknown -> sensed -> misidentified/identified). The engine keeps the true name internally so a mis-ID is always correctable. - Retry control (decision 8, new): an identifyRetryable flag. Flora default to retriable (once/in-world-day, as today); a special item with identifyRetryable:false grants the player ONE own-skill attempt ever, then requires an external means. External identifyItem (NPC/scroll/narration) is never gated by the flag and always reaches the true tier, even correcting a false belief — so no identification is terminally wrong. Updates section 9 (new deceptive + retry subsections), the resolved-decisions list, the net-effect callout, and the footer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold the design-pass answers into the doc: - Skill-source: item-level identifySkill wins, per-category map is the fallback. - Skills/categories: herbalism/arcana/machinery ship as real default-world skills; add a first-class 'contraption' item type for non-magical devices. - Gate defaults: magic items keep BOTH gates (seen usually open, but available for concealed/invisible/obviously-radiating items). - Partial identification: revealed widens to an unknown -> sensed -> identified ladder with an identificationTier() accessor; flora keep false<->true. - Failure is truthful and non-terminal: no false names; a missed check only spends the daily attempt and never locks out a later NPC/scroll full reveal. - Rename flora*->item* and neutralize the GM tags in the same phase. - Per-type payload accessor for 'hide the payload until identified'. Rewrites section 10 from open questions to resolved decisions and updates the Phase 2 prose, code sketches, TOC, and footer to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
In Vault mode the Claude key + image providers live on the server and the client apiKey is only a sentinel. On resume, restoreGameState reloads apiKey from storage (empty in Vault mode, since the sentinel is never persisted), clobbering the sentinel that detectVaultMode had adopted — and the re-adoption in bootResume was gated to the main window (!IS_DETACHED_EDITOR). So in the detached editor apiKey stayed empty, and every GM/art button that guards on !apiKey (Art › Generate among them) reported a missing key even though the vault would serve the request. - bootResume: re-adopt the Vault sentinel in every window (detached editor included); keep the Direct-mode login prompt gated to the main window. - artGenerateAll: resolve the vault probe first and allow generation when a vault is present (!apiKey && !isVaultMode()), matching startGame's key gate. - Tests: update the gm-seam resume-path source assertion; warm the memoized vault probe in test_art_generate_all so the one-time /vault/config fetch doesn't count against the per-batch fetch assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
- abilities.html: mark phases 1-4 shipped in the phasing table (plus the engine-rolled saving-throw axis), resolve Q5, refresh status badges and footer; Phase 5 (handbook retrofit) noted in-progress. - server/README: retitle Phases 1-3, document the /vault/generate image/sound proxy, custom provider descriptors, usage tracking and whole-game Auth0 login; correct the non-loopback token note; refresh the layout section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A racial ability is inherent -- members are born with it -- so the GM should never
author one as a "checked" ability the character rolls a d20 to use. Rewrite the
race-edit ABILITIES directive to mandate kind:"passive" for every racial ability
and forbid the modifier/appliesTo/bonus (checked) shape. A racial passive still:
(1) WAIVES checks it makes moot (breathe water, darkvision), and/or
(2) MODIFIES a roll -- a signed bonus/penalty on a named roll (saving throws,
to hit, AC, damage, initiative, or free-form), self or enemy -- which tips
the OTHER rolls made in play (a "+2 to your saving throws" passive folds
into the engine's save).
Nothing mechanical is lost: passives already carry roll-modifiers and the engine
handles racial passives-with-modifiers (playerSavingThrowModifiers scans all
abilities; the dossiers + card renderers show them). Only the GM's authoring
guidance changes; the model still tolerates any kind for hand-authored data.
- test_race_abilities.js: racial round-trip uses passives; directive assertions
updated (mandates passive, forbids the checked shape).
- DM Guide Races>abilities row + Designs/abilities.html S3 updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomShow a small, unobtrusive footer at the very bottom of the game's login/setup
screen when the vault is behind Auth0 and a user is signed in.
- server: /vault/config now returns config.auth = { email, logoutUrl } for an
authenticated player (buildClientConfig gains a userEmail option; server.js
passes req.oidc.user.email). Nothing is returned in Direct/loopback/no-Auth0 mode.
- client: detectVaultMode captures config.auth into vaultAuth; reflectVaultAuthFooter
renders "Logged in as <email> · Sign out" into #vault-auth-footer (10px, muted),
the Sign out link pointing at the vault logout route. Hidden when no user.
- tests: server buildClientConfig auth-block cases; client (test_gm_seam) captures
config.auth + renders/hides the footer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomEarlier I restricted a passive's roll-modifiers to enemy-only, reasoning a passive
has no player roll. That was wrong: a passive isn't rolled ITSELF, but it modifies
the player's OTHER rolls -- a "+2 to your saving throws" passive still boosts the
saving throws the GM asks the player to make (and likewise any other affected
check). So a self ("your") target is valid on a passive.
Make the roll-modifier target select KIND-INDEPENDENT again (self + enemy for both
Checked and Passive): abilityEditorRollmodTargetOptions drops the passive param and
always offers both; the row builder no longer forces enemy; abilityEditorKindChanged
no longer re-scopes existing rows. Tests updated to assert the kind-independent
target select.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAdd server/branding/auth0-theme.json -- an Auth0 New Universal Login Branding Theme styled to the app's dark/gold palette (colors lifted from text_adventure.html: --gold #c9a84c, --bg #0d0b08, --bg-panel #13110d, --bg-raised #1c1812, --border #2e2820, --text #d4c9b0, --red #8b2020, --green #4a7c5c). Themes apply to the standard tenant.auth0.com login, so NO custom domain is required. server/branding/README.md documents both apply paths (Dashboard visual editor, or Management API PATCH/POST with curl) and the two must-be-public assets: the Cinzel font (font_url must be a woff2 file URL, not a Google Fonts CSS link) and the logo/background image URLs (left blank -> plain colors, the pragmatic choice for a local tenant). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The post-logout returnTo defaults to the OIDC baseURL (VAULT_PUBLIC_URL root), so the Auth0 app's Allowed Logout URLs must contain that exact base URL -- NOT <VAULT_PUBLIC_URL>/admin and NOT the app's /admin/logout route. Fix the README so the logout redirect isn't rejected with Auth0's "something went wrong". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The vault read only process.env, so there was nowhere to put config besides the shell / start script -- even though .gitignore already reserved .env. Add a zero-dependency .env loader: - server.js loadDotEnv(file): reads server/.env (or VAULT_ENV_FILE) and sets each KEY=VALUE into process.env WITHOUT overriding anything already set (the real shell/host environment always wins). Skips comments/blank lines, unwraps quoted values, splits on the first "=". A missing file is a silent no-op. - server/.env.example: a documented template of every variable (access token, Claude key, master key, networking/TLS, the full Auth0 set + admin/player lists). Now local + production runs can keep all secrets in one gitignored file; Node 20.6+ users can alternatively use `node --env-file=.env server.js`. - test/test_env.js covers load/skip/unquote/no-override/missing-file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When Auth0 is configured, the whole hosted game now requires a sign-in, not only
the admin page. The Auth0 session gates the app + /vault/config; the existing
bearer-token proxy is unchanged (CSRF-immune) and its token is handed to a
signed-in player exactly as it is to a loopback client -- so the client needs no
changes and there's no hand-built login page (Auth0 Universal Login is the login).
Design (Option B):
- vault-core.js: isPlayerUser(user,cfg) + playerGateDecision({...}) -- pure,
mirroring the admin gate. Any authenticated (verified) user may play by default;
optional VAULT_PLAYER_EMAILS restricts (admins always count). Auth0 off -> game
stays public (unchanged). buildClientConfig hands the token to loopback OR an
authenticated player.
- server.js: install the express-openid-connect auth() middleware app-wide (so
req.oidc reaches both gates), Authorization Code flow, routes kept under /admin/*
(the URLs already registered in Auth0). New requirePlayer gate on the static app
+ /vault/config; unauth -> redirect to login, authed-but-not-allowed -> 403.
- admin.js: drop the local auth() install (now app-wide); requireAdmin unchanged.
- test_vault_core.js: player-gate + token-handoff unit tests. Smoke-checked that
the server boots with Auth0 env set. README documents the player-login setup
(env vars + Auth0 callback/logout URLs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA PASSIVE bypasses the player's own roll, so a roll-modifier targeting "your"
(self) is a contradiction — there is no player roll for it to modify. But a passive
CAN still alter a foe's roll (a dread aura giving enemies -2 to hit). So scope the
roll-modifier target select to the kind:
- "Checked" ability → self ("your") or enemy, as before.
- "Passive" ability → enemy only. A row built while passive is enemy-locked, and
switching an existing ability to passive re-scopes its rows to enemy (a self row
coerces to enemy, visibly, so the DM can adjust).
New abilityEditorRollmodTargetOptions(target, passive); the row builder reads the
current kind; abilityEditorKindChanged rebuilds each row's target select.
- test_ability_rollmods.js: Checked offers self+enemy; Passive offers enemy only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom"Modifier" collided with the "Roll modifiers" section, which was confusing. Rename the Kind radio label to "Checked" (a situational bonus applied on a d20 check) -- label-only; the stored kind value stays "modifier", so the data model and all downstream logic are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Roll modifiers section lived INSIDE #ability-ed-modifier, which is hidden when the kind is Passive. So a passive that carries a roll-modifier (e.g. Choir-Marked -> -2 to enemy to hit) showed the modifier in the preview but had no visible/ editable control -- you couldn't see or change it in the editor. Move the Roll modifiers field out of the modifier block to a sibling after the passive block, so it stays visible for BOTH kinds (roll modifiers are orthogonal to kind -- a modifier OR a passive may also carry combat-roll bonuses). No JS change: abilityEditorKindChanged only toggles the modifier/passive blocks, which no longer contain it. - test_ability_rollmods.js: assert the section sits outside the kind-toggled blocks (rollmods container appears after #ability-ed-passive in the DOM). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A GM-authored ability can be a MIXED shape -- e.g. kind:"passive" that ALSO
carries a legacy roll-modifier ("Choir-Marked -> passive, -2 to enemy to hit").
abilitySummaryText (race card, item grant card) and characterAbilityRowHTML
(character sheet) special-cased passive/new-style abilities and returned early,
dropping the effect.modifiers -- so the race card showed only "passive . <cond>"
while the editor preview (which appends modifiers for every kind) showed the -2.
The modifier is real and IS applied at play (racial abilities with effect.modifiers
feed the GM dossier); this was a display-only gap.
- New shared abilityRollmodSummary(a) -> "-2 to enemy to hit; +1 to your AC".
- abilitySummaryText + characterAbilityRowHTML append it on the passive and
new-style branches (the legacy branch already renders it via formatAbilityEffect).
- abilityEditorPreview reuses the same helper (keeps its crossed-swords marker).
Now the race card / character sheet / preview all agree.
- test_character_abilities.js + test_race_abilities.js: a passive-with-modifier
shows BOTH the passive marker and the modifier.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe Icon input collected a glyph that was normalized and persisted but never rendered anywhere an ability appears (character sheet rows, race card, item grant rows, and the GM dossiers all show name + effect + condition + description, no icon). Its greyed ✦ placeholder also read as a stray "+". Remove it: - Drop the Icon input from the modal; the Name field now spans the row. - Stop reading/prefilling it (abilityEditorForm, openAbilityEditor) and drop the `icon` field from the canonical ability record (normalizeAbility). - Remove the orphaned .ability-ed-icon CSS. No display or test referenced ab.icon, so nothing else changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Deleting an ability from a race card or an item card used the browser confirm() dialog. Replace it with a styled #ability-delete-modal that matches the app's other confirmations (dark panel, gold title, red Remove button) -- mirroring the sound-delete-modal pattern. - Shared askDeleteAbility(name, sourceLabel, onConfirm) stashes the pending deletion as a callback; confirmAbilityDelete() runs it, closeAbilityDelete() cancels. removeRaceAbility / removeItemAbility now route through it (the only two ability-delete paths; the DM grant has no per-row delete). - test_ability_editor.js: deletion defers until confirm; cancel keeps; confirm drops; static wiring asserts the modal exists and the browser confirm() is gone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
- Widen the ability-editor modal to 560px (from the default 460) via a .modal-box.ability-ed-box modifier, so a roll-modifier row's check select and the Custom free-text field sit comfortably side by side (mirrors the existing .map-bg-box wider-modal pattern). - Theme the opened <select> dropdown lists to match the app: the option popup was the OS-default white with light text (unreadable). Add dark option rows (var(--bg-raised)) with light text (var(--text)) and gold optgroup headers (var(--gold)) -- the same convention as .settings-select / #regions-stitch. Theme-aware (adapts to the app's light theme too). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The race-edit directive only described the legacy effect.modifiers shape, so the
GM authored every racial ability as a GM-adjudicated roll-modifier -- even a stat
knack like "+2 dex on precarious footing" that the engine could resolve directly.
The downstream (normalizeRaceAbilities, effectiveAbilities fold-in, race card,
character sheet, dossiers) already handled new-style racial abilities; only the
authoring contract lagged.
Teach the directive all THREE shapes, chosen by what the ability does:
(A) Situational modifier (engine-resolved): { kind:"modifier", bonus,
appliesTo:{skills,stats,tags}, condition } -- folded into the check + manifest.
(B) Passive: { kind:"passive", condition, waives:{tags} } -- always-true capability.
(C) Combat-roll / debuff modifier (legacy effect.modifiers): to hit / AC / damage /
initiative / saving throws, self or enemy -- GM-adjudicated (saving throws fold
into the engine's save roll).
Pick one shape per ability; no shape-mixing.
- test_race_abilities.js: applyRaceSpec round-trips a new-style MODIFIER + PASSIVE;
race card renders both; directive-shape assertions for (A)/(B)/(C).
- DM Guide Races>abilities row + Designs/abilities.html S3 updated for the three shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe roll-modifier's game check is now a grouped <select> with a free-text escape
hatch, marrying the two ways a modifier is honoured:
- "Engine-resolved" group (Saving Throws) — read + computed by the deterministic
engine, so the exact canonical name matters; picking it removes any typo/GM-
hallucination risk.
- "GM-adjudicated" group (To Hit, Armor Class, Damage, Initiative) — narration
guidance the GM folds in.
- "Custom check…" — reveals a free-text field for an ad-hoc GM pass-through check
("+2 to your persuasion vs nobles"), preserving the open-ended model.
A legacy/free-form roll name opens in Custom mode with its value intact. The
select value carries a "__custom__" sentinel that the reader resolves from the
free-text field. CSS scoped under .ability-ed-field so the fields aren't blown
out by the generic width:100% rule (and the custom field gets the larger share).
tests/test_ability_rollmods.js: grouped options, Custom sentinel + resolution,
custom round-trip, legacy free-form -> Custom mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe roll-modifier row's controls were styled with .ability-ed-rollmod-row .rm-*
(specificity 0,2,0), which LOST to the generic ".ability-ed-field input[type=number]
/ select { width:100% }" rule (0,2,1). The number field therefore rendered at
width:100% and, being flex:none, expanded to the full row width -- pushing the
self/enemy target and the roll-name/check field out of view (measured: delta 460px,
check select squeezed to ~20px). This is why the roll modifier appeared to be "only
a number field" with nothing to connect it to a check.
Scope the row rules under .ability-ed-field (specificity 0,3,0) so they win, and
pin explicit flex-basis on the number/target fields. Verified with a headless
Chromium render: delta 58px, target 78px, check select fills the remainder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe roll-modifier row's free-text roll name is replaced by a select of specific game checks (Saving Throws, To Hit, Armor Class, Damage, Initiative). Combined with the amount and the self/enemy target it forms a canonical modifier like "+2 to your saving throws", still gated by the ability's condition; stacking several rows builds a stronger trait. Why a dropdown: it guarantees a canonical roll name instead of typo-prone free text. This matters especially for saving throws, whose engine-side folding matches the roll name exactly (isSavingThrowRoll) -- "saves throw" would silently fail to apply. A legacy/free-form roll name authored before the dropdown is preserved as an extra selected option, so no existing ability loses data. - ABILITY_ROLLMOD_CHECKS + abilityEditorRollmodCheckOptions(); the row's .rm-stat is now a <select>. Datalist removed. - tests/test_ability_rollmods.js updated for the select (parse selected option, curated-list wiring, free-form preservation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Make the "GM rules if/when; engine owns the roll + math" clarification prominent in each doc, framed for its audience: - Field Guide (guide.html): new player-facing "Saving throws" section (nav + #p-saves) covering how a save is rolled (engine-rolled via the dice bag, d20 + stat mod + applicable saving-throw ability bonuses, ladder, manifest), with a "the roll is yours, the ruling is the GM's" callout and an in-combat note. - DM Guide (Handbook/dungeon-masters-guide.html): a "judgement vs. resolution" note in the foundational "two different masters" section, plus a pointer in the Races > abilities row explaining a "saving throws" modifier folds into the engine's save roll (condition-gated ones only when the GM judges they fit). - Designs/abilities.html: new sub-section 7.5 (saving throws: the engine rolls, the GM rules) + a GM-contract bullet, codifying the boundary as design principle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A monster/NPC/hazard can now force the player to make a SAVING THROW, and the
saving throw is resolved by the ENGINE (via the dice bag) rather than passed
through to the GM. Any of the player's saving-throw ability bonuses (a legacy
"+2 saving throws" roll-modifier) fold into the engine's computation instead of
riding on the GM's memory.
Out of combat (new "savingThrowRequest"):
- The GM requests a save { stat, dc, label, abilities?, prompt?, icon? }; the
engine surfaces the dice bag, the player rolls a d20, and the engine computes
total = d20 + stat mod + applicable saving-throw ability bonuses, grades it vs
the DC, shows a manifest breakdown, and relays the resolved outcome back for
the GM to narrate. Mirrors the skillRollRequest machinery.
In combat (existing awaitRoll mode "saving-throw"):
- combatSaveBonus folds the player's saving-throw ability bonus into the engine's
reported roll (the same way Surprise Attack augments initiative), on both the
submitted-roll and timeout-auto-roll paths. The combat SAVING THROWS rule now
tells the GM the engine applies that bonus (don't add it twice).
Bonus resolution: unconditional saving-throw modifiers apply automatically;
condition-gated ones (e.g. "+2 saving throws . against fear") apply only when the
GM names the ability. Gathered from own/granted abilities, race, and equipped
gear; clamped to the ability-bonus cap.
- tests/test_saving_throw.js covers the gather/resolve/relay + combat fold-in.
- test_rogue_skills.js updated for the refactored submitCombatRoll/auto-roll paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe ability modal only edited the new-style fields (check bonus, skill/stat/tag binding, waives). A GM-authored legacy roll-modifier ability -- e.g. "+2 to your saving throws . against Cantos comprehension" stored as effect.modifiers -- showed only its condition and was SILENTLY DROPPED on save (data loss). - Add a repeatable "Roll modifiers" section to the modifier block: [+/-N] to [your/enemy] <roll name>, with a datalist suggesting saving throws / to hit / AC / damage / initiative. Rows render from ab.effect.modifiers on open, read back via abilityEditorReadRollmods(), and fold into spec.effect on save. - Fix a phantom "+1" bonus default: a legacy roll-modifier ability (check-bonus 0) now shows 0 instead of 1 when edited. - Preview line now includes the roll-modifiers. - tests/test_ability_rollmods.js drives the real render->read round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A context-agnostic modal (#ability-editor-modal / openAbilityEditor) that authors ONE ability and hands the normalized record back via an onSave callback — so it launches from any host with zero standing UI. Kind toggle (Modifier | Passive) drives progressive disclosure; a live preview renders the ability exactly as the sheet + GM dossier will show it; Save runs the form through normalizeAbility (same validation/clamp as everywhere). Launch points wired: - Races editor — a + Ability button + per-row edit/remove; the section now always shows so a DM can author on an empty race. - Items editor — a Grants (equipped) section with the same controls. - Character sheet — a DM-only + Grant ability button (grants via applyPlayerAbilityChanges). Also a shared abilitySummaryText() reused across the race card, item card, and item popup. Tests cover the modal wiring + each host's save/edit/remove path; the race-card test updated for the always-shown section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Close a completeness gap: racial (and entity) abilities were normalized by
the legacy normalizer, which stripped the new kind/bonus/appliesTo/waives
fields — so a DM/GM-authored racial passive ("breathe underwater") or check
bonus wouldn't survive load, and Phase 3's race adapter only worked in a
test that bypassed normalization. normalizeRaceAbilities now delegates to
the canonical normalizer, preserving the new fields (legacy effect.modifiers
still kept for the racial-abilities dossier + combat rolls). GM authoring of
racial abilities now works end-to-end through the existing race editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomItems can now CONFER abilities while equipped (the Boots-of-the-Cat case): - Item model gains an abilities field (normalized, own-field so it serializes + survives restore), set in makeItem from the spec/catalog. - equippedAbilityGrants() collects abilities from the player's currently- equipped items (deduped by item id for two-slot gear) and folds them into effectiveAbilities, tagged with the item as their source. Unequipping removes them automatically (recomputed live, no persistence). - The item detail popup shows a Grants (equipped) line summarizing each conferred ability (a modifier's bonus + binding, or a passive), so the player sees what an item will give before wearing it. Tests: an item preserves its abilities, an equipped item confers them (source: item), and unequipping removes them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character sheet's Abilities section now renders the new ability shapes,
not just legacy racial modifiers. A MODIFIER shows its signed situational
bonus + binding ("+2 · WIS, hearing") with the condition on a meta line; a
PASSIVE shows a muted "passive" marker (no number) with its condition +
what it waives; a granted ability shows its source label. Legacy racial
abilities keep their existing modifier summary. So the abilities that
Phases 1-3 made functional are now visible to the player.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomMake abilities ARRIVE from sources during play, not just from authored data: - effectiveAbilities() now merges sources: the player's own/granted/timed abilities plus new-style RACIAL abilities (a race-granted passive or check bonus). Pure legacy-modifier racial abilities stay in the racial-abilities dossier, so nothing is double-listed. Deduped by id/name, active-filtered. - abilityChanges GM directive: applyPlayerAbilityChanges grants or strips an ability in play (a potion's boon, a spell's gift, a plant's virtue, a GM fiat). durationMinutes makes a TIMED grant; sweepExpiredAbilities lapses it on the game clock alongside the status sweep (with a Faded notice). New schema field + rule (distinct from statusChanges) + turn-loop dispatch. - Persistence: player.abilities now normalizes through normalizeAbilities on load/import (not normalizeRaceAbilities), preserving kind/bonus/appliesTo/ waives/expiry so granted + timed abilities survive save/reload. Entity/race abilities keep their legacy normalizer + render path. Item-equipped ability grants are deferred to pair with the item editor (they need an abilities field on the Item model). Tests cover grant/timed/ dedupe/remove/expire, the race adapter (fold-in + no double-listing), and the prompt wiring (schema field, rule, dispatch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Wire abilities into the Game Master's turn contract: - Abilities dossier: the character's currently-active abilities (from effectiveAbilities()), injected after the racial-abilities dossier. Modifier abilities show their signed bonus + binding + condition; passive abilities are flagged as always-true, no-roll capabilities with their waives list. Empty when the character has none. - abilityChecks: a new response field + rule for the SKILL-LESS stat check (d20 + a raw attribute's modifier + any ability bonus vs DC), directing the GM to it instead of the flat 5% long-shot for attribute feats no learned skill covers. GM-rolled in both auto-roll modes; earns no xp. - Passive rule: passives are facts, not rolls - honour them, and never call a check an active passive makes unnecessary (its waives list). - skillChecks entries gain an optional bonus/bonusSource so a modifier ability can reinforce a skill check; a sentence explains when. - Turn loop dispatches result.abilityChecks to applyAbilityChecks. tests/test_abilities_gm.js builds the real system prompt and asserts the dossier (both kinds), the abilityChecks field+rule, the passive rule, the skillChecks bonus, and the dispatch wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
savedGameKey joined character and world with a literal NUL byte embedded in the source, which made the file read as binary to tooling and is fragile: any editor/tool that strips the NUL would silently change every save key and orphan existing saves. Switch to the U+0000 escape (and the matching comment). The runtime value is identical (a single U+0000 char), so save keys are byte-for-byte unchanged; test_load_save stays green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The pure, testable core of the abilities system (Designs/abilities.html):
- Data model: normalizeAbility/normalizeAbilities produce the canonical
record — kind 'modifier'|'passive', clamped situational bonus, appliesTo
{skills,stats,tags}, passive waives, source provenance, expiresAtGameMs.
Tolerant of the legacy racial-ability shape (effect.modifiers preserved).
- effectiveAbilities(): the player's currently-active abilities, filtered by
the game clock (a lapsed timed grant drops out). Structured so race/item/
timed-grant sources merge in later (Phase 3).
- Engine: applySkillChecks gains an optional bonus/bonusSource folded onto
the roll; new applyAbilityChecks resolves a SKILL-LESS d20-vs-DC against a
raw stat (strongest of the named stats), no proficiency, no xp. Shared
checkOutcome ladder and a fuller manifest that reads back the formula with
the stat as SCORE (modifier) and each layer named.
- Two-lane guard: abilities never touch effectiveStat() (tested).
tests/test_abilities.js covers both kinds, the clamp, expiry filtering, both
check paths, the manifest format, and the two-lane guard. Also update
test_guide_button for the external "handbook -> PDF" change (a762091).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomSome inherent traits aren't a bonus on a conscious action — they're always-true capabilities (breathe underwater, darkvision, immunities) that need no roll. Add a `kind` on the ability record: 'modifier' (rides a check, the default) vs 'passive' (a standing fact that grants no bonus and can WAIVE a check it makes moot). New §4.1; threaded through the three-lane table, the GM contract (a "passives are facts, waive moot checks" rule + dossier flagging), the UI (capability badges with no number), phasing, a new decision Q7, and an over-waiving risk. Passives, like modifiers, never touch effectiveStat(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Frame the six ability scores (raised by level-up point allocation) as the build-defining substrate every other system reads off; skills resolve on top of a governing stat. Refine status effects as usually-temporary but allow permanent ones, and note any status (temporary or permanent) always appears in the sheet's Statuses block. Add a callout tying the three lanes back to the base stats (effectiveStat = base + status; abilities layer on the roll, never the stat). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
§7.3 Composing a check — spine (skill or bare stat) plus additive ability layers resolve as ONE d20 by default; separate checks only for separable outcomes (the anti-dice-soup rule), spine + layers at GM discretion. §7.4 The manifest shows the full formula with each contribution named and the governing stat as score AND modifier (DEX 16 (+3)), so players see their stat allocations pay off. Noted as a small format change to the existing skill-check manifest; Phase 1 scope updated to include it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Design-only spec for a third gameplay lane beside skills and status effects: abilities are inherent (race) or granted (equipped item, spell, potion, special flora) — never learned — and express situationally as a conditional bonus folded into a check only when their condition holds. Covers the three-lane model (learned skill vs inherent/granted ability vs blanket status effect), the ability record + hybrid binding (skill / stat / tag+condition), source-owned lifecycle, the engine check path (a situational bonus on skillChecks plus a new skill-less abilityChecks stat check that replaces the 5% floor for stat-plausible actions), the GM contract, UI, persistence/migration, phasing, and open decisions. Handbook/guide retrofits are deferred to a later phase until the design locks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Beside the Field Guide button in the story toolbar, add two icon buttons: a Dungeon Master's Guide button (DM-only, matching the "//" command that also opens it) wired to openDMGuideWindow, and a Player's Handbook button wired to a new openPlayersHandbookWindow that opens Handbook/players-handbook.html in its own named popup. Both share the existing toolbar button styling and name themselves via title/aria-label. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Uploading a portrait on Character → Profile set player.portrait but never recorded it in the gallery, unlike a freshly-painted portrait. It now pushes the uploaded image to player.gallery (deduped, newest last) using the same pattern as generation, so the player can revisit it and restore it later via the lightbox's "Use" button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Server Vault's static resolver denied the whole Handbook/ directory, so in Vault mode "//" (Dungeon Master's Guide) and the "/" Player's Handbook lookups 404'd — Handbook/dungeon-masters-guide.html was blocked. That directory is shipped, runtime app content the game opens/fetches same-origin (the DM's Guide + Player's Handbook HTML, their PDFs, and the dmg-images they embed), not repo docs or source, so it belongs on the served side of the fence. Removed 'Handbook' from STATIC_DENY_TOP; traversal, dotfile, and the source/docs denials are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
TASK 3 — Dropping a Gallery thumbnail onto the main character portrait set player.portrait and re-rendered the Profile, but never called updateSidebar(), so the sidebar Character block and the standalone Portrait block kept showing the old portrait until the next turn. portraitDrop now calls updateSidebar() after renderCharacter(), matching the other portrait-set sites (useLightboxImageAsPortrait, the paint variation path). Test extended to cover the drop path refreshing the sidebar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
TASK 1 — Rename the "Atlas" tab and its "Show Atlas" setting to "Guide": the playthrough tab button, the setting label, the placeholder title/hint, the completion-chip tooltip, the room-visited tooltip, and the generated document header. Internal ids (playthrough/showPlaythrough) are unchanged. TASK 2 — Fix the Login Ambient looping after login and surviving a music-off on the login page. The login cues are html5 sounds started before any user gesture, so the browser queues the play until the first gesture unlocks audio. When that unlocking gesture is the same one that leaves the screen (Begin/Continue) or mutes the toggle, a bare stop() runs before the queued play fires and the ambient starts anyway. stopLoginCues now unloads (not just stops) each cue instance, destroying the audio node and clearing the queued play; cues reload cheaply on the next login show. Tests updated to assert the stop+unload contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The vault now tallies what it actually spent, from real numbers only. Claude's Messages-API usage block is parsed from each GM reply and priced at published per-model rates (pricing.js: cache read 0.1x, cache write 1.25x the input rate; unknown models record tokens but no fabricated cost). Generations record one request each plus any token usage the provider reports (Gemini usageMetadata, surfaced by the descriptor executor); media providers that report nothing are counted with no invented tokens/cost. An aggregate-only store (usage.js — per-provider running totals, no per-request log, no prompt bodies, mode 0600) backs GET/POST /admin/api/usage and a "Usage & cost" panel on /admin (per-provider requests/tokens/cost, a grand total, and Reset). A blank cost column (—) means the provider returned no priceable figure. Unit-tested (pricing math, record/snapshot/reset/persistence) and wired into the GM + generate integration tests; usage file is gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Increment 2 of the provider catalog: the six AI-Generation slot dropdowns (Image / Icon / Map / Gallery / Sound / Video) are now built from a provider catalog instead of hard-coded <option> lists — so a custom (vault) provider appears in exactly the slots it declares, with no client change. - Each built-in provider registry entry declares its `slots`; a client catalog is assembled from them and is the source of truth in Direct mode. - In Vault mode the server catalog (built-in slot overrides + custom providers, from /vault/config) is merged OVER the client built-ins by id — the server's slot edits and custom providers win, while a client-only provider the server doesn't run (video/Runware) is retained. Direct mode never depends on the server, so nothing regresses offline. - populateGenerationProviderDropdowns() rebuilds each slot's <select> from the catalog on settings sync; the per-slot getters/setters validate against the slot's catalog list (a stored provider no longer offered falls back to the first offered). imageProviderLabel resolves custom labels from the catalog. - New test_provider_catalog.js covers the Direct built-ins, the Vault merge (custom added, override applied, video retained), slot filtering, dropdown population, and getter fallback. Design doc 3b updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two related image-to-image fixes for Vault mode: 1. Emphasis portrait (GM-triggered + the `// emphasis` DM command) now routes through the vault so the Nano Banana key stays server-side. The two key gates accept Vault mode (key lives on the server), and paintNpcEmphasisPortrait takes a vault branch like the gallery does. 2. The source portrait was not reaching Gemini: the client sent the portrait reference as-is, but a keyless-Pollination portrait is a URL, and the vault can only embed a data: URI inline — so a URL was silently dropped and the result was a literal text-to-image, not a variation of the portrait. Both the gallery and emphasis vault paths now read the source to a base64 data: URI first (via imageToInlineData, the same conversion the browser-direct Nano Banana path uses), so the bytes actually ride along. - text_adventure.html: add imageToDataUri(); use it in paintImageVariation and paintNpcEmphasisPortrait vault branches; gate emphasis on hasNanoBananaKey() || isVaultMode(). - Tests: vault-mode emphasis routing + inline source; gm_seam gallery wiring updated for the data-URI conversion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
In Vault mode, generating a gallery portrait variation (Character → Profile) failed with "no Nano Banana (Gemini) key" because the image-to-image path still ran browser-direct, where no key exists — the key lives in the vault. Route it through the vault like the other generation paths. - providers.js: the descriptor engine gains optional init-image injection — when a descriptor declares `imageInput.partsPath` and the client sends params.initImage (a data: URI), the executor parses it and prepends an inlineData part to the request body. The nanobanana descriptor declares it. So the source portrait rides along server-side and the key never reaches the browser. - text_adventure.html: vaultImageParams builds an image-to-image param set (variation-framed prompt + initImage) for nanobanana; paintImageVariation takes a vault branch in Vault mode instead of calling the provider directly. - Tests: engine-level inline-part injection, /vault/generate end-to-end with a source portrait + stored Gemini key, and the client routing/param wiring. Design doc 3a updated (image-to-image now vault-routed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the Editor → Art → Review popup (and every detail popup), clicking the placeholder "Generate" for an NPC or item no longer bails with "add a prompt first" when the object has none. It now mirrors the encounter/map Generate flow: author a prompt via the GM when reachable, otherwise compose a local fallback prompt from the object's own data, then paint — so one click always produces a portrait. - generateEntityPopupPortrait / generateItemPopupPortrait: drop the `!apiKey` bail; ask the GM only when a key is set, then resolve `prompt = existing || fallback` and paint. Never block on a missing prompt. - Add entityPortraitFallbackPrompt / itemImageFallbackPrompt (name + race/ type + description → a portrait / object-study prompt), matching encounterFallbackPrompt. - generatePortraitForEntity / generateImageForItem take an optional prompt override so the fallback paints without persisting a GM-less prompt. - The auto-on-open path (ensureItemPortraitForPopup) stays conservative — it still no-ops without a key, so opening a popup never spends on generation unprompted; only the explicit button falls back. - Tests updated for the new behavior + signatures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Server-side foundation for building the AI Generation panel from data instead of hard-coded provider lists. Each provider (built-in and custom) declares which generation slots it can fill; the client will build each slot's dropdown from this (next increment). - descriptor-schema.js: a shared slot vocabulary (image/icon/map/gallery/ sound/video) + validation. A descriptor's `slots` must be a subset of those valid for its media kind; `gallery` (image-to-image) requires supportsImageInput. Slots default from the kind when unspecified. - providers.js: built-in descriptors carry default slots (Nano Banana also gets gallery + image-input, matching today's client behavior). - vault-core.js: buildProviderCatalog() merges built-in defaults, admin overrides, and custom descriptors into one catalog; /vault/config now serves `providerCatalog` + the `slots` vocabulary alongside the legacy generateProviders list. - admin.js/admin.html: GET /admin/api/providers returns the catalog + vocab; POST /admin/api/providers/:id/slots edits any provider's slots (built-in → settings override; custom → the descriptor). supportsImageInput is a fixed capability the editor validates against but never changes. Each provider card gains an editable slot multi-select (gallery disabled without image-to-image). - Schema, store, and admin/config integration tests extended. Design doc updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add the Claude-authored custom-provider flow to the Server Vault: an admin pastes a provider descriptor, previews the exact validated data, and — on approval — the vault stores and runs it through the one trusted executor. What's authored is DATA, never code, so it can be validated and rejected in a way arbitrary code never could. - descriptor-schema.js: validateDescriptor() enforces a fixed shape — id (no built-in shadowing), image/sound kind, a literal PUBLIC host (loopback/private/link-local/IP-literal refused = the SSRF allow-list), an https host-pinned URL, known auth styles (query/header/compound-header/ none) naming the key by ref, generic response mapping (bytes/json-base64; poll stays built-in-only), bounded size/depth, and drops unknown keys so a stored descriptor is exactly what runs. - providers.js: runDescriptor now re-pins every outbound URL (and poll URL) to the descriptor's declared host + https at run time — the last SSRF fence, applied to built-in and custom descriptors alike. - descriptor-store.js: plaintext store for custom descriptors (secrets stay in the encrypted key store, keyed by auth.ref); validate/add/remove. - admin.js/admin.html: GET/POST/DELETE /admin/api/providers + a dry-run validate route; a Providers panel (built-in + custom, paste → Validate → Add → Remove). Custom providers get an auto-derived key card so their key is settable like any other. - server.js/vault-core.js: /vault/generate resolves built-in OR custom descriptors; /vault/config advertises custom generate providers. - Tests: schema + store unit tests; admin CRUD, custom-descriptor generation, and runtime host-pin integration tests. Design doc 3b marked built. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Split the startup hint into two full URLs (http://localhost:PORT/ and .../admin) so the admin link is CTRL-clickable from a terminal instead of a bare /admin path.
A key saved under one master key can't be decrypted after VAULT_MASTER_KEY changes, so getKey returns null and generation silently falls back to keyless — yet the admin still showed it "configured" (the entry exists), which is confusing. Make that state visible everywhere: - keystore.status(): `configured` now means present AND readable; a present- but-undecryptable entry reports `stale:true` (and no last-4) instead. - startup banner: shows "⚠ STORED BUT UNREADABLE — VAULT_MASTER_KEY changed; re-enter it in /admin" for a stale key. - VAULT_DEBUG generate log: notes when a key is stored-but-unreadable. - /admin card: shows "⚠ unreadable — re-enter" (red) instead of configured. Test: keystore test covers the wrong-master-key case (getKey null, status stale, not configured; a genuinely-absent key is never stale). All server suites pass.
Replace the single "GM key" line with a per-provider summary listing every managed key and whether it's set — from the store (with last-4) or the env (Claude only) or not set — so the operator can see at a glance which keys the vault holds. Keeps the "Claude key required to play" hint when none is resolvable.
Opt-in (VAULT_DEBUG=1) one-line log per /vault/generate call showing the provider, the resolved key ref, whether a key was found (redacted last-4), and whether the vault will server-fetch with the auth header or hand back a keyless URL. Makes it easy to confirm a stored key (e.g. Pollination) is actually sent as Authorization: Bearer, rather than guessing at dashboard lag. Redacted; off by default.
Resuming a saved game whose stored Claude key couldn't be decrypted in this browser showed "Your saved Claude API key couldn't be read — re-enter it below". In Vault mode the client never holds the Claude key (the vault does), so that prompt is wrong — and it points at a field that's hidden in Vault mode. It fired partly due to a race (vault detection is async). Fix: in the resume branch, await ensureVaultDetected() first; in Vault mode adopt the sentinel key (so the app's !apiKey gates pass) and never prompt; in Direct mode keep the existing re-enter prompt. Verified in a browser (Vault-mode boot: apiKey = sentinel, no resume-fail note, no JS errors); static wiring covered in test_gm_seam. App suite 322/8.
The vault was sending the Pollination token as a ?token= query param, which per Pollinations' API docs is NOT the tracked/authenticated method — the account showed no usage. Backend apps must send Authorization: Bearer <token>, which the vault's server-side fetch can now do (the old <img src> couldn't). - providers.js: Pollination auth is now style:'header' name:'Authorization' prefix:'Bearer ' — so a stored token is sent as a Bearer header and counts against the account. applyAuth gains a `prefix` option for header auth. - Keyless efficiency: response.urlIfNoKey — when no Pollination key is set there is nothing to protect, so the vault hands back the plain keyless URL (browser loads it) instead of fetching + embedding a ~1MB data URI, keeping saves compact and avoiding a needless round-trip. With a key it still fetches server-side and embeds the bytes (the token never leaves the server). Tests: test_providers now asserts the Bearer header (keyed) and the URL return (keyless, no fetch); test_generate checks the Bearer header upstream. Verified with two browser E2Es (keyed → data URI + Bearer; keyless → URL, no server fetch). All server suites + app suite 322/8 pass.
Route image and sound generation through the vault so those provider keys
(e.g. a Pollination token) also stay server-side instead of the browser.
Providers become DATA descriptors, not code:
- server/providers.js: a trusted executor (runDescriptor) + built-in
descriptors for pollination, nanobanana, higgsfield, elevenlabs. Each
captures endpoint, auth-injection style (query / header / compound-header,
with an optional keyless tier), request template, response extraction
(bytes -> data URI, json-base64, poll -> url), and Higgsfield's poll loop.
This is the reusable core Phase 3b's "author a provider via Claude" builds on.
- server.js: POST /vault/generate runs the named provider's descriptor with
the vault-held key injected (resolved by the descriptor's auth.ref, so
provider "pollination" -> key "pollinations") and returns { dataUri | url }.
- vault-core: /vault/config now advertises generateUrl + generateProviders.
Client: generateImageWithProvider / generateSoundWithProvider route through
the vault in Vault mode (the client resolves shape/model/framing into
normalized params; the vault owns transport + key). resolveImageProvider
trusts the selected provider in Vault mode (the client can't see server-side
keys). Image-to-image variations (initImage) are not yet vault-routed (3b).
Tests: test_providers (engine, all 4 providers, stubbed upstream),
test_generate (endpoint integration), plus static wiring in test_gm_seam;
repointed two static-regex image tests to the refactored dispatch. App suite
322/8; all server suites pass. Browser E2E: an image generated in-app routed
through /vault/generate, the vault injected the stored Pollination token
upstream, and the browser request carried no token. Design doc marks 3a built.Move the Claude model <select> off the login screen into a new "Claude" section at the top of the AI Generation sub-panel (it's the GM/text provider). The login keeps only the Claude API Key field. Persistence is unchanged (getSelectedModel/setSelectedModel → localStorage tlr_model); populateModelSelect/onModelSelectChange now target #setting-claude-model, and the panel repopulates the row each time it opens. In Vault mode the server owns the model (chosen on /admin), so the Claude model row is hidden and a "Managed by the Server Vault" note is shown (reflectVaultModeInLogin). Tests: repoint test_model_select at the new select id; widen the ai_settings_panel open-class window for the added populateModelSelect call. App suite 322/8. Verified in a browser: Direct mode shows the picker in the panel (login field gone), Vault mode hides it with the note, no JS errors.
The rest mechanic (banner, time skip, fatigue recovery) only fired when the
GM emitted a "rest" state change. The GM sometimes narrated the character
NOT resting — e.g. wrongly deciding they were "already rested" even while
fatigued — and omitted "rest", so an explicit "sleep" did nothing.
Add an engine safety net mirroring reconcileContainerOpenIntent:
- detectRestCommand(userInput): conservatively recognizes an explicit
sleep/rest/camp/nap command (whole-input match + optional qualifiers,
first-person/polite lead-ins stripped), so narrative mentions ("rest my
hand", "the rest of the coins", "attack the sleeping guard") don't trip
it; "…until <time>" is left to the GM's timeSkipUntil.
- reconcileRestIntent(userInput, sc): after the GM turn, if the player
clearly asked to rest and the GM emitted no "rest", apply it — engine
owns rest, not the GM's discretion. Only when the GM omitted rest (no
double-apply), never mid-combat, and honours the light-rest budget.
Also tighten the GM prompt (rules 11 & 11b): remove the "to recover"
opt-out and forbid narrating the character as "already rested" when the
Fatigue line shows a tier — an explicit rest must always emit "rest".
Tests: new test_rest_intent (detection true/false positives, applies when
GM omits, no double-apply, combat-suppressed, static wiring). App suite
322 pass / 8 pre-existing. Verified in a browser (boots clean, no errors).When served by a vault, the Claude key, model, and keys all live server-side,
so the login's "Claude API Key" field, "Model" picker, and "API Keys" button
are redundant — hide them in Vault mode (Direct/standalone still shows them).
reflectVaultModeInLogin() now hides the shared key+model .setup-field and the
api-keys button.
Make the GM model admin-authoritative:
- settings.js: a small non-secret JSON settings store (separate from the
encrypted key store).
- vault-core: GM_MODELS/GM_MODEL_IDS (mirrors the app's MODEL_CHOICES) +
a settingsFile path.
- server.js: build the settings store (default = first model) and stamp the
vault-configured model onto every proxied anthropic call (validated),
overriding whatever the client sent.
- admin: GET /admin/api/keys carries { settings: { model, models } }; a new
POST /admin/api/settings/model persists a validated choice; the Claude card
gains a Model dropdown that saves on change.
Tests: settings unit; admin integration covers model get/set + proxy
stamping; made test_admin/test_server_integration hermetic with
VAULT_SETTINGS_FILE; seam test gains static assertions for the login hiding.
Verified in a real browser (login fields hidden in Vault mode & visible in
Direct mode; admin model dropdown saves + persists across reload). All server
suites pass; app suite 321/8.Add server-side key management so the vault, not env vars, is the source of provider keys — with a write-only admin page behind an Auth0/allow-list gate. - keystore.js: AES-256-GCM encrypted-at-rest key store. Master key from VAULT_MASTER_KEY (never persisted beside the ciphertext); set/get/remove and a write-only status (configured + last4, never the value). Disabled gracefully when no master key is set. - vault-core.js: managed-provider list (the admin cards), Phase-2 config (master key, keys file, admin emails, AUTH0_*), auth0Configured(), isAdminUser() (fixed email allow-list, pluggable to a role/permission claim later), and a pure adminGateDecision() (allow/login/forbid) so the gate is unit-testable without a live IdP. proxyGM() gains an injectable resolveKey so the store is preferred over the env key. - admin.js + admin.html: /admin page (app palette, one card per provider, write-only set/remove) and its key API, behind the gate — Auth0 (OIDC) when configured, loopback-only dev gate otherwise. Auth0 wiring via express-openid-connect, activated only when the AUTH0_* env is present. - server.js: build the store, resolveKey (store then env fallback), mount the admin router, refuse to start on a malformed master key. Tests: keystore unit; vault-core extended (admin authz + gate decision); admin integration (write-only CRUD, store-key preferred over env, page serves); verified the admin UI renders + Set works in a real browser and that an Auth0-configured server still boots and serves the game. All server suites pass; app suite 321/8 unchanged. express-openid-connect added to deps. Design doc + READMEs marked Phases 0-2 built; image/sound proxy routing called out as follow-on 2b.
Add server/ — a Node/Express server that hosts the app and proxies GM (Anthropic text) calls so the provider key stays server-side and never reaches the browser. Server: - vault-core.js: framework-agnostic logic (config + validateConfig secure-by-default, constant-time Bearer auth, loopback-gated client config, the allow-listed proxyGM that injects the vault-held key, safe static-path resolution with traversal/dotfile/denylist guards). Unit- tested with plain node (no Express needed). - server.js: thin Express binding — GET /vault/config (Vault-mode discovery), POST /vault/gm (auth-gated proxy), guarded static hosting, HTTP or HTTPS. Refuses to start without an access token, or on a non-loopback bind without TLS. - package.json/lock (express ^5), README, .gitignore (node_modules, .env, certs), and an integration test that boots the app on an ephemeral port with a stubbed upstream and drives it over HTTP. Client (text_adventure.html): the gmFetch seam gains Vault mode. At boot detectVaultMode() probes /vault/config; when served by a vault it routes GM calls to the proxy with a Bearer token and NO provider key, adopts a sentinel apiKey so the app's !apiKey gates pass, and bypasses the login key requirement. Direct mode stays the default/fallback so standalone (file:// or plain static host) use never regresses. Tests: extend test_gm_seam with detection + proxy-routing coverage; widen the key-encryption static assertions for the new gmFetch shape. Full app suite 321 pass / 8 pre-existing unrelated fails. Verified with a real browser-through-vault E2E (auto-detect, GM via proxy, key stays server-side, zero JS errors). Design doc + Designs README marked Phases 0-1 built.
Migrate every hand-written Anthropic fetch onto the gmFetch() transport
seam introduced in the previous commit. Each `fetch('https://api.anthropic
.com/v1/messages', { method, headers, body: JSON.stringify(BODY) })`
becomes `gmFetch(BODY)`; the endpoint, method, the four headers, and the
API-key injection now live in one place, while every call site keeps its
own body, response.ok branch, response.json() and response parsing
byte-for-byte (behaviour-identical Direct mode). This is the single
chokepoint a future Server Vault re-points (see Designs/server-vault.html,
Phase 0 = "the seam").
Also centralizes key injection: the decrypted key (await getApiKey()) is
now sent once inside gmFetch rather than repeated at every site.
Tests: extend test_gm_seam with gmFetch coverage and an anti-drift guard
(no raw Anthropic fetch may exist outside gmFetch; exactly one place issues
the request). Re-point the static-wiring assertions in test_key_encryption,
test_spell_usage, and test_make_book at the new seam structure. Full suite:
321 pass, the 8 pre-existing unrelated failures unchanged. Verified in a
real browser (seam posts correctly, key injected, zero JS exceptions).Introduce GM_ENDPOINT + callGM() as the single chokepoint every GM (text)
call will funnel through, plus gmTextFromResponse() for the shared
text-block join. callGM behaves identically to the ~63 hand-written
Anthropic fetches it will replace (Direct mode): same endpoint, headers,
body shape, and parsing. Contract: it does NOT throw on an HTTP error
(callers keep their own if(!res.ok) branch); only a network failure
rejects; it returns { ok, status, text, data, response }. This is the one
place a future Server Vault re-points GM traffic (see
Designs/server-vault.html, Phase 0). No call sites migrated yet.Clarify how the vault decides which authenticated user is an admin. "Check the admin role claim" hides an Auth0 setup step: roles are not in the ID token by default and roles/permissions are restricted claim names, so a role gate requires a post-login Action injecting a namespaced claim (or RBAC on an API + an access token). Lead the v1 recommendation with a sub/email allow-list, which needs no extra Auth0 configuration, and promote role/permission claims to a later "more than one operator" step. Updated §10 (prose, table, callout, recommendation), §15 Phase 2, and §16 Q5 for consistency. Also add the missing Videos/ folder to the §13 asset list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012fZip7N8f8KKQifaHsKrfe
Update §10 of the Server Vault design doc to delegate admin authentication to Auth0 rather than a home-rolled admin password: the vault runs as an OIDC Regular Web App (Authorization-Code flow), stores no admin credential, and restricts access by an admin role/permission claim or user allow-list; the operator manages the tenant. Keep the play and admin gates independent so gameplay never depends on the external IdP being reachable. Thread the change through the admin-page section, Phase 2, the Q5 decision, and the Designs README index.
A design + decisions write-up for a Node/Express HTTPS server ("Server
Vault") that hosts the app and proxies its AI calls so provider keys never
reach the browser. Grounded in the app's real state (text = ~63 inline
Anthropic fetches with no abstraction; images = the {label, key(),
generate()} provider registry; AES-GCM keys in localStorage under a
non-extractable IndexedDB CryptoKey).
Covers: a thin, allow-listed credential-injecting forward proxy; transport
(HTTPS now, SSE later, WebSocket reserved); a declarative provider-adapter
model; the "add a provider from Claude's code" ask reframed as validated
descriptors rather than runnable code (RCE-safe); an encrypted-at-rest key
vault (master key from env); auth-first / not-an-open-relay posture; an
admin page that mimics the API-Keys dialog with write-only keys and
remove-key-not-provider; a Direct-mode fallback so standalone use never
regresses; a consolidated threat model; 5-phase build order; and 11 open
decisions each carrying an explicit recommendation. Indexed in the Designs
README.Both the Transcript and novelized Story books previously rendered in a new
browser tab (window.open('', '_blank')). Route them through a shared
openBookWindow() helper that passes a feature string (popup=yes, explicit
centred dimensions, toolbar/menubar/location/status=no), so browsers spawn
a borderless popup window with no toolbar or address bar. Falls back to a
plain window if the browser ignores the features.When the region chip beside the story room title is clicked, the region detail popup is opened without a host room popup to anchor to. Its fixed positioning fell back to `right: 14px` against the viewport, so it landed over the sidebar. Mirror the faction popup: resolve the surrounding .panel-view / .editor-subview and right-align to that container's edge (via window.innerWidth - c.right + 14), matching the item/NPC story popups.
A light rest now knocks fatigue down two tiers (reducing but not fully clearing it) while keeping your prepared spells, and may be taken only twice before a real sleep or camp is required. Update section 04 of the rest-and-fatigue design doc (formula, before/after/residual table, the per-rest budget, engine ownership) plus the new "Shipped" decision entry, and the Player's Handbook rest-table note and prose.
A light rest now knocks sleep-deprivation fatigue down by TWO tiers (Exhausted→Tired, Fatigued/Tired→Rested) — easing it without the full reset a sleep gives — instead of the old flat ~8h pay-down that often left a deep Exhausted character still exhausted. It keeps the spell loadout (unchanged), keeps the small HP/MP recovery, and may be taken only twice (REST_MAX_BEFORE_SLEEP) before a real sleep or camp is required; sleep/camp reset the count. A rest over budget is refused in applyStateChanges before any clock advance, so it costs no time and grants nothing. New: Player.restsSinceSleep (round-trips with the save; legacy saves default to a full budget), fatigueLevelFor / fatigueOnsetHoursForLevel, and restBudgetAvailable / restsRemaining. The light-rest ledger line now notes how many rests remain before sleep. The GM rest directive documents the 2-tier drop + twice-before-sleep limit and tells the GM not to author its own "rested" status on a light rest (the engine owns fatigue) — removing the "rested boon next to an exhausted debuff" contradiction. Sleep/camp keep their well-rested boon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a small region pill next to the room title in the story heading (on room entry and on time-of-day changes) when the room has a region. It mirrors the room popup's Region link: clickable to the region detail popup when the region matches a defined world region, a plain pill for a set-but-undefined region name, and nothing when the room has no region. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character > Profile "Spellbooks" section now renders only when the
character knows the Spellcasting skill (knowsSkill('spellcasting')),
mirroring the caster-only Spellbook tab. Non-casters no longer see an
empty/irrelevant Spellbooks section; learning Spellcasting reveals it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomRelocate the AI section (the "Providers" button that opens the AI Generation slide-out) from the bottom of the Settings popup to the top, directly under the title, and title it "AI Providers". The button id, wiring, and the slide-out panel are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Update the DM guide to the decided item taxonomy standard. The main guide's item table said classes = "Kind tags for the item" (now wrong): replace it with a `subtypes` row (kind/taxonomy tags) and a `classes` row (optional character-class restriction), matching the book rendering (which already had the combined subtypes/classes row) and the app. Point both guides' Flora sections at a `subtypes` tag (herb/tree/fungus/flower) instead of `classes`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adopt the decided standard everywhere items are authored: "subtypes" holds an item's kind/taxonomy labels (a specialization of "type"), and "classes" is reserved for an optional character-class restriction — resolving the long-standing overlap between item kind-tags and character classes. Switch every item-authoring GM directive that still put kind labels in "classes" over to "subtypes" (the room-flora guide, DM flora-addition, stub-flesh item schema, world-expansion + single-room-addition item schemas, the editor Rooms inline-item schema, and requestItemEdit — which now also documents "classes" as the optional class restriction). Creature / entity directives keep "classes" for their kind+role labels (a creature has no class-restriction concept). Clean the two seed items that still used legacy classes-as-kind (Iron Sword, Spellbook) onto "subtypes". No data-model change was needed — resolveItemKinds already migrates legacy bare-classes to subtypes on ingestion, so old worlds keep working. Adds test_item_subtypes_directives.js to lock the directive standard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Refresh Web/Reports/progress-report.html so it captures the design-doc and handbook consistency fixes (spells.html status chips; DM-guide quest-beat Location + class equipmentSlots rows). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Document two shipped mechanics the handbooks hadn't caught up to: - Add the quest-beat `location` field row to the beat-field tables in both DM-guide renderings (the Journal shows it as a clickable Location chip that opens the room popup). - Add the class `equipmentSlots` field row to the main DM guide's Classes table (the book rendering already had it), matching the app schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Spells design doc's header chips read "Proposed / Design for your
review", contradicting both the Designs README ("Phases 1a–1b built &
shipped; 1c proposed") and the doc's own body ("Status: shipped (Phase
1b)"). Update the status-row to the shipped+proposed pattern used by
combat.html / weapon-damage.html: "Phases 1a–1b shipped", "Phase 1c
proposed", "14 decisions locked · 0 open".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomRefresh Web/Reports/progress-report.html from git history (791 commits across 17 active days), capturing this session's Editor/Regions/Atlas/Flora and Journal-quest work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The red "Delete region" button (.modal-btn-danger) only carried the colour override, not the shared base rule, so it lacked the standard modal-button padding and rounded corners. Add .modal-btn-danger to the shared .modal-btn/.modal-btn-ghost base selector so it matches Cancel and the other modal buttons in padding, border-radius, and type. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Overlay an "Add" button on the bottom-right corner of the Editor > World > Regions SVG map. It asks the GM (requestNewRegion) to name ONE new region distinct from the existing ones, appends it with a fresh id + next palette colour, then re-tiles the whole map for the new region count from the existing seed. The re-tile updates every region's polygon/label, but each existing region's DATA — id, name, description, colour, images, and its room assignments — is preserved; the map outline (seed-derived) stays put. The new region is selected and the view re-renders. A duplicate name still gets a unique id, and a GM response with no name adds nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each quest beat can now carry an explicit `location` (a room id or room name). The Journal > Quests view renders a "📍 Location" field for beats that have one, as a chip that opens the room popup on click (reusing questRefClick's location path). A location that resolves to no room still shows its authored text as a non-clickable chip; beats without a location show no field. QuestBeat gains a `location` own-property that round-trips via serializeWorld and the World/Quest rebuild (and is preserved across quest merges). The world-generation and quest-edit GM directives now document an optional per-beat "location" so the GM can author it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor > World > Profile "Narrative" field had no explicit height and fell back to a small default. Give #wprofile-narrative a dedicated min-height of 220px (still vertically resizable) since it typically holds the longest content — the GM's story/plot/quest guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Move the region detail panel's "Delete" button into the region-name row (.region-detail-name) after the name input; the flexed input pushes it to the right edge, so it floats right on the same row as the title instead of sitting on its own line below it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Rename the user-facing label from "Playthrough" to "Atlas": the main tab
button, the "Show Atlas" settings toggle, the tab placeholder title, the
atlas document header ("Atlas — <world>"), and the completion-chip / room
Visited tooltips. Internal ids, the showPlaythrough setting key, and all
function names are unchanged, so saved settings and existing wiring keep
working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA "Delete" button now appears below the region name in the Regions detail panel whenever a real region is selected (not the "No Region" filter). It opens a confirm modal that names the region and states how many rooms are currently associated with it. Confirming removes the region from world.regions.list and unsets the region property on every room that referenced it (case-insensitive by name); cancelling leaves everything intact. Deleting the selected region clears the selection and re-renders the Regions view (and the Rooms editor cards, which show the region tag). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the player switches to a different saved game on the login screen while the login music/ambient is playing, the cues now follow the newly selected save: if it configures DIFFERENT "Login"/"Login Ambient" audio, the current cues stop and the new ones start; if it resolves to the SAME audio, the current cues keep playing (no restart / audible jump). Adds loginCueSignature() (a stable id+source fingerprint of the resolved login + loginAmbient cues, so fresh Sound instances of the same audio compare equal) and syncLoginCuesAfterConfigChange(prevSig). refreshResumable Cache snapshots the signature before swapping _resumableLoginCueSounds, then calls the sync helper — which only acts on the login screen with cues actually sounding, so cold boot and logout still start their cues fresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the GM authors a room while building or extending the world, it now decides — within that same room-creation turn, the way it already weighs NPCs and items — whether the room's description implies natural plant flora, and if so places it as a type-"plant" item in that room. Obvious flora is woven into the room's own description and left visible; subtle flora is gated behind the existing discovery (seen/seenCondition) and identification (revealed/apparentName) fields. This is prompt-level guidance: a shared ROOM_FLORA_AUTHORING_GUIDE fragment spliced into every room-authoring directive — world expansion, single-room addition, stub-flesh, and the editor Rooms tab — plus a "populate rooms with fitting flora" instruction in full world generation (which already carried the flora mechanics). The editor Rooms inline item-type enum now admits "plant". No new GM call is added; the room-install path already routes room items through makeItem, preserving every plant gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Both a full sleep/camp and a spell memorization pass time via animateRestNotice's sped-up sweep. During that sweep the command input + send button are now disabled so the player can't queue a turn mid-rest; the sweep re-enables them when it settles (unless a GM turn is still in flight or the player is down). gmSubmit defers its own re-enable to the sweep when a sleep sweep is active, so the lock isn't clobbered right after the turn resolves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The upper-right completion chip now acts as a button: clicking it (or pressing Enter/Space) opens the Playthrough tab via openPlaythroughFromChip. If the "Show Playthrough" tab is hidden, it enables that setting first so the click always lands on the atlas. Added a pointer cursor + hover affordance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
roomSetMusic now updates the card header's music indicator in place: it adds the .room-music-badge when a track is selected (inserted to the LEFT of the "visited" chip) and removes it when the selection is cleared, so the indicator's visibility tracks the Music picker without a full re-render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Render the header tags as [other tags][music badge][visited chip] so the music indicator sits to the left of the "visited" chip. setRoomVisited now appends the live chip (instead of prepending) so it stays to the right of the music badge on toggle too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Relocate the "Visited" checkbox out of the card header and into the card body: it now rides the "Banner" section-label row (the row that heads the banner portrait), floated to the right edge via a flex space-between layout. The header still carries the live-updating "visited" chip, which setRoomVisited continues to toggle on change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The banner-carousel "Pinned" checkbox previously rendered only for time slots that had an image. Now it renders for every time of day; when the current slot has no image to pin, the checkbox is disabled and dimmed (room-banner-pin-disabled) with a "add an image to pin it" tooltip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Move the "Visited" checkbox out of the below-header status row into the top-right corner of each room card, absolutely positioned inside the <summary> so it stays visible even when the card is collapsed. Clicking the checkbox (or its label) stops event propagation so ticking it never toggles the <details> collapse state. setRoomVisited now live-updates the "visited" chip in the card header (add/remove without a full re-render), in addition to saving and refreshing the completion chip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
In the Playthrough atlas, room/place names (incl. exit destinations), flora, items, NPC/being & item lore hooks, and factions are now clickable links that open the matching detail popup. Each link carries data-pt-cat (the Compendium category) + data-pt-name; a delegated click handler routes it through the shared showCompendiumEntityDetail resolver, which already handles people/monsters/ animals/items/plants/magic/places/factions. Races (no popup) stay plain text. Links reuse the existing comp-name-link hover style. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each Editor > Rooms card gains a "Visited" checkbox beneath its header that toggles the room's `visited` flag — which feeds the World map (which rooms are drawn), the Playthrough atlas, and the completion %. It reflects the current state, and setRoomVisited saves + refreshes the completion chip on change (save-only, matching the other room-card controls; the header "visited" tag re-derives on the next Rooms render). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The single-call enrichment couldn't reliably cover a big generated world in one
request (context + output limits). Chunk it:
- Per-beat unlock hints are requested in batches of PT_ENRICH_BEATS_PER_CHUNK (8),
so each call handles a slice rather than enumerating every beat, with a
PT_ENRICH_MAX_CHUNKS (40) hard cap against runaway worlds.
- One compact route call authors the overall dependency-aware route from quest/
beat TITLES only (small even for large worlds).
- A shared gmJsonCall() helper does the POST + JSON parse for every call.
- Progress is reported per call ("charting unlocks 2/5"); a mid-run failure still
saves the partial guide (Regenerate to retry); a world past the cap notes it
covered the first N beats.
Verified: a 10-beat world runs 2 unlock chunks (8+2) + 1 route call and every
beat gets a hint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomTwo additions on top of the completion atlas: 1. "Show Completion %" setting (Settings > Interface, off by default) shows a chip in the Story view's upper-right with the live overall world-completion %. It recomputes on every sidebar refresh and on switching to Story, so it tracks newly authored content automatically; it's a no-op when off. 2. "Enrich with GM" button on the Playthrough tab. It hands the GM the world's beats/lore/factions/rooms and asks for a dependency-aware suggested route (with concrete player phrasing) plus per-beat "how to unlock" hints — filling the "(GM discretion)" gaps that sparse/generated worlds leave. The result caches on world.playthroughGuide (round-trips via serializeWorld / the World constructor), renders as a "Suggested route" section + per-beat "GM suggests" lines, and the button becomes "Regenerate walkthrough". Suggestions only — labeled as such, since the runtime GM still adjudicates. Guarded on having an API key. The completion % stays fully deterministic; only the guidance prose is GM-authored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Playthrough tab now introspects the AUTHORED world (the live world + catalogs) and renders a completion guide: a titled header with an honest caveat (covers authored content, not GM-improvised runtime content; dice/GM-judged steps are attempts), a completion-summary panel, then walkthrough sections — Quests -> beats (each beat's unlock trigger, journal text, and item rewards), Lore hooks (per- object lore + unlock keys), Factions (reveal conditions), Flora (identify DCs), and Rooms & routes (exits, hidden exits, items, room lore). computeWorldCompletion() powers a behind-the-scenes completion %: unlocked/ discovered vs authored total across quest beats, lore hooks, factions revealed, and everything catalogued in the Compendium (people/places/items/monsters/plants/ animals/magic). Overall % is the average of the dimension percentages. GM-emergent lore and class-gated content are deliberately excluded so the denominators are honest. switchTab renders it; the tab stays behind the "Show Playthrough" setting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A new "Show Playthrough" checkbox (Settings > Interface, off by default) reveals a "Playthrough" main tab positioned to the right of the Maps tab. The tab's view is a placeholder for a forthcoming playthrough document. - switchTab toggles the Playthrough tab + view alongside the others. - applyShowPlaythroughSetting() shows/hides the tab from the setting, and if the setting is turned off while the Playthrough view is active it falls back to Story so the panel never sits on a hidden tab. Applied on boot and synced in the settings panel. Widen a byte-window regex in test_nanobanana_model.js that the added syncSettingsControls lines shifted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Review is now the default inner tab shown when no previous Art inner tab was selected: the Review button/panel carry the active class in the markup, activeArtInnerTab defaults to 'review', and the unknown-tab fallback is 'review'. Opening the Art editor tab now dispatches through switchArtInnerTab(active) — matching how the other multi-inner-tab editors render — so the active inner panel's content (Review by default) is rendered on open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Sleeping in a room furnished with a bed or mattress now makes the positive, timed status the player receives that turn (the GM's "well-rested"/"refreshed" boon) last 50% longer. - roomHasBed(room): scans the room's descriptive prose (base + time-of-day + detailed) and item names/descriptions for bed/mattress/cot/bunk/bedroll, using whole-word matching so "bedrock"/"embedded" don't false-positive. Beds are scene furniture, not discrete items, so prose is the reliable signal. - applyBedRestStatusBonus(sc, psc): on a full sleep/camp in a bed room, multiplies the durationMinutes of positive, timed statuses in the turn's playerStatusChanges by 1.5 before they're committed. Negative and untimed statuses are untouched; a light rest earns no bonus. A green story note flags the bonus. - The GM rest contract now asks for a brief positive rest boon on a full sleep/camp and notes the engine applies the bed bonus automatically, so the GM shouldn't hand-adjust the duration for it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Memorizing a spell from the Spellbook tab used to jump to the Story tab to show the sped-up banner. Now the story banner is still added (it just isn't switched to) and a compact mirror of the same sweep renders inside the spell's own card, below the Cast/Memorize/Forget row: a "Memorizing..." line, the sped-up in-world clock, and a thin MP-blue progress bar. The player stays on the Spellbook tab. animateRestNotice gains optional onFrame/onDone callbacks so the single sweep loop also drives the in-card bar (re-resolved by spell id each frame, so it survives a Spellbook re-render) and clears + refreshes the card when it settles. The in-progress state is in-memory, matching how the sleep banner behaves on a mid-sweep reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The CSS-only muted-hover (a :hover variable bump) had two flaws: setting the muted CSS variables on the hovered element cascaded to every muted descendant (so hovering a container's gaps lit all muted text at once), and a full-width block's hover box extended past its text (so hovering the empty space after the text still lit it). Replace it with a mousemove helper (initMutedHover) that adds .muted-hover-lit to ONLY the muted-coloured element the pointer is directly over, and only while the pointer is over that element's actual text glyphs — hit-tested against a Range's client rects so trailing empty space and inter-line gaps don't count. The class sets the color property directly (not the variable), so it never cascades into descendants that declare their own colour. Theme-aware; the muted-colour probe is recomputed after a theme flip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
animateRestNotice captured the notice card and its bar elements once and bailed the whole sweep on card.isConnected === false. If a story re-render (the windowed log remounts from messageLog) or a late mount swapped that node mid-sweep, the animation drove a detached node while the visible card sat frozen at its default completed markup — the header clock kept fast-forwarding (it paints the persistent header element) but the banner's bar and mini-clock never moved. Re-resolve the card BY ID every frame and drive the loop purely by time, with no isConnected short-circuit: whatever card is live each frame gets painted (bar, mini-clock, is-animating state, click-to-skip) in step with the header, so a remount or brief absence no longer strands the animation. When no card is mounted for a frame the bar paint no-ops while the header keeps sweeping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The time-of-day / image-# indicator and the Pinned checkbox each had their own row beneath the carousel image. Wrap them in a flex .room-banner-footrow (justify-content: space-between) so the indicator sits bottom-left and the Pinned toggle bottom-right on a single row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Muted text throughout the app is drawn with var(--text-dim) / var(--text-muted), and those two variables are used only for text colour. A single global rule now targets the innermost hovered element (:hover:not(:has(*:hover)) — the element actually under the cursor) and lifts both muted variables toward the normal text colour, warmed a touch with gold. Any muted text beneath the pointer repaints brighter, with no per-element edits; text coloured with any other variable is unaffected. Theme-aware (reads --text/--gold), with a short color transition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Below the banner image in the Rooms card carousel, add a right-aligned "Pinned" checkbox. When checked, the currently-viewed time-of-day image is used as the room's banner for every time of day — in the story, the Places compendium thumbnail, and the Art tab. Adds Room.bannerPinnedTime (a time-of-day key, or '' when un-pinned) which getBannerImageFor honors for all hours when the pinned slot has art (falling through to normal per-time resolution when it's empty). The field round-trips through serializeWorld / new World, with unrecognized keys dropped on load. The checkbox shows only when the viewed slot has an image; when the room is pinned to a different slot, a muted note names it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Swap the two inner-tab buttons so the bar reads Review, Missing, Style, Audio. Missing stays the default-active tab; only its position moves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Every Lore card showed the same generic scroll icon. Now each resolves the portrait of whatever the lore is about: the explicit `subject` the GM tags the lore with (new optional loreDiscover field), else the most specific catalogued subject (NPC, monster, item, plant, place, race) whose name appears in the entry's title or text. The shared .compendium-thumb img (object-fit:cover, fixed square) crops it to the placeholder size like every other card; entries with no known subject keep the scroll icon. Adds subjectImageByName() (resolves any named subject's picture the way the detail popups do) and loreThumbImage(); threads an optional subject through discoverLore and the loreDiscover GM contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Settings › Interface gains a "Toggle off Tooltips" checkbox. When checked, no mouse-over / focus tooltip appears anywhere in the app. The gate lives in showAppTooltip — the single funnel that displays a tooltip — so it covers both the themed app-tooltip and adopted native titles; adoptNativeTitle still strips the raw title so the browser's own bubble never shows either. The setting persists (default off) and the checkbox syncs to the saved value when Settings opens. Widen three pre-existing tests' byte-window regexes that the inserted lines shifted (showAppTooltip body; syncSettingsControls body). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The first-identification story card rendered the plant portrait as a full-width banner. Shrink it to a ~30% thumbnail (scoped to .flora-discovery-media so no other popup is affected), with the no-image placeholder sized to match so the layout doesn't jump when the generated image swaps in. The image already opens the shared lightbox on click; the smaller thumbnail keeps that behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Show the effective identify DC (authored identifyDC, else the Herbalism skill's baseDC) in two DM-facing places for a plant that is still gated (not yet known on sight): - The in-game item detail popup gains a DM-only "Identify DC" field, shown only while the plant is unidentified. Players — including trained herbalists — never see the number, since it is concealed game info. - The Editor Flora card's Discovery section gains an "Identify DC N" chip alongside the found/known gate chips, so the DM sees the target at a glance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A class name stored in camelCase/PascalCase (e.g. "ScaffoldScout") now renders
with the words separated by spaces ("Scaffold Scout") everywhere it is shown to
the player, while the stored value stays raw camelCase — it remains the key into
world.classes and the value of player.class, so gating, comparisons, <option>
values and persistence are unaffected.
Adds a classDisplayName() helper and routes the display sites through it: the
sidebar and character-sheet class line, the login and Character Creator class
dropdown option text (values kept raw), the Editor class cards and progression
dialog, the player Progression tab, the title banner, item class-restriction
notes, the learn-from-book gated messages, the printable book cover, and the
session gameLog lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomWhen a plant TYPE is identified for the first time (via the Herbalism check
or an external identifyItem), the story now shows a green "flora-discovery"
card with the plant's PORTRAIT above its description. If the plant has no
image yet, one auto-generates the same way the item detail popup does on
first view — author a prompt via the GM when missing, then paint (keyless-
capable via the fallback provider) — and swaps into the card when ready.
• emitFloraDiscoveryCard(it) builds the card (image or icon-glyph
placeholder + description) and fires generateFloraDiscoveryImage.
• The generated image is patched into BOTH the live DOM node AND the stored
messageLog html (patchFloraDiscoveryImage), so it persists across a
re-render / restore — mirroring updateStoryRoomBannerMarkup for banners.
• First-discovery is gated by floraTypeCatalogued (checked before the
compendium upsert): a second patch of an already-known plant gets only the
plain "You now recognize" line, not another card.
• Reuses the existing item image pipeline (requestItemImagePrompt →
generateImageForItem → applyItemTypeField), so the image also propagates to
the item's type/copies and its Compendium entry.
Verified in a real browser: has-image card renders the image; a no-image
plant shows the glyph then auto-generates and patches (live DOM + messageLog);
the first-discovery gate suppresses the card on a second patch. Unit tests
cover the card markup, the image patch/persistence, and the gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAfter identifying a plant, its Compendium → Flora entry kept the descriptive apparent name in the title. Cause: the GM auto-catalogues first sightings (rule 13) and may create a Flora entry under the apparent name while the plant is unknown; compendiumDiscover is first-write-wins by id, so the later true-name discover couldn't rename it. Added recatalogIdentifiedFlora(it): an upsert that finds the plant's existing Compendium entry — under the true-name id, or one still titled/keyed with the apparent name — and rewrites its id/name/description/type/image to the identified plant (collapsing any apparent+true duplicates), or discovers it fresh if none exists. Wired into both identify paths (the Herbalism check resolver and the identifyItem/external-source handler) in place of the plain compendiumDiscover call. Lore is keyed by item name, so the rename also re-associates the entry's lore correctly. Verified in a real browser (a stale "a patch of grey lichen" entry becomes "Bellrot Lichen" on identify, no duplicate) plus unit coverage of the true-id, apparent-slug, duplicate-collapse, and no-prior-entry cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Herbalism plant-identification check always rolled the d20 in-engine,
ignoring the "Auto-roll skill checks" setting. With auto-roll OFF the player
should roll their own die. Fixed:
• Refactored attemptFloraHerbalismIdentify into floraIdentifyReadiness (gate,
no roll/mutation) + resolveFloraIdentify (resolve with a KNOWN d20) +
attemptFloraHerbalismIdentify (auto path = gate then in-engine roll). The
auto path is behaviour-identical to before.
• The identifyFlora handler now branches on getSetting('autoRollSkillChecks'):
ON → roll in-engine and show the manifest at once (unchanged); OFF → prompt
the dice bag ("Roll Called For") for the first attemptable plant and defer.
• Since this check is engine-owned (unlike GM-driven skill/combat rolls that
relay to the GM), it introduces a local continuation: rollDie routes a
pending flora d20 to handleFloraDiceRoll, which resolves + reveals + grants
xp + prints the manifest locally, then dismisses the bag. The daily attempt
is consumed on the roll, not at the prompt, so cancelling doesn't waste it.
• Multiple named plants under auto-roll-off are examined one at a time (the
first attemptable one is prompted).
Verified in a real browser (auto-roll OFF: plant stays unidentified, dice bag
opens, the player's d20 resolves it; auto-roll ON: resolves at once). Extended
test covers both dice modes, the readiness/resolve split, and the wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomFixes a GM adjudication failure: a character who had learned Herbalism was
still told "you have no herbalist's training" and had the plant identified by
an NPC (identifyItem) instead of their own Herbalism check (identifyFlora).
The skill WAS in the prompt — the model just reasoned from the character's
class ("a scout, not a herbalist") and ignored the skills list.
The engine already knows knowsSkill('herbalism'), so the [UNIDENTIFIED FLORA]
tag no longer asks the GM to infer it. When the player has Herbalism the tag
now reads "THE PLAYER HAS THE HERBALISM SKILL — the moment they examine this
plant you MUST set identifyFlora … do NOT narrate that they lack a herbalist's
training and do NOT route them to someone else"; when they don't, it says they
can't self-identify (use identifyItem). A companion line on the Character
Skills dossier flags it authoritative: a listed skill is known regardless of
the character's class/archetype.
Verified the generated prompt in a real browser (both branches) plus test
coverage of the tag wording and the authoritative-skills note; design doc
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomCommitting a spell to a field spellbook is no longer instant — it advances
the game clock by 1 in-world hour per point of the spell's MP cost. Like
sleeping, the authoritative clock jumps to the finish instant (so the elapsed
time is saved correctly even on a mid-animation refresh), then an on-screen
banner + progress bar fast-forwards the header clock over the span.
• memorizeSpell computes hours = Number(sp.mpCost) || 0 and advances
gameClockEpochGameMs, then surfaces the story view and prints a
buildMemorizeNotice banner driven by the shared animateRestNotice sweep
(the same machinery as the sleep notice), retinted to the arcane MP colour.
• A 0-MP spell stays instant (a plain line, no banner). The Memorize button
tooltip now states the hour cost.
• buildMemorizeNotice reuses the .rest-card / .rest-progress markup + CSS via
a new .rest-card-memorize variant, so no new animation code is needed.
Realizes the spell design's Decision D (loadout changes cost time) — shipped
as an MP-derived cost rather than a separate timeToMemorize field; spells doc
updated to rev. 6. New test covers the banner markup/pluralization, the
1-hour-per-MP clock advance, and the instant 0-MP path; verified end-to-end in
a real browser (8-MP spell → 8 hours, header clock sweeps and settles).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomHerbalism no longer auto-identifies flora. A trained herbalist still SPOTS
plants on sight (the SEEN gate), but recognizing a plant's true name +
consumption effect is now a DC roll made when they examine the local flora:
• The GM fires the new "identifyFlora" state change when a herbalist studies
the flora; the engine (attemptFloraHerbalismIdentify) rolls d20 + WIS mod +
Herbalism proficiency vs the plant's identifyDC (authored, else the
Herbalism skill's baseDC).
• Success reveals the plant, catalogues it, and grants Herbalism xp (+1, or
+2 on a critical) — the skill improves with practice — and prints a
"Herbalism — Plant Lore" check manifest.
• ONE attempt per plant per in-world day (tracked on the plant via
herbTryDay / floraDayIndex). A same-day retry is refused; a failed check
means waiting until the next day to try that plant again.
floraIdentified now only auto-knows for the DM; identifyItem stays the direct
(no-roll) path for learning a plant from an NPC, a book, or descriptive text.
Added an optional per-plant identifyDC threaded through
makeItem/catalogItemShape/applyItemSpec/reItemObj. GM contract, authoring
directives (world-gen, DM flora addition, Flora edit box) and the design doc
updated; flora test updated (herbalist no longer auto-identifies) plus a new
test covering the check, xp, the once-per-day cooldown, and next-day retry.
Verified end-to-end in a real browser (a herbalist's examine rolls the check
and prints the manifest; success reveals + grants xp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe Character → Profile Skills section now shows an origin chip on each skill row — the class it belongs to (its class gate; e.g. a Rogue's Lockpicking reads "Rogue"), or "Anyone" when ungated — mirroring the Editor → Player → Skills cards. An off-class marker also appears when the character wields a skill outside its discipline (at reduced proficiency), reusing the Skills tab's .skill-gate / .skill-offclass styling. The chip sits between the skill name and the right-aligned stat/level meta. Rendered from knownSkills (which already carries classes + offClass); no data change. Verified in a real browser (Warrior shows Swordsmanship → Warrior, Lockpicking → Rogue + off-class, Foraging → Anyone) plus a unit test covering the data source and the rendered markup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Abilities section on the Character → Profile tab merges in the abilities inherited from the character's race (playerRaceAbilities), but setCharacterRace saved the new race without re-rendering, so the racial abilities shown stayed stale until the sheet re-rendered for some other reason. setCharacterRace now re-renders the character sheet, so switching race immediately refreshes the Abilities section to the new race's inherited abilities. Verified in a real browser (Human → Elf → Dwarf → Human swaps the badged racial abilities each time) plus a wiring check in the race test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the Character → Profile tab, the Skills glance moves from between Spellbooks and Treasure to directly under the Abilities section, so a character's learned proficiencies sit alongside their abilities. Pure section reorder in renderCharacter; no data or behavior change. Verified in a real browser: the section order now reads … Attributes, Abilities, Skills, Statuses, … with a single Skills section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor → Player → Classes card now shows a "Skills" section listing the class's inherent (begins-knowing) skills, placed between Base stats and Starting inventory. Each skill renders as a chip (icon + name) matching the card's inventory/equipment chip sections; hovering a chip reveals the skill's governing stat + description via the shared app-tooltip (data-tiphead / data-tip), like the class-progression dialog's skill chips. A class with no inherent skills shows a muted "None" note. Resolved read-only via classInherentSkills (which validates ids against the active skill catalog) → skillById; authored as before via the class edit box / world-gen. Added a .npc-chip.has-tip help-cursor affordance. Verified in a real browser (Rogue shows Lockpicking / Sneak / Surprise Attack chips) plus a unit test covering the chip-per-skill render, placement, tooltip wiring, and the empty state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two sleep fixes: 1. The top-right game clock now FAST-FORWARDS through the night while the sleep/camp timer runs, instead of jumping ahead instantly. The authoritative clock still advances immediately (so the passed time is saved correctly even if the player refreshes mid-animation), but the DISPLAY is swept: animateRestNotice now drives both the notice's progress bar and the header clock from the start hour to the wake hour over a few real seconds. renderClockDisplay (extracted from updateRealmCalendar) paints each frame; a new _restSweepActive flag holds off the 1-second calendar tick so it can't snap the clock to the destination mid-sweep; a final updateRealmCalendar(true) settles statuses/fatigue/time-of-day on the wake hour. 2. Fixed "sometimes the sleep timer doesn't run — the banner shows as already complete." Root cause: a restored save with a missing/null clock left the game clock frozen, so a sleep advanced no time (start === end) and the animation, which needs end > start, snapped straight to done (this also froze the header clock). restoreGameState now falls back to initGameClock() when the snapshot lacks a valid clock, so the clock runs and sleeps time correctly. The rest notice still defaults to the completed state, so it reads correctly where animation can't run (no rAF / reduced motion → snap-and-settle). Verified in a real browser (the header clock sweeps 9:00am → 9:26 → 9:53 → … across a sleep, then settles) plus extended unit coverage of the sweep, the tick-suppression guard, renderClockDisplay, and the restore fallback. Rest design doc updated to rev. 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The app already had one themed tooltip system (.app-tooltip, driven by data-tip / data-tiphead and positioned by showAppTooltip), but it was used in only a few places — ~450 controls across the game still relied on the browser's default, off-theme `title` bubble that also clips inside scrolling panels. Rather than hand-convert every site (and have new title="" attributes regress it), the delegated tooltip listener now ADOPTS any native title on first hover/focus: adoptNativeTitle moves the title text into data-tip and removes the title attribute so the native bubble never appears, then showAppTooltip renders the themed one. This covers every title — static markup, JS-generated, present and future — with no per-site edits, and matches SVG map nodes and dynamically re-rendered content because adoption happens lazily on interaction (newest title wins if a live node is re-titled). Accessibility is preserved: an icon-only control (no letters or digits in its text) that lacks an aria-label gets one from the title, while a control with real visible text keeps its own accessible name. No app logic reads element title attributes, so stripping them is safe. Tooltip body/head gain overflow-wrap so long titles (paths, URLs) wrap instead of overflowing. Verified end-to-end in a real browser (native title on a shipped button renders as the styled tooltip on genuine hover) plus a unit test covering the adoption helper and the listener wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New Designs/ write-up documenting the shipped Flora discovery/identification system and a Phase 2 roadmap for generalizing it to magic items and contraptions. Chapters cover the two independent opt-in gates (seen / revealed), the five Item fields and the four functions they thread through, the itemHiddenFromPlayer + floraIdentified + displayItemName helper chain, the revealItem/identifyItem GM contract and per-turn context tags, authoring paths, and backward compatibility. The Phase 2 chapter lays out the two plant-coded seams (widen isPlantType, unbind the hard-coded Herbalism skill) and recommends binding the identifying skill to the item itself (item.identifySkill) with a category-default map — plants→Herbalism, magic→Arcana, contraptions→Machinery — so any object can name who may read it. Closes with open decision points held for a design pass. README index row added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Plant-type items now support two independent, opt-in gates so flora must be
found and identified through play:
• SEEN — an unseen plant (seen:false + seenCondition) is hidden from an
ordinary player until they study/examine/search the local flora and the
GM fires "revealItem". Obvious/room-described/quest plants stay visible.
• IDENTIFIED — until a plant is revealed (revealed:false + apparentName,
optional revealCondition) a player without Herbalism sees only its
descriptive apparent name; the true name + consume effect stay hidden
until learned via the Herbalism skill, an NPC, a book, or descriptive
text (the GM fires the new "identifyItem" verb). Herbalism and DM sight
bypass both gates automatically.
Data model: seen/seenCondition/revealed/apparentName threaded through
makeItem, catalogItemShape, applyItemSpec, and reItemObj (gated by
isPlantType; serialize/restore for free). Display: floraIdentified /
displayItemName / knowsHerbalism helpers; itemHiddenFromPlayer hides unseen
flora; player-facing name sites (room "You notice", sidebar, character
sheet, item popup) show the apparent name and hide the consume effect while
unidentified; the Compendium withholds a plant until it is both seen and
identified. GM: per-turn room context tags [UNSEEN FLORA]/[UNIDENTIFIED
FLORA] with the true name kept GM-eyes-only; response contract, field spec,
revealItem (now covers unseen flora) and the new identifyItem handler.
Authoring directives added to the world-gen schema, the DM Flora addition
handoff, and the Flora edit box. Flora editor cards show a Discovery
read-out. New test_flora_discovery.js; full suite green (pre-existing
unrelated failures unchanged).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAdd a tiny ✨ icon button just left of the Alignment select in the Character Creator. Clicking it sends the character's Background (and summary) to the GM, asks it to choose the ONE fitting alignment from the world's alignment set, and selects that alignment in the dropdown (also writing player.alignment). - New requestCharacterAlignment(): a scoped GM request that lists the world's valid alignments (with their descriptions) and returns the chosen one, validated against that set. Kept separate from the shared requestCharacterEdit so it doesn't add an alignment-change capability to the Profile's Ask-GM box. - originGenerateAlignment(btn): drives the request with a loading state, then updates #origin-align-select (adding the option if missing) and player.alignment. Verified with Playwright (mocked GM response): the button sits to the left of the select, and a "brigand" background flips the alignment from True Neutral to the GM's pick, updating both the dropdown and player.alignment. Tests + a screenshot confirm placement and behavior. Suite 295/301 (6 pre-existing failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
After the Allocate Stat Points dialog during first-time creation, a new "Pick a Skill" dialog lists every skill the world defines — rendered as the same cards as the Editor › Player › Skills tab — and the player chooses ONE starting skill (it joins any their class already grants). The dialog has a Back button that returns to the stat-allocation dialog, and a Confirm that grants the skill and finishes creation. - New #skillpick-modal + openSkillPick/renderSkillPick/pickSkillSelect/ confirmSkillPick/backFromSkillPick. Cards reuse buildSkillCard; class-inherent skills the character already holds are shown but marked "Class skill" and not pickable. Selecting a card highlights it; Confirm is gated on a selection. If the world defines no pickable skills, the step is skipped. - The creation stat allocation is now DEFERRED: confirming it stores the deltas (_creationStatDraft) and opens Pick a Skill instead of committing. The commit happens in finalizeCharacterCreation, together with granting the skill and clearing creationPending. So Back from Pick a Skill reopens a still-editable stat allocation (pre-filled from the deferred draft), and a mid-creation refresh never leaves stats half-applied — it returns to the Character Creator and the points are all still available. Level-up allocations are unchanged (immediate commit, no skill step). Verified with Playwright + a screenshot: creator → Begin → stat allocation → Confirm → Pick a Skill (11 world skills, the Warrior's inherent one marked), Back restores the pre-filled allocation, and picking + Confirm commits the stats, grants the skill, and ends creation. Tests reworked for the deferred flow + skill step. Suite 295/301 (6 pre-existing failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Allocate Stat Points dialog now shows a "Back" button in the bottom-left corner during first-time character creation, returning to the Character Creator. Level-up allocations don't show it (there's nothing to go back to). - creationPending now stays TRUE through the creation stat-allocation step and is cleared when that allocation is CONFIRMED (moved out of confirmOriginDialog into confirmStatAllocation). This is the single signal for "still creating": it drives the Back button's visibility, the dialog title, and the existing resume-on-refresh logic — so a refresh anywhere during creation (creator OR stat allocation) now returns to the Character Creator, consistent with the earlier requirement. - openStatAllocation shows the Back button and titles the dialog "Allocate Stat Points" when creating, or hides it and shows "Level Up — Allocate Stat Points" otherwise. - backToCharacterCreator closes the dialog, discards the uncommitted allocation draft (the 3 points stay unspent), and reopens the creator; pressing Begin there reopens the stat dialog (Back still available). Verified with Playwright + a screenshot: Back sits bottom-left opposite Confirm, returns to the creator with points preserved, is absent on a level-up, and the title switches accordingly. Tests updated for the moved flag + Back button. Suite 295/301 (6 pre-existing failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a player-authored physical-appearance description, shown above the Background section on both the Character → Profile tab and the Character Creator dialog, and fold it into the portrait image prompt. - Data model: new player.appearance (empty by default; serializes with the save; edits commit in place via the existing beginEditCharField, so it persists a refresh like Background). New CHAR_FIELD_PLACEHOLDER.appearance. - Profile: an "Appearance" section above Background — a click-to-edit text area plus a tiny ✨ button (generateCharacterAppearance) that asks the GM to draft one from the character's name, race (+ race description), gender, and class. - GM: requestCharacterEdit now passes the current appearance as context and offers an "appearance" action (built from name/race/gender/class, observable looks only); submitCharacterRequest applies the returned appearance. The portrait instruction now LEADS with the appearance. - Image prompt: new deterministic portraitPromptWithAppearance wrapper is added to the paint chain (appearance → gender → race), so the written appearance is guaranteed to shape the generated portrait regardless of the GM's phrasing. - Character Creator: the same Appearance field + ✨ button sits above Background, live-synced by syncOriginAppearance (like the Background field), and the creator's portrait Generate already runs through the shared paint chain, so it honors the appearance too. Verified end-to-end with Playwright + a screenshot: the Appearance section renders above Background on both surfaces with its ✨ button, a set appearance is embedded in the final portrait prompt alongside race/gender, and a GM-written appearance live-syncs into the open creator. Tests updated for the new paint chain and extended for the appearance field/action. Suite 295/301 (6 pre-existing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character Creator and the Profile tab already share ONE portrait path —
regenerateCharacterPortrait → submitCharacterRequest('paint me a portrait') →
requestCharacterEdit → the deterministic portraitPromptWithRace/Gender wrappers —
so both build the same prompt from the live player, and the Creator applies the
selected picks first. The race description is included both in the GM directive
(characterRaceContext) and appended deterministically to the final image prompt.
The one input that varied was the character's BACKGROUND: the GM directive told
it to craft the portrait from "class, summary, and background". Background is the
only appearance-input that evolves over a character's life, so a Creator-time
portrait (empty background) and a later Profile-time portrait (rich background)
diverged even though the code is identical.
Per the chosen behavior, build the portrait from the character's FIXED APPEARANCE
— race, gender, class, and short summary — and NOT the life-story background, so
the same character yields a consistent portrait whenever it's generated. The GM
still honors an explicit "base it on my background" / described-look request.
This is a shared directive change, so both surfaces stay identical and both
become background-independent.
Verified the deterministic wrappers with Playwright (race + gender embed from
player state). Tests extended. Suite 295/301 (6 pre-existing failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA browser reload while the player is still in the Character Creator (first login, before pressing Begin) now returns them to the creator to finish, with their work intact. - New persisted player.creationPending flag: true from character construction until Begin is pressed (confirmOriginDialog clears it). It serializes with the save and survives the restore round-trip, so a refresh knows creation is unfinished. Old saves lack it → treated as already-created. - restoreGameState now centrally resumes the setup on every resume path (a plain browser refresh AND the login "Continue" button): resumeCharacterSetup reopens the Character Creator when creationPending, else resumes an unfinished stat allocation. Removed the Continue-branch's separate openStatAllocation call now that it's centralized (so both paths behave alike — a raw refresh previously reopened neither). - The creator's Race / Gender / Class / Alignment pickers now persist their choice the moment it's made (onchange → the same validated setters, a class change re-derives the loadout), matching how Name and Background already commit on blur. So every field survives a refresh and the reopened creator is prefilled with exactly what was chosen. Verified with Playwright: a fresh character is creation-pending; picks persist on the player; the flag survives a JSON→reInstance restore (as restoreGameState does); resume reopens the creator prefilled (Elf / Rogue / Seraphine); and after Begin the flag clears so a later resume goes to stat allocation instead. Tests extended; suite 295/301 (6 pre-existing failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
- Clicking the character portrait in the Character Creator now opens the shared enlarge lightbox (openCharacterPortraitModal), matching the Profile. - The ♻ Generate now applies the creator's current picks (Race, Gender, Class, Alignment) to the live player BEFORE painting, so the portrait prompt reflects the selected Name/Race/Gender/Class — identical to the Profile, where those fields are already committed. requestCharacterEdit reads them off the player (name, class, gender, characterRaceContext), so applying the picks first is what makes the prompt consider them. Refactored the pick-application into a shared applyOriginSelections() used by both originGeneratePortrait (the ♻ button) and confirmOriginDialog (Begin), so generating and confirming stay consistent — a class pick re-derives the loadout via applyPlayerClass in both paths. Verified with Playwright: clicking the portrait opens the lightbox with the portrait src, and Generate applies Race=Elf/Gender/Class so characterRaceContext feeds the prompt. Tests extended; suite 295/301 (6 pre-existing failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Flesh out the Character Creator dialog:
- The portrait's Upload/Generate are now the Profile's icon buttons
(⬆ upload / ♻ generate, .npc-portrait-icon-btn) instead of text buttons.
- Added an editable Name field above Race, in the Profile's large gold display
style (.char-name), click-to-edit via the existing beginEditCharName — it
commits in place.
- Added an Alignment select beneath the Class field (world alignments, falling
back to the nine classic ones; defaults to the character's current alignment).
Confirm applies the choice to player.alignment.
- Added a full-width horizontal rule (out to the modal-box edges) and, below it,
the editable Background section with the tiny ✨ GM-generate button — reusing
beginEditCharField('background') and generateCharacterBackground.
- syncOriginBackground mirrors a GM-written background into the open dialog
(called from renderCharacter alongside syncOriginPortrait), so Generate
reflects live without resetting the pickers.
Verified with Playwright + a screenshot: the dialog shows the gold Name above
the pickers, the icon portrait buttons, Alignment under Class, the full-width
rule, and the Background field with the ✨ button; editing the name commits, a
GM background live-syncs, and confirming applies the alignment before stat
allocation. Tests extended; suite 295/301 (6 pre-existing failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomExpand the new-character origin dialog into a "Character Creator": - Renamed the dialog title from "Name Your Origin" to "Character Creator". - Added a portrait column in the upper-left with the character portrait (or a placeholder) and Upload / Generate buttons beneath it, reusing the existing uploadCharacterPortrait / regenerateCharacterPortrait handlers. A new syncOriginPortrait() mirrors the live portrait into the dialog, called from renderCharacter so an upload or GM-painted portrait updates the preview without resetting the pickers. - Added a Class selector beneath the Gender field, listing the world's playable classes and defaulted to the character's current class. Changing it re-derives the character's class-based state in place via a new applyPlayerClass(): base stats (HP/MP + attributes), starting inventory, starting spells, and inherent skills, plus the class-seeded summary, while preserving identity (name, race, gender, portrait, DM flag, room, purse, the 3 creation points). Verified end-to-end with Playwright: the dialog shows the portrait column and Upload/Generate, the Class picker lists all world classes and defaults to the current one, the portrait preview live-syncs, and switching class re-derives HP/STR to the new class before stat allocation opens. Tests extended; suite 295/301 (the 6 failures pre-exist and are unrelated). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A brand-new character now chooses their race (from the world's defined races) and gender in a dialog that appears right after the world opens and BEFORE the stat-point allocation modal. On the Character → Profile sheet, the Gender and Race fields are now read-only text for a normal player and editable dropdowns only for a DM. - New non-dismissable "origin" modal (#origin-modal) with a Race picker (characterRaceOptions — Human + the world's races) and a Gender picker (GENDER_OPTIONS). openOriginDialog(onDone) fills and shows it; confirmOriginDialog writes player.race / player.gender through the existing validated setters, then runs the continuation. startGame's creation tail now calls openOriginDialog(() => openStatAllocation()), so the two dialogs appear back to back only for a genuinely new character (not on level-up or resume). - renderCharacter gates the Gender/Race controls on player.isDM: a DM keeps the selects; a non-DM sees read-only .char-gender-value text. - Tests updated (test_stat_allocation, test_character_race) for the new flow and the DM gating, plus new assertions for the origin dialog. Verified end-to-end with Playwright: the origin dialog shows the world's races, the stat modal stays hidden until confirm, race/gender persist, and the Profile renders read-only for a player and editable for a DM. Suite 295/301 (6 pre-existing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The field-spellbook loadout strip sat inside the scrollable #spellbook-view and scrolled away with the spell cards. Make it position:sticky at top:0 so it stays pinned to the top of the Spellbook tab while the cards scroll beneath it. The strip's background is already opaque so cards don't bleed through; the scroll container's top padding is dropped (and the strip sticks flush) so nothing shows above it, and the empty-state variant gets an opaque background for the same reason. Verified with Playwright against the real CSS: after scrolling the cards 600px, the strip stays at 0px offset from the view's top. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Refresh Web/Reports/progress-report.html from git history via tools/gen-progress-report.js — now 713 commits across 16 days (2026-06-30 → 2026-07-20), including today's Weapon Damage docs pass, the Rooms editor Items/Flora/Fauna split with flora consumption effects, and the Compendium unlocked-lore display. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When any Compendium object has UNLOCKED lore, its lore now renders beneath the description in a dedicated, italic "Lore" section — on the browsing cards (which previously showed lore only in the DM's editable section) and in the detail popups. - New compendiumCardLoreFromObj / compendiumCardLoreHTML render a read-only, player-facing "Lore" block (italic) beneath a card's description, shown only once the object's loreUnlocked is true. Wired into every card surface: the main Compendium loop (People, Places, Items, Monsters, Flora, Fauna, Races, Magic items), the Factions cards, and the Magic › Spellbooks cards. The Lore tab's own entries never nest a lore section. - The shared popup lore field (buildLoreFieldHTML → .item-lore-scroll) is now italic too, and the record-only fallback detail builder gained the lore field. - Locked lore stays hidden from players on the card; a DM keeps the editable Lore section as before. Adds tests/test_compendium_lore.js (14 checks): the unlocked/locked/no-lore gating, the italic styling on both surfaces, catalog resolution by name, and the popup lore field. Suite 295/301 (the 6 failures pre-exist and are unrelated). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The flora/edible consumption effect was rendering as an easily-missed "If Consumed" row inside the item card's Details block. Promote it to its own clearly-labelled "Effects" section on the item card, and render it as a status CHIP styled exactly like the Character sheet's active-condition chips — green for a net-positive buff, the affliction red otherwise — with the stat summary and duration read out inside the chip (reusing char-status-chip / char-status- eff / char-status-timer). The section renders whenever the item carries a consumeEffect, so authored data is never hidden. The item detail popup keeps its "If Consumed" field. Tests updated to assert the dedicated section, the green buff chip for a positive effect, and the red (non-buff) chip for a negative one. Suite 294/300 (the 6 failures pre-exist). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
DM Editor → Rooms cards now list a room's contents in three labelled sections
instead of one: Items (ordinary gear), Flora (type "plant" items), and Fauna
(type "animal" entities). Each chip keeps its original array index so the
existing click-to-open wiring still resolves the right item/being; the plant is
shown under Flora only (not double-listed in Items), and the animal under Fauna
only (not in Entities (home)).
Flora and edibles can now carry a consumption status effect — the status a
character gains from eating or drinking the item:
- New own-field `consumeEffect` on items ({ label, effects:[{stat,delta}],
durationMinutes, grade }), the same shape applyPlayerStatusChanges applies.
It is normalized to a NON-permanent status (a finite, capped duration) and
kept only on consumable-ish types (plant/consumable/potion/food); it is
threaded through makeItem, catalogItemShape, reItemObj, and applyItemSpec
exactly like the weapon-damage own-field, so it serializes and survives
restore.
- The effect shows on the Flora/edible item card and the shared item detail
popup as an "If Consumed" field (not a room-card section).
- GM authoring directives now ask for it: the // DM flora-addition prompt, the
Editor item-edit prompt, and the new-world generation schema all document a
consumeEffect that may be positive or negative and short or long, but never
permanent.
- The per-turn GM context surfaces each present flora/edible's authored effect,
so consuming it yields exactly that status via playerStatusChanges.
Adds tests/test_room_flora_fauna.js (28 checks): the non-permanent
normalization, the type gating, the card/popup display, the room-card section
split, applyItemSpec authoring/clearing, and the GM-directive wiring. Full
suite 294/300 (the 6 failures are pre-existing and unrelated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomReflect the shipped Weapon Damage Phase 1 (the stat, its retrofit, and its display/authoring) across the design docs and both handbooks, without claiming the Phase 2 combat math that consumes the stat. - Designs/README.md: list the three docs that existed on disk but were unlisted (containers, player-progression, entity-leveling). - Designs/weapon-damage.html: past-tense the pre-feature baseline (now "Where it started"), mark Phase 1 built & shipped, bump to rev. 3. - Designs/combat.html: reciprocal links to weapon-damage.html, noting the stat ships as data/display/authoring while combat consuming it is Phase 2. - Handbook DMG (both web and book variants): add ac/acBonus and damage/damageBonus/damageType rows to the item-field tables, add a weapon-damage entry to "Where to go next"/Appendix C, and fix the broken favicon path. - Handbook player's guide: note that a weapon now shows a Damage stat (display only; GM still narrates each blow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Sync Designs/weapon-damage.html + README with the build: header chip → "Phase 1 built & shipped", the §12 Phase-1 row marked shipped with the delivered surfaces + test files, and the index status updated. Phases 2–3 remain proposed; the five decisions stay locked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Second slice of the weapon-damage build (Designs/weapon-damage.html) — the stat now shows wherever AC shows, and can be authored like AC. No combat math. - DM Items editor card: Damage / Magic Dmg / Damage Type kvRows for weapons (mirroring the Base AC / AC Bonus rows). - Shared item detail popup (buildItemDetailHTML — used by the compendium, the in-game popup, and the equipped-item popup): Damage / Magic Dmg / Damage Type fields beside Base AC / AC Bonus. - Equipment screen: a small gold ei-dmg tag next to a weapon's name in the inventory list, and the weapon's damage in the equipped-slot tooltip. - applyItemSpec authors damage/damageBonus/damageType (normalized), weapon-only and cleared when the type changes — the exact AC role-split. - GM/DM schemas advertise the optional fields with §05 dice guidance: the main-turn addItem note, the DM item-authoring directive, and the world-gen items shape. Covered by test_weapon_damage_display.js (18 assertions). Updated the equipmentSlots-schema regex in test_equipment_slot.js (the addItem shape now carries damage fields after equipmentSlots). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
First slice of the weapon-damage build (Designs/weapon-damage.html): - Core helpers: parseDamageDice / normalizeDamageDice (strict, tolerant, sane bounds), isWeaponType, worldDamageScale (Decision C), weaponAbility (finesse/ranged → DEX), and weaponDamageLabel. - makeItem copies damage/damageBonus/damageType as own fields, ONLY for weapon/sidearm and only when valid — the offensive twin of ac/acBonus. They serialize and survive restore for free (reInstance keeps own fields); reItemObj additionally canonicalizes a saved dice string, coerces the modifier/type, and strips the fields off non-weapons. - Retrofit (Decision B, manual-first): inferWeaponDamage (deterministic heuristic — subtypes → name → slots → value tie-breaker → default), assignWeaponDamage (idempotent, non-clobbering, weapons only), and retrofitWorldWeaponDamage (bulk walk of catalog + rooms + containers + entity/player inventories; returns a count; caller persists). No combat-flow or display changes yet. Covered by test_weapon_damage_model.js (50 assertions: parse/normalize edges, makeItem gating, save→restore round-trip, inference, idempotent/non-clobbering retrofit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The five open questions are resolved: (A) multi-die manual rolls roll all N for real in one dice-bag action via the near-invisible label-count/dim-others change; (B) retrofit manual-first (no auto on-load); (C) damageScale exposed at default 1; (D) enemy damage stays GM-authored through Phase 2, natural weapons deferred to Phase 3; (E) crit flagged from the to-hit roll the engine already captures, in both auto and manual mode. Flip the section-14 cards from Open to Locked, retitle it "Decisions locked," bump the header chip + eyebrow to rev. 2, update the footer, and mark the Designs/README index "ready for Phase 1." Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reframe the three options as a comparison table making the real distinction explicit — how many of the N dice are genuinely the player's roll vs. engine-filled, and whether the dice bag needs a new control — since the bag rolls one die per click today. Switch the recommendation from (b) to (c) (roll all N for real in one action) so manual mode keeps the "the player really rolled it" promise, and capture the proposed near-invisible dice-bag change: prepend the count to the relevant die's label (D6 -> 2D6) and dim/disable the other dice, driven off the existing combat.awaiting state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Convert the weapon-damage design from docs/weapon-damage-design.md into Designs/weapon-damage.html, matching the house style of the other design docs (game palette, theme-aware light/dark, numbered sections, callouts, syntax-colored formula blocks, tables, and decision cards). Add it to the Designs/README.md index (Proposed; not built — 5 open questions, 3 phases) and remove the now-redundant markdown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A design doc for giving weapons an engine-authoritative damage stat in this game's d20 idiom: the data model (damage/damageBonus/damageType, mirroring ac/acBonus), a single computeWeaponDamage() with a pluggable roll source so "who rolls" is a setting (autoRollDamage, mirroring autoRollSkillChecks — GM rolls vs. player rolls via the dice bag), dice tuned to the current HP economy with a per-world damageScale escape hatch, a deterministic idempotent retrofit (inferWeaponDamage/assignWeaponDamage/retrofitWorldWeaponDamage) wireable to a button or // meta-command later, editor/popup/sheet surfaces, persistence (free via own-field serialization), a phased rollout, a testing strategy, and open decisions. No code changes — planning only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Resting at full HP/MP while fatigued printed "◈ You rest, already fully
rested." — contradicting the still-fatigued character and making it look like
the rest did nothing. The mechanic actually DID run (a light rest eases
sleep-deprivation), but a short rest only partially dents deep fatigue, so
the player can remain fatigued; the message just never reflected fatigue.
applyRest's light-rest line now reports what the rest did: recovered HP/MP
(as before), plus — when the character was carrying fatigue — that the
weariness was eased, and when they REMAIN tired/fatigued/exhausted it says so
and points them at real sleep ("only real sleep will fully shake it off")
instead of "already fully rested." A rest that clears the tier notes the
weariness eased; only a genuine no-op (full HP/MP and not tired) still reads
"already fully rested."
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomChanging the Login sound (Sounds › Configure) saves the new pick into the game snapshot immediately, but logging out still played the OLD login cue — and only after a full login/logout cycle (or a refresh) did the new one play. Cause: logout() called playLoginCues() BEFORE refreshResumableCache(), so it started the cues from the stale in-memory _resumableLoginCueSounds left over from the previous refresh. refreshResumableCache() (which rebuilds those cues from the current snapshot) ran afterward — too late for this logout, correct for the next one. Fix: make logout() async and await refreshResumableCache() BEFORE playLoginCues(), so the cue cache is rebuilt from the current snapshot first. The AudioContext is already unlocked from in-game audio, so the short await doesn't cost autoplay permission. Both logout callers are fire-and-forget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Type <select multiple> on the Add-a-Sound dialog (and each sound card) showed selected rows in the browser's default stark blue. Style the selected options with the app's gold palette instead: gold fill with dark (--bg-panel) text. An inset box-shadow forces the gold fill to render even while the select is focused, where WebKit/Blink would otherwise paint their own OS highlight over a plain option:checked background. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a DM-only meta-command that triggers the emphasis-portrait mechanism directly, without waiting for the GM to author one: // emphasis <npc> → a RANDOM dramatic look from a preset pool // emphasis <npc>: <prompt> → the DM's own wording (case preserved) It resolves the NPC tolerantly (current room first, then the world; exact → startsWith → includes), then reuses maybeRenderNpcEmphasis to edit that NPC's existing portrait via Nano Banana and render it above the story. Clear DM notices report an unknown NPC, a missing Nano Banana key, or an NPC with no portrait to edit; the command is listed in "// help". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
At an intense or pivotal conversational beat, the GM may now (at its
discretion) author a one-line prompt to re-render the speaking NPC's
portrait — a facial close-up of anger/grief/fear, a full-body zoom-out, a
being drawing a blade or dropping into a fighting stance. The prompt
describes only the change to the subject's look, expression, or stance.
The GM emits it as a new optional turn-result field:
"emphasisPortrait": { "npc": "Exact Name", "prompt": "<subject-only edit>" }
The engine edits the NPC's EXISTING portrait via Nano Banana (image-to-image
— the prompt is inherently accompanied by the portrait), forcing that
provider regardless of the Settings Image-AI choice since only it edits from
an inline source. The painted "emphasis" portrait renders in the story panel
just ABOVE the NPC's dialogue: a placeholder holds the spot synchronously so
it lands above the narration, then is filled when the async edit resolves (or
removed on failure). Requires a Nano Banana key and an existing portrait to
edit; skips gracefully (logged) otherwise. The finished image is baked into
the stored story so it survives refresh/export.
System prompt: adds a discretionary rule under 6a plus the response-format
field + field note, instructing the GM to use it sparingly and to write a
style-free, subject-only modification prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomOn the Editor › Entities › NPCs and Monsters cards, the Reputation field was a read-only chip. It's now click-to-edit while keeping the exact same chip look: clicking swaps it for a small number input pre-filled with the current value. Enter or blur commits (clamped to the engine's [-100, 100] range) and re-renders so the chip returns with the new value's matching label and colour; Escape cancels. Only the cursor (and a title tooltip) hint the affordance — the chip's own styling is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Selecting a prior save (the name field's saved-games disk menu, or the "Import Game" file button) used to copy the snapshot into the active slot and jump straight into the game. Now it pre-loads that save into the login screen as the last-played "continue" game — world title, branding, character name/class, and login cues all reflect it, "New Game" is unchecked, and the Begin button reads "Continue Your Journey". The player then clicks Continue to actually enter. Adds a "Location" field to the login dialog, visible only when a game is pre-loaded (continuing), showing the room the character will resume in (resumableSaveLocation, cached in refreshResumableCache from the snapshot's current room). A new preloadSaveIntoLogin() drives the loadSavedGame and importSavedGame paths. Also factors the resumable-save gate into snapshotIsResumable(), which now honors the imported-save exemption (matching restoreGameState) so the login "continue" state agrees with what restore actually accepts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a resumable save is present, checking "New Game" on the login screen now defaults the world select box to that saved game's world (when it's one of the selectable library worlds), so starting fresh lands on the world you were just playing. In populateWorldSelect the saved game's world takes precedence over the remembered last-chosen world, but an active in-session pick still wins, and a saved world not present in the library falls back to the existing remembered/Default logic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor › World › Login tab's Background and Logo upload buttons used the small, borderless npc-tool-btn style with a separate hidden file input clicked by id. The Editor › Map › Background tab's Upload button uses the gold-outlined, uppercase item-generate-btn style as a <label> that wraps its file input directly. Restyle the Login tab's two buttons to that same convention: an item-generate-btn <label> wrapping the file input (with its stacked bottom margin dropped so it stays vertically centred beside the reset ✕). The per-asset reset ✕ and all upload behaviour are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Room background music started only in describeRoom (on entering a room), but resuming a saved game — a "Continue" login or a browser refresh — rebuilds the story from the saved log and never calls describeRoom, so a resumed session stayed silent until the player walked into another room. restoreGameState now calls resumeRoomMusic(), which forces a fresh play of the current room's configured Area track. A "Continue" click is a user gesture so it plays immediately; a bare refresh is gesture-less (autoplay blocked), so it arms a one-time first-gesture fallback — mirroring the login-cue unlock — that starts the track on the player's first click/keypress. The fallback stands down when the Music toggle is used or on logout, and stays silent while Music is muted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
An item whose catalog entry carried a generated/uploaded icon (iconImage) showed the small painted glyph on the equipment paper doll but the plain emoji everywhere else — the Items block, the inventory list, char-inv, the "You notice" line, the compendium. Root cause: makeItem() copied the item's full close-up `image` from the catalog base/spec but never copied `iconImage`, so makeItem-created instances (starting inventory, room loot, GM-granted items) lacked the icon. The paper-doll slot resolves straight from ITEM_CATALOG (which has iconImage), which is why only that one site looked right. makeItem now copies iconImage from the spec/catalog base as an own field (spec wins over base, only when set) so it renders consistently across every list site and survives serialize → restore via reItemObj. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Journal > Quests tab now has a left-side list of questlines — each quest with an unlocked beat, shown by title with a checkbox that toggles whether that quest's beats appear on the timeline. A leading 'All' option shows/hides every questline at once (indeterminate when only some are shown). Quests with no unlocked beats are never listed, and the empty-journal placeholder still shows when nothing is unlocked anywhere. The timeline renders in a two-column filter+timeline layout; entry markup and ids are unchanged so Compendium journal cross-links still resolve. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each of the Background and Logo buttons now has a small reset (✕) button beside it that clears the stored image back to the built-in default. The ✕ shows only when that asset is actually set. clearLoginBackground / clearLoginLogo empty the field, persist, re-render the preview, and re-apply the (now default) branding to the login screen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A new 'Login' inner tab on the Editor > World tab lets a DM brand this world's login screen. Two top-right buttons: 'Background' uploads a backdrop image (stored on world.loginBackground) and 'Logo' uploads a logo (world.loginLogo, kept as PNG so transparency survives). The tab previews the login screen live — the chosen background, or the built-in default when none is set, under the SAME overlay mask as the real login screen — with the logo (or the world title it replaces) centred. The branding is applied to the actual login screen via applyLoginBranding: the backdrop swaps the login overlay's background image, and the logo is shown in place of the world title. Wired into every login-screen path — built-in default, a staged/imported world, and continuing a saved game (cached in refreshResumableCache) — mirroring the login-title handling. Both fields persist via serializeWorld / rebuildWorldFromSnapshot, and downscaleImageFileToDataUrl gained an opts.mime so logos encode as PNG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Unlocking a quest beat now posts a nice story banner (reusing the Title Earned banner styling for visual consistency) in place of the plain 'Journal updated' line, and plays the DM-configured sound cue. A normal beat shows a 'Quest Beat Unlocked' banner and plays the 'Quest Beat Unlocked' cue; a beat that finishes its quest shows a 'Quest Complete' banner and plays the 'Quest Completed' cue, falling back to the beat cue when no completion cue is configured. Both cues are no-ops when nothing is configured for them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A faint-until-hover icon button (mirroring the login screen's sound toggle) sits just left of the Dice Bag control (right:184px). It mutes or plays the room's background music: toggleStoryMusic flips the persisted storyMusicOn setting, stops the current track when muted, and resumes the current room's music when unmuted. maybePlayRoomMusic stays silent while muted, so entering rooms starts nothing until it's turned back on. The button reflects the saved preference on game entry/restore (initStoryMusicButton), and the muted state stays visible (not faded) so it's obvious music is off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The detached World Editor header now surfaces a subtle amber-dot 'Unpublished changes' indicator (and highlights the Update Library World button) whenever the current draft differs from its saved-worlds library entry — i.e. it has edits not yet published, or isn't in the library at all. refreshUnpublishedHint compares the serialized draft against the stored library entry; it runs on editor load, after every draft edit, and after a publish (which clears it). Both sides go through the same serialize path in the editor, so a freshly-loaded, unedited world reads as in-sync (no normalization false positive). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The world DRAFT (what the editor writes) and the saved-worlds LIBRARY (what new games seed from) are separate stores, and previously a library-world edit auto-mirrored every change into the library — so a half-finished draft could drift onto new games. Now edits persist to the draft only, and promoting them into the library is an explicit, DM-initiated action: an icon button in the detached editor header (left of the logout button, tooltip "Update Library World") opens a confirmation dialog, then updateLibraryWorld() publishes the current draft into the library under its name. Neither the editor nor this push ever touches an in-progress game save. This lets the DM test freely in the draft and publish a working version only when ready. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
buildRoomCard now renders a gold music-note badge in the card header, right-aligned within the header tags group, whenever the room has musicSoundId set. Its tooltip names the chosen track (falling back to the id if the sound was deleted). Rooms without Music show nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Display-only change to the Map inner tab button text (Art -> Background).
The tab id (mtab-art) and switchMapInnerTab('art') key are unchanged, so
all wiring, rendering, and saved state are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe Sound Type field is now a multi-select: a sound object holds a types[] array (e.g. one clip serving both Victory and Level Up). The Sound model normalizes from types[] or a legacy single type string, keeps a type mirror (= first type) for backward-compat, and de-dupes / drops unknown types. The Add-a-Sound dialog and each card's Type field are multi-selects (populated from SOUND_TYPES, Ctrl/Cmd-click to pick several); confirm/edit store every chosen type and never allow an empty set. soundHasType/soundTypeList centralize type membership so every filter treats a sound as belonging to ALL its types — the Sound Configuration dialog lists a sound under each matching cue, and room music matches any Area-typed sound. Card headers show a badge per type. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Configure dialog only offered Login / Login Ambient / Initiative slots, so the many other sound types (DC Check, Victory, Level Up, and the newly added event cues) had nowhere to be wired. SOUND_CONFIG_KEYS is now DERIVED from SOUND_TYPES — every type gets a slot except the two contextual backgrounds (Area = a room's Music, Ambient = scene ambience), which are chosen per-room/scene, not globally. A stable camelCase key is generated per type, preserving the existing login/loginAmbient/initiative keys so saved soundConfigs keep resolving. Deriving from the vocabulary means the dialog can never fall behind future type additions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Room music (the room's Music field → musicSoundId, an Area sound) already loops on entry, but moving to a DIFFERENT room that used the same track stopped and replayed it, restarting from the beginning. maybePlayRoomMusic now skips the restart when the new room's Music resolves to the same Sound that is already playing (checked by sound identity + live isPlaying), so a shared track continues seamlessly across rooms. A look still never restarts; a room with different music (or none) still swaps/stops as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Extend SOUND_TYPES (and the static Add-a-Sound dialog dropdown, kept in sync) with the new cue kinds: Battle Defeat, Quest Beat Unlocked, Task Completed, Task Accepted, Subtask Completed, Quest Completed, Take Damage, Give Damage, Camp/Sleep Started, Camp/Sleep Finished, Camp/Sleep Interrupted, Title Earned, Skill Acquired. So a DM can author and tag sounds for these events on the Art > Sounds card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a resumable save exists and the login screen is in Continue mode (New Game unchecked), the dialog title, tagline, header, and document title now reflect the SAVED GAME's world instead of the built-in default. refreshResumableCache caches the snapshot world's name + loginDescription, and refreshNewGameHint applies them via applyLoginContinueWorldTitle on every refresh and New Game toggle. A New Game (or no save) leaves the staged/selected/default title untouched, as those paths already own it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The saved-game login cue matched any world sound of type "Login" / "Login Ambient". It should instead honor the DM's picks in the Sounds › Configure dialog — i.e. the save's soundConfig.login / soundConfig.loginAmbient selections. resumableLoginCuesFromSnapshot now resolves through resolveCueSound(soundConfig, sounds, cueKey), exactly like the live engine, so a "Login"-typed sound that isn't the configured pick is no longer used, and the login screen plays whatever the Configure dialog selected. The source-presence fallback in loginCueSound is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The saved-game login-cue feature preferred any world sound of type "Login"/"Login Ambient" over the default — but if that sound had no playable source (empty path AND data, e.g. a type tag added without an attached file), loginCueSound returned it anyway, it played nothing, and it shadowed the working default, so the login screen fell completely silent with no way to play sound (the toggle did nothing). loginCueSound now prefers a saved cue only when it actually has a source (src() truthy); otherwise it falls back to the configured/built-in default. A saved cue with real audio still overrides as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
If the currently-saved game's world defines a sound of type "Login" (the one-shot fanfare) and/or "Login Ambient" (the loop), the login screen now plays those instead of the built-in default. refreshResumableCache reads the resumable save's world sounds and caches the matching cues by type (_resumableLoginCueSounds); loginCueSound() prefers them per-cue — so a save that overrides only "Login" still keeps the default ambience — and startLoginCues plays whatever loginCueSound resolves, tracking the exact instance so the mute toggle and stopLoginCues still silence it. With no resumable save (or none of those sound types), it falls back to the configured/built-in cue exactly as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The GM occasionally emitted invalid JSON from unescaped double-quotes inside the narration string (dialogue closing the string early). The output-format contract now has a dedicated STRINGS & ESCAPING section spelling out the escapes for ", \\, \n, \r, \t, and — the structural fix — instructs the GM to write all spoken dialogue in TYPOGRAPHIC (curly) quotation marks, reserving the straight double-quote for JSON syntax, with a worked CORRECT/WRONG example. Curly quotes never need escaping, so this habit avoids the most common malformed-JSON failure. highlightSpeech already recognizes curly quotes, so dialogue still renders as NPC speech, and extractJsonObject still salvages raw control characters as a net. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Quest Threads section handed to the GM listed each beat's title and trigger but not its id, while questUpdate.beatId is matched against the world data's exact beat id. A model can only slugify the title (e.g. "the_calling" for id "beat_the_calling"), so the engine's exact-id lookup failed and the unlock was dropped with no error — the beat could never unlock no matter how well the trigger was met. - questSummary() now prints "(beatId: <id>)" on each beat line so the GM can echo the id back verbatim. - resolveQuestBeat() resolves a questUpdate tolerantly: exact id first, then a normalized id/title match that is prefix-safe (handles a dropped "beat_" prefix), so a near-miss id still unlocks the right beat. - The unlock path now logs (never fails silently): an unknown quest or an unresolvable beat id logs an error, and a fuzzy-only match logs a note so the DM can see the GM sent an inexact id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A bare "/" already opens the player's full Field Guide window. Make the DM channel symmetric: a bare "//" now opens the full Dungeon Master's Guide in its own popup window (openDMGuideWindow, window 'tlr-dmguide'), the DM counterpart to the Field Guide. The "//" meta-command menu, which the bare "//" used to show, now lives under "// help" (with its existing "// ?" / "// commands" aliases). All other "//" routing — local ops, the GM intent-router, DM-only gating — is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking an item in the story's 'You notice:' line opened the item popup but omitted the Loot button that the sidebar Items block shows for the same floor item. The story-entity link handler now passes loot=true to showStoryItemDetail for data-story-item links (which only ever name items lying on the room floor), so the popup offers Loot just like the Items block. showStoryItemDetail still gates the button on the item actually resolving to a room floor, so an item already in the pack never shows it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character › Profile vitals showed HP, MP, XP, and Weight but not the sleep-deprivation fatigue that the sidebar's RE bar tracks. A Rest vital now renders below Weight, reusing the same fatigue helpers: its fill colour and value word follow the tier (Rested → Tired → Fatigued → Exhausted), colour-coded to match the sidebar bar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A class can add a bespoke equipment slot (e.g. a "Climbing Gear" slot, id "climbing_gear"). Tagging an item's SUBTYPE with that slot id felt like the natural way to make it fit — but subtypes are taxonomy, and the equip drag/drop reads itemEquipmentSlots (equipmentSlots), so the slot never activated. itemEquipmentSlots now also honors a CUSTOM class slot id named among an item's subtypes: a slot in classSlotVocab that appears in the item's subtypes is added to the slots it fits, so the drop target lights up. Scoped to custom class slots only — an ordinary taxonomy subtype like "sword" still never equips a non-weapon in a base slot, keeping taxonomy and the shared slots distinct. The explicit equipmentSlots route still works. The class-slot GM directives now note both ways to tag gear for a custom slot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A discovered item's Compendium card could be blank even though the item's detail popup showed a portrait. The card used the entry's stored imageUrl — captured at discovery from the item INSTANCE, which is often blank while the catalog TYPE carries the art — whereas the popup resolves the image live. The compendium card now falls back the same way the popup does when its stored snapshot has no image: an item resolves to a live instance's picture or the catalog type's by name (new itemImageByName); a being resolves to its portraitImage() (conversation close-ups / encounter art). Places already resolved their banner live. This fixes existing blank entries and future ones, and it only fills blanks — a stored imageUrl is used as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor › Player › Classes cards now carry an "Equipment slots" section listing a class's class-specific changes to the shared paper-doll layout — the ops from its equipmentSlots field. A replace shows "<base> → <new>" (e.g. the Mage's Shield → Spellbook), an add lists the new slot, a remove names the dropped base slot; a class with no custom slots shows a muted "Uses the default equipment slots" note. Read-only display, sitting after Starting inventory; the slots are still authored through the class request box (applyClassSpec). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Uploading a map background downscaled it to 512px — fine for a small portrait, but the map backdrop is a full-viewport image (and now viewable large in the World-map lightbox), so the confirm preview and the applied wallpaper looked far blurrier than the file uploaded. downscaleImageFileToDataUrl now takes an options argument: maxDim (longest-edge cap, still defaulting to 512) and quality. The Editor › Map › World "Background" upload requests maxDim 2048, so the backdrop stays crisp while the save it rides in stays bounded. An image already within the cap is never upscaled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When autoplay started the login-screen music on a refresh, the first interaction (ticking New Game / Dungeon Master, or any click) audibly restarted it. The first-gesture fallback — armed to start the cues when autoplay is blocked — fired on that click and unconditionally called startLoginCues(), which stops and replays the cues. It now skips the restart when the cues are already sounding: Sound gains an isPlaying() (howler.js live state), and the fallback returns early if loginCuesArePlaying(), only starting the cues when autoplay was actually blocked. Either way the fallback still disarms after the first gesture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Buying, bartering for, or being given multiple items in a single turn dropped all but one. The main-turn schema exposed only a singular "addItem", so a GM handing over three purchased goods crammed them into an array — and makeItem() on an array has no name, yielding one "Unknown Item" while the other two silently vanished. The engine now accepts a plural "addItems" array (and tolerates an array mistakenly placed in "addItem"), granting every item — treasure still routes to the trove, ordinary goods to the pack, per-item quantities honored, malformed entries skipped. The response schema gains an "addItems" field, and the directive tells the GM to use it for any multi-item acquisition (a purchase of several goods, a gift of many) and never to put an array in "addItem". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Art › Missing decided "has art" from the raw canonical fields (entity.compendiumImage, the catalog type's image), while the popups and the Review gallery resolve through fallbacks — so after importing a generated world, entities whose portraits live in conversationImages and items pictured only on a live instance were wrongly re-listed as missing (and the batch would regenerate art they already had). Missing now resolves the same way: entities via portraitImage(), items via the catalog picture or a live instance's, sharing one live-instance index with the Review gallery so the two always agree. Also give the World Palette dropdown's swatch list the app's thin, styled scrollbar (scrollbar-width: thin + the 4px WebKit scrollbar with a --border thumb), matching every other scroll area. Tests: add test_art_missing_resolve; update test_art_tab/test_art_sync to strip every portrait source when simulating a missing entity; extend test_world_palette for the scrollbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Review gallery read each object's raw canonical image field — entity.compendiumImage and the catalog type's image — while the detail popups resolve through fallbacks the gallery ignored. After exporting and importing a generated world, portraits commonly live in those fallbacks: an entity's portrait in its per-location conversationImages (compendiumImage blank), and an item's picture on a live room instance (the catalog type blank). So every gallery cell showed a placeholder even though clicking it opened a popup with the image. Resolve gallery images the same way the popups do: entities via portraitImage() (conversation close-ups → compendium → encounter art), and items via the catalog picture falling back to a live instance of the same name. Add regression coverage for both fallback paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Maps › World tab now carries a "View" button in its upper-right corner that opens the world's 2D map background image in a large, centered lightbox with drag-to-pan and scroll/±/reset zoom. The button appears only when the world has a map background. - Register a dedicated MAP_VIEWS.worldbg pan/zoom state pointed at the lightbox's own viewport + stage, so the lightbox reuses the shared map pan/zoom machinery (drag, wheel, touch, +/−/reset). - Add the lightbox overlay markup and its styles; open resets to a fresh centered view. Close via the ✕, the backdrop, or Escape. - Toggle the View button's visibility from the world-map render path. - Add tests/test_world_map_view_lightbox.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The save indicator is reference-counted and lingers ~500ms after the last save before restoring whatever the status bar said beforehand. If a new save (e.g. the frequent per-turn autosave) began DURING that linger window, beginStorageStatus saw an op-count of 0 and captured the still-displayed "Saving game… do not close the browser." text as the restore point. When that save finished, the linger dutifully restored it to "Saving…", leaving the indicator stuck indefinitely. Track a lingering flag and skip re-capturing the prior status when a save resumes inside the linger window, so the real pre-save status is preserved and restored. Add a regression test that drives the exact mid-linger resume sequence with a controllable timer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two tweaks to the sped-up sleep/camp progress clock: - Halve the sweep speed: the real-time span now doubles (per-hour factor and the min/max clamps), so a full night takes roughly eight seconds instead of four — an easier pace to follow. - Hide the "sleeping…" / "making camp…" label once the sweep is over. The label is now CSS-hidden unless the card is mid-animation, so it shows only while the clock is running and vanishes when the sleep completes (and never appears on the default completed state). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reaching a class-progression beat that carries a title now announces it with a framed, gilt story banner instead of a single line of text. The banner headlines the earned honorific in the display font over a warm glow, with a "Title Earned" kicker, a subtitle naming the character, level, and class, and a small flourish; it fades in gently (and honors prefers-reduced-motion). - Add buildTitleBanner and the .title-banner card styles. - applyProgressionForLevel posts the banner (as a self-contained system card) in place of the old one-line notice; the load-time reconcile stays silent so already-earned titles are not re-announced. - Add tests/test_title_banner.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A single-page, book-like Dungeon Master's Guide suitable for print, mirroring the Player's Handbook exactly in theme, colours, and layout (warm parchment page, gold rules, drop-capped chapters, callouts, figures, and reference tables). It is distinct from the existing in-game dungeon-masters-guide.html, which is the web/browsing variant of the field guide. The book adapts the authoring material into a cover, a table of contents, thirteen chapters across six parts (Getting Started; Conceiving a World; The World Builder; The World Editor; Prompt & Image; Running & Growing), and three appendices (data-object cheat-sheet, glossary, and where-to-go-next). Figures reuse the existing dmg-images screenshots. The PDF is rendered from the HTML via the browser's print pipeline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A sleep or camp no longer jumps time instantaneously. The rest notice now carries a progress bar with a live game clock that sweeps from the moment sleep/camp began to when it completes — a sped-up game-time clock — filling over a few real seconds before the recovery line is revealed. - buildRestNotice renders the progress track, fill, and a live clock face + time, defaulting to the completed state so the notice still reads correctly if the sweep can't run. - restClockFrame purely computes each frame (game instant, time string, clock face, fill percent) from a 0..1 progress fraction. - animateRestNotice resets the card to the start instant and drives the fill + live clock forward with requestAnimationFrame, clearing the is-animating flag on completion; it honors prefers-reduced-motion and a missing rAF by leaving the card completed, and a click skips to the end. - applyRest mints a unique notice id and kicks off the sweep after printing the card. - Add tests/test_rest_clock_anim.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character block's five vital rows — HP, MP, XP, WT, RE — now carry the reusable themed app-tooltip (data-tiphead + data-tip), shown on hover/focus by showAppTooltip, rather than a bare native title (which HP/MP/XP lacked entirely). Each tip explains what the vital is and how it works: HP as life force restored by rest/healing, MP as the spell energy pool, XP as progress to the next level, WT as carried load vs. STR/CON capacity with the encumbered warning, and RE as rising fatigue that forces rest. - Each row is keyboard-focusable (tabindex) and reads as help-able via a new .stat-row.has-tip cursor/hover style. - Replace the old native-title tooltips on WT and RE. - Add tests/test_vitals_tooltips.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character equipment paper-doll now derives its slots from the character's class. Every class starts from the shared default slot layout; a class's optional equipmentSlots field lists ops that selectively replace, remove, or add slots to fit how the class operates. The Mage's Spellbook slot (previously a hardcoded override that swapped out Shield) now lives in this data field. - Add applyClassEquipOps + normalizeClassEquipmentSlots to resolve and sanitize a class's slot ops against the shared layout. - Register class-introduced custom slot ids into a lazily-cached slot vocabulary (classSlotVocab) so gear authored for a class slot survives slot normalization and carries a human label; keep type-inference restricted to shared base slots so a bare spellbook tome never auto-equips. - Read equipmentSlots in applyClassSpec and invalidate the vocab on world build, snapshot restore, chunk merge, and class edits. - Update the world-gen and class-edit GM directives to explain a class can declare new or class-specific equipment slots. - Add tests/test_class_equip_slots.js; keep test_mage_spellbook_slot.js green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The download-icon menu at the top of the tabs row now has an Export World item between Export Game and Export Character. It calls the same exportWorld handler as the Editor World Chunks tab, writing only the world data JSON envelope (no character or game save) to a file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Editor Art Missing now includes an 'Item Icons' section listing any catalog item still using a placeholder emoji glyph (no generated 32x32 iconImage), and the Generate-all batch paints those icons (paintIconForItem → applyItemTypeField, no re-render, like the image path). Icon cards are tracked separately from an item's picture via an 'icons' pseudo-category key, so an item missing both appears as two cards and the batch/pulse handle each independently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
During a Generate-all batch, the pulsing card cue was applied to a DOM element by index, so returning to Editor Art Missing (which re-renders the cards) dropped it even though generation continued. Track the card being generated by key (artProcessingKey) and re-apply the pulse in renderArt to the matching card, so the cue survives any re-render mid-batch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Editing a saved world via the login World menu's Edit button opened a draft editor that only wrote the private drafts store, while a new game seeds from the saved-worlds library — so generated room/item images were visible on re-edit but missing in play. Now the disk-menu Edit opens the draft editor with ?libworld=1, and the draft-editor save mirrors each edit back to the library entry (via IS_LIBRARY_WORLD_EDIT). startGame also re-reads the chosen saved world fresh at Begin (the copy staged at pick time could be stale), preserving the class pick across the re-stage. Uploading or deleting a world clears any stale working-copy draft so it can't shadow the library entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Mirror the name field's saved-games menu for saved worlds. The Upload button beside the World select is now icon-only, and a disk button to its left opens a dropdown of the saved-worlds library. Each world row selects it for a new playthrough and carries Export / Edit / Delete actions on the right: - Export this world to a file (downloads the world envelope JSON) - Edit this world (opens it in a new World Editor window) - Delete this world (inline confirm; removes it from the library + picker) Adds deleteSavedWorld to the library and reuses the load-save menu styling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The login screen's New-Game World row now has an Upload button to the right of the select. It opens a file dialog for a World-data JSON, validates it (accepting a world-export envelope or a bare world; rejecting saved games, character files, and non-worlds), saves it to the saved-worlds library, refreshes the picker, and selects the uploaded world — so it is available for new character playthroughs without a page reload. Distinct from Import Game, which stages one world (or resumes a save) for the next Begin without adding it to the library. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Generating a portrait from an NPC/Monster detail popup already updated the entity, any open popup, the sidebar, and the Compendium (when active), but not the DM Editor's NPCs/Monsters/Fauna tab behind the popup. propagateEntityPortrait now also re-renders the active editor entity tab (via rerenderEntityTabFor), so the card updates immediately. The entity detail popups are siblings of the card view containers, so the re-render never disturbs the open popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Saving a world in the World Editor now refreshes the login screen's New-Game world select so the just-saved world appears immediately, with no page reload. saveNewWorld calls populateWorldSelect() after saveSavedWorld(); it preserves the current selection and is a safe no-op in a detached editor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reintroduce 'classes' on items with a new meaning: the character/entity class names the GM has restricted an item to (e.g. a Mage-only Arcane Dagger). Empty = any class may use it. Distinct from 'subtypes' (the item taxonomy). - Disambiguation: resolveItemKinds treats a source with 'subtypes' as new (its 'classes' = restrictions); a bare 'classes' with no 'subtypes' stays the legacy taxonomy, so old saves/worlds/imports are unaffected. - Item model, makeItem, registerInlineItem, reItemObj, the GM spec-apply, and the catalog migration all carry subtypes + classes. - Item card gains a 'Classes' input (below Subtypes) wired to setItemClasses, with a hint listing the world's classes. - The item popup shows the restriction and warns an off-class holder; the GM inventory manifest flags restricted items so it enforces off-class use. - GM world-gen + addItem schemas describe subtypes (taxonomy) and the optional classes (restrictions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The item taxonomy field (kind tags like sword, one-handed weapon) was named 'classes', clashing conceptually with character/entity classes. Rename it to 'subtypes' throughout the item model, serialization, catalog, and DM editor, and relabel the item card field 'Classes' as 'Subtypes'. Backward compatible and lossless: itemSubtypes() reads either key, migrateItemSubtypes/migrateItemCatalog upgrade legacy data on load, and makeItem + the GM item spec-apply accept a legacy 'classes' spec. Character, entity, and skill 'classes' are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Item cards now show an Icon section below Details: a 32x32 preview of the item's current inventory icon plus Generate and Upload buttons. Generating paints the same flat inventory icon the item popup makes; uploading uses a local file. Both share the new icon with the item's catalog type and every live copy by name and refresh every on-screen reference, so the icon updates everywhere it is seen. Extracts the shared paintIconForItem (icon prompt + paint) and applyItemIconEverywhere (store + propagate + refresh) helpers, which the popup icon button and the new editor card buttons all route through. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
World generation assigned each NPC/Monster a race but never asked the GM to create the corresponding race entry, so custom races never reached world.races (absent from Editor Races and the player Compendium). Add a races object to the world-gen output schema and a rule requiring every race used on a being to have a matching catalog entry, so it appears in the DM Races roster and is discovered into the Compendium when first encountered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Style field on Editor World Regions is a map-specific prefix and should default to blank. Maps already inherit the world Art Style automatically at generation time when no map style is set, so no world-art-style fallback is needed on this field. Reverts the earlier default-to-artStyle change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The shared map Style prefix (Editor World Regions, and the mirrored Map Art tab) now falls back to world.artStyle when the DM has not set a custom map style, so the Style field is pre-filled with the world's art style and maps match the world's look by default. Map generation is unchanged: an unset style already applied the world art style automatically; it is now the explicit prefix instead, with the same result. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Painting or uploading a portrait for a being (from the Editor NPCs/Monsters tabs or from a detail popup) now overwrites that being's portrait everywhere it appears: the ENTITY_CATALOG type, every live instance by name, the discovered Compendium entry, any open detail popup, the sidebar Occupants box, and the Art Review gallery. Adds propagateEntityPortrait, which generatePortraitForEntity and uploadNpcPortrait route through. The prompt sync stays backfill-not-clobber; only the portrait image now propagates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor World Regions empty state now offers a Number of Regions input above the Generate button; the value is clamped (1-12) and passed to the GM so it authors exactly that many regions, replacing the fixed 3-7 range. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
GM-created monsters were coming back with empty abilities because the world-generation and world-expansion entity schemas omitted the field. Add abilities to both entity schemas and a BEING ABILITIES rule that requires every being (and especially every monster) to have combat-relevant class abilities. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Generating or uploading an item image in the DM Items/Magic editor edited the catalog object but left any open item detail popup stale — the popup usually holds a live inventory/room copy, a different object. A new afterEditorItemImage hook (called from the generate, regenerate, and upload paths) shares the new image with the catalog type + every live copy by name (applyItemTypeField) and refreshes on-screen references. refreshOpenItemPopupFor now also matches an open popup by NAME (rebuilding from its own item), and guards getComputedStyle, so the popup shows the newly generated picture immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The square-shape framing hint (icons, items, and the default) emitted "Render a
square framed image, roughly 512x512." Now any square-shape Nano Banana
generation drops that wording and keeps only the size ("Roughly 512x512. No
text or watermark."), keyed on the resolved square shape so it holds even when
the shape arg is unset. Maps still append just "No text or watermark."; the
meaningful non-square hints (portrait, wide) keep their full "Render <orient>"
form.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomNano Banana icon generations were getting the spurious "Render a square framed image, roughly 512x512." hint. Icons own their framing (fill-the-frame 32x32 spec), and the "square framed image" wording is misleading, but the pixel size is still useful (the icon is viewed enlarged in a lightbox). So an icon now appends "Roughly 512x512. No text or watermark." — dropping only the wording. Maps still append just "No text or watermark."; other images keep the full hint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Pure tab-button reorder (switchEditorTab toggles by id, so it is order- independent). New order: Map, World, Quests, Player, Entities, Races, Items, Rooms, Encounters, Dungeons, Magic, Environment, Art. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Map generations already state their own size and framing in the prompt (the world map's 1376x768), but the Nano Banana provider appended its default 'Render a square framed image, roughly 512x512. No text or watermark.' hint — contradicting the prompt. generateImageWithProvider now threads the image to the provider, and nanoBananaGenerate appends only 'No text or watermark.' for a map (kind === 'map'), leaving non-map images with the full framing hint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
1) Replace the rest/fatigue text label under the Character stat bars with a
stat BAR (RE) like HP/MP/XP/WT. It fills as the character tires and deepens
green→amber→red across Rested → Tired → Fatigued → Exhausted; it reads FULL
at the 'fatigued' threshold (FATIGUE_FULL_HOURS) — the point to rest, sleep,
or camp — and stays full through exhaustion. The value word tracks the tier.
updateSidebar drives width (fatigueFillPct), colour, and the word.
2) Make clear in the GM directive that WAITING IS NOT RESTING: merely waiting,
idling, or keeping watch advances the clock but does not restore HP/MP or
ease fatigue — those hours count as time awake, so the character grows more
tired. Only a deliberate rest/sleep/camp ('rest') recovers. (The engine
already accrues fatigue from elapsed game time, so a wait from midnight to
morning is ~8 more hours awake; the directive stops the GM mislabeling a
wait as a rest.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomOn the Editor › Map › Art tab, a new 'World Name' field sits at the top, defaulting to the current world's name; editing it renames the world (rejecting blank) and updates the app header. It's the name the map generator titles across the top of the map. The world-map prompt generator now folds in two hard-coded specifics via withWorldMapSpecifics(): it states the fixed 1376x768 map dimensions (not duplicated if already present) and appends, at the very end, the instruction that the world name appears in large lettering across the top of the map. The GM directive is also told to compose for the 1376x768 landscape and to leave headroom for the auto-added title. Both the GM-authored prompt and the local fallback carry the specifics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Under the HP/MP/XP/WT stat bars, a new 'Rest' line shows where the character sits on the rested→exhausted spectrum — Rested, Tired, Fatigued, or Exhausted — colour-coded by severity (green→amber→red), with a tooltip noting hours awake. fatigueConditionLabel() derives the point from hoursAwake(); updateSidebar writes it (text + class) into #rest-condition each refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New Editor › Map › Art subtab, mirroring the region-map editor: a shared map Style + Preset, a Map prompt (with a ✨ GM-suggest), the generated Image, and Generate/Upload buttons beneath it. The image is stored on the world as world.mapImage (prompt on world.mapImagePrompt) — a 2D render of the whole world — persisted via serializeWorld / rebuildWorldFromSnapshot. Generating the Map prompt asks the GM to lay out ALL of the world's EXTERIOR areas (rooms with no interiorOf; indoor rooms are excluded) in their relative cardinal positions, built from worldMapLayoutText(). The shared map Style prefix (world.regions.mapStyle) is reused so the world map and region maps read as one atlas; Generate prepends it and paints with kind:'map'. Wiring: map-inner Art panel + mtab-art button; switchMapInnerTab handles 'art' and renders renderMapArt(); World constructor reads mapImage/mapImagePrompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character › Spellbook tab is now visible only when the character knows the Spellcasting skill (caster classes start with it; others can gain it only the hard way). applySpellbookTabVisibility() toggles the tab button and, if the Spellbook tab was open when Spellcasting is lost, falls back to Profile. It runs from updateSidebar (game entry / refresh) and refreshSkillViews (learn/forget), and switchCharacterTab guards against landing on the tab without Spellcasting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A new optional 'Narrative' section (after Prologue) lets the DM give loose summaries of the world's stories, plots, and quests — an over-arching narrative and its threads. The GM draws on it heavily when crafting quests and plots (and the entities, areas, and items they rely on), both at world generation and during play. - World model: new world.narrative field, persisted via serializeWorld / rebuildWorldFromSnapshot; the built-in world ships an example narrative. - World Builder: a Narrative textarea (with a ✨ Generate button → createNarrative/requestNarrative) after the Prologue; captured in collectWorldEditorFields and restored on Import. requestWorldGeneration now emits a 'narrative' schema field, ties the authored quests to it, and states the from-scratch DERIVATION ORDER (Tone→Name→Theme→ArtStyle/Rules→Prologue→ Narrative). A drafted narrative is informed by the prologue. - Play: buildSystemPrompt adds a GM-eyes-only Narrative section after the Prologue; the World › Profile tab edits it live (setWorldProfileNarrative); world-expansion (buildWorldDigest) threads it into new-region authoring. - Field Guide updated with the section and the ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Mage's paper-doll now trades the shield slot for a Spellbook slot (off-hand position, spellbook glyph) that accepts ONLY a field spellbook — an item of type "spellbook" carrying the "field" class modifier. - itemEquipmentSlots maps a field spellbook (isFieldSpellbook) to a dedicated 'spellbook' slot id, authoritatively. That id is deliberately kept out of the shared slot vocabulary (EQUIPMENT_SLOT_IDS), so no other item type can ever resolve to it — the slot accepts field spellbooks and nothing else. A plain (non-field) found spellbook fits no slot. - CLASS_EQUIP_SLOTS holds per-class paper-doll overrides; the Mage entry drops the far-right shield and turns the off-hand shield into the Spellbook slot. equipSlotsForPlayer() applies the override; renderEquipment and the equip/ unequip label lookups use it. Other classes are unchanged (shield kept, no spellbook slot). - Added a spellbook slot glyph and a 'Spellbook' label for the item Slots line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When leveling while the Character › Progression tab is open, the timeline stayed stale — a milestone the new level just unlocked kept showing as a future beat, and the reached/next markers and level header didn't move. awardXp now calls a new refreshProgressionView() after applying the level-up, re-rendering the timeline when it's the active subtab (and harmlessly skipping otherwise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
updateFatigue() runs on every ~1s clock tick. Its early-return guard only covered the 'already at this exact fatigue tier' case, so the common rested state (no fatigue wanted, none present) fell through and called updateSidebar() every second. updateSidebar re-renders the Equipment paper-doll (and other panels), which wiped transient UI each tick — open <select> dropdowns reset and the equipment-slot drag highlight cleared, a visible ~1s flicker. Broaden the guard to early-return whenever the current fatigue state already matches what's wanted (including rested/none), so updateFatigue only mutates state and re-renders when the tier actually changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Class progression was authored and displayed but never applied: reaching a milestone level granted nothing, so a Rogue hitting level 3 kept the default 'Adventurer' title and never received its level-3 class skill. awardXp now applies the reached level's beat(s) via applyProgressionForLevel: the honorific title (shown under the character's name), class/gen skills, stat deltas, stat points, and 'other' boons. A per-class applied-levels ledger on the player makes each beat grant exactly once — never re-applied on a later level-up or a reload. reconcileProgression() runs at character creation and on every load so a fresh level-1 character gets its opening milestone and, critically, existing saves that leveled before this fix receive the title and reward skills they were owed on the next resume. A level with no beat is left unmarked, so a milestone the DM adds later still applies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Dragging a weapon-type item (the Mage's Oak Staff) on the Equipment tab highlighted no slot: itemEquipmentSlots returned [] whenever an item had no explicit equipmentSlots, and the drag highlight (equipDragStart) derives the compatible slots from it. Fresh built-in items carry equipmentSlots, but older saves, imported worlds, and free-form GM-authored gear often omit the field — so a thing that IS a weapon wasn't recognized as equipping in the Weapon slot. itemEquipmentSlots now derives a default slot from the item's type via the same slot-vocabulary normalizer used for authored gear (weapon→Weapon, armor→Armor, ring→Ring, staff/wand→Weapon, …) when no equipmentSlots/equipmentSlot was set. An explicit (even empty) equipmentSlots list still wins, so authored intent — including deliberately non-equippable gear — is never overridden. Read-time only; no data migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The status bar stuck on 'Typing...' after an engine-only command (look, verbose, // and / directives, quest-beat toggles). The input listener sets 'typing' on each keystroke, but submitting clears the field programmatically — which fires no 'input' event — so the typing state was never cleared. GM turns recovered via showTyping()/hideTyping(); engine-only paths just returned. Reset the bar to 'Ready' in handleSend right after clearing the input. Every GM path re-sets 'Thinking...' synchronously via showTyping() before it awaits, so there's no flicker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Casting a utility spell (Light, Detect Magic) from the loadout out of combat resolved entirely engine-side, so the GM never learned it happened — and a concealed item that only reveals 'with a light source' stayed hidden even after the player cast Light. The reveal is GM-adjudicated (free-text revealConditions the engine can't interpret), so castSpell now hands an out-of-combat utility cast to the GM whenever the current room actually hides a concealed item; the GM reveals it per rule 3d if the cast satisfies its condition. Gated to stay a free, GM-free action otherwise: no escalation without an API key, mid-turn, in combat (those casts already submit), for non-utility spells (heals/buffs/damage), or when nothing in the room is concealed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Narrow the DM object-delete meta-command to 'delete'/'del' only. 'remove', 'destroy', 'erase', and 'rm' no longer trigger a local room-object delete, so they remain available for the GM's in-game/narrative use (e.g. removing a reference from a description). A '// remove …' line now falls through to the GM router as before instead of being claimed locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Removes a named item, NPC, or monster from the current room permanently, e.g. '// delete gold ring' or '// delete goblin warlord'. It's a local fast-path command (no API key, no story turn): applyDMMetaCommand claims the delete/del/ destroy/erase/remove/rm syntax and dmDeleteRoomObject resolves the target — a being first (like '// list'), then a floor item searched into containers — and splices it out of the array that holds it (the defining room.entities list for a being; the room floor or a container's contents for an item). A miss is reported and still counts as recognized, so it never falls through to the GM router. The 'delete/remove … status …' phrasings stay owned by the status handlers (they run earlier), and scene keywords (room/player/world) are refused. A being in an active fight is left alone until combat ends. Documented in the in-app '// help' reference and the Field Guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Generating, regenerating, or uploading a spell's portrait only re-rendered the DM editor card, so the spell's icon stayed stale everywhere else it appears — the field-spellbook loadout, the Character › Spellbook / Profile tabs, and the floating spell-detail popup all read sp.image but weren't refreshed. Call refreshSpellViews() the moment sp.image is set (in generateImageForSpell and uploadSpellImage) so the new art shows immediately wherever the icon is viewed. refreshSpellViews now also repaints the open spell-detail popup in place (it stashes its spell id), matching the live loadout behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The '// create a concealed key … revealed with a light source' debug directive produced a plainly-visible item: the debug-directive translator prompt (buildDMMetaSystemPrompt) never documented the concealed/revealCondition fields, so the model had nowhere to emit them, even though makeItem and the placeItems apply path already support them. Expose concealed/revealCondition on the placeItems schema and add a CONCEALED ITEMS instruction block with a worked 'concealed key' example, so a hidden-item directive now sets concealed:true + revealCondition on the placed room item. The item is then correctly hidden from the player until the GM reveals it, and '// list <item>' shows the concealed fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the floating Field Spellbook popup is open, memorizing or removing a spell on Character › Spellbook now re-renders the popup's Loadout view immediately. refreshSpellViews() (already called by memorizeSpell / clearCarriedSlot) drives a new refreshOpenSpellbookLoadouts() that re-renders only the loadout icons in place for any open, visible spellbook popup, leaving its current Details/Loadout view undisturbed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two bugs, one root cause — the _loginNeedsUnlock mechanism: - After logout/timeout the login cues never restarted (logout cleared the flag, so no first-gesture fallback was armed), and clicking the toggle didn't recover them. - On reload the toggle took two clicks to mute (the flag hijacked the first click into 'start' instead of 'flip'). Rework the login-cue engine: - Split startLoginCues() (plays now, respects mute, no arm) from playLoginCues() (plays + always arms the one-time first-gesture fallback). - Every login-screen show — cold boot, manual logout, inactivity timeout — now calls playLoginCues(), so the music starts on the first interaction exactly like a page reload. - toggleLoginSound is a plain flip (on = !showingOn) and disarms the fallback; clicking an ON toggle mutes in a single click. - Remove _loginNeedsUnlock entirely and the inactivity forced-button-off (superseded: logout/timeout now behave like reload, per the report). Update test_login_music / test_sound_handler / test_world_palette to the new function shapes; rewrite test_login_sound_unlock for the new behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Items can carry concealed:true + revealCondition text. A concealed item is present in the room but hidden from the player — the 'You notice' line, the sidebar Items block, the map popup, and Compendium discovery all skip it — until the GM reveals it once the player meets the condition (a light source, detect magic, a search). - makeItem persists concealed + revealCondition (own fields → survive restore). - itemHiddenFromPlayer / visibleRoomItems gate every player-facing item surface; a DM always sees concealed items (they author them). - New 'revealItem' state change (name or array) flips concealed→false, prints a 'You notice …' line, and catalogues the item. applyStateChanges wires it. - GM contract: revealItem in the response schema + field spec; rule 3d (concealed items / dark rooms); the room-creation directives tell the GM to author dark rooms with concealed items; the room-items dossier flags each concealed item as [CONCEALED — reveal condition: …]. - Add tests/test_concealed_items.js (17 checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On Editor > World > Regions, the empty state showed a top-right Generate button AND a center 'Generate regions' button — a duplicate. Drop the top-right button in the empty state (the center one covers it, and now carries the busy 'Generating…' state); the top-right button appears only once regions exist, as 'Regenerate'. Extend test_regions_editor.js to assert the empty state has exactly one (center) Generate button and the populated head shows the Regenerate button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Sleep and camp now require — and grant their recovery only on completing — a full 8 in-world hours, and the engine times that block itself. Rest stays the flexible light tier. - FULL_REST_HOURS = 8. applyRest: sleep/camp advance a fixed 8h, grant the tier's full recovery (65% / 90%), fully reset fatigue, and clear a caster's loadout; a light rest keeps its flexible min(cap, rate*hours) scaling and only eases fatigue. - A pleasing story-window notice (buildRestNotice) with a little game clock spanning the night: start/end clock-face + time and "8 hours pass", then the recovery and any downgrade/loadout note. Rest stays a plain line. - applyStateChanges suppresses the GM's time skip for sleep/camp (engine owns the 8h), preventing a double clock advance. GM contract (rest field spec + rule 11b) updated: no time skip for sleep/camp. - Left a seam for future random-encounter interruption of the block. - Docs: rest-and-fatigue design doc, character-progression, Player's Handbook, and Field Guide updated to the 8-hour model. - Tests: update test_rest_camp (8h block + notice) and test_fatigue (sleep/camp fully reset fatigue). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Refines the earlier inactivity-logout audio fix. Instead of leaving the
toggle showing 'on' while silent (a gesture-less show can't autoplay), an
inactivity logout now sets the toggle honestly OFF. The player turns it
back on with a single click, which plays the Login + Login Ambient cues
again (that click is the user gesture the browser needs).
- Factor the button's on/off rendering into setLoginSoundButton(on, btn),
reused by the toggle and by logout({viaTimeout:true}).
- logout() forces the toggle off only on the inactivity (viaTimeout) path;
a player-clicked logout still plays immediately. The cold-boot
first-interaction unlock path is unchanged.
- Update tests/test_login_sound_unlock.js to the new flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA faction's editor Lore section + 'Unlocked for the player' checkbox had no player-facing effect: buildFactionDetailHTML never rendered the lore, unlike the item/being/room detail popups (which all use the shared buildLoreFieldHTML). So ticking the box changed nothing the player could see, and it does NOT feed the Compendium > Lore tab (that's the separate earned-knowledge system). Add buildLoreFieldHTML(fac) to the faction detail popup so an unlocked faction lore shows to the player (and a DM sees it flagged 'locked' with the unlock hint until then) — consistent with every other subject. The lore sits in the existing fixed-height scroll box, so the compact popup stays bounded. Detailed Description remains omitted (popup stays compact). Update test_npc_popup_factions.js to the corrected behavior; add tests/test_faction_lore_popup.js (8 checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The name at the top of the Profile sheet is now editable in place, like the summary/background fields. Click it to edit; Enter or blur commits, Escape cancels. Unlike the prose fields it stays single-line and can't be blank — a blank or cancelled edit reverts to the previous name, and pasted line breaks collapse to one line. On a real change the name persists and the sidebar name + sheet re-render (updateSidebar), guarded by charFieldEditing so the periodic re-render can't clobber an in-progress edit. Add tests/test_char_name_edit.js (13 checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
An inactivity auto-logout re-shows the login screen with NO user gesture,
so the browser blocks the login cues from autoplaying — yet the sound
button still showed 'on'. The toggle then read that stale 'on' state and
muted silence on the first click, so it took two clicks to get sound and
appeared broken. (A cold page load had the same latent issue.)
- Track _loginNeedsUnlock: set on gesture-less shows (inactivity logout via
logout({viaTimeout:true}), cold boot), cleared once a real gesture starts
the cues. A player-clicked logout is itself a gesture, so it's unaffected.
- toggleLoginSound: while an unlock is pending, the click STARTS the cues
(this click is the gesture) instead of muting; otherwise it flips normally.
- armLoginCueUnlock: a one-time first-interaction listener starts the cues
on any gesture except the toggle itself, so audio also begins if the
player just starts typing. playLoginCues arms it on gesture-less shows.
- Update the login-sound/palette static tests to the new shapes; add
tests/test_login_sound_unlock.js (14 checks).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom- Player's Handbook (Ch.6) and Field Guide: expand the recovery and fatigue sections with tables (recovery rate/ceiling/notes per tier; fatigue awake-hours/stage/penalty). - New Designs/rest-and-fatigue.html: full design doc for the coupled downtime systems — tiered HP/MP recovery, outdoors-only camp, caster loadout lapse, the sleep-deprivation fatigue clock, the GM contract, data/functions reference, locked decisions, and six proposed follow-ups (hinder-while-exhausted, collapse risk, survival curve, passive regen trickle, hunger/thirst, comfort modifiers). - Designs/README.md: index the new doc; refresh the spells (Phase 1b shipped) and progression (rest model) rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Time awake without sleep now degrades the character through three tiers, paid down by rest/sleep/camp (the mirror of the recovery feature). - Track player.awakeSinceGameMs (the clock instant of the last sleep). updateFatigue() syncs a managed status each tick: Tired at 18h awake (DEX/INT -1), Fatigued at 30h (STR/DEX/INT -2, WIS -1), Exhausted at 42h (STR/DEX/INT -3, CON -2). Penalties fold through effectiveStat like any status; the engine announces only when fatigue worsens. - The engine OWNS the condition (deterministic, one tier at a time); the GM is told the tier in the prompt to narrate it but must not author its own sleep-tiredness. Wired into the per-second tick, each turn, and load. - applyRest pays fatigue down at the tiered pace: rest (1.5/hr, cap 8h) < sleep (4/hr, cap 30h) < camp (full reset). A full night clears it. - Moderate curve, stat-penalties-only (no forced collapse), per the design choice. Docs: Player's Handbook + Field Guide (a Fatigue section) and the character-progression design doc (a paired callout). Add tests/test_fatigue.js (26 checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Deliberate downtime now restores SOME (never all) HP and MP, scaled by
tier and hours: recovered = min(cap, rate x hours) x maxPool.
- rest (0.10/hr, cap 0.35) < sleep (0.14/hr, cap 0.65) < camp (0.18/hr,
cap 0.90). Camp implies food + fire and is the most restorative.
- Camp is OUTDOORS-ONLY (a room with no interiorOf); indoors it degrades
to a sleep, with a note to the player.
- A caster who sleeps or camps loses every field spellbook's prepared
loadout and must re-memorize; a light rest keeps it.
The GM emits a structured rest:{ mode } directive alongside its time skip
(matching the existing combat / skillCheck / timeSkip patterns); the
engine (applyRest) does the deterministic recovery, clock advance, and
loadout clear, and prints the ledger line. The system prompt gains the
rest field + spec, rule 11b, and an INDOORS/OUTDOORS note on Current Room.
Docs: document the mechanic in the Player's Handbook (Ch.6) and Field
Guide, and mark the character-progression rest/regen growth idea shipped.
Add tests/test_rest_camp.js (22 checks).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomEnforce the character-level cast gate and hand the GM the mechanical loadout so offensive casts resolve precisely (Designs/spells.html §13). - castSpell now gates on player.level >= spell.level (Decision K/H): an above-level carried spell is refused with a level hint and spends no mana; the Spellbook card's Cast button is disabled to match. - buildSystemPrompt hands the GM the carried loadout (Decision J — only the memorized set) with each spell's level/MP/target/effect, plus the caster's spell-attack bonus (Spellcasting proficiency + governing mod) and save DC (8 + that), so a single-target cast resolves as an attack vs AC and an area/debuff cast as an enemy save (Decision I/F). The live combat block carries the same spell-attack line. - New helpers spellcastingAttackProfile() and carriedLoadoutSpells(). - Update Designs/spells.html to mark Phase 1b shipped. - Add tests/test_spell_phase1b.js (18 checks). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Document the Journal Tasks tab, the tenth Compendium subtab (Races), the stat-point allocation (3 at creation, +1 per level), the derived AC stat, the Profile Race selector and Abilities section (with racial badge), the portrait Gallery + lightbox Use button, and the Maps Region subtab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
- character-progression: correct the level-up formula (no auto stat boost; +1 allocatable point) and document the stat-allocation modal; add AC, race, and abilities to the character-facing section; retire the moot WIS quirk note. - combat: mark Phase 1 shipped; correct the AC model (armor ac resets base, signed acBonus on other wearables; entity AC = 10 + DEX mod + armor). - character-spells / spells: scope the offensive-spell gap to out-of-combat and note the level-gate is designed but not yet enforced in castSpell. - quests-and-journal: document the real Tasks feature + NPC-authored tasks. - world-image-baker: add the Map AI provider + region-map image slot. - containers: correct Rogue inherent skills; character-skills: partial → +1 xp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Correct ambient chance range (0–100, defaults to 40), document the region Map section + Map AI provider, replace the stale // help note with the DM-guide how-to popup behavior, and add entity-table rows for NPC tasks and structured race abilities. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds guidance to the NPC-editor tasks directive that the GM can CREATE everything a task's cycle needs end-to-end — the item(s) to fetch/deliver and any other NPC the task routes through (e.g. the Innkeeper who knows where the tool lies) — and that the PRESENCE of a task item/NPC can be GATED by the task's state: it need not appear at its target location until the player accepts the task or completes a prerequisite subtask. Placement and timing are left to GM discretion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The NPCs-tab GM directive already documents the full task structure and constraints (name, goal, currently-known subtasks with per-step xp, completionXp, difficulty/reputation-scaled rewards, the reveal/cross-out update flow). But the roster it was given listed neither the NPC's reputation toward the player (which the reward-scaling rule depends on) nor the NPC's existing tasks. The roster now includes both for NPCs — reputation, and each existing task with its subtask progress — so the GM can scale task rewards correctly and update/extend tasks instead of duplicating them. Monsters are unaffected (tasks are NPC-only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each subtask now carries an "xp" reward and each task a "completionXp" (the final reward for finishing the whole task). The GM directive tells it to assign these proportional to the task's complexity and difficulty — especially when it involves defeating enemies — and to scale them down slightly when the NPC's opinion of the player (reputation) is neutral or unfavorable. On the Journal › Tasks card, a subtask's XP badge appears only after that subtask is completed, and the completion reward only once the whole task is Completed. Rewards are awarded exactly once (tracked by `awarded`/`completionAwarded`), so re-checking a subtask never re-grants XP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
NPC (entity) objects now carry a "tasks" array — the errands/favors the NPC can
request of the player. Each is a Task { name, description (goal),
milestones/subtasks: [{ text, done }] }, sharing the Journal Task shape (the
Task model gains a "name" and accepts "subtasks"/"goal" aliases).
The NPC-creation GM directive (requestEntityEdit, NPC-only) now tells the GM to
DECIDE whether the NPC has tasks, considering nearby areas and other NPCs, each
defined by a name, a goal description, and a list of currently-known subtasks —
with the multi-part, multi-NPC example (Aldric → Innkeeper → Ashfen Moors) and
the reveal-a-new-step / cross-out-a-step flow. applyNpcSpecToEntity supports
"tasks" (replace) and "addTasks" (upsert by name) so steps can be revealed as
the story unfolds. Tasks are seeded on the Entity, read by makeEntity, and
backfilled/persisted across reloads (reEntityObj).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAdds the same top toolbar the editor tabs use — a filter box, Import/Export, and
Collapse all / Expand all — to the Journal › Tasks tab.
Introduces a Task object: { id, dateAccepted, dateFinished, owner (NPC name),
description, milestones: [{ text, done }] }, stored on player.tasks (normalized,
backfilled on restore/import, persisted and exported with the character).
Tasks render as collapsible cards with a state badge in the upper-right —
Accepted (no milestone done), Started (≥1 done), or Completed (all done, or a
finish date set). Milestones are checkable; completing the last one stamps the
finish date (re-opening one clears it). Import/Export tasks as JSON.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New "Abilities" section on the Character sheet lists the character's own abilities (player.abilities) followed by those inherited from their race, each racial one marked with a "Racial" badge. Both use the structured ability shape and show name, mechanical effect, and description. Adds the player.abilities field (normalized, backfilled on restore/import, persisted with the character). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Race abilities are now structured objects instead of plain strings:
{ name, description, effect: { modifiers: [{ target: self|enemy, stat, delta }], condition } }.
Each ability's status effect is a list of signed stat/roll modifiers applied to
the character or their foes, optionally gated by a free-form condition (time of
day, place, class, or any factor) — e.g. Nightvision = +3 to your to-hit at
night; a dread presence = -3 to enemy to-hit. Effects aren't confined to one
stat and may include enemy debuffs; every one must be rooted in a real mechanic.
- normalizeRaceAbilities/normalizeRaceAbility/normalizeAbilityModifier + a
formatAbilityEffect summary; legacy string abilities upgrade to { name }.
- raceFieldPatch + the requestRaceEdit directive now author this structured shape.
- The race card's Abilities section shows each ability's name, mechanical effect,
and description.
- The play-time GM prompt carries the player's racial abilities so the GM applies
them (in the right condition) to its checks and combat rolls.
- Persists across reloads/exports via normalizeRace.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomWhen a race has abilities set, the card now shows a dedicated "Abilities" section (one ability per line) instead of a Details row, shown only when the race actually has abilities. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Part 1: every newly generated Character-profile portrait is now added to the image gallery (deduped), so earlier looks are kept. Part 2: the gallery lightbox gains a gold "Use" button (shown only for Gallery images) that replaces the current portrait with the image being viewed. The button is hidden for non-gallery images and for the portrait's own enlarge view. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds an AC row beneath the base attributes (STR–CON) in the sidebar Character block's Stats subsection, populated from playerAC() on every updateSidebar. It's set apart as a derived stat with a rule above it and a gold value, and carries a tooltip explaining what AC is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Portrait generation now deterministically appends the character's world-defined race description (its detailed one, else the short one) to the image prompt — alongside the existing gender embedding — so the painted portrait resembles how that people is described to look. A no-op for the base "Human" default and any race the world doesn't describe. Adds portraitPromptWithRace, applied at the character-sheet portrait paint site. Updates test_image_provider's paint-call assertion for the new wrapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Race objects now carry an "abilities" list (normalized like traits — array or comma/semicolon string → array of strings). The GM can assign them via the Races-tab edit directive (added to raceFieldPatch's whitelist and the directive guidance), they show on the race editor card, feed the race description generator, and persist across reloads/exports via normalizeRace. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the GM authors/edits a character's background or summary (Character › Profile), the request now includes the character's race and its detailed description (from world.races, falling back to the short description), plus an instruction to keep the background consistent with that race — so the character reads as a believable member of it. Added the reusable characterRaceContext() helper and injected it into the requestCharacterEdit directive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each stat on the new-character point-allocation dialog now carries a themed hover/focus tooltip (the reusable app-tooltip, not a native title) describing what the attribute is and where it comes into play — grounded in real mechanics (DEX→Armor Class, CON→hit points, INT→learning skills, CHA→reputation, etc). The stat name gets a help cursor + ⓘ affordance and is keyboard-focusable; the tooltip is dismissed when the dialog closes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking Generate on an encounter portrait with no prompt yet now composes one first — GM-authored when an API key is set, else a local fallback built from the encounter's name and the beings it involves — then paints, instead of bailing with "Add a prompt below first." Mirrors the region-map compose-then-paint flow. The GM prompt request is factored into a reusable DOM-free core (requestEncounterPromptFromGM) shared with the ✨ suggest button. Updates test_encounter_portrait (new no-prompt behavior) and widens a regex window in test_art_style_override (generateImageForEncounter grew a line). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
rebuildWorldFromSnapshot — the reload/import path — bypasses the World constructor and rebuilt each collection explicitly, but omitted races entirely. serializeWorld saved world.races, yet restore never read it back, so every race and every Races-tab edit vanished on refresh. Restore races via normalizeRaces(snapWorld.races), and add the per-race "Override World Art Style" flag (ignoreArtStyle) to normalizeRace's whitelist so that toggle round-trips too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds "races" as a discovery-backed Compendium category with its own tab, empty state, and Reveal-All support. describeRoom now records a race card the first time the player encounters a world-defined race — a being of that race present in the room, or the player's own race. Only races the world actually defines (world.races) are catalogued, matched case-insensitively; free-text entity races don't pollute the tab. Each card carries the race's name, description, and portrait. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a Race select below Gender on the Character sheet: a base "Human" default always present, plus every custom race the world defines (world.races), by name. The choice persists on player.race (backfilled to "Human" on older saves/imports). The character export bundle now carries the character's matching Race object (by id) alongside the existing class/spell/skill/faction/alignment defs, and Import Character merges it into the destination world — so a custom race travels with the character and stays selectable after the import. The base "Human" bundles no definition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Region subtab now mounts its map image in a pan/zoom viewport (drag to pan, wheel or +/−/reset to zoom), reusing the room maps' pan/zoom machinery. applyMapTransform now drives both the SVG room maps (transform attribute) and the HTML image stage (CSS transform). The view resets to a fresh, centered state only when the mounted map changes (new region), so incidental refreshes keep the player's pan/zoom. Clicking the map no longer opens a lightbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Maps › Region subtab (was the blank "Area" subtab) now displays the map image of the region the player is currently in, derived from the region tag of their current room. Falls back to a friendly placeholder when the room has no region or the region has no map yet. Click to enlarge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds an "Upload" button next to the Map Generate button that stores a chosen local image (downscaled data URI) on reg.mapImage, mirroring the banner's Upload. Crucially, normalizeRegions() — which runs on every reload/import via rebuildWorldFromSnapshot — rebuilt each region from a field whitelist that omitted the map fields, so generated/uploaded maps (and the shared map style) were silently dropped on reload. Added mapImage + mapImagePrompt to the per-region whitelist and the world-level mapStyle to the returned shape, so they now persist across reloads and world exports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each region card now shows, above the Map section, two fields: - Style: a textarea holding a map-image prompt PREFIX prepended to every region's map prompt, so all the maps share a consistent look. It's stored world-level on world.regions.mapStyle (persists/exports with the world) and editable from any region card. - Preset: a dropdown of built-in style presets that fill the Style field. The default is the oil-on-aged-parchment cartographer's-map style; three more (ink & quill, watercolour, antique engraving) are included. generateRegionMap prepends the Style prefix to the map prompt (and skips the generic world art style when a Style is set, so the map's own look wins). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New Map AI section in the AI Generation panel, mirroring Icon AI: a provider picker (Pollination / Nano Banana / Higgsfield) with provider-specific model rows. It's an independent "map" image-generation kind, stored under its own keys (mapProvider / mapNanoBananaModel / mapHiggsfieldModel) and resolved via the shared per-kind machinery. Region map images (Editor › World › Regions) now generate with kind:'map', so this section governs them. Also restores the updateSidebar header-world-title line (loaded world's name in the app header), whose hunk was dropped by an earlier rebase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each region card now has a "Map" section below its banner: a map-image prompt with a ✨ GM-suggest to its right, the generated map image below it, and a right-aligned gold Generate button under the image. The ✨ suggest hands the GM the region's room names and how they connect by cardinal/relative direction (e.g. Market Row to the north of the Village Square) so it can compose a direction-aware prompt that places each location near its real relative position. The gold Generate button paints from the current prompt, composing one first when none exists. Adds reg.mapImagePrompt / reg.mapImage (persisted with the world), plus regionRoomLayoutText, requestRegionMapPromptFromGM, suggestRegionMapPrompt, generateRegionMap, setRegionMapPrompt, and buildRegionMapEditor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
updateSidebar now keeps #header-world-title in sync with world.name, so a fresh game, a resumed/imported world, or a DM-renamed world all show the right title instead of the hardcoded default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Give .msg-ambient a left border (a vertical blockquote rule) + left padding so a room/NPC ambient beat reads as a set-apart aside from the main narration (keeping its italic styling). All ambient beats already render via addMsg(text, 'ambient') → msg-ambient, so the single style change covers every ambient path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a full 'Ambient behaviours — a room that breathes' subsection to the Rooms chapter: the per-time-of-day model, the four parts of an act (type speech/sound/action, prompt, chance %, interval s), a worked five-beat evening-into-midnight tavern set (the exact examples requested), the two ways to add beats (GM box vs the room card's Ambient section incl. sound synthesis via the Audio AI), and how the GM composes each beat fresh from the prompt grounded in the room's description and its per-time atmosphere/mood. Links the ambient field-table row to the new subsection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The "//" console authored the live game (rooms, beings, plants, items/state) but had no way to ANSWER a DM's how-to question — the DM counterpart to the player's "/" Field Guide / Handbook lookup. Wire Handbook/dungeon-masters-guide.html in as that fallback: the GM intent-router gains a "help" kind (a question/how-to, not an authoring instruction), and dmMetaRoute dispatches it to a new askDungeonMastersGuide that answers from the most relevant Guide excerpts in the shared help popup (a new 'dmg' source with its own title + full-guide link). Loader/fallback-text mirror the Player's Handbook infra; the // help sheet notes the new question route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A level-1 character now begins with 3 unspent stat points to allocate at creation (Player.unspentStatPoints 1 → 3); the creation-time allocation modal already reads the pool, so it presents all three. Level-ups still grant +1 each as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Nano-Banana (Gemini) icon generator sometimes returned a sheet of icon variations or an icon boxed in a border/tile. Reinforce the shared icon prompt to: demand ONE single icon as ONE image (not a grid/sheet/collage/variations); fill the frame edge to edge with essentially no margin or padding; and forbid any border, frame, outline box, rounded tile, background panel, matte, or padding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Refine Armor Class into two distinct item roles: - "armor"-type items carry a base "ac" that RESETS the wearer's base AC when equipped (chain mail, plate…) — it replaces the default 10 + DEX mod and does not stack. Only armor resets the base. - any OTHER worn item carries a signed "acBonus" (Ring of Armor +5, cursed cloak -2) that ADJUSTS the total on top of the base. makeItem, catalogItemShape, and the GM item-edit applier now keep whichever field fits the item's type and clear the other, so the two roles never collide. playerAC() computes base = equipped armor's ac (else 10 + DEX mod) + the sum of every equipped wearable's acBonus. Item cards/popups show "Base AC" for armor and a signed "AC Bonus" for other gear; the character sheet AC note and combat to-hit line are reworded. All three GM item-creation directives (Items editor, room-flesh world data, and the in-play placeItems/addItems turn contract) now instruct: every armor item MUST include a base "ac"; other worn gear may carry a signed "acBonus"; the two are mutually exclusive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The character level was a small dim span on the class line. Move it into its own .sidebar-char-level column at the right of the block — opposite the name/title — rendered as a large (28px) gold number under a small "Level" label. The identity column (name/title/class) keeps flex:1 so the level sits flush right. updateSidebar still fills #player-level-display unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Armor-type items now carry an optional 'ac' field (their Armor Class contribution), threaded through makeItem, the catalog template (catalogItemShape), the GM item-edit applier + directive, and reItemObj round-trips — only for the 'armor' type, a non-negative integer, shown when present. playerAC() now calculates from the gear the player has EQUIPPED (player.equippedItems): 10 + DEX mod + the sum of each equipped piece's 'ac' (falling back to a legacy 'defense'). It's surfaced on the Character sheet as an 'Armor Class' derived stat, and armor items show their AC in the editor card details and the item detail popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking an inventory chip or a filled slot previously opened the far-right shared sidebar popup. Give the Equipment tab its own popup (#equipment-item-popup) that floats just LEFT of the right-hand inventory column and is anchored to the top of the tab content pane. It lives as a sibling of #equipment-view (which renderEquipment rewrites) inside the now-position:relative equipment subview, so it survives re-renders, never extends above the pane (top:14px within it), and is height-clamped to the pane bottom. Registered in the entity/item/clamp popup id lists so it dedupes, re-renders on image changes, and re-clamps on resize. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A filled slot is now draggable. Dragging the equipped item and dropping it anywhere except back onto its own slot (empty space, the figure, or a different slot) unequips it: the slot's id is removed from player.equippedItems and the slot reverts to its empty glyph. Dropping it back on its own slot keeps it equipped. equipSlotDragStart marks the source slot (without the inventory-equip drag state, so equipDrop won't re-equip it); equipDrop records a drop back on the own slot; and equipSlotDragEnd unequips unless that flag was set. A slot-origin drag never equips into whatever slot it happened to land on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Single-clicking an inventory chip, or a slot that has an item equipped, now opens that item's detail popup (the shared #sidebar-entity-popup that floats over the main panel). Inventory chips gain an onclick → equipShowItemPopup(name); a filled slot gains an onclick → equipShowSlotItem(slotKey), resolving the equipped id to the item. Empty slots stay inert. Dragging still works — a click without a drag opens the popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Dropping an inventory item onto a compatible Equipment slot now equips it: the item's icon replaces the empty-slot glyph in that slot, and the item's id is recorded on a new player.equippedItems map (equipment-slot position key → item id). renderEquipment draws the equipped icon (via equippedItemById) and marks the slot .slot-filled; the item stays in the pack (equip effects/persistence-of-stats are still a future update). player.equippedItems is a serialized own field, so it rides the normal save/restore and character export/import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
While dragging an inventory item on the Character › Equipment tab, the slots the item actually fits now light up and accept the drop, and every other slot dims and rejects it. Compatibility uses the item's canonical equipmentSlots (via itemEquipmentSlots); each drop target carries its canonical slot id as data-eqslot (mirrored positions like the two boot slots share one 'boots' id, so a boots item lights both). equipDragStart resolves the dragged item and toggles .slot-compatible / .slot-incompatible on every slot; equipDragOver only preventDefaults (allows the drop) for a compatible slot, else sets dropEffect 'none'; equipDrop guards the same way; equipDragEnd clears the highlight. A non-equippable item lights nothing and can't be dropped anywhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
playLoginCues() played the Login + Login Ambient cues unconditionally, so logging out back to the login screen restarted the music even when the sound toggle button showed "off" (its muted state persists across the in-session logout). Gate playLoginCues on the #setup-sound-btn toggle: if the button is present and NOT sound-on, stay silent; otherwise (on, or no button yet at early boot) play as before. This covers every re-show of the overlay — logout, world-editor logout, and boot-into-login. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The enlarged equipment paper-doll read a touch too big; reduce the viewport-height bound on the figure width by about 20% so it fills the panel comfortably without dominating it. Slots scale with the figure as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The equipment paper-doll figure was capped at a flat max-width of 480px, leaving the tab mostly empty on larger panels. Since the body art is landscape (width drives height), raise the cap so the figure grows to the full column width, but bound it by viewport height so a tall/wide panel enlarges it without overflowing: max-width: min(100%, 88vh). Keeping width:100% + the image's height:auto means the figure box still equals the image box, so the percentage-positioned slots keep ringing the body and scale up with it (bigger body AND bigger slots). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Player-Classes › Progression dialog already confirms before overwriting: when a class already has a progression, pressing Generate opens the class-prog-overwrite modal (Replace & generate / Cancel) instead of silently redrafting, while a class with no progression generates straight away (classProgGenerate → runClassProgEdit). This behavior had no test; add one covering the confirm path, the cancel path, the no-progression straight-to-generate path, and the button/modal wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Animal-type entities weren't tallied anywhere: the monstersKilled hooks explicitly excluded animals, and only monster discoveries bumped an encounter counter. Add two Combat & Peril statistics — Creatures encountered and Creatures slain — that track animal types (referred to as creatures on this tab). - compendiumDiscover fires creaturesEncountered for the 'animals' category (alongside monstersEncountered for 'monsters'). - Both kill hooks (applyEntityDamage and the applyStateChanges entityKilled path) now bump creaturesKilled for an animal, monstersKilled for other non-NPC entities, and neither for an NPC. The two keys are added to the stat catalog, so newPlayerStats seeds them and normalizePlayerStats backfills 0 onto older saves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On Editor › Art › Audio, each sound card's "▶ Play" button just fired the clip; clicking again started an overlapping second playback and there was no way to stop it. Turn it into a play/stop toggle: pressing Play starts the clip and the button becomes "■ Stop"; pressing Stop silences it and reverts to Play. Only one preview plays at a time — starting another stops the previous — so a sound can't be started while already playing, and the button reverts on its own when a (non-looping) clip finishes. buildSoundCard reflects the playing state so a re-render keeps the Stop label, and setSoundFile/confirmDeleteSound clear the preview when the playing clip is unloaded or removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
An item's generated inventory icon (it.iconImage) lives on the catalog type. When the item popup opens for an item that isn't carried or placed live — e.g. from Art › Review, or a DM "reveal all" Compendium — compendiumDetailBodyFor builds a minimal record from the compendium entry or catalog definition. Those records dropped iconImage, so the popup's Icon field fell back to the emoji glyph even though the Art › Review Icons group showed the generated icon (e.g. Hard Bread). Carry iconImage through both fallback records — sourced from the catalog type, where the icon actually lives — so the popup's Icon field matches the gallery. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
clampPopupHeight only bounded a popup to its offset parent when that ancestor clipped overflow; otherwise it sized the popup to the window height. On the Art › Review tab the gallery popup's offset parent (the Review panel) has visible overflow and ends at the bottom tab bar, so a long item popup was sized to the window and spilled past the Missing/Review/Style/Audio tabs, its lower half obscured by the story's "What do you do?" input box. The base CSS already caps every one of these popups at its offset parent (max-height: calc(100% - 28px)); mirror that in the JS by clamping to the offset parent's bottom whenever it is nearer than the window edge. A tall popup now fits within the panel and scrolls internally instead of overflowing. This also keeps the in-game story/sidebar popups above the input row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The item popup's in-popup buttons (✨ generate icon, ♻ regenerate portrait) resolve their item via itemFromPopupButton, which only walked up to the __popupItem that showItemPopup stashes. Popups shown via showEntityPopup — Art › Review, the Compendium, the map/editor detail popups — never set __popupItem, so the button resolved to null and clicking did nothing (the reported Review-tab icon Generate bug). Give itemFromPopupButton a fallback: when no __popupItem is found, resolve the item by the popup title (its name) via findItemByName. Additionally, have generateItemIcon swap the icon preview in the popup in place on success, since refreshItemVisualsEverywhere only re-renders the ITEM_POPUP_IDS popups (not the showEntityPopup ones), and clear the button's busy state there too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
An imageless NPC popup shows a ✨ Generate button that paints its portrait on demand, but the item popup's no-picture placeholder showed only the icon glyph — so on the Art › Review tab (whose popup, unlike showItemPopup / the Compendium, does not auto-generate) an item had no way to generate its picture from the popup. Add the same Generate button to the item placeholder, backed by a new generateItemPopupPortrait handler that authors an image prompt via the GM if the item lacks one, paints the picture, and swaps the placeholder for it in place. It resolves the item by NAME (findItemByName) rather than the popup's stashed __popupItem, so it works in every popup surface — including Art › Review and the Compendium, which are shown via showEntityPopup and don't stash it — and then refreshes every on-screen reference (sidebar, sheet, story, compendium, and the Review gallery thumbnail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Objects with no image yet showed a per-type multicolor emoji (🏛 👤 ⚔ 🐾 📦 🌿 ✦) that clashed with the app's muted warm palette and read too bright. Replace every placeholder glyph with the app's crossed-swords logo (icon.svg) — warm gold on a dark tile, already on-palette — dimmed to 0.32 opacity at rest so the gaps read as quiet placeholders, brightening to 0.75 on hover. Drop the now-dead per-type ph glyph fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Review gallery renders object thumbnails, but generating an image or icon from a detail popup updated the underlying data without re-rendering the gallery, so the cell kept showing its old thumbnail (or placeholder) until the tab was reopened. Add refreshArtReviewIfActive(), which re-renders the gallery only while Review is the active Art subtab, and call it from the three image/ icon store points: generatePortraitForEntity (entity portraits), generateImageForItem (item pictures), and refreshItemVisualsEverywhere (item icons). The gallery popup is a sibling of the view container, so re-rendering the view leaves any open popup intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The gallery previously listed only objects that already had art. Now it lists EVERY object grouped by type, and any without an image renders a placeholder cell — a dashed, muted box with a per-type glyph (🏛 Rooms, 👤 NPCs, ⚔ Monsters, 🐾 Fauna, 📦 Items, 🌿 Flora, ✦ Magic) — so the gaps are visible at a glance. Placeholder cells are still clickable and open the object's popup, which for entities carries the ✨ Generate button. renderArtReview drops the has-image filters and marks each cell with/without an image; new .art-gallery-ph / .art-gallery-cell-empty styling. The empty state now only shows when a world has no objects at all (rooms/items always yield cells). Verified in Chromium: a world with one arted room/NPC/item shows those as images and everything else as placeholders; clicking a placeholder opens its popup with Generate. Updated test_art_review_gallery.js (placeholder cell + empty-state). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking a gallery cell did nothing: it routed through showCompendiumEntityDetail, which shows #compendium-entity-popup — an element that lives inside #view-compendium. That panel-view is display:none whenever the Editor tab is active, so the popup was injected into a hidden ancestor and never appeared. Give the Review gallery its OWN popup, #art-review-popup, placed inside the Art panel (which IS visible when the tab is active; .art-inner-panel is position:relative, so it floats top-right over the gallery and stays put while it scrolls). New openArtReviewPopup(category, name) builds the same detail body via compendiumDetailBodyFor and shows it there with showEntityPopup; cells call it instead. Items still route via the 'items' category. Verified in Chromium by actually clicking a cell and checking the popup's real on-screen bounding box (not just its own style.display — the gap that let the original bug through): the object popup now renders visibly over the gallery. Updated test_art_review_gallery.js: cells wire to openArtReviewPopup, and a structural check asserts the popup lives in the Art panel, not the compendium view. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Editor › Art › Review tab (formerly a placeholder) is now an image gallery of all the game's imagery, grouped by the object type it represents: Rooms (banner art), NPCs / Monsters / Fauna (portraits), Items / Flora / Magic (item pictures), plus a dedicated "Icons" group showing each item's icon — its generated game icon if it has one, else its emoji glyph. Every cell is a clickable square that opens that object's detail popup via the shared compendium resolver (showCompendiumEntityDetail), which resolves live beings/rooms and falls back to the catalog. Items always route through the 'items' category so a magic item opens its item popup rather than the Magic tab's spell/spellbook routing; the group heading conveys the real type. Rooms use a representative banner (getBannerImageFor falls back across time slots); entities are de-duplicated by name. renderArtReview is wired into switchArtInnerTab; a responsive thumbnail grid + captions in new CSS; an empty state when a world has no imagery at all. Verified in Chromium: groups render by type with thumbnails + captions, the Icons group shows emoji/generated icons, and clicking a cell opens the object popup (portrait, item, room). Added test_art_review_gallery.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A design/roadmap write-up (Designs/world-image-baker.html) for a proposed offline tool: a Node script — downloaded from the app pre-wired with the current AI settings — that walks a world JSON, generates every image from its authored prompts through the right per-kind provider, and writes references back into the world. Captures the analysis and recommendations: architectural fit (the app already renders relative Images/ paths), image-slot enumeration surface, prompt- composition parity, per-provider routing (Pollination / Nano Banana / Higgsfield polling), the runtime-selectable asset mode (remote URL / download→relative / embed bytes), portability via embedded-bytes or a world+assets zip bundle, env-first key handling, resume/retry/concurrency, keeping the script in sync with the app, a 4-phase build order, 7 open decisions, and risks. Added to the Designs README index. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Cosmetic: detail-popup field VALUES (Type, Condition, Description, Race, etc.) now render with their first letter capitalized — e.g. an item's Type "weapon" shows as "Weapon", Condition "worn" as "Worn". Centralized the eight identical inline `field` helpers (Item/Flora, NPC, Spell, Faction, Region, Room, Lore builders) into a shared popupFieldHTML that runs the value through capFirstFieldValue. That helper only uppercases a LEADING lowercase ASCII letter, so HTML-valued fields (links, icons, the lore scroll box — all start with "<") and digit/symbol-led values (Health "12 / 20", Reputation "+5, …") are left untouched. Verified in Chromium: Item & Flora Type/Condition/Description and NPC Race are capitalized, while HTML link fields are intact. Added test_popup_field_capitalize.js; updated two assertions that expected the old lowercase rendering (test_item_icon_gen, test_item_lore). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Refreshing the login page went silent: playLoginCues only fired on logout / world-editor logout, never on boot, so a plain reload that lands on the login screen (fresh visit, or a login that didn't auto-resume) never started the Login / Login Ambient cues. bootResume now calls playLoginCues() when it ends on the login screen — gated on the setup overlay being visible, so an auto-resume straight into the game stays silent. Cues resolve from the live world if one loaded, else the built-in WORLD_DATA (existing fallback). Browsers permit the autoplay once the origin has media engagement; a cold first load stays silent until the sound toggle gesture. Verified in Chromium: a fresh login boot now loops Audio/torch.mp3, while the guard skips playback when the overlay is hidden (resumed into game). Added a bootResume assertion to test_login_music.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Group both block-pinning toggles together: the Sticky Exits Block checkbox moves from the Sidebar section into the new Blocks section (alongside Sticky Portrait Block). Pure markup relocation — the id, onchange, apply, and sync wiring are unchanged, so behavior is identical. Hide Portrait stays under Sidebar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Portrait sidebar block is now the FIRST block (top of the sidebar, before Character). Added a new "Blocks" section to Settings with a "Sticky Portrait Block" checkbox: when on, the Portrait block is pinned to the sidebar's top edge (position: sticky; top: -16px, mirroring Sticky Exits) so it stays visible while the rest of the sidebar scrolls beneath it. - applyStickyPortraitSetting toggles a body.sticky-portrait class; persists via the settings store, applied at startup (alongside the other sidebar settings) and reflected on open (syncSettingsControls). Verified in Chromium: Portrait renders first; enabling the setting pins it while scrolling; it persists and syncs. Extended test_portrait_block.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A live animal ENTITY (e.g. a rat created via // meta) opened from Compendium › Fauna was mis-routed to the item branch of compendiumDetailBodyFor, so its popup omitted the entity Type and its "generated" portrait was stored on a throwaway item — reaching neither the Compendium entry nor the Environment › Fauna editor. Resolve a live animal entity first (matching entityCompendiumCategory === animals) and render buildNpcDetailHTML — which shows Type and offers the entity ✨ Generate button. That button's generatePortraitForEntity stores the image on the entity's compendiumImage (→ Fauna editor) and backfills the discovered Compendium entry's imageUrl (→ Compendium card). Animals with no live entity still fall through to the item branch (record/catalog fallback). Verified in Chromium: the rat popup shows Type "Animal" + the entity Generate button (not an item), and generating propagates the portrait to both the entity and the Compendium entry. Added test_compendium_fauna_link.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New "Portrait" sidebar block: a squared, cropped fill (aspect-ratio 1/1, object-fit: cover) of the character portrait that jumps to Character › Profile on click (reusing openCharacterProfile). It's a standard .sidebar-section [data-section="portrait"], so it's auto-listed and toggleable in the ☰ Sidebar Blocks menu; updateSidebar paints the image (or a glyph placeholder when unset). New "Hide Portrait" setting (Settings › Sidebar): toggles a body class that hides ONLY the small portrait in the Character block (#sidebar-portrait); the standalone Portrait block is unaffected. Persists via the settings store, applied at startup (alongside applyStickyExitsSetting) and reflected on open (syncSettingsControls). Verified in Chromium: the block renders squared/cropped below Character, click jumps to Profile, it appears in the ☰ menu, and Hide Portrait hides only the Character-block portrait. Added test_portrait_block.js; widened one brittle char-distance assertion after the new sync line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Removes the Music inner tab from the Art editor: its tab button, its panel (#art-inner-music with the toolbar/filter/import-export), its entry in ART_INNER_TABS, and the switchArtInnerTab render dispatch. The Art inner bar is now Missing · Review · Style · Audio. The underlying music helpers (renderMusic, world.music, the room-card Audio prompts' Music subsection) are left intact — they're unused by the UI now but harmless, and renderAudioAssetTab no-ops safely when #music-view is absent. Switching to the removed 'music' key defensively falls back to Missing. Verified in Chromium: the Music tab/panel are gone, the Audio tab still renders, and no errors. Updated test_art_inner_tabs and test_audio_tabs_toolbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a Gameplay section to the Settings popup with an "Enable Respawns" checkbox. It persists through the shared settings store (setSetting/getSetting, key: enableRespawns, default on) and reflects the saved value when the popup opens (syncSettingsControls). The setting is stored but not yet consulted by the respawn logic — behavior wiring can follow. Verified in Chromium: renders under a Gameplay section, defaults on, and its toggle persists and syncs. Added test_enable_respawns.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Display-only change: the Art inner tab's visible label is now "Audio". The
internal id (atab-sounds), panel (art-inner-sounds), and switchArtInnerTab('sounds')
wiring are unchanged, so world.sounds and all sound handling are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomRoom editor cards gain a "Music" section: a picker listing only "Area"-type
Sounds plus a ▶ preview button. The chosen sound id is stored on
room.musicSoundId; when set, that Area sound is LOOPED as the room's background
music the moment the player enters (describeRoom → maybePlayRoomMusic).
- buildRoomCard: Music section (Area-filtered picker → roomSetMusic, ▶ →
roomPlayMusic one-shot preview), with an empty-state hint when no Area sounds
exist yet.
- Engine: maybePlayRoomMusic loops the room's sound on entry, gated on room id
so a "look" (re-describing the same room) doesn't restart it; entering a
different room stops the previous room's music (or silences it when the new
room has none). Room music is also stopped on logout.
- Persistence: musicSoundId round-trips via serializeWorld (rooms) + the refresh
rebuild (reInstance) and the fresh-build/import path (addRoom).
Verified in Chromium: the picker lists only Area sounds, the setting persists
through a refresh rebuild, entry loops the sound, look doesn't restart it, and
moving rooms swaps/stops it. Added test_room_music.js. Also refreshed two
brittle char-distance assertions after the logout() edit and updated the AI
settings-panel button label ("AI Generation" → "Providers", renamed upstream).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe login ambient kept looping after entering the game via "Continue Your Journey". Root cause: logout leaves the login screen up on the LIVE world, so playLoginCues plays that world's Sound instance; "Continue" then rebuilds the world from the save (a new object with new instances), so stopLoginCues — which re-resolved the cue by the current config — targeted a different, silent instance and left the original looping. Fix: remember the exact Sound instances playLoginCues (and the login mute toggle) start, in _loginCueSounds, and stop THOSE in stopLoginCues regardless of which world is current now. The existing config-resolved stop (live world + built-in) stays as a fallback for cues started by other paths. Verified in Chromium: an ambient started on world W1 is stopped on enter-game even after the world is replaced by W2; nothing is left looping. Added a world-replacement regression case to test_sound_handler and updated the stopLoginCues assertions in test_login_music. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The login-cue path now resolves configured sounds from the live world when one exists, else from the built-in WORLD_DATA — so the very first login screen (no world yet) still plays sound instead of being silent. - configuredSound(cueKey): live-world config + sounds if a world is built, otherwise the WORLD_DATA config + a once-normalized, cached Sound list (bootSounds), so a cue started pre-world can be stopped/toggled on the same instance. playConfiguredSound / stopConfiguredSound route through it. - stopLoginCues also stops the built-in instance directly, covering the start-before-world / stop-after-world-built transition (startGame builds the world before pauseLoginVideo). - WORLD_DATA now ships the built-in login sounds through the Sound system, restoring the audio the boot script used to hard-code: Login -> Music/Intro.mp3 (one-shot), Login Ambient -> Audio/torch.mp3 (loop). Verified in Chromium: with no world, playLoginCues plays Intro.mp3 (no loop) + torch.mp3 (loop); the loop stops on enter-game even after the world is built. Updated the sound tests (fresh world now ships login sounds) and added fallback coverage to test_sound_handler / test_login_music. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The boot script's hard-coded backgroundMusic (Music/intro.mp3) and torch (Audio/torch.mp3) Howls — exposed as window.loginBackgroundMusic / window.torch — are gone, along with the playLoginMusic / stopLoginMusic helpers. The login screen's sound now comes entirely from the DM-configured "Login" (one-shot) and "Login Ambient" (looping) Sound objects on the active world, played via the engine sound handler (playLoginCues / stopLoginCues) already wired at the login-show sites (logout, world-editor logout) and stopped in pauseLoginVideo. - Deleted the #howler-fallback-boot <script> and its window.onload (its only job was the hard-coded audio; boot is unaffected). - pauseLoginVideo now stops only stopLoginCues; dropped the two playLoginMusic() calls now that playLoginCues() covers those sites. - toggleLoginSound (the login mute button) now toggles the configured Login Ambient loop: mute stops both cues, unmute resumes the looping ambience. Rewrote test_login_music.js to assert the removal + new cue wiring, and updated the login-show assertion in test_sound_handler.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Settings popup's five AI provider sections (Image / Icon / Gallery / Sound / Video AI) moved out into #ai-settings-panel, a panel that slides out to the LEFT of the Settings popup. In their place the popup now shows a single "AI Generation" button that toggles the panel. - Relocated the AI section markup into #ai-settings-panel (its own titled, scrollable popup body); every control keeps its original id, so syncSettingsControls() still drives the provider/model pickers unchanged. - Added the "AI Generation" button (settings-ai-btn) in the Settings popup. - toggleAiSettingsPanel / openAiSettingsPanel / closeAiSettingsPanel drive an .open class (opacity + translateX slide). closeSettings() collapses the panel, and the outside-click guard treats a click inside the panel as inside Settings. - CSS: the panel joins the shared floating-popup box styling, anchored just left of the Settings popup (with a narrow-screen fallback), plus the button styling. Verified in Chromium: opening Settings › AI Generation slides the panel in to the left with all five sections, the provider setters still work, and closing either the panel or Settings collapses it. Added test_ai_settings_panel.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
git mv progress-report.html into Web/Reports/. Point gen-progress-report.js at the new output path (Web/Reports/progress-report.html), create the directory if missing, and update the self-exclusion check (git ls-files now lists the report under its new path) so the LOC metric still skips the generated file. Regenerated the report at the new location. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Introduce a new "Login Ambient" sound type and config slot. While the login
screen is showing, the DM-configured "Login" sound now plays once and, in
parallel, the configured "Login Ambient" sound loops underneath it.
- SOUND_TYPES gains "Login Ambient"; the Add dialog Type select and the
Sound Configuration dialog pick it up automatically (both are data-driven).
- Sound.play() accepts { loop: true }, applied to the cached Howl each call.
- playConfiguredSound(cueKey, opts) forwards the loop option; add
stopConfiguredSound(cueKey) to silence a looping cue.
- New playLoginCues() / stopLoginCues(): fire the Login (once) + Login Ambient
(loop) cues wherever the login screen is shown (logout, world-editor logout),
and stop them in pauseLoginVideo() — the central "leaving the login screen"
hook. The Login cue moved here from game entry so both play in parallel on
the login screen.
Tests: extend test_sound_handler.js (loop flag, playLoginCues/stopLoginCues,
Login-once + Ambient-loop bundle) and update the type/slot expectations in
test_sound_add.js and test_sound_config.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomIntroduce playConfiguredSound(cueKey), which plays the Sound the DM mapped to a game cue (world.soundConfig[cueKey] -> a sound id in world.sounds) via that Sound's built-in play(). It is a safe no-op when nothing is configured for the cue, the mapped sound was deleted, or howler.js is unavailable. Wire the cues at the moments they represent: - 'initiative' fires in beginCombat when a fight starts - 'login' fires in startGame as the game screen opens Add test_sound_handler.js covering the handler's play/no-op behavior and a live beginCombat firing the configured Initiative sound. Widen the brittle char-distance window in test_combat_opens_spellbook.js to accommodate the new call between combatLog and openSpellbookLoadoutForCombat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a left-aligned gold "Configure" button to the Art › Sounds bottom bar (Configure left, Add right via space-between), opening a Sound Configuration dialog that maps the world's cues to chosen sounds: - A new "Login" sound type (appended to SOUND_TYPES, so Area stays the default; also added to the Add dialog's Type select). - The dialog is driven by SOUND_CONFIG_KEYS: a "Login" picker listing only Login-typed sounds and an "Initiative" picker listing only Initiative-typed sounds, each with a "— None —" option and the current choice pre-selected. - Save writes the chosen sound ids to world.soundConfig (None clears a slot); Cancel discards. soundConfig persists with the world — added to serializeWorld, the World constructor, and the refresh restore path (rebuildWorldFromSnapshot), via normalizeSoundConfig. Added test_sound_config; updated test_sound_add's SOUND_TYPES + bar assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reading the chosen file into a data: URL at pick-time so a sound actually plays (browsers don't expose the real local path — only the file name). - Sound gains a `data` field (a data: URL of the file's bytes). src() prefers data over path; play() streams whichever it has. `data` is a normal enumerable field, so it serializes with the world (like the app's other base64 media) and travels through save/refresh/export — the sound plays after a reload. - Picking a file (Add dialog or a card's Browse) now shows the name immediately and reads the bytes into a data: URL via FileReader (readSoundFileToDataUrl): confirmAddSound embeds the pending data; setSoundFile embeds it on a card and drops the stale cached clip. - Cards show an "Audio embedded — saved with the world" note when data is present (and a muted hint to re-select the file when only a bare path exists). Extended test_sound_add: file-pick embeds a data: URL, play() streams it, src() prefers data over path, path-only still plays from the path, and the embedded data round-trips through serialization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Promote the plain sound objects to a Sound class carrying { id, title, type,
path } and a built-in play():
- play() lazily loads the clip via howler.js with html5:true (so it can stream
a file path), caches the Howl ON the object, then plays it. A second play()
reuses the cached clip. stop()/unload() round it out.
- The cached Howl lives in a NON-enumerable _howl slot, so the loaded audio
stays inside the object but never serializes into the saved world (JSON only
carries id/title/type/path).
- world.sounds are rebuilt into Sound instances everywhere the world is
constructed (World constructor and the refresh restore path
rebuildWorldFromSnapshot), via normalizeSoundList.
Each sound card gains a "▶ Play" action button (playSoundCard → sound.play()).
Changing a card's Path unloads the stale clip so the next play reloads it, and
deleting a sound stops + unloads its clip first.
Extended test_sound_add: the Play button markup, Sound instances with play(),
lazy-load + html5 + cache-reuse (via a fake Howl), _howl excluded from JSON,
and reload-on-path-change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomReplace the floating gold "+ Add" pill FAB on the Sounds tab with a fixed bottom button bar that mirrors the Missing tab's Generate bar: a right-aligned #sounds-gen-bar (sharing the #art-gen-bar styling — border-top, padding) holding an "Add" button with the Missing tab's .art-generate-btn look (rectangular, uppercase gold). Removed the now-unused .sounds-add-fab CSS. Updated test_sound_add's button assertions and loosened test_art_generate_all's #art-gen-bar selector regex (now a shared #art-gen-bar, #sounds-gen-bar rule). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
serializeWorld and the World constructor already carried world.sounds (and music/dungeons), but the browser-refresh restore path — rebuildWorldFromSnapshot — reconstructs the world via reInstance from an explicit field list, bypassing the constructor. It didn't copy sounds/music/dungeons, so a sound added in the DM editor vanished on reload even though it was saved to the snapshot. Copy the three forward-compatible lists through rebuildWorldFromSnapshot so they survive save → refresh → restore. Added a regression assertion covering that exact path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Replace the flat sound rows with proper cards: - Each sound is a collapsible <details> card. The header shows the title + type tag and a caret; Collapse all / Expand all and per-card toggles persist via the existing soundCardsCollapsed set. - Expanded, the card exposes editable Title, Type (the SOUND_TYPES select), and Path fields. Editing Title/Type live-updates and re-renders; Path opens the OS file picker (per-card hidden audio input) and stores the chosen file name. setSoundField validates Type (falls back to Area) and never lets Title go empty, then persists via saveGameState. - Each card has an X delete button in the top-right that opens a themed "Delete this sound?" confirm naming the sound; confirming splices it out of world.sounds (and clears its collapse state) and re-renders. renderAudioAssetTab now delegates sounds to renderSoundCards; music keeps the simple shell. Extended test_sound_add to cover the card markup, field edits (incl. type-fallback and title-revert), and the cancel/confirm delete flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a floating gold "+ Add" button in the bottom-right of the Editor › Art ›
Sounds panel. It opens a themed dialog capturing a new Sound data object:
{ id, title, type, path }
- Title — free text (required).
- Type — a select of Area / Ambient / Initiative / DC Check / Victory /
Level Up (SOUND_TYPES; unknown values fall back to Area).
- Path — a read-only field backed by an <input type="file" accept="audio/*">;
Browse (or clicking the field) opens the OS file picker and stores the chosen
file's name (browsers don't expose the full local path).
Confirming pushes a normalized Sound onto world.sounds (ensureAudioAssetList),
with a unique slug id per title. The Sounds view now renders each sound as a
row showing its title, type, and path.
Crucially, Sounds are stored WITH the world data: serializeWorld now includes
sounds (plus the sibling music/dungeons lists, which had the same latent gap),
and the World constructor reads them back — so a Sound round-trips through
save/export/import and rebuild.
Added test_sound_add (markup, model, validation, unique ids, rendering, and the
serialize→rebuild round-trip); updated the audio-toolbar test's sounds
empty-state assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAdd a tip callout in "Becoming a DM" pointing to the dedicated, start-to-finish Dungeon Master's Guide (Handbook/dungeon-masters-guide.html), so DMs discover the full authoring walkthrough from inside the Field Guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A standalone, progressive authoring guide (Handbook/dungeon-masters-guide.html) that walks the whole world-building road: conception, planning, the World Builder, every World Editor tab, prompt & image generation, and running/growing a world. Mirrors the Field Guide's styling (shared palette, TOC search, scroll-spy, callouts, figure.shot) for consistency, and cross-references the Field Guide, Player's Handbook, and Designs deep-dives rather than duplicating the GM internals. Structure (39 sections, 5 parts): - I · Conceiving — the seed of a world, design pillars, planning, a checklist. - II · The World Builder — the New-World screen field by field, generation, the editable World JSON, save/export/import, world-vs-saved-game. - III · The World Editor — a section per tab (Map, World Profile/Chunks/ Regions/Factions, Rooms, Encounters, Dungeons, Entities NPCs/Monsters, Races, Environment Flora/Fauna, Classes + the Progression dialog, Skills, Items, Magic Items/Spells/Spellbooks, Quests) with per-object field tables. - IV · Prompt & Image Generation — the global Art Style, the Art › Style tab, per-object prompts and the ✨ handoff, the Missing batch dashboard, image providers & keys, and audio. - V · Running & Growing — // debug commands, quest/beat + /hint testing, the Logs tab, growing a live world, and consistency habits. Includes 24 screenshots (Handbook/dmg-images/) captured from the built-in demo world covering the login, World Builder, and every editor tab + the class progression dialog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reorder the DM Editor tab bar so Encounters sits immediately to the right of Rooms (Rooms → Encounters → Dungeons) instead of at the far-right end. Button reorder only; the Encounters subview and wiring are unchanged. Updated the Dungeons-tab test that pinned the old Rooms→Dungeons adjacency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Style subtab's sections are capped at 760px but were left-aligned. Center them within the scroll view (margin: 0 auto) so the tab reads as a centered column instead of hugging the left edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a short, understated entry for /hint to the Slash commands section of the "/" guide window (guide.html) — enough that a player who opens the guide can discover it, without advertising it prominently in-app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Typing "/hint" (also "/hints" or "/ hint") asks the Game Master for a subtle,
in-world hint about the nearest actionable quest thread — favouring one tied
to the player's current room or someone/something present with them (e.g. an
NPC standing nearby who figures into a locked beat).
It's a standalone GM call modeled on requestRoomAmbient: it reuses
buildSystemPrompt() — which already carries the GM-eyes-only Quest Threads,
the current room, and who's present — so the GM has everything needed to pick
the "nearest" beat. Only the returned narration is used; the call never
touches conversationHistory or game state.
- Routed from the "/" handler (before the Field Guide lookup), so it doesn't
collide with reference-doc questions.
- buildQuestHintDirective (pure, unit-tested) enforces secrecy: the hint stays
diegetic and never reveals the quest/beat/journal system, never names a beat,
and must not advance the plot, unlock anything, or change state.
- Rendered as a set-apart .msg-hint aside ("💡 A Hint") so it never reads as
plot narration. A busy guard + isProcessing block prevent concurrent calls.
Added test_hint_command covering routing, the directive's content + secrecy
rules, and the rendering/wiring (end-to-end rendering verified via Playwright).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomClicking a Compendium card title opened a detail popup on People, Monsters, and item-like tabs but not on Places or Factions. Wire both up the same way, via the existing delegated .comp-name-link handler → showCompendiumEntityDetail → compendiumDetailBodyFor: - Places: the title now opens the shared read-only room popup (buildRoomPopupHTML) — the same body the room links elsewhere use. The discovered entry id is the room id; falls back to a name match, then to the minimal compendium record if the live room is gone. - Factions: the title now opens the faction detail body (buildFactionDetailHTML), resolved by name. The redundant "Compendium" jump button is dropped when the popup is opened from within the Compendium (new opts.inCompendium); it still shows everywhere else. Added 'places' to the clickable-name category list and gave the Factions card title the .comp-name-link class + data-comp-name. Updated the two compendium tests that pinned the old markup. Added test_compendium_place_faction_links covering the wiring and both new detail-body branches (including case-insensitive lookup and no-match safety). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a "Progression" tab to the Character sheet (between Skills and
Statistics) that shows the player's class progression timeline — the
milestone beats authored on their class via the DM Progression dialog.
The player-facing view reuses the DM dialog's .prog-* beat styling +
progRewardHTML, and adds:
- a header (class · level · earned title),
- a left status rail: Achieved (reached), Next (the milestone just ahead),
or Later,
- reveal-aware masking: a beat the player has REACHED is shown in full; an
un-reached beat is shown as a teaser only if the DM flagged it `revealed`,
otherwise it is masked ("A milestone yet to be revealed") so its rewards
don't leak,
- dimmed (off) marker dots for un-reached beats.
Built-in classes ship without progression, so the tab shows a friendly empty
state until a DM charts one. (Per-beat achieved dates arrive later with
runtime progression tracking.)
Added test_progression_tab covering the markup, wiring, and the
reached/next/masked/teaser rendering logic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe Editor › Art › Music and Sounds subtabs were bare "coming soon" placeholders. Give each the usual editor toolbar at the top — a name filter (with its inline clear button), an Import/Export group, and Collapse all / Expand all — matching every other editor tab. Behavior is still TBD, so the card views render an empty state for now. - Replaced both placeholder panels with the standard toolbar + card view, plus a compact import/export status line (these panels have no bottom GM bar, so feedback sits at the foot of the panel). - switchArtInnerTab now renders Music/Sounds when shown. - The new toolbar/view ids join the shared toolbar / scrollable-view CSS groups so they look identical to the other tabs. - Config-driven shell handlers over a forward-compatible world.music / world.sounds list: renderMusic/renderSounds (empty state), set/clear filters, collapse/expand, and export/import. Added test_audio_tabs_toolbar covering the markup, wiring, empty state, and safe handler + import behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a "Dungeons" tab to the DM Editor, placed immediately after Rooms. The dungeon system isn't designed yet, so the card view shows an empty state — but the tab carries the same top toolbar as every other editor tab: a name filter with its inline clear button, an Import/Export group, and Collapse all / Expand all, plus the standard GM request bar at the bottom. - editor-sub-dungeons markup + switchEditorTab wiring (button/subview toggle and render dispatch). - The dungeons ids join the shared toolbar / view / edit-bar / relative- positioning CSS groups so it renders identically to the other tabs. - Shell handlers backed by a forward-compatible world.dungeons list: renderDungeons (empty state), setDungeonFilter/clearDungeonFilter, collapseAllDungeons/expandAllDungeons, exportDungeons/importDungeonsFromFile, and dmEditDungeons (an honest placeholder until the dungeon model lands). Added test_dungeons_tab covering the markup, wiring, empty state, and safe handler behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The GM occasionally emits a literal (unescaped) newline, carriage return, or tab inside a JSON string value, which is illegal JSON and made JSON.parse throw "Bad control character in string literal in JSON". Two-pronged fix: - extractJsonObject (the shared preprocessor run before nearly every GM JSON.parse) now escapes raw control characters that appear INSIDE a string literal as it copies the object out (backslash-n, -r, -t, -b, -f, or -uXXXX). Escaped sequences, structural/formatting whitespace between tokens, and everything else are left untouched, so valid JSON round-trips unchanged. - Strengthened the GM output-format contract to explicitly forbid raw newlines/returns/tabs in strings and name each escape. Added test_json_control_repair covering the reported failure and edge cases (CRLF, tabs, already-escaped input, pretty-printing, post-backslash newline, fences plus prose, and an exotic NUL control char). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Reorder the DM Editor tab bar so Races sits immediately after Entities (Entities → Races → Rooms) instead of at the far right end. Button reorder only; the Races subview and its wiring are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Remove the top-level Factions editor tab and relocate its full panel (filter/import-export toolbar, faction cards, GM edit box) into the World tab as a fourth inner subtab beside Profile, Chunks, and Regions. - switchWorldInnerTab now handles the 'factions' panel and renders it. - switchEditorTab redirects any legacy 'factions' call to the World tab so existing entry points keep working. - #world-inner-factions is position:relative so its absolute corner toolbar anchors inside the panel (matching the old subview's behavior). - Updated test_factions_editor to assert the new inner-tab wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a DM Editor "Entities" tab (placed after Quests, before Rooms) and move the former top-level NPCs and Monsters tabs under it as bottom-anchored inner subtabs, mirroring the Player (Classes/Skills) and Environment (Flora/Fauna) inner-tab pattern. - New .ent-inner* CSS joins the shared inner-tab selector groups. - switchEntityInnerTab(sub) toggles the NPCs/Monsters panels + tab buttons and renders the active one. - switchEditorTab redirects legacy 'npcs'/'monsters' calls to the Entities tab so existing entry points keep working. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Group the DM Editor's Classes and Skills catalogs under a new "Player" outer tab
placed right after Rooms. The two former outer tabs become bottom-anchored inner
subtabs (Classes | Skills) of the Player subview, mirroring the Magic/Environment
inner-tab pattern.
- HTML: new #editor-sub-player with .pl-inner / .pl-inner-body / two .pl-inner-panel
(classes, skills) + .pl-inner-tabs; the existing Classes and Skills content moves
in verbatim (ids preserved, so renderClasses/renderSkillDefs/dmEdit* still target
the same nodes). Removed the standalone Classes/Skills outer tab buttons + subviews.
- CSS: register .pl-inner* in the shared inner-tab selector groups.
- JS: switchPlayerInnerTab(classes|skills); switchEditorTab renders the Player tab
via it and redirects any legacy switchEditorTab('classes'|'skills') call to the
Player tab with that inner panel active. Cleaned two now-dead CSS selectors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomReorder the DM Editor subtab bar so World sits second (Map → World → Quests → …) instead of last. Button-order only; the tab id, handler, and subview are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Skill names in the progression timeline now show a custom, themed hover tooltip (not the native title) describing what the skill does — a gold header with the skill name + governing stat, and its description below, with an arrow pointing at the anchor and a dotted-underline affordance. Implemented as a small reusable, position:fixed tooltip driven by data-tip / data-tiphead attributes (showAppTooltip + delegated hover/focus listeners), so it isn't clipped by the dialog body's scroll and can be reused anywhere via the same attributes. Hidden on scroll and when the dialog closes. - progRewardHTML wraps a skill name in a .prog-skill span carrying its description + "Name · STAT" header when the skill resolves. - New #app-tooltip element + .app-tooltip CSS; test coverage for the data-tip wiring and the helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Move the class card's Progression button to the right of its row and switch it from the gold modal-btn to the dark modal-btn-ghost variant (with the beat-count badge recolored to a subtle gold chip that reads on the dark button). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each DM Editor Classes card gains a gold "Progression" button that opens a big
dialog showing that class's level-milestone timeline. The button row is split:
a left-aligned "Generate" drafts (or, with an "Are you sure?" overwrite confirm,
redrafts) the whole progression via the GM; a GM ask bar just above the row
refines it in natural language; a right-aligned "Confirm" simply closes.
This is the authoring surface for the Player Progression design — authored data
only for now (applying rewards on level-up + the player-facing timeline are later
phases), and it resolves the open question: built-in classes ship WITHOUT a
progression; it's a DM addition (possibly hardcoded later after experimenting).
- Data model: class.progression = [{ level, title (optional nickname), revealed,
rewards:[classSkill|skill|stat|statPoints|other] }]; normalizeProgressionBeats /
normalizeProgressionReward; applyClassSpec ingests/replaces it; classProgression().
- requestProgressionEdit(class, instruction, mode) GM handoff (generate vs edit),
authoring class skills via the skills array + returning the full timeline, which
replaces the class's progression wholesale.
- Dialog: renderClassProgression timeline (named vs unnamed beats, hidden tag,
tagged reward rows), openClassProgression, generate/overwrite-confirm/edit/close.
- tests/test_class_progression.js (34 checks); widen a regex budget in
test_stat_allocation for the new sibling modal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomFold in the design decisions and a pacing refinement: - Titles: player CHOOSES which earned honorific to wear (player.earnedTitles pool + a Character-block picker); GM narrative titles coexist. Locked (richer option). - Stat rewards: BOTH fixed bumps and allocatable points. Locked. - `other` reward: tight typed effect vocabulary + a noted DM-adjudicated fallback (e.g. +2 skill-book reads, -2 enemy initiative). Locked. - Runtime state on the PLAYER (reached-beats log, earned titles, worn title), not the class beat — so it travels with the character on export/import like skills. The authored template (level, optional title, revealed, rewards) stays on the class. Locked. - Pacing: not every level gets a beat and not every beat gets a nickname. Beats cluster early and space out later (or e.g. 5 named beats across 50 levels); nicknames are reserved for the meatier, periodic milestones with slightly bigger rewards — never forced per level. Data model makes `title` optional. Updates the data model (authored template vs player runtime), the timeline sketch (adds an unnamed lighter beat), the apply flow (log-as-guard), authoring cadence guidance, the reuse table, the decisions (four locked + cadence), and phasing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Per feedback: the character level stays anchored front-and-centre on the timeline line (the beat marker), and to its right the GM coins a creative milestone nickname as the header above the rewards — e.g. a Warrior's "Skirmisher" (-2 enemy initiative) or a Mage's "Scholar" (+2 on skill-book read checks). The nickname doubles as an earned honorific: on reaching a beat it is adopted as the existing player.title shown beneath the name (sidebar + profile) and already fed to the GM, so the climb (Adventurer -> Skirmisher -> Vanguard) is worn openly with no new display surface. Updates the beat data model (new `title` field), the timeline sketch (nickname headers + a title-linkage callout), the apply/authoring sections, the reuse table (player.title), and the decisions (locked: every beat has a coined nickname; open: auto-latest vs player-chosen title). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Design-only proposal (rev. 1, not built) for a per-class, level-keyed milestone timeline the GM charts at class creation. Each beat is named by the level that unlocks it, grants gameplay-rooted rewards (a class-specific skill authored then, a generic skill, a stat bump, extra allocatable points, or a typed "other" boon) to the right, stamps the in-world date it was earned to the left, and carries a `revealed` flag controlling player visibility — mirroring the Journal/Quests timeline. Progression is strictly additive on top of the uncapped, study-driven skill layer: level never caps a player's skills. Grounds the design in shipped systems (class data model + applyClassSpec, the awardXp level-up hook, the stat-allocation modal, learnSkill/applySkillSpec, the quest-beat timeline + currentRealmDateString, the Classes editor card) and records locked decisions (additive not a cap; sparse level-keyed beats; the revealed flag) plus open ones (fixed vs allocatable stat rewards; the `other` effect vocabulary; runtime state placement; built-in class timelines) and a phased build order. Companion to character-progression, character-skills, and entity-leveling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Rev. 1 of entity (NPC/Monster) leveling — deliberately prompt-only. A single shared ENTITY_LEVEL_BUDGET_NOTE gives the GM a concrete rule of thumb tying a being's level to its stat and HP budget (level 1 = ordinary; ~+1 to 1-2 signature attributes and ~+8-12 HP per level; everyday creatures 6-18), instead of the vague "scale to power and role" alone. It is injected into all four being-authoring prompts (new-world generator, DM add-being box, NPC/Monster editor, region/room expansion), and the region template gained a stats block so its beings scale too. No engine change: level is still not computed from or validated against. Also adds Designs/entity-leveling.html capturing the analysis (how being stats are set today, what the engine actually reads, the player model for contrast) and the roadmap: hold entity leveling loose until the player progression system lands, then make level the enabler for how many skills a being can hold, with the GM granting fitting skills to NPCs/Monsters at authoring time (extending the class inherent-skills model). Locked + open decisions recorded. - ENTITY_LEVEL_BUDGET_NOTE constant beside EQUIPMENT_SLOT_IDS_LIST, referenced by every being-authoring prompt (single source of truth). - tests/test_entity_level_budget.js covers the constant, the four injections, the region stats block, and the design doc's coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Every level-up (and character creation, level 1) now hands the player 1 stat point and opens a blocking modal to spend it. The modal shows all six base attributes (STR/DEX/INT/WIS/CHA/CON) with +/- steppers and a running pool of remaining points. Points are spent one at a time; a point added to a stat can be taken back, but no stat can drop below its pre-allocation value. The allocation is uncommitted until the gold Confirm button, which enables only once every available point is placed. Multiple levels gained at once accumulate their points and are allocated in sequence. - Player gains player.unspentStatPoints (1 at creation via the constructor, +1 per level in awardXp); persisted and backfilled to 0 on old saves. - awardXp's old flat auto stat boost (+1 to five attributes) is replaced by the single allocatable point; HP/MP vitals still rise automatically. - New modal (outside #setup-overlay so it renders in-game) + openStatAllocation / adjustStatAlloc / confirmStatAllocation, opened at creation, on level-up, and to resume an unfinished allocation after a reload. - tests/test_stat_allocation.js covers the point economy, +/- bounds, the confirm gate, base-stat application, and the awardXp change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Bring the skills documentation in line with this session's mechanics across the three surfaces (skills design doc, Player's Handbook, Field Guide), plus the in-app offline fallbacks: - Inherent/signature skills: every class begins knowing 1-3 class-specific skills (Warrior->Swordsmanship, Rogue->Lockpicking/Sneak/Surprise Attack, Mage->Spellcasting, etc.), scaled to skill-vs-gear reliance. Now documented as data-driven (world.classes[name].inherentSkills, authoritative; the const is a fallback) with the DM as final arbiter, and the GM assigning them on class creation. - Book learning: reading a skill book now takes a d20 + INT check vs the skill's DC, one attempt per skill per character level, retryable after leveling up, book not consumed on a miss. Documented in all three docs and the handbook flow. - Auto-roll skill checks setting: documented (GM rolls by default; off => GM requests the player's own d20) in the design doc, handbook, and field guide Settings section. - Design doc: rewrote the "Acquiring a skill" section (inherent + book flow) and added a "who rolls" callout. Field Guide: split inherent vs book-learned skills, added the setting, and enriched the Classes/Skills editor notes on DM authority. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The four built-in classes now declare their signature inherent skills in their WORLD_DATA definitions: Warrior -> Swordsmanship, Rogue -> Lockpicking/Sneak/ Surprise Attack, Mage -> Spellcasting/Runecraft, Ranger -> Tracking/Spellcasting. So every class begins knowing at least one class-specific gameplay skill. A class's own inherentSkills is now AUTHORITATIVE: classInherentSkills() uses the class definition's list when present (built-in or GM/DM-authored) and only falls back to the hardcoded CLASS_INHERENT_SKILLS defaults when a class declares none. This makes the DM the final arbiter — they can add or remove a class's inherent skills for their world and the change takes effect (previously the const would always union its entries back in). - WORLD_DATA.classes: each built-in class gains an inherentSkills array. - classInherentSkills(): class data wins over the const; const is the fallback. - test_class_skills.js: covers the built-in grants, the authoritative override (DM removal), and the const fallback path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the GM authors a character class it must now also create 1-3 class-specific gameplay skills, at least one of them an INHERENT signature skill the character begins knowing. The count scales to how much the class leans on skill mechanics vs gear: a skill-reliant, lightly-armored archetype (rogue/scout) warrants 2-3; a gear-and-armor front-liner (warrior/knight) warrants just its one inherent skill. Applies to both class-creation surfaces (the DM Classes editor and the new-world generator). Inherent skills are now data-driven: a class records its begins-knowing skill ids on world.classes[name].inherentSkills, and a new helper classInherentSkills() unions that with the built-in CLASS_INHERENT_SKILLS table (filtered to skills the live catalog defines). The Player constructor and the restore backfill grant via this helper, so a GM-invented class's character begins knowing its signature skill and it survives save/rebuild. - classInherentSkills() helper; Player constructor + restore backfill use it. - applyClassSpec stores inherentSkills (filtered to real skills). - requestClassEdit: directive + schema now ask for class skills, ingests a top-level "skills" array before applying classes, and lists existing skills. - New-world generator: rules + schema now author per-class skills + inherentSkills. - tests/test_class_skills.js covers the data-driven grant, ingestion, dangling-ref filtering, and the directives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Obtaining a skill from a book now requires a d20 + INT-modifier roll against the skill's DC (its baseDC). Each skill may be attempted only once per character level, pass or fail: a failed attempt leaves the book intact and locks that skill until the character levels up, when it can be retried; a success learns the skill and consumes the book as before. A natural 20 always succeeds, a natural 1 always fails, and the check tests INT (not the skill's own governing stat). - Player gains a skillLearnAttempts ledger (skill id -> attempted level), seeded in the constructor and backfilled on load for older saves. - readBook rolls the check, refuses a repeat attempt at the same level, and shows a "Skill Study" manifest card (reusing the skill-check card style). - The book detail popup surfaces the INT DC on the Read button and shows a "spent this level's attempt" note after a failed try. - tests/test_book_learn_check.js covers fail/retry-blocked/level-up-retry/success, the INT modifier, natural 1, and the wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The built-in WORLD_DATA set prologue and rules but omitted tone, theme, and artStyle, so Editor › World › Profile showed those three fields blank. The World constructor, serializeWorld, and the profile renderer already handle them — only the source data was missing. Add all three, matching the world's established dark-fantasy identity and the item art's oil-painting house style. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Settings popup anchors at top:62px (below the top bar) but inherited the base popup rule's max-height: calc(100% - 28px), which assumes the 14px top anchor — so its bottom overshot the window by ~48px and the tall settings list ran off-screen. Give it max-height: calc(100% - 76px) (62px top + a 14px bottom gutter, matching the app's other popups); the .room-popup-body then scrolls within the cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
New Settings › Dice checkbox "Auto-roll skill checks" (default ON). When ON, the GM rolls each skill check's d20 itself and reports it via skillChecks, as before. When OFF, the GM must not roll: it requests the player's own d20 via a new skillRollRequest response field, the engine surfaces the dice bag, captures the player's D20, and relays it to the GM as a "[SKILL ROLL — …]" message so it resolves the check on the next turn using that exact roll. - Setting checkbox + syncSettingsControls reflection (default true). - buildSystemPrompt: skillChecks rule now has two branches; the skillRollRequest field + note appear only when auto-roll is off (ON prompt unchanged). - Engine bridge: pendingSkillRoll + setSkillRollAwaiting/handleSkillDiceRoll/ submitSkillRoll; rollDie relays an out-of-combat d20 into a pending request; applyTurnResult honors skillRollRequest only when the setting is off and not mid-combat; beginCombat clears a lingering request. - tests/test_auto_roll_skill.js covers the setting, both prompt branches, and the request→relay bridge; widen a regex budget in test_nanobanana_model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Per feedback, remove the hardcoded rule that paired weapon and sidearm. An item now fits EXACTLY the slots its `equipmentSlots` array lists — no slots inferred or added. A two-handed weapon is ["weapon"]; a light blade usable main- or off-hand (a dagger) is ["weapon","sidearm"]. - normalizeEquipmentSlots: normalizes + dedupes each token (canonical id / synonym / drop-unknown) and returns the array as-is — the two sibling-append lines are gone. - Default world retuned: iron_sword / oak_staff / shortbow / bone_wand and the inline Spear / Crossbow / Iron Sword → ["weapon"]; the catalog + inline Dagger → ["weapon","sidearm"]; crown ["head"], pendant/locket ["amulet"], Chain Coif ["head"] unchanged. - All GM/worldgen prompts (addItem, "//" placeItems, item-editor, being-add, new-world forge) reworded from "interchangeable, list both" to "list exactly the slots that apply — a two-handed weapon ["weapon"], a dagger ["weapon","sidearm"]". No "interchangeable" language remains. Legacy single `equipmentSlot` still migrates to a one-element array (no pairing). Verified: tests/test_equipment_slot.js updated (no-pairing normalizer, retuned default data, prompt wiring incl. an assertion that no prompt claims interchangeability) — all pass; full suite shows only the pre-existing unrelated failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Gear now lists an ARRAY of the slots it fits (`equipmentSlots`) instead of a single `equipmentSlot`. WEAPON and SIDEARM are interchangeable — a weapon can go in the sidearm slot and a sidearm in the weapon slot — so normalizeEquipmentSlots always PAIRS the two: list either and the item accepts both. e.g. a sword is ["weapon","sidearm"], a dagger ["sidearm","weapon"] (primary first). - normalizeEquipmentSlots(v): accepts an array OR a bare string (a single slot or the legacy `equipmentSlot`), normalizes each token (canonical id / synonym / drop-unknown), dedupes, and appends the weapon⇄sidearm sibling. Returns [] when non-equippable. normalizeEquipmentSlot (single) is kept as the per-token helper. itemEquipmentSlots(it) reads the array, falling back to the legacy single field. - makeItem / catalogItemShape / applyItemSpec / reItemObj all handle the array and MIGRATE a legacy single `equipmentSlot` → the paired array (dropping the old key on save-restore and editor edits). Own field ⇒ serializes automatically. - Default world: every equippable item is an array — weapons/staff/bow/wand → ["weapon","sidearm"], daggers → ["sidearm","weapon"], crown → ["head"], pendant /locket → ["amulet"], Chain Coif → ["head"]; non-gear left slotless. - All GM/worldgen prompts (addItem, "//" placeItems, item-editor, being-add, new-world forge) now document the equipmentSlots array + the weapon/sidearm interchangeability. The item editor card shows a "Slots" row (e.g. "Weapon / Sidearm"). Verified: tests/test_equipment_slot.js rewritten for the array (pairing, synonym, unknown-drop, legacy migration, default-world population, editor card, prompt wiring) — all pass; full suite shows only the pre-existing unrelated failures. Real-browser check confirms the card shows joined slots, no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Items gain an optional `equipmentSlot` holding the id of the equipment slot the gear is worn/wielded in. A new canonical registry EQUIPMENT_SLOTS (11 ids: head, amulet, armor, clothing, weapon, sidearm, shield, gloves, bracers, ring, boots) is the single source of truth, and the Character › Equipment paper-doll (EQUIP_SLOTS) now carries the SAME ids on each figure position — the mirrored boot/shield positions collapse to one logical slot — so gear and slots share one vocabulary across the built-in world, imported/forged worlds, and new gear. Model: - normalizeEquipmentSlot() coerces any authored/saved value to a canonical id: a known id passes, a synonym maps in (helmet→head, necklace→amulet, mainhand→ weapon, feet→boots, cloak→clothing, dagger→sidearm, …), anything else → '' (not equippable). Applied in makeItem, catalogItemShape, applyItemSpec (NL/import editor), and reItemObj (restore). Own field ⇒ serializes/restores automatically. Default world: the equippable catalog items and NPC-carried gear are populated — iron_sword/oak_staff/shortbow/bone_wand→weapon, dagger→sidearm, ancient_crown→ head, sapphire_pendant→amulet; inline Spear/Crossbow/Iron Sword→weapon, Dagger→ sidearm, Stolen Locket→amulet, Chain Coif→head. Non-gear (potions, keys, materials, books, treasure) left slotless. Consistency for NEW gear/worlds: the addItem, "//" placeItems, item-editor, being-add, and full new-world forge prompts now instruct the GM to set equipmentSlot to a valid slot id for wearable/wieldable gear (and omit it otherwise). The item editor card shows a Slot row for equippable items. Verified: tests/test_equipment_slot.js (canonical registry + figure consistency, normalizer, model plumbing, default-world population incl. NPC inline items, editor card, prompt wiring) — all pass; full suite shows only the pre-existing unrelated failures. Real-browser check confirms the paper-doll uses canonical ids and the item card shows the slot, no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The initiative bonus was applied and summed into the roll message to the GM, but nothing surfaced it to the player. Now, when a character with Surprise Attack rolls initiative, the engine prints the SAME .skill-manifest info card the GM's per-turn skill checks use — "🗡️ Surprise Attack (DEX) — initiative d20 14 +10 = 24 → +10 initiative" — above the GM's narration, on both the manual and auto-rolled initiative paths. combatInitiativeBonus now flags when it applied; showSurpriseAttackManifest renders the card, pulling the skill's icon/name/stat from the (world-editable) catalog. The roll message to the GM is unchanged. Verified: test_rogue_skills gains manifest-card assertions (both roll paths); full suite shows only the pre-existing unrelated failures. Real-browser initiative roll renders the card styled identically to other skill cards, with no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each nested object/array field in a "// inspect" card is now its own
collapsible group that BEGINS COLLAPSED — so an inspect opens showing just the
top-level fields, and any nested content ({n}/[n] branch) expands on click. A
branch row carries a ▸/▾ caret (▸ collapsed) and toggles via
dmInspectToggleBranch; each row links to its nearest enclosing branch
(data-parent), and a row is visible only when every branch on its path is open,
so collapsing a parent re-hides descendants of still-collapsed sub-branches.
The existing card-level header toggle (whole table) is unchanged.
Verified: test_dm_inspect gains assertions for the branch toggle, the collapsed
start (▸ caret + nested rows display:none), and the parent-linking; full suite
shows only the pre-existing unrelated failures. Real-browser drill-down opens
to top level only, then container → contents → 0 → "Silver Ring" reveals step
by step while an unclicked sibling branch (lock) stays collapsed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomTwo new Rogue-inherent skills (alongside Lockpicking): - Surprise Attack — grants a flat +10 on the player's INITIATIVE rolls. The engine applies it in submitCombatRoll / the auto-roll path and states the boosted total in the "[COMBAT ROLL — initiative: … = 22]" message; the combat contract tells the GM the bonus is already summed (no double count). Gated on the initiative purpose + knowing the skill. - Sneak — an OUT-OF-COMBAT skill: while intentionally sneaking, the GM rolls it and on success beings are far less likely to detect the player's presence. It cannot be used in a fight — applySkillChecks drops any 'sneak' check while combat is active, and the skill's dossier description tells the GM the rule. Both added to SKILL_CATALOG (Rogue-gated) and CLASS_INHERENT_SKILLS.Rogue, so a fresh Rogue begins knowing them and they're backfilled onto existing Rogue saves on restore. normalizeSkills now guarantees every class-inherent skill exists in any world's skill set (injected from the built-in when absent), so older worlds/saves gain them too. Verified: new tests/test_rogue_skills.js (catalog + Rogue-only grant, +10 initiative math/gating, normalize guarantee, wiring) — all pass; updated test_container_traps' pinned inherent-skills assertion. Full suite shows only the pre-existing unrelated failures. Real-browser run confirms a Rogue starts with all three, the initiative message carries "+10 (Surprise Attack) = 22", and a sneak check mid-combat is dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Dropping an item rebuilt it with `new Item(...)`, which copies only the constructor args — so a dropped CONTAINER lost its `container` object (and other own fields like weight/size/teaches), leaving an empty non-container on the floor. A basket put down after looking inside showed no child, and re-examining did nothing because it was no longer a container. The only reason the drop cloned was to split one unit off a stack. Now: - Whole-stack drop (the common case, and the only sane one for a container): MOVE the real inventory object to the floor, preserving its container object, concealed contents, and open/seen state intact. - Split-stack drop (qty > 1): copy ALL of the item's own fields (Object.assign over an Item-proto object), and give a container a fresh deep-normalized copy so the dropped and kept units never share one contents array. - Floor stacking is guarded so two same-named containers never merge (each keeps its own contents). Verified: new tests/test_drop_container.js (container survives drop with contents + open/seen state, own fields preserved, stack split keeps fields, containers don't merge, stacked-container deep-copy) — all pass; full suite shows only the pre-existing unrelated failures. Real-browser drop shows the basket on the floor with its potion indented beneath it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The GM was narrating extra, invented items inside a container that the engine holds only specific items in. Two reinforcements: - Strengthened the Containers "AUTHORITATIVE CONTENTS" rule: the engine's "inside: …" list is declared COMPLETE and EXHAUSTIVE — the GM must describe ONLY those items and nothing else, and must NOT invent, add, embellish, or imply any further contents (no extra coins, gems, trinkets, papers, rags, dust, bones, vials, "assorted odds and ends", …). If the engine lists two items, exactly those two are inside — never a third. This holds for a container's initial description too, and an "inside: empty" container is genuinely empty (nothing conjured to enrich it). - Extended the authoritative list to CARRIED containers. The system prompt's inventory line now annotates a pack container the same way the room-items line does — an OPEN one lists its exact contents, a closed/locked one stays concealed — via a shared containerNoteForGM() helper. So the GM describes what the player actually has inside a carried container from engine truth, not invention. (Room-items output is byte-identical to before.) Verified: test_container_contents_authoritative gains the no-invent assertions; test_inventory_container_grouping asserts a carried OPEN container's contents are listed to the GM while a closed locket's stay hidden. Full suite shows only the pre-existing unrelated failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Mirrors the room Items block grouping in the Inventory block: a carried container now shows a state glyph (🔒/📂/📦) and, once looked into, lists its concealed contents (container.contents) indented beneath it as clickable child rows. So putting a ring into a carried container displays the ring as the container's indented child instead of a separate top-level item — the reported behavior. - updateSidebar: the inventory render gains makeInvLi (glyph) + makeInvContentLi (indented child, data-inv-container / data-inv-content), gated on containerContentsSeen so contents stay hidden until the container is opened/ examined and hide again when it's closed — same rule as the Items block. - CSS: #inventory-list li.inv-item-contained (16px indent + ↳ connector), mirroring #room-items-list li.room-item-contained. - Click routing: showSidebarInvContainerContentDetail resolves the child from the PACK container (pack-first, so a same-named floor container can't shadow it) and opens its popup; the inventory delegated handler checks the content link before the plain item link. Verified: tests/test_inventory_container_grouping.js (closed→hidden, open→ indented child directly beneath, close→hidden again, the put-a-ring flow moves it from top-level to child, locked never shows, plus wiring) — all pass; full suite shows only the pre-existing unrelated failures. Real-browser render confirms "Wooden Box 📂 / ↳ Silver Ring" with no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
"// create a locked chest" was producing a container literally named "Locked
Chest". A container's name should say what it IS ("Wooden Chest"); its
locked-ness lives in container.lock (and the sidebar/story already show a 🔒/📂
glyph). Two-part fix:
- Engine: makeItem now strips any standalone "locked"/"unlocked" word from a
CONTAINER item's name (via stripContainerLockWord) — "Locked Chest" → "Chest",
"Wooden Chest (Locked)" → "Wooden Chest", interior words removed, empty parens
and dangling separators tidied, and it falls back to the original if stripping
would empty the name. Only containers are touched (a non-container "Locked
Diary" keeps its name), and "Warlock" is not mistaken for the lock word. This
covers every creation path (DM "//" placeItems, story GM addItem/placeItems,
world authoring); restore is unaffected (it doesn't route through makeItem).
- Prompt: the DM meta directive now instructs the GM to name a container by
what it is and express locked-ness only through the lock object, never as
"Locked Chest"/"Unlocked Chest".
The container stays actually locked — only the name changes.
Verified: tests/test_dm_container_nested.js gains helper + makeItem +
placement-path + prompt-wiring assertions — all pass; full suite shows only the
pre-existing unrelated failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe "// list <object>" inspector card's header is now a click target that folds its table away and back — handy for tucking a big dump aside without clearing the story. Cards start EXPANDED (down caret ▾); a click collapses the table and flips the caret to ▸, another re-expands it. dmInspectToggle walks the header's next sibling (the table) and swaps the caret glyph. Updated tests/test_dm_inspect.js (clickable header + starting caret + toggle logic). Verified in a real browser: initial state expanded, click collapses, click re-expands, no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
API Keys dialog gains an optional Runware card (masked input, runware.ai link), wired into the API_KEY_FIELDS table so it pre-fills, saves-on-close, syncs, and restores exactly like the other optional keys — backed by a SAVED_RUNWARE_KEY storage key, a `runwareKey` global, and a getRunwareKey() decrypt-on-demand getter for parity with the other providers. Settings gains a "Video AI" section (after Sound AI) with a Provider select (Runware only for now) and a Model select below it populated with fast Runware models — LTX 2.3 Fast and Seedance 2.0 Fast — mirroring the Gallery/Higgsfield model-row pattern. Choices persist via settings; unknown ids fall back to the defaults (Runware / LTX 2.3 Fast). Reflected when Settings opens. This is provider/model SELECTION scaffolding only — video generation itself is not wired yet (adding a model or provider is a single registry line each). Verified: tests/test_video_provider.js (card + API_KEY_FIELDS wiring, section markup, registry, provider/model defaults + persistence + fallback, model-row population) — all pass; full suite shows only the pre-existing unrelated failures. Real-browser check confirms the card renders and the Video AI selects populate + persist with no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A new deterministic "//" meta-command dumps EVERY data property of an in-scene
object — including nested ones and engine/GM-only fields — into a formatted,
colour-coded table in the story, so a DM can inspect the live shape of things in
the current room down to the engine level.
Targets resolve locally (no GM call, no API key, DM-only):
- "// list room" → the current room
- "// list player" (or me/self) → the player
- "// list <name>" → a being present in the room, or an item on the floor / in
the pack / in the trove — containers are searched recursively, so a ring
inside a chest resolves. Match is exact → startsWith → includes. Aliases:
list / inspect / dump / props / debug.
The walker recurses own enumerable properties with a depth cap (8), a total-row
cap (600), cycle detection, and long-string truncation (image data URIs, lore),
rendering nested objects/arrays as {n}/[n] branch rows with indented children.
Values are colour-coded (numbers, booleans, strings). Added a "// help" row.
Verified: tests/test_dm_inspect.js (resolution incl. nested container items,
table render with nested + underscore GM-only fields, aliases, unknown-name
error without GM handoff, cycle/truncation guards, DM-gating) — all pass; full
suite shows only the pre-existing unrelated failures. Real-browser render of a
trapped chest confirms the nested lock/trap/contents table looks right.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomNative checkboxes rendered a bright white unchecked box that clashed with the dark theme — `accent-color: var(--gold)` only tints the CHECKED fill, leaving the unchecked background at the browser default. Replaced the native control with a themed one via `appearance: none`: a dark, subtly bordered box (using --bg-panel / --border) that fills gold with a dark checkmark when ticked, plus a gold hover border and a focus ring. Works in both dark and light themes. Also fixed `.setup-field-check input[type="checkbox"]` (it forced width:auto, which would collapse an appearance:none box) to an explicit 15px. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two new counters under the Magic & Skill group, beneath "Skill checks rolled": "Skill checks success" and "Skill checks failure". applySkillChecks buckets each rolled check by whether it met the DC — a clean pass (critical/success) counts as success, a partial or miss counts as failure — so the two always sum to the rolled total. Updated tests/test_statistics.js (catalog membership, rendered rows, bucketing wiring). Verified end-to-end in the browser: rigged rolls split correctly (2 success / 2 failure of 4 rolled) and both rows render in the card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The "/" out-of-character channel previously consulted only the Field Guide
(guide.html, which documents APP/UI usage), so an in-game mechanics question
like "how does lockpicking work for rogues?" was answered — or deflected — from
the wrong reference. It now consults BOTH references and routes the question:
- Player's Handbook (Handbook/players-handbook.html) — in-game RULES &
MECHANICS: skills, spells, combat, dice, containers & traps, lockpicking,
reputation & factions, time, XP/leveling, coin.
- Field Guide (guide.html) — how to USE THE APP: buttons, tabs, saving,
the Editor, settings/providers, importing worlds.
Both docs are fetched (cached) and the most relevant sections of each are
embedded in the lookup prompt; the model decides which reference the question
is about and answers only from that one, returning a "source" ("handbook" |
"guide"). The answer popup's title and full-document footer link switch to
match — "Player's Handbook" for rules, "Field Guide" for app usage — and the
"/" echo line is now source-neutral ("Looking it up — …").
Implementation:
- Generalized extractGuideSections into extractDocSections(html, levels) so the
Handbook's h1 chapters + h3/h4 subsections parse (Field Guide stays h2/h3).
- Added loadPlayersHandbookText() + a fallback used if the fetch is blocked
(e.g. file:// / offline), mirroring the Field Guide's fallback.
- parseFieldGuideResponse now also extracts the "source" field (kept before the
trailing "html" so the lenient quote-tolerant parser still works).
- showFieldGuideAnswer(remark, html, source) sets the title/footer by source.
- stripHtmlToText now decodes common typographic entities (mdash, rsquo,
ldquo/rdquo, hellip, numeric) so reference excerpts read cleanly.
Verified: updated tests/test_field_guide.js (dual-source extraction, routing,
popup title/footer per source, source parsing) passes; full suite shows only
the pre-existing unrelated failures. Real-browser check over HTTP confirms the
actual Handbook loads (41 sections) and a lockpicking question surfaces the
right rules section with entities decoded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe Character › Statistics cards hugged the panel's left/right borders because #statistics-view had no padding of its own. Give it the same inner gutter the sibling Character views use — padding: 14px 24px 20px (24px sides), matching #skills-view — with a thin scrollbar for tall tallies, and trim the intro line's inset so it aligns with the grid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A new Character subtab shows a grouped, saved-with-the-game tally of the character's deeds across the Realms — 35 counters across five groups (Exploration & Discovery, Items & Loot, Combat & Peril, Magic & Skill, Progression & Wealth). Data model: - player.stats seeded by newPlayerStats(); normalizePlayerStats() coerces a saved/imported map to the canonical shape and backfills 0 for any stat a save predates, so older saves migrate cleanly. - trackStat(key, by) accumulates a counter; trackStatMax()/trackWealthPeaks() hold max-ever peaks (spending never lowers max gold/silver/copper); trackItemAcquired() bumps the pickup total + by-type "obtained" counters. - Serialized whole with the player in the save snapshot; restored + normalized on load. UI: chartab-statistics button, character-sub-statistics subview, and renderStatistics()/refreshStatisticsView() building the grouped table (kept live while the tab is open). Themed CSS for the stats grid/cards/table. Event hooks wired through the engine: places/rooms (describeRoom), people met & monsters encountered & items discovered (compendiumDiscover), lore (discoverLore/unlockItemLore), item pickups (lootRoomItem, takeItemFromContainerByName, GM pickup/addItem), potions drunk / items destroyed (consumeInventoryItem), treasures & value (grantTreasure), monsters slain & damage dealt (applyEntityDamage + GM entityKilled), damage taken (negative hpDelta + sprung traps), spells learned/cast & MP spent/earned (learnSpell/castSpell/mpDelta), skills learned & checks rolled (learnSkill/applySkillChecks), turns played & people spoken to (applyTurnResult), factions discovered (recordFactionMet), containers opened & traps sprung/disarmed, xp earned & levels gained (awardXp), deaths (refreshPlayerAlive), and peak wealth (updateSidebar). Adds tests/test_statistics.js (data model, helpers, grantTreasure/ compendiumDiscover hooks, rendered table, save/restore backfill, plus source wiring for the turn/combat hooks). Widens a regex budget in test_loot_gm_note.js for the added lootRoomItem line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two floating buttons in the top-right corner of the Editor > Map viewport: - "Background" opens a file picker; the chosen image is downscaled and shown in a centered preview lightbox with Cancel / Confirm. Confirm stores it on world.mapBackground and paints it as --map-bg-url (the backdrop of both the player Maps tab and the editor map); Cancel closes the lightbox and leaves the current background unchanged. - "Reset" clears the override so the built-in default (:root's --map-bg-url) returns. Both persist via saveGameState(), mirroring the World Profile field editors. The image is downscaled through the shared helper so the save stays small. Verified with a node test and a real-browser run (buttons present; pick -> preview -> Confirm applies and closes; Reset restores the default). Suite 171/177 (same 6 pre-existing failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A player-focused companion to the in-game Field Guide, designed for print rather than on-screen navigation. Handbook/players-handbook.html renders to Handbook/The-Lost-Realms-Players-Handbook.pdf (Letter, ~24pp) via headless Chromium. - Professionally styled in the game's palette: a dark, atmospheric cover with a placeholder illustration frame; a clean warm-cream interior with gold and dark-brown ink, display-serif chapter openers with drop caps, gold rules, callouts, and reference tables. - 17 chapters + two appendices covering the player experience end to end: getting started, the screen, how to play, character, vitals & encumbrance, combat, skills (inherent/book/off-class), spells, containers/loot/traps, reputation & factions, time, the compendium & lore, journal & quests, maps, settings, and binding your own book — plus a quick-reference appendix and a note on the DM/GM roles. - Embeds the real player-facing screenshots (Images/guide/*) where they exist and clearly-marked placeholder frames for the cover and newer features (skills, spells, containers/traps, dice). Regenerate with: headless Chromium page.pdf() over the HTML (see Handbook/). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
character-skills.html was written before class-inherent skills and the off-class "lesser effect" shipped. Updated to rev. 3: - Skills are now acquired three ways — class-inherent (Rogue's Lockpicking, a caster's Spellcasting, seeded at creation), book-learned, or off-class the hard way at a reduced proficiency (OFF_CLASS_SKILL_PENALTY). - Gate model is now soft vs. hard (hardGate closes a skill to other classes; spellcasting is the one root gate). classes = affinity, not a hard wall. - Worked example gains an off-class Warrior line; the check formula notes the penalty; the catalog marks inherent/hard-gate; §06 rewritten; §07/§08 note CLASS_INHERENT_SKILLS, skillIsOffClass/skillHardGated, and the off-class badge. Designs/README.md: added an "Audience: developer/design" note distinguishing these specs from the player-facing guide.html, and refreshed the skills row. No engine/behavior change — documentation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Image generation now resolves its provider and model per CONTEXT ("image",
"icon", "gallery") instead of only from Settings › Image AI.
- New "Icon AI" section in Settings with the same provider choices as Image AI
(Pollination / Nano Banana / Higgsfield) and the same provider-specific model
sub-rows (Nano Banana model, Higgsfield model), shown/hidden like Image AI's.
ALL icon generation (generateItemIcon) now routes through this Icon AI
provider + model, independent of the Image AI selection.
- Gallery AI (pinned to Nano Banana) gains its own Nano Banana model select
underneath, mirroring the Image AI Nano Banana row.
Implementation: providerForKind / nanoModelForKind / higgsModelForKind /
modelIdForProviderKind resolve each context from its own settings (the "image"
kind keeps the original keys, so nothing migrates); paintImageFromPrompt takes
opts.kind ('icon' for the glyphs); generateImageWithProvider and the Nano
Banana / Higgsfield generate() funcs accept and honor an explicit per-context
model; paintImageVariation passes the gallery model. Provider-fallback and Logs
labels are kind-aware.
Verified: new test_icon_ai_provider.js (per-kind resolution + wiring) and a
real-browser run (Icon AI model rows toggle by provider; Gallery model row
present with both models; Icon vs Image keep independent models). Updated four
image tests that pinned the changed lines. Suite 170/176 (same 6 pre-existing
failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe Maps renderer placed a child room by a fixed grid vector per direction, and anything without a compass vector — "in", "out", "up", "down", or a direction it couldn't decipher — used offset [0,0], stacking the child directly on top of its parent. Two rooms could also claim the same cell and overlap. Connectors carried no direction, so a non-compass link was ambiguous. - Auto-layout: a new claimCell() takes the desired cell, or — when it's taken — the nearest FREE cell via an outward ring search. Every room lands in a distinct cell; overlaps (undecipherable directions, or two rooms wanting the same spot) spill off to the side. Rooms unreached by exits also get a free cell instead of piling on the origin. - DIR_OFFSET now understands diagonals (ne/nw/se/sw); up/down/in/out/enter and any unknown direction fall back to a zero vector and are placed by claimCell. - BFS is scoped to the rendered room set (each map lays out its own rooms). - Every connector draws a real line now (including up/down/in — previously a vertical stub) and carries a small halo'd DIRECTION LABEL at its midpoint, so a link reads clearly even when the layout couldn't honor the literal compass direction. Verified in a real browser (an "in" exit + two rooms colliding north → distinct cells, "in"/"north"/"up" labels present) plus a source wiring test. Suite 169/175 (same 6 pre-existing failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Refresh Designs/containers.html to match the shipped system. Traps and Lockpicking (previously slated for Phase 3) are now documented as implemented, alongside the take/put item-movement actions, the look-inside reconcile, and the authoritative-contents rule that landed since the doc was written. - New sections: "Item movement — take / put / look-inside" and "Traps & Lockpicking" (lifecycle, trust model, Rogue-inherent skill + off-class lesser effect, authoring). - Data model updated with container.trap and contentsSeen; helper table and containerChanges action set brought current. - Re-scoped the roadmap: Phase 2 stays Destructible containers (noting a brute-force open should spring a trap); Phase 3 is now nesting + search polish (first-class hidden-button search, a distinct Perception sense, nested containers, richer trap effects). - Status badges and notes updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A skill learned outside its class gate now carries a small "off-class" badge on its Character → Skills card, with a tooltip explaining the reduced proficiency. Makes the "lesser effect" visible to the player, not just to the GM. Purely presentational; the numbers were already penalized. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Containers can now hide a TRAP that punishes a careless opener — the payoff
for the examine-before-open distinction. Full vertical slice:
Lockpicking skill:
- Rogue now BEGINS knowing Lockpicking (inherent, like a Mage's Spellcasting),
seeded at creation and backfilled on restore.
- Off-class "lesser effect": any class may learn a soft-gated skill the hard
way, but wields it at a reduced proficiency (OFF_CLASS_SKILL_PENALTY). The
penalty flows into skill checks and the GM dossier. Spellcasting stays a
hard gate (no off-class casting).
Trap model + engine (container.trap: name, detectDC, disarmDC, damage,
effect, hint, detected/disarmed/sprung):
- Hidden until DETECTED (examine + a Lockpicking check the GM rolls →
containerChanges "detectTrap").
- DISARMED via a Lockpicking check ("disarmTrap"), or from the popup's new
Disarm button (client-side roll: d20 + DEX + Lockpicking prof vs disarmDC;
nat 1 botches and springs it).
- TRIGGERS automatically when an armed container is opened (story open, UI
Open button, or look-inside reconcile) — the engine applies the damage +
status authoritatively and warns loudly. "springTrap" lets the GM fire it
for a narrated botch. Sprung traps never re-fire.
- Survives save/restore (normalizeContainer/reItemObj carry the trap).
GM contract + authoring:
- System prompt tags each container with a GM-only trap note (state + DCs +
effect), never leaking an undetected trap to the player.
- Containers guidance (5b) + containerChanges schema document detect/disarm/
spring; addItem and the // placeItems schema document the trap object.
- Popup shows trap state (armed/disarmed/sprung) once known.
Verified: 25-check node suite + full real-browser run (Rogue detect→disarm→
open safe; Warrior careless open → takes the hit). Suite 168/174 (same 6
pre-existing failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomReported bug: on a closed but UNLOCKED jar, the player typed "look inside the jar", the GM narrated prying the lid loose, but emitted no "open" containerChange — so the engine left the jar closed (the popup still showed an Open button; contents concealed). "Look inside / open" is an unambiguous OPEN intent from the PLAYER'S words; a bare "examine" is not (examining is not opening). Rather than key the fix off the GM's action verb, added a deterministic post-turn safety net (reconcileContainerOpenIntent, run from applyTurnResult with the player's input): when the input clearly asks to OPEN / look INSIDE / peer/reach into / pry / uncork / lift the lid of a specific, named, UNLOCKED, still-closed container in the room, the engine opens it and reveals its contents so state matches the narration. Idempotent when the GM did emit the open; never forces a LOCKED/sealed container; leaves a plain "examine" alone. Also strengthened the GM prompt: opening/looking-inside a closed unlocked container is an "open" action and must emit the state change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Investigated the intermittently-missing container contents ("// create a chest
with a silver ring in it" → ring sometimes absent). Traced every engine path —
makeItem/normalizeContainer, the placeItems applier, extractJsonObject, the
open/reveal flow, and save/restore (reItemObj/reRoomObj) — and all preserve
nested contents. A full real-browser run of the exact reported directive
creates the chest, nests the ring, and reveals it on open with zero loss.
So the engine handles the correct directive shape reliably; the intermittency
is GM output variance (the ring occasionally floated to the room, buried in the
description, or the request routed to "room"). Added a concrete WORKED EXAMPLE
to the DM directive prompt showing the exact nested-contents JSON for "a chest
with a ring in it", stating the item must live ONLY in container.contents —
never a second placeItems entry, never description-only. Anchors the shape so
the GM produces it consistently.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomReproduced the reported case in a harness: the engine state was already correct at every step — the system prompt listed "Wooden Chest (container, OPEN — inside: Silver Ring)", the Open-button note listed the ring, and the examine reveal named it. The fault was the GM's prose contradicting that authoritative state (likely anchored on an earlier "the chest is empty" narration from when the ring had been taken out). Two reinforcements so the narration can't drift: - Containers guidance now declares the room-items "inside: …" list the single source of truth: never call an open container empty when items are listed inside it, and trust the current list over anything narrated earlier (the player may have added/removed items via the interface). - The UI Open-button GM note now explicitly says the container is NOT empty and re-lists its contents when it has any. Engine behaviour unchanged; prompt/note wording only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The previous tightening was too absolute — it forbade treasure rings entirely. A ring CAN legitimately be treasure when it's a purpose-built collectible meant only to be found, collected, and admired (a gem-encrusted ceremonial ring), either at the DM's request or as a deliberate dungeon-loot prize. What should not happen is a ring defaulting to treasure. Softened the item-typing guidance in both channels to "wearable/usable by DEFAULT, treasure only as a deliberate trophy": a ring is "type":"ring" unless it is specifically designed as an esteemed trophy, in which case "treasure" is correct. Behaviour unchanged; guidance only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A "Silver Ring" was being typed "treasure", so it landed in the display-only Treasure trove instead of the usable pack — surprising players who expected jewelry in their inventory. Tightened the item-typing guidance in BOTH prompt channels so the GM types anything WEARABLE or USABLE (rings, amulets, worn crowns, weapons, armour, potions) by its mechanical slot/use — landing it in the usable inventory — and reserves "type":"treasure" strictly for pure display trophies with no wear or use (loose gems, idols, art objects): - main GM addItem docs - the "//" DM-command channel (placeItems + nested container contents), whose "a ring is a ring/misc/treasure item" line explicitly permitted the mistake Engine behaviour is unchanged (treasure still routes to the trove); only the authoring guidance changed. Updated test_treasure.js to the new wording. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The ring wasn't lost: it was typed "treasure", so grantTreasure filed it in
the display-only Treasure trove (Character Profile), not the usable pack.
But the "take" containerChange posted no story line, so the item silently
vanished from the chest with no clue where it landed.
The "take" and "put" handlers now post a concise system confirmation. When a
taken item routes to the trove (any treasure-type item), the line says so
("— it goes to your Treasure trove"), and "put" confirms the item is now
inside the container. Added the same trove note to the UI Loot buttons
(lootContainedItem, lootRoomItem) which had the same latent confusion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomSymmetric to last commit's "take": there was no way for the GM to move an item from the pack INTO a container, so "put the ring in the chest" fell back to a room addItem and the ring landed on the floor instead of inside the chest. New containerChanges action "put" (with "item" / "items"): the engine moves the named item(s) from the player's pack (or trove, for a returned treasure) into an OPEN container's contents, stacking quantities. Closed/ locked containers and items the player doesn't hold are no-ops. New putItemIntoContainerByName helper mirrors takeItemFromContainerByName. GM prompt (schema + Containers section) updated to emit "put" for story-command deposits instead of a room "addItem". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The container model routed ALL looting through the popup Loot button, and the GM prompt told the GM never to move contained items itself — so a story command like "retrieve the ring from the chest" was narrated but nothing actually moved the ring out. It stayed inside (and kept showing as an indented child in the sidebar). New containerChanges action "take" (with "item" / "items"): the engine moves the named item(s) out of an OPEN container into the player's pack (treasure to the trove), performing the transfer the GM's prose describes. Closed/locked containers and unknown names are no-ops. Factored the move into takeItemFromContainerByName, shared with the UI Loot button. GM prompt (schema + Containers section) updated to emit "take" for story-command retrievals instead of a duplicating "addItem". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Once a container was examined or opened, its contents stayed indented in the Items block forever because contentsSeen persisted through a close. Closing now clears contentsSeen, so the indented children disappear again (matching the system prompt, which only lists a container's contents while it is OPEN). The player must reopen — or look inside again — to see them, rewarding memory of what was in the chest. Examine and open still reveal contents as before; only the close path changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Looting through the interface isn't a story turn, so the GM's memory still placed the item where it was. Both loot paths now queue an out-of-band GM note (delivered next turn) stating the item has moved into the player's possession and is no longer inside the container / lying on the floor: - lootContainedItem (container Loot button) - lootRoomItem (floor Loot button) Mirrors the existing openUnlockedContainerFromPopup queueGmNote pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Opening a container through the popup's Open button changes engine state but isn't a story turn, so the GM's memory still had it shut — a later "examine the chest" got narrated as closed. openUnlockedContainerFromPopup now queues an out-of-band GM note (via queueGmNote, the same channel used for respawns and //-spawned beings) stating the player opened the container, that it is now OPEN, and what is revealed inside — with an instruction to stop describing it as closed. The note is delivered on the GM's next turn, and the live system prompt already reflects the container as OPEN with its contents. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Revisited the Items-block indentation to match the real intent: once a container has been examined or opened (containerContentsSeen), list its ACTUAL concealed contents (container.contents) indented beneath it — never floor items that merely share a name. - updateSidebar now renders each seen container's container.contents as indented content rows (data-room-container / data-room-content), instead of matching room.items against contents by name. Floor items are left entirely alone, even when a name coincides. - A content row click opens that concealed item's popup via a new showSidebarContainerContentDetail resolver (guarded on containerContentsSeen). Locked/un-looked-into containers show nothing. Rewrote test_room_container_grouping.js for the corrected behavior (contents shown only after examine/open; a name-sharing floor item stays a separate top-level row; multiple contents all listed; locked shows none). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The local keyword/regex matchers that classified // commands (room vs being vs plant vs item) were an endless source of language traps — e.g. "a jar with a ring inside it" parsed "inside" as a build direction. Static analysis can't reliably infer intent from free-form language. New routing: 1. Deterministic fast-path: handleDMMetaCommand tries applyDMMetaCommand first for exact player-state ops (heal, set str 10, gold +100, teleport, learn a spell). These stay instant, exact, and work without an API key — they're structured commands, not natural language. 2. GM intent-router: everything else goes to dmMetaRouteViaGM, which asks the GM (requestDMMetaRoute) to classify the command into room | being | plant | item, then dispatches to the SAME specialized authoring pipelines as before (dmMetaAddRoom / dmMetaAddBeing / dmMetaAddFlora / dmMetaViaGM). Intent is judged by the GM; the engine only dispatches on a structured kind field — reliable. Removed the brittle matchers and their keyword lists entirely: matchDmWorldAdd / matchDmBeingAdd / matchDmPlantAdd, parseDirectionFromText, DM_DIR_ALIASES, DM_CONTAINMENT_RE, and the DM_*_NOUNS sets. Updated the DM meta tests accordingly (test_dm_meta_router.js replaces the old container-routing test; test_dm_add_being.js and test_dm_animal_plant_add.js keep their placeBeings/placeFlora engine coverage and swap matcher checks for router wiring). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
"// create an open jar with a silver ring inside it" was mis-routed to
world-building: matchDmWorldAdd saw "inside" (a direction alias → "in")
and treated the command as "build a room in the 'in' direction", so the
GM authored a room with the ring lying on its floor.
matchDmWorldAdd now guards the ambiguous in/out prepositions: when the
matched direction is "in"/"out" AND the command reads as CONTAINMENT
("…inside it", "…in it", a trailing "…inside", "containing …", "holding
…"), it isn't a world-add and falls through to the item-creation path
(the container is authored as an ITEM with its contents). Cardinal and
vertical builds (north, downstairs, …) are never treated as containment,
and a genuine interior-room build ("a vault inside the tower") still
routes to world-add.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom"// create an unlocked chest with 5 gp in it" made a CLOSED (unlocked) chest. The 5 gp were correctly inside it (container.contents), but a closed container's popup hides its contents, so it looked empty with no way to open it short of typing "open the chest" in the story. An unlocked container has no barrier, so its popup now offers an "Open" button. openUnlockedContainerFromPopup lifts the lid (open + contentsSeen, persisted), lists the contents in the story as clickable links, and re-renders the popup so the contents appear with Loot buttons — visible and lootable in place. Locked containers get no such button (they must be opened through play: a key, a pick, a hidden button, or a puzzle). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The sidebar Items block only groups a floor item under a container once the player has actually LOOKED INSIDE that container — so the association is earned through engagement, not revealed up front. - Containers gain a container.contentsSeen flag, set when the player OPENS or EXAMINES an accessible container (never for a locked one). Unlike `open`, it is NOT cleared on close — the player still remembers what was inside — and it persists through save/reload (normalizeContainer / reItemObj carry it; older saves default to not-seen, open containers count as seen). - New helper containerContentsSeen(it) = contentsSeen || open. The updateSidebar grouping now gates on it instead of "unlocked", so a closed, never-opened container (even an unlocked one) no longer groups its matching floor items until the player looks inside. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
In the sidebar Items block, a room item that ALSO appears inside a VISIBLE (open or unlocked) container in the same room is now listed indented directly beneath that container (class room-item-contained, with a ↳ connector) instead of at the top level — so its relationship to the container reads at a glance. - Matching is by item name against the container's contents. A locked/closed container conceals its contents, so a matching floor item is NOT grouped and stays at the top level. - Unrelated items and rooms without containers render exactly as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The non-draft detached Editor window's browser-tab title now reads
"<world name> — World Editor" (was "<world name> — Editor"), matching the
"World Editor" header shown in that window. The draft-editor title
("World Draft Editor") is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomEntering the World Builder from the login page now sets the browser-tab title to "<world name> — Builder", mirroring the detached Editor's "<world name> — Editor". - setWorldBuilderTitle(name) sets document.title to "<name> — Builder", or "World Builder" when no name is set yet; with no argument it reads the World Name field. - Wired on entry (openWorldEditor), live as the World Name field is typed (oninput), and after a world is generated (using the forged world's name). logoutWorldEditor restores the login-page title (the built-in world name). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The detached Editor window now has a header mirroring the World Builder page: the title "World Editor" on the left and a right-aligned power (⏻) button that returns the window to the login page. - Header markup added as the first child of #view-editor (title block + power button). Since #view-editor is a flex column and #editor-view is flex:1, the header stacks on top and the editor fills the rest. - CSS scopes it to detach mode: hidden by default (#detach-editor-header display:none), shown as a flex row (title left / button right) under body.detach-editor, reusing the World Builder header's gold title and bordered power-button styling. - detachedEditorReturnToLogin() strips the detach/draft/player params and reloads, landing on the login page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Respawn checkbox on the Monsters editor card rendered with the browser's default checkbox color instead of the app theme. Add accent-color: var(--gold) to .npc-respawn-toggle input, matching every other themed checkbox in the app (settings, sidebar-blocks, art-style override, etc.). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Creatures can now be set to RESPAWN: after being slain, they return to life at their starting location once a COOLDOWN of in-world days elapses, restored whole as if never dead. When one materializes into the room the player is in, a flashy gilded message announces it. Model & persistence: - Entity gains respawn (bool), respawnCooldownDays (int), and a lazily stamped _diedAtGameMs. Read in makeEntity, applied via applyNpcSpecToEntity, and backfilled in reEntityObj for older saves. Engine: - checkMonsterRespawns() runs on the clock tick (every second, skipped in combat): stamps the death instant the first time it sees a respawner dead (so cooldown counts from death across any kill path and survives a reload), then revives it once cooldown days pass — full HP, cleared statuses, back at homeLocation, death stamp cleared. respawnEntity() queues a GM note so the story GM knows the creature is alive again, and prints a .respawn-flash materialization banner when the player is present (silent otherwise). Editor UI: - Creature cards (monsters/animals, not NPCs) get a Respawn checkbox and a Cooldown number field that go together; Cooldown is only editable while Respawn is checked (setEntityRespawn re-renders to reflect it). GM directive: - The being-edit directive advertises respawn/respawnCooldownDays for creatures so the GM can author recurring foes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a player examines a container that is open or otherwise accessible, the story window now lists its contents with a clickable link per item (and wealth like coins), each opening that item's detail popup. A locked, still-closed container reveals nothing. - New containerChanges action "examine": when the GM adjudicates the player looking inside an OPEN or accessible (unlocked/lidless) container, the engine lists its contents in the story as clickable links; a locked container is refused (contents stay concealed). - Shared renderer (containerContentLinksHTML / listContainerContentsInStory) used by both the new examine action and the existing "open" reveal, so opening a container now also yields clickable contents instead of plain text. - Delegated #narrative handler + showStoryContainerContentDetail resolve a content link to the item in the container's live contents and open its popup (guarded so a locked container never leaks contents via a stale link). - Taught the GM (Containers section + containerChanges field doc) when to emit action "examine" (open/accessible only, never locked/sealed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The DM Editor NPC/Monster/Fauna cards now show an "Ambient Behaviors" section (beneath Profile) listing each being's authored ambient behaviors the same way the Encounters tab does — type, trigger, chance, target, and prompt. Extract the encounter card's ambient-item rendering into a shared buildAmbientItemsHTML(ambient) helper (so both surfaces present behaviors identically, and it now also labels a "oncePerPresence" trigger), reuse it in renderEncounters, and add the section to renderEntityCards from ent.ambient. The section appears only when the being has ambient behaviors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Creating "a jar with a ring inside it" via // produced a plain jar whose description merely mentioned a ring, instead of a jar CONTAINER holding a separate ring item. The engine already supports this (normalizeContainer maps container.contents through makeItem), so the gap was purely the // debug-directive guidance: - its container examples listed only chest-like vessels (chest, box, strongbox, barrel, crate, coffer, sack) — no small vessels like a jar, bottle, vial, urn, or pot, so the GM didn't treat a jar as a container; - its only "contents" example was coins, so the GM never saw that an arbitrary item (a ring, a scroll) should be authored as its own nested item rather than narrated in the description. Broaden the placeItems container schema note and add a dedicated "CONTAINERS & NESTED ITEMS" rule: any vessel that can hold things is a container (now naming jars/bottles/vials/urns/pots/pouches/etc.), and an "X with Y inside it" request must author the container AND each thing inside as its own complete item in container.contents (keeping its real type/value so it can be looted separately) — never just mentioned in the container's description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
During a fight the GM only saw each enemy's ability NAMES (in the entities-present dossier), not what they do — so the effect was unclear and the GM rarely brought abilities to bear. - combatStatePromptBlock now adds an "Enemy abilities" section listing each living foe's abilities WITH their descriptions, so the GM can actually adjudicate them (a foe with no abilities adds no section). - The Combat Contract gains an ENEMY ABILITIES rule: the GM CONSIDERS a foe's abilities on its turn and uses one only when it fits the tactics of the moment (goal, positioning, HP, effectiveness). They are options, not a script or a per-round obligation — do not fire one that makes no tactical sense, never invent abilities a foe lacks, and adjudicate the chosen ability's effect straight from its description through the normal to-hit / entityDamage / saving-throw / status fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A "//"-created animal (e.g. a rat) is an animal ENTITY. It showed in the
player's Fauna compendium but not the DM Editor's Fauna tab, because that
tab was item-backed (animal-type ITEMS, of which none are ever created)
while animal entities were routed to the Monsters tab ("bestiary =
monster + animal"). Editor and compendium disagreed on what "Fauna"
means.
Align them with a clean People / Monsters / Fauna split that mirrors the
compendium:
- monsterEntities() now excludes animals; new animalEntities() is the
Fauna roster. A non-npc, non-animal type still falls to Monsters.
- The Environment -> Fauna subtab is now entity-backed: renderFauna
paints animal entity cards (like NPCs/Monsters), with its own
faunaCardsCollapsed set and toggle listener.
- The Fauna Apply box routes to the ENTITY editor via a new "animal"
ENTITY_EDIT_KINDS entry (defaultType animal), not the item editor.
- The Fauna import/export adapter is entity-backed (serializeEntitySpec /
applyEntitySpecs), array-shaped under a "fauna" key.
- Refresh paths that add or edit entities ("//"" being-add,
requestEntityEdit, rerenderEntityTabFor, npc/monster importers) now
refresh the Fauna tab too, so a new animal shows immediately.
- Any stray legacy animal-TYPE item folds into the Items tab instead of
being orphaned.
Updated test_flora_fauna.js to the new model and added
test_editor_fauna_split.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomOn the Compendium -> Monsters tab (and every other Compendium card), clicking the portrait generate button with no prompt yet just showed "add a prompt first" instead of producing one. compendiumRegenerate already had the "author a prompt first, then paint" convenience, but it was gated on artTabVisible(), so it only fired on the Art tab. Lift that gate to apiKey alone, so one click on the Compendium portrait button both authors the prompt (via compendiumSuggestPrompt) and then generates the portrait from it - matching the Art tab and the editor tabs' Generate button. With no key it still degrades to the existing "add a prompt first" hint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Asking the being-editor GM to add "ambient behaviors like the ones on encounters" produced a daily routine instead. The engine fully supports per-entity ambient beats (entity.ambient, evaluated on the same timer path as encounter ambients), but the editor's edit path never exposed them: the being-edit directive listed routine/routineDescription but no "ambient" field, and applyNpcSpecToEntity ignored spec.ambient. With no ambient field advertised, the GM mapped the request onto the nearest recurring-behavior field it was offered - the routine. Wire ambient through the being-editor: - Advertise "ambient" (replace) and "addAmbient" (append) in the being-edit directive, with the encounter-ambient vocabulary (type/target/chance/intervalType/interval/prompt) and an explicit note that it is distinct from a time-of-day routine. - Apply spec.ambient / spec.addAmbient in applyNpcSpecToEntity, cloning each beat per instance to match makeEntity. - Re-run setupEncounters after an edit so newly-authored ambient timers register and start firing without a reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A "// create a chest here with 5 gp in it" request routed through the GM
debug-directive fallback and came back as addItems, which drops the item
into the player's pack. Created objects should land on the floor of the
current room by default; only an explicit personal-possession phrasing
("give me...", "to my inventory") should stock the inventory.
Add a placeItems channel to the debug-directive schema (items -> current
room, containers keep their concealed contents) alongside addItems
(-> inventory), teach the translator prompt to default to the room and
reserve addItems for personal-possession phrasings, and implement
placeItems in applyDMMetaDirective.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA new checkbox under Settings > Sidebar pins the sidebar's Exits block to the bottom of the scrolling sidebar so it never scrolls off, while the other blocks scroll behind it. The setting toggles a body.sticky-exits class that CSS uses to make the exits section position: sticky (bottom-pinned, solid panel background, a soft top shadow, tucked flush past the 16px bottom padding). Off by default; persists, is reflected on the control when Settings opens, and applied at boot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A // world addition now resolves interior vs exterior rooms and stitches interior ones into the anchor's building so they appear on the room-specific interior map — and an up/down add becomes a stacked map LEVEL. - The GM directive teaches interior vs exterior: a cellar/storeroom/upstairs is INTERIOR (set interiorOf to the structure root); a backyard/street/field is EXTERIOR (stays on the world map). Going up/down is always a floor of the same building. The anchor's structure context (its root id/name) is supplied so the GM points interiorOf at the right room. - New engine linking (linkDmAdditionInterior) folds any GM-flagged interior room — and, as a safety net, the primary room of any up/down add — into the anchor's structure root's interiors, so structureLevels assigns it a level and the Maps tab shows the structure. When the anchor has no structure yet, it becomes the root (its building), so e.g. a plain tavern gains an interior map with a cellar a level below. structureRootId resolves the topmost root from any interior room. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
castSpellFromPopup no longer hides the popup on a successful cast, so a caster can cast the same spell again (e.g. round after round in combat) without reopening it. The popup is still dismissed with its close button or by clicking away. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Entering combat as a caster now pops open their active field spellbook's item
popup — which already defaults to its Loadout view during a fight — instead of
force-switching to the Character > Spellbook tab. The popup floats over
whatever tab is showing, so no navigation is forced. Still gated on
knowsSkill('spellcasting') and on carrying a field spellbook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomClicking Cast (spell popup or spellbook card) while a fight is on now submits the spell as the player's combat action aimed at the current target, so a caster needn't retype "I cast X at the Y" every round. castSpell spends the mana and applies self-effects as before, then submits a [COMBAT ACTION]: self spells cast "on myself", an offensive spell targets the current foe, and an area / multi-foe cast names the group and leaves the area math to the GM. It only fires on the player's action turn (a cast is refused, with no mana spent, while a die roll is pending), and out-of-combat casting is unchanged. Players wanting finer targeting can still type an action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a spellcasting player (a Mage — or anyone who knows the spellcasting
skill) enters combat, beginCombat now opens their spellbook loadout for them:
a new openSpellbookLoadoutForCombat switches to Character > Spellbook in the
'Known' scope, where the memorized loadout strip (their castable spells)
renders. It's gated on knowsSkill('spellcasting'), so a non-caster's view is
left undisturbed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomclampPopupHeight capped a popup's height to the window bottom, but a popup inside an overflow-hidden container (the Compendium view, which ends above the story input row) would grow past the container and have its lower half clipped — looking like it rendered behind the input box. Clamp to whichever is nearer: the window edge or the popup's clipping offset-parent's bottom, so a tall popup fits within the view and scrolls internally. Window-anchored popups (story, char-quick) are unaffected since their offset parent doesn't hide overflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The item/room/NPC/faction/region/spell detail popups sat at z-index 6-8, below the dice-bag popup (z-index 8), so a detail popup opened while the bag was open could be hidden behind it. Raise the shared detail-popup block and the fixed faction/region/spell popups to z-index 9 so they always stack above the dice bag while staying below the modals/lightbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
This reverts commit 5a407407d932b788328d1727d0e1d55d34777f64.
The item/room/NPC/faction/region/spell detail popups sat at z-index 6-8, below the dice-bag popup (z-index 8), so a detail popup opened while the bag was open could be hidden behind it. Raise the shared detail-popup block and the fixed faction/region/spell popups to z-index 9 so they always stack above the dice bag while staying below the modals/lightbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Detailed Description on NPC/monster detail popups was an always-open field that could stretch a long popup. It's now a collapsible <details> (collapsed by default) via a new shared collapsiblePopupField helper, matching the Abilities field's caret/summary styling; it sits after the Description and is omitted when empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the GM authored an inventory ref with an item's display name instead of
its catalog id (e.g. { "ref": "Wolf Fang" } rather than "wolf_fang"),
makeItem couldn't resolve it and rendered "Unknown Item". makeItem now
resolves a name-as-ref to the matching catalog id (case-insensitive), and when
no such item exists, treats the stray ref as the item's name so it becomes a
real inline item (registered into the catalog) instead of a nameless
placeholder. Genuine id refs and inline items are unaffected. This fixes every
path through makeItem — spawned-being inventories, loot drops, room items.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe monster/NPC detail popup now shows a collapsible 'Abilities' field after the Description, listing the being's class abilities (name + optional description) from ent.abilities. It's a native <details> collapsed by default with a caret and an (N) count, and is omitted entirely when the being has no abilities. The abilities list carries no explicit CSS display so a closed <details> hides it correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Generating an item icon only re-rendered the popup, leaving the sidebar, character sheet, story links, and other same-named copies showing the old emoji/icon. Now the new icon is propagated to the catalog type + every live copy by name (applyItemTypeField), and a shared refreshItemVisualsEverywhere updates all current references: the sidebar inventory/room lists + Character sheet + quick popup (updateSidebar), any open item popup, the Compendium and DM item editor when active, and the story's 'You notice' links (patched in place, since past narration isn't otherwise re-rendered). Off-screen surfaces pick up the new icon on their next render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The inline generated-icon <img> was 1.2em (~15.5px in the sidebar's 13px context) while the emoji glyphs it stands in for render ~18px, so icons looked noticeably smaller. Bump .item-glyph-img to 1.4em (~18px in the sidebar) so it visually matches the surrounding emojis; the em unit keeps it proportional at every render site (character sheet, quick-popup tiles, chips, etc.). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Nano Banana Pro was rendering icons too dark, driven by the world art style prepended to the prompt. Add a later, explicit instruction that the SUBJECT must be bright, vividly lit, and saturated — overriding any dark/dim styling for the subject — so the icon reads clearly against the dark app background. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add an instruction to the item-icon generation prompt that the subject must fill the frame — drawn large enough to span nearly the full height/width with only very thin margins — so generated icons don't come back small and lost in empty space. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Beneath the portrait image in the item popup, a tiny ♻ button now floats at the bottom-right corner to repaint the portrait from the item's prompt. It appears only when the item has a portrait; a new regenerateItemPortrait handler resolves the exact popup item (like the icon generator), authors a prompt via the GM if one is missing, repaints via generateImageForItem, shares the new image with the item's type + live copies, and re-renders the open popup in place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Selecting Nano Banana as the Image AI provider now reveals a Model dropdown beneath it to choose between Nano Banana (gemini-2.5-flash-image) and Nano Banana Pro (gemini-3-pro-image-preview), mirroring the existing Higgsfield model-row pattern. The choice drives the actual Gemini model in the API call and persists as a setting. The Logs name the exact model: the provider label reflects the selection, so 'Generating a … image via …' and the API payload log line read 'Nano Banana Pro' when Pro is chosen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The item popup's icon chip painted a transparent-indicator checker over --bg-raised, so a transparent icon (e.g. from Nano Banana) read as a lighter, checkered square instead of the app's Background colour. Composite the icon chip over var(--bg) — the exact value the palette editor's Background swatch edits — so a transparent icon blends seamlessly into the app background and matches it (and tracks any per-world palette override). A hairline border keeps the chip defined. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The dice bag is anchored to the story's bottom-right, so resizing the window moves it and reflows the story column — but the max-widths measured for the messages under the bag were only recomputed on open/close, leaving them stale after a resize. Add a debounced (rAF-coalesced) resize handler that re-runs applyDiceMessageNarrowing while the bag is open, mirroring the open/close recompute; it's a no-op when the bag is closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The generated icon sits on the game UI, so its own background must not clash. The icon prompt now requires the background to be either fully transparent (alpha) or a solid dark colour matching the game background — explicitly forbidding a light/white box (which a provider that can't emit alpha, e.g. Pollinations JPEG, would otherwise bake in). The dark colour is resolved from the current --bg at generation time, so it honours any per-world palette override the player set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When an item has a generated icon (it.iconImage, from the Item popup's icon generator), show that image wherever the item's emoji glyph would otherwise appear — not just in the popup. A shared itemIconHTML(item) helper returns an <img> (sized in em so it aligns with surrounding glyphs) when the item has a generated icon, else the sanitised emoji, and every item-glyph render site routes through it: sidebar inventory + floor-items lists, the character-sheet inventory/treasure/magic/book cards, the equip-inventory strip, the story 'You notice' links, the quick-popup tiles, container contents rows, the compendium/editor item chips and thumbnails, and the item popup's image placeholder (plus its on-the-fly portrait restore path). Map badges stay emoji (SVG <text> can't embed a raster image). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Next to the Field Guide button sits a palette dropdown listing the app's core colours as labelled swatches for the active light/dark theme. Clicking a swatch opens a colour-wheel editor to the left of the dropdown (a native colour input plus a hex field) that recolours the app live as you edit. The overrides are stored on world.palette, keyed by the light/dark theme so each can be tuned independently, and persist with the game save (serializeWorld / rebuildWorldFromSnapshot). They apply to the document root only while that world is active — reapplied on world load and on a theme flip, and cleared on logout — so the customisation rides with the SAVE and never touches the login screen or the shipped built-in world. Dragging the wheel recolours live without persisting; the value is saved on commit (wheel change / hex blur) to avoid thrashing a full save per frame. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The item popup now shows Type and a new Icon field side-by-side. The Icon cell renders the item's current glyph — a real emoji, or the raw word a GM may have authored, shown verbatim so the DM sees exactly what was written — and a tiny generate button. The button paints a minimalistic, flat 32x32 web-app icon on a transparent background (world Art Style still prepended for consistency), stores it on the item (it.iconImage), and re-renders the popup so the icon replaces the glyph in place. The generated icon persists with the item; for now it is surfaced only in the popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A killed rat dropped a tooth whose GM-authored icon field was the literal word "tooth", so the sidebar Inventory rendered the word instead of an emoji. Add sanitizeItemIcon: it keeps a genuine emoji glyph but replaces any icon that's empty, contains ASCII letters/digits (a stray word/phrase like "tooth"), or is absurdly long with a type-fitting default (weapon → ⚔️, potion → 🧪, treasure → 💎, … else 📦). Applied at every layer so both new and already-saved items are clean: the Item constructor, makeItem, catalogItemShape, reItemObj (fixes items already in a save on reload), and the render sites (sidebar inventory + room lists, item popup placeholder, char-quick tiles, container contents) — so the fix shows live without a reload. Also tightened the addItem directive: "icon" MUST be a single emoji, never a word. Test: new tests/test_item_icon_sanitize.js (glyph kept, word/empty/phrase replaced, type-aware defaults, creation/restore/catalog/render wiring). Verified in Chromium: a word-icon "tooth" renders 📦, a word-icon treasure renders 💎. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The story action box now keeps a modest, in-session ring of the player's submitted inputs (deduped consecutively, capped at 50). Pressing ↑ in the box walks back through previous commands, populating the input; ↓ walks forward and, past the newest, restores whatever the player was mid-typing. The caret lands at the end of a recalled line, and recall is inert while the input is disabled (a turn is processing). Session-only; a single-line <input> so the arrows don't fight caret movement. recordCmdHistory runs on every send (including "//" and "/" commands); navigateCmdHistory drives the ↑/↓ handler bound to #cmd-input. Test: new tests/test_cmd_history.js (record order/dedupe/cap, ↑/↓ navigation, draft stash/restore, edge cases, wiring). Verified in Chromium. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Refresh the point-in-time snapshot via tools/gen-progress-report.js: 297 commits across 8 days (2026-06-30 → 2026-07-12), busiest 2026-07-09. Picks up this session's work (containers, GM handoff/targeting fixes, enemy-roll summary, loot, portable character, Music/Sounds tabs, etc.). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Diagnosis from a captured handoff payload: the system prompt WAS correct (the living rat was in "Entities present" with the fresh-note), but the message history showed the GM killing a same-named rat and then "look at the dead rat" — and it had no record the new rat spawned, because a "//" spawn goes through a separate authoring call and never touches the story GM's conversation. A note buried in the 70k-char system prompt didn't override that recent, vivid history. Two deterministic fixes, injected into the message actually SENT (not the stored transcript, so history stays clean and notes don't accumulate): - queueGmNote / pendingGmNotes: when "//" places a being (dmMetaAddBeing), queue an out-of-band note; sendToLLM injects it into the next turn so the GM is TOLD a new, living being appeared (the memory it was missing). - combatDisambiguationNote: when the player's action is an ATTACK that references a being present as BOTH a corpse and a fresh living one, inject an explicit "target the living one, start combat with it, don't say it's dead" directive. Scoped to attack intent so "look at the dead rat" is unaffected. The handoff log reports how many engine notes were injected, and the dumped payload reflects exactly what was sent. Tests: new tests/test_gm_spawn_target_note.js; updated the handoff-log, fresh-entity-note, and token-limit tests for messagesToSend. Verified end to end in Chromium: a "//"-spawned rat + "attack the rat" sends both notes while the stored history keeps only the raw input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Two changes for the "GM thinks the rat I just killed is still the only rat" report. 1) Diagnostics: the player-turn handoff log now dumps the FULL payload sent to the GM — the system prompt (with the live "Entities present" list, room items, combat state) AND the whole message history — into the existing collapsible Logs line, so exactly what the GM receives is inspectable. The system prompt is built once and reused for the request and the log so they can't drift. 2) Fix: the earlier findEntityAnywhere/"authoritative present list" change fixed engine targeting but not the GM's narration, because its memory (and the conversation history) still says the rat is dead. When a room holds a LIVING being AND a slain same-named corpse, buildSystemPrompt now appends an explicit note to that being in "Entities present": an earlier one was slain and its corpse lies here, but THIS one is a different, living individual present now — act on it, don't deny it, and your memory of the kill doesn't apply. The corpse is still never listed as present. The note only fires for the ambiguous same-name case. Tests: new tests/test_gm_fresh_entity_note.js (the note fires only with a same-named corpse; payload logging wiring); updated tests/test_gm_handoff_log.js (full-payload dump) and tests/test_turn_token_limit.js (system: systemPrompt). Verified in Chromium end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add two new bottom inner tabs — "Music" and "Sounds" — after Style in the
Art tab bar, each with a placeholder panel ("coming soon"), matching the
Review tab's pattern. switchArtInnerTab is refactored to drive all five
tabs from an ART_INNER_TABS list.
Test: extended tests/test_art_inner_tabs.js (Music/Sounds markup, order,
panels, switching); updated tests/test_art_style_subtab.js for the
refactored validation. Verified in Chromium.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomMove the Phase-1 container design/roadmap from Design/Containers.md to a styled, self-contained HTML page at Designs/containers.html (dark-fantasy theme, table of contents with anchors, code blocks, phase badges) and update the in-code reference to the new path. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Introduce containers — items that hold other items concealed from the room
until opened. Phase 1 of the design in Design/Containers.md.
Model: an Item may carry a `container` object { open, capacity (size
units), contents:[Item], lock:{ method:none|key|pick|button|sealed,
keyName, pickDC, button:{hidden,revealed,hint} } } plus an `item.size` for
capacity accounting. Contents live only in container.contents — never
room.items — so they stay hidden from the room list, the sidebar, and the
GM's "Items in room" line until revealed. Serializes via makeItem /
catalogItemShape / reItemObj (contents rebuilt as real Items, recursively).
Adjudication mirrors combat: the GM rolls any check (lockpicking via the
existing honest skillChecks) and reports the result through a new
stateChanges.containerChanges [{ container, action: open|close|
revealButton }]; the engine owns concealment, open state, and moving
contents into the pack (applyContainerChanges / lootContainedItem —
treasure routes to the trove).
UI: the item popup shows fullness, a player-facing lock line (never the
numeric pick DC or the GM's button hint), and, when open, a Contents list
with per-item Loot buttons that re-render in place. The sidebar room list
shows a 🔒/📂/📦 container glyph.
GM directives: a Containers rules section (rule 5a), the containerChanges
field spec, and container flagging in the "Items in room" line — with
guardrails against revealing unearned contents or soft-locking required
loot.
Destructible HP, hidden-button search flow, traps, and fragile contents
are Phase 2/3 (documented in Design/Containers.md).
Test: new tests/test_containers.js (model, concealment, locked-popup
hiding + DC privacy, open/reveal, loot-to-pack/trove, closed refusal,
save round-trip, directive wiring). Verified end-to-end in Chromium.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomAfter killing a "rat" and spawning a fresh "rat" in the same room, "attack the rat" engaged nothing: findEntityAnywhere returned the first name match — the dead one — so beginCombat filtered it out as non-living (no fight started) and any entityDamage/status hit the corpse. The GM also tended to re-narrate the slain rat as if no rat were present. - findEntityAnywhere now ranks same-named matches and returns the best: living-in-current-room > living-elsewhere > dead-in-current-room > dead-elsewhere. So combat start, damage, and status all resolve to the fresh living being; a lookup still falls back to a slain one when no living match exists. - GM prompt: the "Entities present" line now states it is authoritative — a being narrated as slain is gone, and a same-named being listed now is a different, freshly-present individual to act on (don't claim it isn't here or re-narrate the dead one). Test: new tests/test_entity_targeting.js (living-here preference, corpse avoidance, current-room preference, dead-only fallback, prompt wording). Verified in Chromium: with a dead + live rat present, findEntityAnywhere, beginCombat, and applyEntityDamage all hit the living rat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Following on from the boon/affliction wording: positive statuses now have two tiers. A potent or magical good — a real stat buff, a blessing, an enchantment — is announced as a green "Boon"; a minor, mundane, short- lived lift like resting or a nap is a green "Benefit". Harm stays a red "Afflicted"; a neither-good-nor-bad condition is a muted "Status". - Status objects carry a GM-assigned "grade" (boon | benefit | affliction | neutral), threaded through asStatusObj + applyPlayerStatusChanges. - statusGrade() returns the GM's grade when set, else infers one: a positive status with real stat effects → boon, a plain descriptive positive → benefit, a stat penalty → affliction, else neutral. So a bare "rested" reads as a Benefit without the GM doing anything. - The added-status notice buckets by grade; statusIsPositive() (green chips) now means boon-or-benefit. - GM directive: rule 15 and the playerStatusChanges field spec teach the grade spectrum and that a plain rest is a "benefit", not a "boon". Test: extended tests/test_status_polarity.js (grade inference, explicit GM override, notice bucketing, directive wording). Verified in Chromium: rest → green Benefit, buff → green Boon, GM-forced grades honored, poison → red Afflicted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Resting printed "◈ Afflicted: rested." in red — wrong word and wrong colour for a beneficial condition. The added-status notice now classifies each status and announces it accordingly: - harmful → "◈ Afflicted: …" in red (unchanged) - beneficial → "◈ Boon: …" in green - ambiguous → "◈ Status: …" in a muted tone Classification goes through a new statusPolarity(): a status with stat effects is decided by their net sign; an effect-less descriptive status (like "rested" or "poisoned") is decided by its label against curated positive/negative word lists, else neutral. statusIsPositive() — which drives the green buff styling on the Character-sheet and sidebar status chips — now routes through it too, so "rested" also shows a green chip. Test: new tests/test_status_polarity.js (effect-sign and label-based classification, chip helper, notice partitioning/wording/colour). Verified in Chromium: "rested" → green "Boon", "poisoned" → red "Afflicted", a stat buff → green "Boon". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The end-of-fight recap listed the fallen's dropped items as plain bold
text. Render each as a story link instead: clicking it opens that item's
popup — and, because the drop lies on the current room's floor, the popup
carries the Loot button so the player can inspect and take it right there.
- endCombat builds the loot list as <a> links wired to
showStoryItemDetail(roomId, name, true), capturing the room the fight
ended in so a scrolled-back recap still resolves.
- showStoryItemDetail gains a loot flag: it passes { loot: true } to the
item popup only when the item is still on a room floor (never for one
already in the pack).
- lootRoomItem now resolves the item on the current room's floor first,
then anywhere it lies, and closes whichever popup (sidebar or story) is
open — so looting from a recap link works even after moving on.
Tests: extended tests/test_combat_victory_recap.js (dropped items are
loot-flagged story links) and tests/test_loot_button.js; updated
tests/test_item_popup_portrait.js for the showStoryItemDetail signature.
Verified end-to-end in Chromium: a victory drop → click → lootable popup
→ Loot moves it to the pack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe Loot button's own CSS overrode its text color to gold on a gold background, so the label (an emoji + "Loot — take into inventory") was effectively invisible. Drop the override so it inherits the standard gold action button (.regions-btn: black text on gold) and shorten the label to just "Loot". The descriptive text remains as the button's tooltip. Also add a GM directive on the "pickupItem" field: before honoring a loot/pickup, judge whether the thing is small and light enough for this player to carry off given their physique (STR/CON) and current load. A large or fixed fixture — barrel, cask/casket, chest, bookshelf, table, anvil, statue, wardrobe, door — is not lootable; the GM leaves pickupItem null and narrates that it's too big/heavy to carry, suggesting the player search it or take the small items in/on it instead. Borderline cases scale with STR. Tests: extended tests/test_loot_button.js (exact "Loot" label, no emoji, standard gold button, no gold-on-gold override, and the bulk-check directive). Verified in Chromium: the button renders black-on-gold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On an enemy turn the GM rolled the enemy's dice internally and only
narrated the result in prose — the player couldn't audit the math. Add a
combat.enemyRolls field the GM populates whenever it rolls on an enemy's
behalf (initiative, to-hit, damage, a save it makes), and render those
rolls as a dim, monospaced "Enemy Rolls" accounting manifest directly
beneath the narration.
- formatEnemyRoll composes each row forgivingly: an explicit notation
("1d6+2") is used as-is (with the rolled total appended), otherwise it
builds "dN roll ±mod = total"; the target ("vs AC 13") and outcome
("hit") are appended when present, and absent fields are omitted.
- renderEnemyRollSummary prints one system message per enemy turn; the
turn loop calls it right after the narration.
- Prompt: the combat field schema and COMBAT_CONTRACT now document
enemyRolls and instruct the GM to report the enemy's rolls (only the
enemy's — the player's own come through the dice bag) so the narration
stays consistent with the printed numbers.
Styled deliberately quieter than the skill-check manifest (muted text, no
gold accent) since it's for reference, not the focus of the scene.
Test: new tests/test_enemy_roll_summary.js (formatter shapes + graceful
degradation, single dim manifest with a row per roll, turn-loop + prompt
wiring). Verified end-to-end in Chromium.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomClicking an item in the sidebar's Items block (a floor item in the
current room) opens its popup; it now carries a "Loot" button at the
bottom that picks the item up into the player's inventory (a
treasure-type item goes to the trove, mirroring the GM's pickupItem
handling), then closes the popup and refreshes the sidebar so the item
moves from the room list into Inventory.
The button is gated to that entry point only: buildItemDetailHTML takes
an opts flag and appends the Loot action when { loot: true }, and only
showSidebarRoomItemDetail (the Items-block click) passes it — so item
popups opened from the Inventory block, the story, maps, the editor, or
the compendium never show it.
Tests: new tests/test_loot_button.js (button gating + room→pack move +
treasure→trove + no-op when already taken); updated
tests/test_item_popup_portrait.js for the showItemPopup/buildItemDetailHTML
signature. Verified end-to-end in Chromium.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe four vital bars were staggered: each row used justify-content: space-between with a fixed-width bar and a variable-width value, so a wide value like "100/100" pushed the HP bar left of the others. Rework the sidebar stat-row layout to three columns: a fixed-width label, a bar that flexes to fill, and a fixed-width right-aligned value. Every bar now shares the same left and right edge regardless of value width. Scoped to .stat-row (sidebar only); the Character-sheet vitals are unaffected. Verified in Chromium: with values 100/100, 50/50, 0/100, 4/22 all four bars share identical left/right/width. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
"// add a snake here" was dumping the snake into the player's inventory as an item: "snake" wasn't in the being-noun list, so the being-add path didn't claim it and it fell through to the generic GM item fallback. Taxonomy fix: - Add a DM_ANIMAL_NOUNS set (snake, chicken, dog, cat, bird, frog, lizard, bear, wolf, horse, deer, fish, …) folded into the being-noun list. matchDmBeingAdd now types wildlife "animal" (Fauna), enemy nouns "monster", people "npc". Wolf moves from the enemy set to animal. - Add a DM_PLANT_NOUNS set + matchDmPlantAdd, and route "// add/grow a <tree|herb|mushroom…>" to a new Flora path: requestDMFloraAddition asks the GM for type "plant" item specs and placeFloraInRoom drops them into the CURRENT room (forced to type "plant", registered in the catalog) so they surface in Flora — never the player inventory. Ordering is world-add → being-add → plant-add, and being nouns and plant nouns don't overlap, so a "rose golem" stays a monster. - Strengthen the being-addition directive with the explicit taxonomy: ordinary non-magical creatures are "animal"; dangerous/magical ones are "monster"; natural plants are type "plant" ITEMS (never beings); a plant-/beast-like monster carries a "plant"/"animal" class as a subtype. - Add "// help" entries for adding animals and growing plants. Tests: new tests/test_dm_animal_plant_add.js (routing + placement). Verified end-to-end in Chromium: "// add a snake here" yields an animal being in the room (nothing in inventory); "// add an oak tree" yields a type "plant" Flora item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a DM asked the GM to add a being, the GM sometimes returned a bare
{ "ref": "Cellar Rat" } — a ref to an id that doesn't exist — instead of
authoring a full being. placeBeingsInRoom rejected it ("a being was
missing a name"), producing an empty/no-being result.
Two fixes:
- Directive: make explicit that { "ref": "<id>" } is ONLY for reusing a
being whose exact id appears in the Existing NPCs/monsters list, that a
DM is almost always adding something NEW (so author a full being), and
that "name" is required — never a bare ref, never a ref to an unlisted
id/name.
- Robustness: placeBeingsInRoom now tolerates a { ref } spec. A ref that
resolves in ENTITY_CATALOG spawns from that template (name filled from
the catalog); a ref that does NOT resolve is treated as the being's
name and authored fresh — so a stray/hallucinated ref yields a real,
named creature instead of a nameless one. The registered catalog
template is keyed by the resolved name. A spec with neither a name nor
a usable ref is still rejected.
Test: new tests/test_being_ref_tolerance.js covers full-spec, resolvable
ref, unresolved ref (the reported case), the no-name/no-ref rejection,
and the directive wording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe empty-slot "Generate" button on an NPC/Monster editor card already knew how to author a portrait prompt (via the GM) before painting when the being had none — but only on the Art tab (the gate was artTabVisible() && apiKey). On the Monsters and NPCs tabs it instead stopped with "Add a prompt above first." Broaden the gate to run whenever an API key is present, so one click on the Monsters (or NPCs) tab both writes the prompt and generates the image, matching the behavior the Items tab's Generate button already had. With no key and no prompt it now hints to set the key or add a prompt, rather than assuming the Art tab. Test: new tests/test_entity_autoprompt.js exercises generateNpcPortrait on the Monsters tab (auto-writes then paints; skips the GM call when a prompt exists; does nothing without a key). Verified in Chromium. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Export Character now emits a self-contained bundle: the character plus the world definitions it references (class, spells, skills, factions, alignment), including the spell/skill defs that its books and spellbooks teach. This makes a character portable into any other world. A new "Import Character…" entry in the Story toolbar's transfer menu brings such a bundle into the world being played. It analyzes the bundle against the live world, merges definitions the world lacks automatically, and — for each definition that CLASHES with one the world already defines — asks the importing player, per conflict, whether to keep this world's version or adopt the character's, via a modal dialog. The character then replaces the active one, dropped at the destination world's starting room, with any still-unresolvable spell/skill reference dropped so no phantom ids remain. The login Import control now recognizes a character file and points the player at the in-game importer instead of failing with a world error. Tests: new functional tests/test_character_bundle.js (bundle contents, analysis additions/collisions, per-choice merge, adoption); updated tests/test_export_menu.js for the new menu item. Verified end-to-end in Chromium (modal interaction, merge, adoption, cancel). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Story tab's export download button now opens a small dropdown menu with
two options instead of exporting directly:
- "Export Game" — the existing full-snapshot export (character + world +
story), unchanged behavior.
- "Export Character" — new; writes ONLY the player character to a JSON file
as a tagged { format: "thelostrealms.character", character } envelope, with
a distinct …-character-…json filename.
The dropdown mirrors the adjacent sidebar-blocks menu (toggle on the button,
close on outside-click / Escape). exportGame is unchanged; exportCharacter and
characterSaveFilename are new.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomTwo fixes to the dice-bag message narrowing: - New messages arriving while the bag is open were not narrowed. The cause: applyDiceMessageNarrowing inferred "pinned" from the live scrollTop, but in addMsg it ran right after a smooth scrollNarrativeToBottom() that hadn't moved scrollTop yet, so the just-arrived (still below-the-fold) message was measured as not under the bag. addMsg now captures the at-bottom state BEFORE appending and passes it as a hint so the narrowing pins + measures the new messages against the bag. Fixes it for single messages and bursts (combat log lines). - Ease the extra clearance from 150px back to 75px (DICE_MSG_GAP 162 -> 87). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Some environments (notably opening the app from a file:// URL) persist
localStorage across reloads but NOT IndexedDB. Since the API key is stored
encrypted in localStorage while the CryptoKey that decrypts it lives in
IndexedDB, the key survived but the CryptoKey did not — so every reload the
stored key became unreadable ("cannot read your keys") and re-entering just
looped, because the in-session durability check can't tell the key won't
survive the NEXT reload.
Fix: a stored ciphertext that won't decrypt on load is direct proof this
origin can't keep the CryptoKey across a reload, so switch the origin to
plaintext storage (localStorage persists that reliably). encryptSecret honors
a per-origin `tlr_secret_no_encrypt` flag; the load-time self-heal sets it
before clearing the unreadable key. After re-entering once, the key persists.
The flag is per-origin, so a proper https / http://localhost deployment (where
IndexedDB persists) keeps full encryption at rest automatically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomBumps DICE_MSG_GAP so the narrowed story messages pull further clear of the dice bag (bag width + a small gap + 150px extra), reducing overlap per feedback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Replaces the shape-outside text-flow spacer with a simpler, more reliable approach (per feedback): while the dice bag is open, the story messages it covers have their max-width trimmed by the bag's width (measured), so their text can't run beneath the bag; closing removes the inline max-width and the marker class entirely. Because narrowing a message wraps its text and makes it taller, the messages beneath it shift up into the bag's band — so applyDiceMessageNarrowing iterates, narrowing each newly-covered message (re-pinning instantly to measure the settled layout) until nothing new falls under the bag. New messages added while open (addMsg) and the windowed innerHTML rebuild re-apply it; combat opens the bag through the same openDicePopup path. Removes the old #dice-flow-spacer float, its shape-outside CSS, the ensureDiceSpacer/syncDiceSpacerHeight helpers, and the trim guard for it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A tall entity popup (e.g. a rat monster with a long description) anchored lower than the default 14px top — the sidebar popup sits at top:62px, and the faction/region/spell popups anchor to a host element — could run off the bottom of the window, because the shared max-height (calc(100% - 28px)) assumes a 14px top. Add clampPopupHeight(popup): using the popup's actual viewport top, cap its height to (window height − top − margin) so its bottom always keeps a comfortable margin from the window edge; the body scrolls within whatever height is left. It's wired into the generic showEntityPopup and the dynamic-top left/faction/region positioners, and a resize listener re-clamps every open popup so the margin holds when the window shrinks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Replaces the earlier padding approach (which pushed the latest lines above the bag) with the user-suggested spacer: a hidden in-flow float (#dice-flow-spacer) carrying the dice popup's footprint. While the bag is open it is revealed and the story text flows AROUND it — shape-outside carves only the bottom-right notch — so to the reader the bag simply sits in a gap in the text and never overlaps the combat messages. The spacer is the very first child of #narrative, ahead of the top sentinel, so the message virtualization (trim/reveal, keyed off that sentinel) never disturbs it; the two innerHTML rebuilds re-create it and trimNarrativeWindow skips it. Its height is synced (on open and on each addMsg) to place the notch over the bag, accounting for the bag's fixed offset above the story's foot; its width/height come from the measured popup. Combat surfaces the bag through the same openDicePopup path, so the flow applies automatically during a roll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The dice-bag popup floats over the story's bottom-right, exactly where the latest combat messages land — so during a roll it hid the roll prompt and results. Opening the bag now reserves space at the foot of #narrative equal to how far the popup rises above the story's bottom edge (measured live and set as a --dice-reserve custom property with a .dice-open class), and re-pins the scroll so the newest lines sit ABOVE the bag rather than behind it. Closing the bag releases the reserve. Combat surfaces the bag through the same openDicePopup path, so this applies automatically when a roll is requested. The re-pin uses the existing instant bottom-chaser (scrollNarrativeToBottomSoon) and yields if the reader has deliberately scrolled up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Root cause: an API key encrypted at rest in one context (e.g. secure http://localhost, where Web Crypto works) could not be decrypted after a reload in a context where Web Crypto was unavailable or the IndexedDB CryptoKey didn't persist. decryptSecret then returned '', and the empty header hit the API with "x-api-key header is required" — which is why the user had to start a new game (re-entering the key) to recover. Four-part fix: - Durability gate: secretCryptoKey now confirms the generated CryptoKey round-trips from IndexedDB before treating encryption as safe; when it can't (private mode, blocked IDB), encryptSecret keeps the value as plaintext rather than writing ciphertext that won't decrypt next load. - Self-heal on load: loadAndMigrateSecret verifies a stored ciphertext actually decrypts in this context; if not, it clears the unrecoverable value and reports the key as missing, so the app re-prompts instead of sending an empty header later. - Continue adopts a re-entered key: the resume branch of startGame now picks up whatever is typed in the login key field when the saved key was missing/unreadable, so recovery works on Continue, not only a new game. - Auto-resume re-prompt: if a session auto-resumes but the saved key was unrecoverable, the login overlay is re-shown with a clear message to re-enter the key, rather than stranding the player mid-game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a fight ends in victory, the story now recaps the spoils: the total XP earned across the whole fight and a list of every item the slain foes dropped (aggregated by name with quantities). Previously combat XP was applied silently (only level-ups surfaced), and dropped loot was only noted per-kill. The fight tallies XP across all awardXp calls while combat is active and aggregates dropped inventory as bodies fall; endCombat snapshots both before teardown and prints them after the victory line (only on victory, and only when there is something to report). The GM combat contract now says to award a kill's XP in the same response that ends the fight so the tally captures it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Living beings are now restricted to three types — npc (a person the player can talk to), monster (a creature, usually fought), and animal (wildlife with agency). The GM chooses one at creation. The two legacy types fold into the new set without losing information: "enemy" becomes a monster carrying an "enemy" CLASS (always hostile to the player), and "merchant" becomes an npc carrying a "merchant" class. These labels now live in the free-form classes list, never as types. entityIsEnemy() reads the class and replaces the old type==='enemy' hostility tests; the GM's combat contract treats a being with the "enemy" class as always hostile. Migration runs everywhere entities are built or restored — makeEntity, applyNpcSpecToEntity, and the save-restore path (reEntityObj) — so existing saves and imported worlds convert transparently. New "race" field on NPCs and Monsters (e.g. Human, Undead, Arachnid, Plant; animals carry none). The GM authors it at creation (added to the being-add and world-gen directives), a DM edits it on the entity card, and it is fed to the GM in the room dossier and shown in the detail popup. Plant-like creatures are a monster with a "plant" class; plants proper remain inanimate items. Categorization follows type: npc→People, monster→Monsters, animal→Fauna in the Compendium and auto-discovery; the NPCs editor lists people while the Monsters editor is the full bestiary (monsters + animals). Default WORLD_DATA migrated: the four creatures → monster + race + enemy class; the two merchants → npc + merchant class; every npc/monster gains a race. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The login-screen Sound button previously toggled a (now-removed) background video's mute, so it was a no-op. It now plays/stops the looping login ambience directly on the Howler backgroundMusic object (window.loginBackgroundMusic): stop() to silence it, play() to resume — the click is a user gesture, so play() is honored even if the browser blocked the initial autoplay. Direction is taken from the track's actual playback state (falling back to the button's class), and the icon/label reflect it. The button starts in the ON state to match the login autoplay, and no longer says "video". No-ops safely when Howler is unavailable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On Editor › Magic › Spells, each spell card now has a collapsible "Usage" section below the Portrait section: a textarea for DM/GM guidance on what casting the spell should actually do — mechanical, narrative/investigative, or permanently world-changing effects, in and out of combat (e.g. Detect Magic reveals a hidden key in a drawer; Firebolt leaves a lasting scorch on a wall). A ✨ button asks the GM to author the usage from the spell's name + description and world magic rules. The usage persists on world.spells[id] (normalizeSpellRecord defaults it, normalizeSpells deep-copies it across reload) and is surfaced to the GM in buildSystemPrompt for the spells the player currently has memorized, so the GM decides and enforces a cast's causal effects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A new OOC "Security" section (separate from the in-character player/DM/GM material) explains how the user's real API keys are handled: kept only in this browser and sent only to the owning provider; encrypted at rest with AES-GCM under a non-extractable IndexedDB key and decrypted only per call; migrated automatically from any legacy plaintext; isolated from other sites by the same-origin policy. It states plainly what this does NOT protect against (in-origin XSS/extensions, full device access), gives good habits (remove/rotate keys, set spend limits, scope keys, prefer the HTTPS host), and notes the planned server-side key proxy. Wired into the TOC and search. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
API keys (Anthropic + Pollinations/Nano Banana/Higgsfield/ElevenLabs) are now encrypted before they touch storage and decrypted only for the moment of each API call. The in-memory key globals hold CIPHERTEXT, not plaintext; the plaintext exists only transiently on the local scope of one request. - Encryption: AES-GCM under a non-extractable CryptoKey persisted in IndexedDB (key material cannot be exported). Values are marked "enc:v1:"; encrypt/decrypt fall back to passthrough if Web Crypto is unavailable so keys still work. - On-demand accessors (getApiKey/getPollinationsKey/…): every Anthropic request now sends await getApiKey(), and each image/sound provider decrypts its key at the point of use. Presence guards read the ciphertext globals unchanged. - Migration: every load path (boot, resume, editor draft) rewrites any legacy plaintext key to ciphertext in place. saveApiKeys and the login Start flow encrypt entered keys; the dialog/login prefills decrypt for the masked inputs so the user still sees/edits the real key. Note: this hardens keys at rest against passive disclosure. A browser app that calls provider APIs directly must reconstruct the plaintext in memory per request, so this is not a substitute for a server-side proxy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Opening a field spellbook popup mid-fight now shows the Loadout view first (your memorized, castable spells as icons) instead of Details — that's what matters in combat. Out of combat it still opens on Details. The toggle button group reflects whichever view is active. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The spell detail popup now has a bottom action row: a "Cast · N MP" button gated by the same loadout/MP rules as the Spellbook tab (enabled only for a memorized, affordable spell; disabled with a reason otherwise), and the Compendium link rendered as a compact 📖 icon instead of a full-width text button to save popup width. Casting from the popup routes through castSpell and closes the popup on success so the story result is visible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The main turn GM call capped output at 1000 tokens. A combat turn's structured JSON (narration plus the combat block, entity/dice/quest/ compendium updates) can exceed that, so the response was cut off mid-JSON and failed to parse. Raised the ceiling to 4000 (a cap, not a target — ordinary turns end well under it). Also detect stop_reason === "max_tokens" and report a clear "the reply was too long and got cut off" message plus a Logs line, instead of a mysterious parse failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Room popups showed their banner art but it wasn't clickable. The banner image (player World-map/location popups and the DM editor room popup) now opens the shared centered lightbox (openItemImageModal) on click, like item and NPC portraits elsewhere, with a "Click to enlarge" hint and a pointer cursor. The missing-banner placeholder stays inert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Monster entry names in Compendium › Monsters were plain text while People and Items were clickable. Added 'monsters' to the clickable-name list so each monster card's name gets .comp-name-link + data-comp-name, and extended compendiumDetailBodyFor to resolve monsters as entities — to the live being's NPC/monster detail popup when placed, else the discovered compendium record — so the delegated click handler opens it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a being's detail popup has no portrait, its placeholder now shows a "Generate" button. Clicking it paints the portrait on the fly: if the being has no portrait prompt yet, it first asks the GM to author one, then paints, then swaps the placeholder for the portrait in place — mirroring the item popup / story-banner on-the-fly flow. With neither a prompt nor an API key it guides the player to add a prompt instead. A being that already has a portrait is unchanged (image, no button). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The block can list non-person occupants (monsters, creatures, animals),
so "Occupants" is more accurate. Only the visible header label changed;
the internal section key ("people") and its wiring are untouched. Updated
the code comments that referenced "the People box" for consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA field spellbook's detail popup now has a Details / Loadout button group at the bottom. "Details" is the usual view beneath the portrait; "Loadout" shows the book's memorized spells as icons only (image thumb, else the spell's emoji). Clicking a loadout icon opens that spell's detail in a new left-side popup, floated just to the left of the spellbook popup (like the faction/region detail popups). An empty loadout shows a hint pointing to the Spellbook tab. Non-field items are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The DM Editor › Art › Style subtab now shows a "World Tone" section above Presets, displaying the current world tone as a read-only line item (title-cased). renderArtStyleTab fills it from world.tone, so it reflects the tone the presets below are drawn from. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The new-world directive already bound everything to the World Rules, but did not state the ordering or give an exclusion example. Now, when rules are provided, it instructs the GM to lock them in FIRST — before writing the prologue, then the rest of the world — and to create none of anything the rules exclude (e.g. if magic is absent/forbidden: no magic-type items, spells, enchantments, artifacts, powers, or beings/events that rely on it). A supplied prologue is also told to stay consistent with the rules. The no-rules branch now authors the rules paragraph first, then the prologue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When Higgsfield is the selected Image AI provider, Settings now shows a "Model" dropdown (hidden for other providers). It defaults to "Higgsfield Soul" and the image API call targets the selected model's endpoint and quality tier. Higgsfield's platform v1 text-to-image API exposes the Soul model (POST /v1/text2image/soul, quality 720p/1080p), so the model registry ships Soul (1080p, default) plus a 720p/faster variant; adding a future Higgsfield text2image model is one registry entry. The setting persists (higgsfieldModel, defaulting to and falling back to Soul), the row is populated + toggled on provider change and on settings open, and higgsfieldGenerate now sends the model's quality and posts to the model endpoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Every object card with an image Prompt section now shows a left-aligned "Override World Art Style" checkbox sharing the generate row. When checked, that object's image is generated from its prompt ALONE — the standing world art style is not prepended — allowing an ad-hoc look for an entity, item, or room that needs to differ from the world's style. Centralized via a new opts.ignoreWorldArtStyle on paintImageFromPrompt and a shared artStyleOverrideCheckbox() helper. Wired across the DM Editor cards (items, spells, NPCs/monsters, factions, races, regions, encounters, and per-time room banners) and the Compendium cards (generic + places), plus the Art-tab batch generator and the story-panel banner button. Each object stores an ignoreArtStyle flag (rooms store it per time-of-day) that persists with the save; makeItem threads an authored flag through item construction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The New World screen now has a tiny "✨ Generate" button beside the World Rules and Prologue fields. Rules generation (new requestWorldRules / suggestWorldRules) asks the GM to write the rules of reality from the world name, theme & premise, and tone. The Prologue button reuses createPrologue, which already frames the draft from name/theme/tone and — when World Rules are set — passes them to the GM so the prologue stays consistent with them. createPrologue now disables both its trigger buttons while it runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking Generate on an item portrait with no prompt only auto-authored a prompt first when the Art tab was visible; on the Items tab it just told the user to add a prompt. Drop the Art-tab restriction so the "write a prompt, then paint" convenience applies on any item editor subtab (Items / Flora / Fauna / Magic / Spellbook / Art) whenever an API key is set. With no key it still falls back to asking for a manual prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The room name shown at the top of a room in the Story panel is now a hyperlink that jumps to that place's Compendium (Places) entry, scrolling to and flashing it. The room is auto-discovered as a place on entry, so the target exists by the time the link is clickable. roomTitleLinkHTML builds a data-story-place anchor (used by both describeRoom and the time-of-day change message); the existing delegated #narrative click handler routes data-story-place to goToPlace, so the link keeps working after the story is replayed from the saved message log. goToPlace now switches to the Compendium main tab first, since it's reachable from the Story panel and not only from within the Compendium. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The login API-key note told users to enter their Higgsfield credential as KEY_ID:KEY_SECRET, but only the key ID is required. Updated the placeholder and note to ask for just the key ID. The auth code still tolerates an optional KEY_ID:KEY_SECRET form (split on the first colon) for accounts that issue a secret, so nothing breaks for either input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Generating a banner from the story placeholder swapped only the live DOM; the story panel is replayed from the stored message log on refresh, so the placeholder markup reappeared even though the room's bannerImages data was saved. generateStoryBanner now rewrites the stored message to the real image markup before saving (updateStoryRoomBannerMarkup), so the generated art survives a refresh and shows in the transcript/story-book exports. Also heal games already saved by the prior build: migrateBannerMarkup now replaces a placeholder banner with the room's current art at restore time (keeping the placeholder + Generate button when the room still has no art). Factored the banner image markup into a shared bannerImageMarkup helper so the live render, the persisted message, and the restore-time heal stay byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each row in the login screen's saved-games dropdown now has an "export to file" button (download icon) alongside Edit and Delete. It writes that save's stored snapshot JSON to a local file via the same downloadJsonFile helper used by the in-game Export Game / Export World buttons, named "<character>-<world>-save-<date>.json" from the save's own library metadata. The file is the portable snapshot shape, so the login "Import Game" button reads it straight back and continues the game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The login screen's saved-games menu labeled each entry "Character — World". Record the player's class in the saved-games library index and render it as "Character the Class — World" when present, falling back to the classless label for older saves that predate the field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When an AI image is generated, add a collapsible "ai" Logs line showing the
exact API request payload as JSON — including the prompt actually sent (after
the world art style is prepended) — so image requests can be inspected.
- logImageApiPayload(provider, payload) logs the payload via gameLog with
{ collapsible: true }; long strings (base64 init-images / data URIs) are
truncated so the prompt stays readable, and it never throws.
- Each provider logs its real payload right before the request: Pollination
its GET url + prompt, Nano Banana its POST body, Higgsfield its
{ params: { prompt, width_and_height } } body.
Adds tests/test_image_payload_log.js; updated test_ai_log.js (its lifecycle
checks now filter to the start/success lines) and test_higgsfield_request.js
(body is now a named var). Verified across providers in a real browser
(collapsible line, prompt included, base64 truncation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomA third inner subtab on Art (beside Missing/Review) for viewing, editing, and testing the world's art style. - Style Prompt: a textarea defaulting to the world's current art style; editing it changes world.artStyle live (persisted), which withWorldArtStyle prepends to every image prompt at generation. - Presets: a dropdown of the presets available for the world's Tone, with a read-only textarea showing the selected preset's full text to copy into the Style Prompt. Reuses ART_STYLE_PRESETS. - Image Prompt: a test subject textarea, a left-aligned "Generate" button (asks the GM for a random subject prompt fitting the world's tone/theme), and a right-aligned gold "Test" button that generates a test image from the Style Prompt + Image Prompt via the current Image AI provider and shows it below. Test runs the real pipeline (subject → style prepended), so it reflects exactly what play produces. switchArtInnerTab gains the 'style' case and renders the tab on open. Adds tests/test_art_style_subtab.js (20 checks); verified end to end in a real browser with mocked GM + image provider. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Beneath the Art Style box in the World Builder, add a "Presets" dropdown whose
options depend on the selected Tone. Choosing a preset copies its text into the
Art Style box, overwriting whatever was there.
- ART_STYLE_PRESETS maps a Tone (the Tone <select> value) to a list of
{ label, text } presets — extend by adding tones/entries. Ships with one
Dark Fantasy preset (the moody oil-painting style).
- populateArtStylePresets rebuilds the dropdown from the current Tone; wired to
the Tone select's onchange, and called on editor open and form-restore. A
tone with no presets shows a disabled "No presets for this tone yet".
- applyArtStylePreset overwrites the Art Style input with the chosen preset's
text, then resets the dropdown to its action label so it re-picks cleanly.
Adds tests/test_artstyle_presets.js; verified end to end in a real browser
(populate-by-tone, overwrite-on-apply, disabled-when-none).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomFollow-up to the render-time Art Style prepend: make it the SINGLE source of styling so a world author sets the look once and every generated image follows, with no re-authoring of prompts (saves credits and re-dos). - withWorldArtStyle now always applies a style at generation: the world's own Art Style when set, else a game DEFAULT_ART_STYLE (the dark-oil-painting fantasy look) so style-less worlds still render coherently. - Stripped the art-style weaving from the GM authoring directives so prompts are pure SUBJECT descriptions: removed the dynamic world.artStyle injections (world-expansion, DM room/being adds, region-stub banner, new-world gen, region banner suggest) and dropped the world.artStyle append from the region/faction/race image-prompt builders. - Neutralized the hardcoded "oil-painting style consistent with a dark medieval fantasy world" phrase across the per-object prompt directives (item, spell, encounter, race, entity/character portrait, location banner, auto-prompt defaults) — they now describe subject/composition only. - buildBannerPrompt (the story-panel fallback) is now style-neutral too. Built-in WORLD_DATA seed prompts keep their baked look (the default world's art direction); only newly authored/regenerated prompts are style-free. Updated tests/test_api_artstyle_prepend.js, test_banner_prompt_artstyle.js, and test_artstyle.js to the new behavior; suite green bar the 7 pre-existing failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Apply the world's Art Style by combining it into the prompt at the moment the image API call is made, rather than relying on it being baked into the stored prompt. Stored prompts (GM-authored or hand-written) are left untouched. withWorldArtStyle() prepends world.artStyle to a prompt; paintImageFromPrompt — the single entry point for every text-to-image generation (portraits, items, banners, spells) — runs it after the empty-prompt guard so a blank prompt never becomes a style-only request. It's idempotent (a prompt already led by the style, e.g. the code-composed banner fallback, isn't doubled), normalizes trailing punctuation, and is a no-op when the world sets no art style. Gallery image-to-image variations bypass paintImageFromPrompt and are unaffected. Adds tests/test_api_artstyle_prepend.js; verified prepend/idempotency/no-op and the empty-prompt ordering in a real browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
World.buildBannerPrompt is the code-composed banner prompt used when a room has no GM-authored bannerPrompt (e.g. the story-panel Generate button on a prompt-less room). It hardcoded a "moody oil painting / muted earthy palette" style and ignored world.artStyle, so on-the-fly banners never matched a custom world art style. Now, when world.artStyle is set, it LEADS the prompt with that style (so its medium and palette govern the render) and keeps only style-neutral scaffolding after — the scene, a world-tone reference, and the frame — dropping the built-in oil-painting/palette clause that would fight a custom style. With no art style set, the built-in default is unchanged. GM-authored prompts are unaffected (they weave the style in at authoring time). Adds tests/test_banner_prompt_artstyle.js; verified both branches in a real browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The looping login-screen ambience (Howler, Audio/torch.mp3) kept playing into the game. Now it stops when the player logs in and resumes when they log out. - The Howler boot script exposes the sound as window.loginBackgroundMusic and no longer autoplays: it starts the loop only when the login overlay is showing, so a session that auto-resumed on boot never hears it. - stopLoginMusic() / playLoginMusic() control it, guarding for Howler being absent (blocked/offline) and against double-stacking a second play. - stopLoginMusic() is folded into pauseLoginVideo() — the single hook every enter-game site already calls — so login stops the music exactly where it pauses the login video. logout() and logoutWorldEditor() call playLoginMusic() to resume it, mirroring their existing playLoginVideo(). Adds tests/test_login_music.js; verified the full lifecycle in a real browser with a mocked Howler (plays on login, stops on enter, resumes on logout, no double-play, and silent for an auto-resumed session). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Generating an image via Higgsfield failed with "Field required: body.params"
because the request didn't match the platform's Soul (text2image) schema.
Corrected against the confirmed platform.higgsfield.ai/v1 contract:
- Body: wrap all generation fields in a "params" object — { params: {
prompt, width_and_height } }. A bare body is rejected.
- Size: send the width_and_height pixel-size ENUM (2048x1152 wide,
1536x2048 portrait, 1536x1536 square) instead of free width/height ints
and batch_size, which the endpoint ignores/rejects.
- Auth: authenticate with the two headers hf-api-key + hf-secret, split from
a KEY_ID:KEY_SECRET credential, instead of an Authorization bearer token.
The API Keys dialog now documents the id:secret format.
- Polling: poll GET /v1/job-sets/{id} (not /v1/jobs/{id}), read the
UPPERCASE status (COMPLETED/FAILED/CANCELED/NSFW), and extract the image
from images[0].url or jobs[].results.raw|min.url.
Adds tests/test_higgsfield_request.js; verified the full submit+poll shape in
a real browser with a mocked provider (params wrapper, width_and_height enum,
split auth headers, job-sets poll, image extraction).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomReported: generating a banner from the story-panel placeholder used
Pollination even though Higgsfield was selected in Settings › Image AI.
Root cause is not the banner code — verified that generateStoryBanner routes
through the shared paintImageFromPrompt → resolveImageProvider path and does
use the selected provider when its key is present. resolveImageProvider only
falls back to keyless Pollination when the selected provider has no key
loaded, and that substitution was recorded solely as a Logs line, so the
switch looked like a bug.
Make it visible: after a story-banner generation, if the provider actually
used differs from the one selected, print an in-panel note naming both and
pointing to the API Keys dialog ("Painted via Pollination — no Higgsfield key
is set. Add one…"). No note when the selected provider was used or when
Pollination itself is the choice.
Extends tests/test_story_banner_gen.js; verified across all three cases in a
real browser (fallback → notice; key present → Higgsfield, no notice;
Pollination selected → no notice).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomTyping "look", "look around", "examine the room", "describe my surroundings",
"where am I", and similar room-survey phrasings now re-surveys the current
room locally — re-showing its banner image, description, and who/what is
present — instead of spending a GM turn. Because describeRoom always
re-renders the banner (the image when one exists, else the placeholder with
its on-the-fly Generate button), the player can call the scene's art back up
any time by looking.
isLookAroundCommand matches only when the target is the room/surroundings
itself (or a bare look verb), so examining a SPECIFIC thing ("look at the
goblin", "look under the table") and "search the room" (a secret-exit action)
stay GM turns. The interception sits with the other local commands (after the
player echo, before the GM dispatch) and is out-of-combat only.
Adds tests/test_look_command.js (positives + negatives + wiring); verified end
to end in a real browser (a "look" re-renders the banner with no GM call).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomWhen a room shows the "Artwork Not Yet Generated" placeholder in the story panel, it now carries a ✨ Generate button that paints the scene's banner on the fly and swaps itself for the real image — no trip to the editor. generateStoryBanner resolves the room's banner prompt for the current time of day; if the room has none, it composes one from the room's description via World.buildBannerPrompt (and persists it), then adds the time-of-day lighting clause. It paints a WIDE banner through the player's chosen image provider (falls back to the keyless one, so it works for any player), stores the image in the current time-of-day slot, keeps the Places compendium thumbnail in sync, saves, and replaces the placeholder in place with a clickable (click-to-widen) banner carrying the room id + time so the existing width toggle resolves live sources. Failures report inline and leave the placeholder ready to retry. Adds tests/test_story_banner_gen.js; verified end to end in a real browser (compose-prompt-when-missing, wide generation, in-place swap, and the failure path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A "//" -built room could show a spurious "Exit 0" badge. Root cause: the GM sometimes returns a room's `exits` as a JSON ARRAY (or with stray numeric keys) instead of an object keyed by direction. normalizeExits ran Object.entries over it, turning array indices "0","1"… into phantom exit directions, and the DM room-stitch (ensureDmAdditionStitched) could write the return exit as a named property onto that array, leaving the index behind. - normalizeExits now guards the choke point every exit flows through: for an array input it recovers each element's own dir/direction field when present and drops the rest; in all cases it skips empty or purely-numeric keys, which are never real directions. Non-object input yields no exits. - ensureDmAdditionStitched coerces the anchor patch and the primary room's exits to plain (non-array) objects before writing the connection, so the chunk stays clean at the source. Adds tests/test_exit_normalize.js, which reproduces the wine-cellar case (a new room whose exits arrive as an array) and confirms the merged room has exactly its real reverse exit and no "Exit 0". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Extends the "//" build-as-you-walk directives (which already author rooms)
to author BEINGS. A directionless build command naming a creature/NPC kind —
"// add a goblin warlord with a rusted axe", "// spawn two skeleton guards",
"// add an npc herbalist" — hands the DM's description to the GM, which
authors the being(s) as structured data and drops them straight into the
DM's current room.
- matchDmBeingAdd recognizes a build verb + a being-kind noun (generic kinds
plus common creature types) and infers a default type (enemy/npc/merchant).
handleDMMetaCommand checks world-add (which needs a direction) first, so a
direction still means "build a place"; no direction means "add a being
here". Player-state ops (status/spell/skill) are excluded.
- requestDMEntityAddition is the sibling of requestDMRoomAddition: a
standalone GM handoff (out of conversation history) that returns a JSON
chunk of 1-3 entities, each directed to include full stats, aggression,
armor, CLASS ABILITIES ([{name,description}]), INVENTORY (catalog refs
and/or inline items, with new item templates registered first), kind
classes, lore, a portrait prompt, and — for NPCs — a profile. Reuses
existing entities/items via {ref} through buildWorldDigest context.
- placeBeingsInRoom spawns them live into the current room, always creating
new instances (so two same-named beings are distinct) and registering a
catalog template, reusing the proven applyNpcSpecToEntity/makeEntity path.
Documents the command in the DM meta help and the Field Guide, and adds
tests/test_dm_add_being.js (25 checks). Verified end to end in a real
browser (the // command → GM chunk → live spawn with abilities + inventory).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomBuilds the known-vs-carried foundation from the Spell System design doc, end to end: - Spellcasting is now a Skill (the root cast gate, Decision L). Added it to SKILL_CATALOG (governing INT), seeded it on caster classes via a new SPELLCASTER_CLASSES set, and made normalizeSkills guarantee it exists in any world. castSpell now requires it — a class without it cannot cast. - Field spellbooks carry a loadout. A type:"spellbook" item with the "field" class gains its own `level` and a `carried` list of memorized spell ids. slots(level) = 3 + (level-1); a spell memorizes only when book.level >= spell.level and a slot is free. Helpers: isFieldSpellbook, bookLevel, bookSlots, bookCarried, activeFieldSpellbook, spellCarried, canMemorize, memorizeSpell, clearCarriedSlot (all combat-locked), backfillCarried. - castSpell gates on the carried set: you cast only what's memorized into your active field book, not your whole repertoire (Decision C). The starter Mage book carries light/detect_magic/firebolt; minor_heal is known but must be swapped in — the intended loadout tension. - makeItem preserves level/carried on spellbook items (serialize + restore); the catalog Spellbook gains level:1 + carried. Restore backfills the skill for existing caster saves and the carried loadout for older field books. - Spellbook tab: a Loadout strip (active book, level, filled/empty slots with clear controls) plus per-card carry markers (carried / ready / blocked), Memorize/Remove actions, and Cast enabled only for a carried, affordable spell. The spell popup shows a read-only loadout status line. Also: gave the small howler-fallback <script> an id so the test harness's greedy script-extraction regex captures the main app script again (the extra bare <script> from the howler/CDN commits had broken all node tests). Adds tests/test_field_spellbook.js (25 checks); suite back to green bar the 7 pre-existing failures. Deferred to 1b/1c per the doc: player-level cast gate (K), combat routing, time-cost memorization, DM editor level/carried fields, scrolls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Lock Decision N: an overreach scroll (spell.level above the caster's normally castable level) requires a channel check — d20 + Spellcasting skill + governing mod vs an overreach-scaled DC — and failure still consumes the scroll (no free lunches). Within-ability scrolls auto-succeed with no roll; either way the effect resolves normally (attack vs AC / save per Decision I), so a scroll bypasses the caster's level but never the target's defenses. All 14 decisions (A–N) now locked. Bump header chip, eyebrow, decisions heading, and footer to rev. 5; refresh the Designs README row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Lock the scroll no-MP rule and the skill-driven effectiveness formula (already captured in F/M), and add an open Decision N for whether casting a scroll needs a success check. Recommendation folded into §09 and Decision N: a scroll within the caster's normal reach auto-succeeds, but an overreach scroll (a high-level spell in low-level hands) takes a channel check (d20 + Spellcasting skill + governing mod vs an overreach-scaled DC); failure still consumes the scroll. Either way the effect resolves normally (attack vs AC / save per Decision I) — a scroll bypasses the caster's level, not the target's defenses. Bump header chip, eyebrow, decisions heading, and footer to rev. 4 (13 locked, 1 open); refresh the Designs README row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold two reviewer additions into Designs/spells.html: - Spellcasting as a skill (Decision L): casting is gated by a Spellcasting skill from the shipped Skills system rather than a class flag. The Mage starts with it; castSpell requires it; the skill level + governing attribute drives spell attack, save DC, and scaling. Any class can learn it, but unfavorable base stats make borrowed magic weak until ground up — self-balancing with no artificial nerf. Wired through §01, §07, §08, §09. - Scrolls are tactical, not teaching (Decision M): scrolls never inscribe a spell into the repertoire. Their edge is that they are NOT level-gated — cast a level-5 spell off a scroll as a level-1 caster — consumed on use, still requiring the Spellcasting skill, recommended at no MP cost. Added a third item kind to the §06 table and a dedicated §09 subsection. Update the reuse table, casting contract, build order, header chip/eyebrow, decisions heading, and footer to rev. 3 (13 decisions locked); refresh the Designs README row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Fold the reviewer's answers into Designs/spells.html: - C: cast only from the carried loadout; known level-0 cantrips exempt (Phase 2) - D: loadout changes cost game-clock time via a new spell `timeToMemorize` field; swapping to a pre-prepared field spellbook is instant; field spellbooks are extremely rare (future crafting path) - E: own several field spellbooks, one active - F: small additive spell-power scaling - G: upgrade items/services — with a critical world-gen directive and a pre-creation checkbox so generated worlds actually seed the upgrade path - H: spell level range 1–9 - I: single-target damage = spell attack vs AC; area/effect = saving throw - J: the GM sees only the carried loadout, not the full repertoire Update body sections 02/03/05/07/08/09/11/13, header chip, eyebrow, and footer to rev. 2; refresh the Designs README status row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The background-music library loaded from cdnjs, which is unreachable offline, under file://, or behind a blocked CDN — so Howl was never defined there. Ship howler.min.js (2.2.4, byte-identical to the cdnjs build — SHA-512 matches the old SRI hash) at the repo root and load it locally instead. Also precache it in the service worker (SHELL + CACHE_VERSION bump) so the installed PWA has it offline. Background music now plays without a network dependency; the earlier typeof-guard remains as a belt-and-braces fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The window.onload init still threw "Howl is not defined" whenever the howler CDN script didn't load (offline, file://, blocked CDN, or an SRI mismatch) — window.onload fires regardless, and the unguarded `new Howl(...)` crashed. Skip the background music when `typeof Howl === 'undefined'` (and wrap the call in try/catch), so the app boots cleanly with no ReferenceError when the audio library is unavailable; music still plays normally when it loads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The draft-editor boot path (?detach=editor&draft=<name> → loadDraftIntoEditor) never runs restoreGameState, which is the only place the API keys are loaded from storage into the globals. So `apiKey` (and the image-provider keys) stayed empty, and every Generate button's `if (!apiKey) return;` guard silently no-opped — no portrait prompt, no detailed description, no lore, etc. loadDraftIntoEditor now pulls the stored API key and calls loadApiKeysFromStorage() at the start, mirroring restoreGameState, so all the editor's Generate buttons (GM text + image providers) work in the detached draft editor. Verified: the draft editor boots with apiKey populated and an item-description Generate reaches the GM (guard passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Locks two spell-system decisions and adds a new one from review: - A (locked): memorization gate is book.level >= spell.level (>=, not strict >); cantrips (level 0) deferred to a later phase. - K (locked): character level gates castable spell level (player.level >= spell.level) — a third cast gate beside carried + MP. Character level = what you can unleash; field-spellbook level = what you can memorize + slot count. - B (locked + applied): a level-1 mage should only receive level-1 spells, so the starting field spellbook's loadout drops the level-2 frost_lance for firebolt (a true level-1 attack). Starter kit is now light · detect_magic · firebolt; frost_lance stays a level-2 spell to grow into. (text_adventure.html: spellbook catalog `teaches`.) Updates the design doc's §04/§07 and decisions section accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Lay out the Phase 1 spell-system expansion as a self-contained HTML design doc, grounded in the shipped magic (player.spells, castSpell, spellbook items) and the just-built combat system. Core proposal: split what you KNOW (repertoire, player.spells) from what you CARRY (a field spellbook's memorized loadout). Field spellbooks become leveled loadout carriers — slots(level) = 3 + (level−1), with a book.level ≥ spell.level memorization gate — distinct from found spellbooks that inscribe a spell into the repertoire via `teaches`. Casting gains a carried-set check and routes damage/debuff effects through combat (entityDamage, spell-attack vs AC, saves, entityStatusChanges). Covers the data model, memorize/swap flow, schools/levels/scaling, acquisition & field-book upgrades, UI, the GM contract, ten open decisions (incl. the level-gate ≥ vs > wrinkle and the Mage starter book carrying a level-2 spell), and a phased build order. Indexed in README; Combat row updated to "Phase 1 built & shipped". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A spellbook the player begins with (the Mage's field spellbook) has its spells already inscribed AND already learned: the Player constructor now folds each starting spellbook's "teaches" ids into the known-spell set. So the Mage starts knowing light, detect_magic, and frost_lance from turn one, and they read as Known in the Compendium's Magic → Spells list (alongside the class starting spells firebolt + minor_heal). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add the "field" class to the starting Spellbook catalog item so it's tagged as a field spellbook (the future magic-loadout carrier), distinct from found spellbooks that come with a spell already written in. Its type is already "spellbook", so it keeps its own Profile section (out of Inventory). Slot/level mechanics are left for the planned spell-system expansion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking a spell in the Character → Profile Spells section now opens the shared spell detail popup (showSpellPopup) instead of navigating away. The spell popup gains a gold, full-width "Compendium" button at the bottom (for known spells) that dismisses the popup and jumps to the spell's entry on the Compendium → Magic → Spells inner subtab, flashing it — mirroring the faction popup's Compendium button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Spellbook tomes (type "spellbook") have their own Spellbooks section on the Character → Profile tab, so they no longer also appear in the Inventory list. The Inventory list and its count now filter out spellbooks; the Spellbooks section is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Item detail popups could render off the bottom of the screen when an item's Lore was very long. Two causes, both fixed: - The popup body (.room-popup-body) is a flex child of a max-height-capped column but lacked min-height:0, so it refused to shrink and overflowed the cap instead of scrolling. Add min-height:0 so the popup always respects its max-height (general fix for any long popup body). - The Lore value now sits in its own .item-lore-scroll box with a modest fixed height (150px) and a styled thin scrollbar, so a long entry scrolls in place while the image/description/other fields stay visible. No widening, so left-positioned popups are unaffected. Verified: a 1500px lore clamps to a 150px scrollable box and the popup bottom stays within the viewport. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The guide documented the DM // meta-commands (Part II) but had no real section for the single / command, and its <a href="#p-guide"> link pointed at a non-existent anchor. - Add a "Slash commands (/ and //)" subsection in Part I (id="p-guide", so the previously-broken link now resolves), documenting both accurately: a single / asks the built-in Field Guide (app help, open to everyone, not the Game Master and never touching the story); a double // is the DM-only debug console that changes live game state. - Tighten the Part II // section to cross-reference the pair and restate the DM-only enforcement (the engine refuses a non-DM's //, no state changes). - Add a TOC entry for discoverability. Verified: all internal guide anchors resolve; the guide renders; and at runtime a non-DM's // is refused with no state change while a DM's // applies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Implements the Phase-1 combat system from Designs/combat.html end to end.
Engine (new combat module):
- A combat session object + per-actor inCombat flags; combatActive().
- The living world pauses during a fight: combatActive() guards added to
tryEncounter, applyNpcRoutines, the ambient handlers, and the calendar
tick's time-of-day side effects.
- The game clock keeps ticking but re-anchors to a slow COMBAT_TIME_SCALE
for the duration (reanchorClock), restored on exit; timed statuses still
expire mid-fight.
- A waiting-input mode with a 30s real-world turn clock + visible countdown
that auto-resolves on lapse (does-nothing action / auto-rolled die).
- The dice bridge: rollDie now submits a matching awaited roll to the GM
(submitCombatRoll); the wrong die, or typed text, is refused with a
reminder.
- Entities gain the player's timed, stat-bending status system
(statusEffects/statusMod, applyEntityStatusChanges, effectiveEntityStat,
per-tick expiry) plus aggression + armor fields.
- Lightweight Armor Class (10 + DEX mod + armor); entityDamage lowers a
foe's HP and, at 0, marks it defeated and drops its inventory to the room
floor. Victory pays xp/loot/treasure through existing paths.
- Capacity-gated fleeing moves the player to a random visible exit.
GM contract:
- New response fields combat{start,awaitRoll,enemyFirst,end,round},
entityDamage, entityStatusChanges; an always-present Combat System
contract and a live ## Combat state block injected while fighting; entity
stat line now shows AC, conditions, and aggression.
Turn loop refactored into gmSubmit + applyTurnResult so combat rides the
same pipeline; a combat input router in handleSend routes rolls vs actions.
New combat prompt bar (HP + countdown), absolutely positioned so it doesn't
disrupt the grid. Combat is transient and reset on load.
Verified: tests/test_combat.js (34 checks) + a Playwright harness driving a
full mocked fight (start → initiative → rounds → victory with loot/xp →
flee); no regressions (same 7 pre-existing suite failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomResolve open decisions F–J from review and revise the combat design doc: - F (clock): the game clock keeps ticking at a slower combat scale (not frozen); each combat turn has a 30-second real-world cap with a visible countdown that auto-resolves on lapse (does-nothing action / auto-roll). Adds the combat-clock re-anchor and the turn-clock section. - G (scope): single-enemy duels for Phase 1; multi-enemy in Phase 2. - H (entity status): entities get the player's timed, stat-bending status model NOW (Phase 1), as a backfilled on-the-fly augmentation; creation directives extended to seed conditions/aggression where relevant. New entityStatusChanges response field. - I (fleeing): capacity-gated flee — hard-blocked by incapacitating statuses, else d20 + DEX vs a GM-set DC taxed by impairments; success moves the player to a random visible (non-hidden) exit. - J (start): engine auto-starts combat when the player attacks any entity; entity-initiated aggression stays GM-declared; new entity aggression field set by DM/GM at creation. Updates §02/§03/§04/§10/§11, the decisions section (all ten now resolved), the build order (entity status, flee, turn timer, auto-start, aggression all Phase 1), header chips, and footer. README row updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Lay out the turn-based combat system as a self-contained HTML design doc in Designs/, matching the established template. Grounded in the current engine (no mechanical combat exists today; it is GM-narrated prose via hpDelta) and the hooks it will reuse. Covers: a combat state (per-actor inCombat flags + a combat session object) that pauses the living world via a combatActive() guard; a general waiting-input mode and the rollDie -> submitCombatRoll bridge that captures the player's physical dice and sends them to the GM; the ## Combat system-prompt block so the GM can refuse out-of-combat actions; initiative (GM-rolled enemy vs player-rolled D20, with surprise/incapacitation skip); the round loop with a combat-log manifest; enemy and player turns (multi-roll actions); to-hit against a lightweight Armor Class (10 + DEX mod, AC-as-DC); a basic d20 saving-throw model; resolution/victory with xp, dropped enemy inventory, treasure, and skill xp; the GM response-field contract (combat.start/awaitRoll/enemyFirst/end, entityDamage, dropInventory, reused fields); ten decisions (five recommended, five open); and a three-phase build order. Indexed in README.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a checkbox to the New World editor. When checked, the world-generation directive instructs the GM to author, for every applicable object: - an image prompt (bannerPrompt for rooms, portraitPrompt for entities, prompt for items), in the world's tone/art style; - hidden lore (history, provenance, or secret), distinct from the visible description; - a loreKey — a concrete in-world condition the player must satisfy to reveal that lore — kept loreUnlocked:false so it starts hidden. The item/entity/room schema fragments gain lore/loreKey/loreUnlocked only when the box is checked; when unchecked the directive is byte-for-byte the prior behavior. Wired through generateNewWorld -> requestWorldGeneration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the Character → Spellbook tab, a spell card whose portrait art exists now shows a cropped, icon-sized square of that portrait (68px, object-fit: cover) in place of the enlarged emoji glyph — same footprint as the glyph, keeping the click-to-enlarge behavior. Spells with no art still fall back to the default enlarged emoji icon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Document the shipped Progression & Vitals, Standing & Morality, Quests & Journal, and Living World systems as self-contained HTML design docs in the Designs/ directory, matching the existing character-skills/character-spells template (theme-aware palette, numbered sections, formulas/tables/callouts, growth-idea decision cards, footer). - character-progression.html: three-layer attribute model (effectiveStat), hidden stat training reviewed at level-up, awardXp curve, HP/MP pools & terminal death, game-clock status effects, derived STR/CON encumbrance. - standing-and-morality.html: per-NPC reputation (±100, seven tiers, CHA+Fame-boosted goodwill, reputation-gated disclosure), the hidden Fame ladder surfaced only as a label, and GM-judged Alignment. - quests-and-journal.html: GM-eyes-only quest threads unlocked one beat per turn by natural-language triggers, fame-credited and NPC-cross-referenced, with a player-facing Journal of discovered beats only. - living-world.html: 24x game clock & realm calendar, time-of-day NPC routines, three-gate probabilistic encounters, timer- & event-driven ambient behaviors. - README.md: index the four new docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A "//" line that adds a place in a direction — "// add an abandoned house to the east", "// build a shrine cave down" — now has the GM author that location (its rooms, the NPCs and items inside, descriptions, lore, and image prompts) and stitch it onto the DM's CURRENT room in the named direction, with a return exit. It merges into the live playthrough via mergeWorldChunk and persists, so a DM can grow the setting by travelling into what they just built. - matchDmWorldAdd detects an add/create/build verb + a direction (parsed via parseDirectionFromText); bare stat/status/spell/skill adds fall through to the normal parser. Routed async in handleDMMetaCommand with the usual input lock + typing indicator, before the deterministic parser and GM fallback. - requestDMRoomAddition authors a focused world chunk (1-3 rooms) connected to the anchor, reusing buildWorldDigest for context and mergeWorldChunk to merge. - ensureDmAdditionStitched guarantees the anchor→new and new→anchor exits even if the GM's exitPatch is missing/malformed, so "walk <dir>" always works. - Refuses to clobber an existing exit (asks for a free direction); DM-only; needs an API key. Help panel + Field Guide updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A new "Theme" section at the top of the Settings popup switches the whole app between the existing dark theme (default) and a new light theme. The light theme overrides the core CSS custom properties via :root[data-theme="light"] — a warm parchment palette with ink-dark text and the same gold accents — so the entire UI re-themes by flipping the variables. The dark theme is unchanged. The choice persists as the "theme" setting and is applied by toggling the data-theme attribute on the document root (applyThemeSetting on load; syncSettingsControls reflects it in the select). Field Guide documents it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The auto-submit silence window is now player-configurable and persisted instead of a hardcoded 1.2s constant. A new "Voice Input" section in the Settings popup holds a "Silence window" stepper (seconds, 0.3–5, default 1.2, 0.1 steps), mirroring the inactivity Timeout stepper. Stored (in seconds) as the voiceAutoSubmitSeconds setting; voiceAutoSubmitMs() drives the debounce so a change takes effect on the next dictated phrase. syncSettingsControls reflects the saved value when the popup opens. Field Guide documents the new setting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
While voice input is on, a recognized phrase now arms a short silence timer (VOICE_AUTOSUBMIT_MS = 1200ms); when the player stops talking the move is submitted automatically — dictate-and-go. Each new phrase pushes the timer back, so a multi-part sentence is sent as one move only after the real pause. Guards: nothing is sent for an empty box; while the GM is processing the submit is deferred and retried (so speech captured mid-turn isn't dropped); and turning voice off cancels any pending submit, leaving the text for manual review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the dice-roll video finishes, the rolled number now flashes large in the
centre of the screen (with its D{n} label) for ~2 seconds, then fades out — a
click-through overlay that never blocks the game. Shown only on the video path
(re-rolling restarts the flash); with the video setting off, the result just
prints to the narration as before. The narration note is still recorded either
way.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe book icon now opens a tiny two-choice menu instead of firing immediately: - Transcript — the original behavior: an exact, chaptered transcription of the story panel's prose, bound into the illustrated PDF. - Story — a new option: the Game Master retells the adventure as chapter-based prose that reads like a novel (summarized, not transcribed), then it's bound into the same illustrated book. Needs an API key; the book tab opens on the click with a "writing…" page and is rewritten with the finished novel + print. Refactor: the cover/appendix/style/print pieces are extracted into shared helpers (bookMeta, bookCoverHtml, bookAppendixHtml, bookStyleCss, bookHtmlDocument, printBookWindow) used by both the Transcript (buildStoryBookDocument) and Story (buildNovelBookDocument) builders. The GM call goes through requestStoryNovelization + normalizeNovel. The menu closes on outside click / Escape. Field Guide + test_make_book updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The World Rules and Prologue textareas were still showing default browser scrollbars. Extend the app-standard thin scrollbar (4px, var(--border) thumb) to every textarea inside .world-profile so they match the panel and the rest of the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Profile panel now scrolls full-width with the familiar thin scrollbar (4px, var(--border) thumb) used across the app, and its content sits in a centered max-width column (via a new .world-profile-inner wrapper) instead of hugging the left edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The World editor gains a leftmost bottom inner tab, "Profile", showing the current world's framing with the same fields and layout as the New World screen: World Name, Theme & Premise, Tone, Scope, Art Style, World Rules, and Prologue — pre-filled from the live world. All fields are read-only except World Rules and Prologue, which are editable and write straight to world.rules / world.prologue (persisted; World Rules feeds the GM system prompt every turn). Scope is derived from the room count, since the world doesn't persist its generation scope. Fields use distinct wprofile-* ids so they never collide with the New World editor's own we-* form. switchWorldInnerTab handles the new panel (renders on open) and validates its argument; Chunks stays the default active tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Rolling a die now plays that die's own clip instead of always D20.webm: rollDie passes the die size to playDiceRollVideo, which points the shared <video> at Videos/D4.webm … Videos/D20.webm (via diceVideoSrc) before playing, reloading only when the source actually changes. A die whose clip file is missing falls through the existing <video> onerror handler, which reveals the roll result with no clip — so this works today with only D20.webm present and each new file drops in without code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Bring the Spellbooks inner subtab's cards fully in line with the other Compendium item cards. For a DM: - Upload (⬆) / Regenerate (♻) portrait controls under the thumbnail, wired to the shared compendiumUpload / compendiumRegenerate on the tome's catalog type. - A collapsible "Prompt" section (with the ✨ GM-suggest button) above the Lore section, via the shared compendiumPromptSectionHTML. The card thumbnail now resolves from the tome's catalog TYPE image (what Upload/Regenerate write), falling back to the instance image, so a DM's edits reflect immediately. compendiumMagicEntryHTML gained a portraitActionsHtml slot. Non-DM players see neither the controls nor the Prompt section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Spellbooks inner subtab's cards now match the other Compendium item cards: - Obtain date at the top of each card. Owned tomes are stamped with an `obtainedAt` in-world date (set when the Generate button mints one, and lazily on first render for tomes acquired by any other path — starting loadout, GM grants, pickups, imports). DM reveal-all catalog tomes the player doesn't hold still show the Undiscovered badge instead. - A DM-only collapsible Lore section, resolved to the tome's catalog definition and wired through applyCompendiumLoreField — identical to the Lore editor on the other Compendium cards (lore text, unlock condition, unlock toggle, Generate). Non-DM players don't get the editor; the date still shows. compendiumMagicEntryHTML gained optional dateText + extraHtml so the shared card renderer stays the source of truth for both inner subtabs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
An auto-generated spellbook (created by the Generate button via generateSpellbookForSpell) is a functional tome, not a lore-bearing artifact, so its detail popup — the one that floats in the upper-right — should never show the Lore or Unlock (DM) fields. Generated tomes are now flagged `generated` (an own field on the Item, so it survives save/restore via reItemObj; also mirrored onto the catalog definition for the DM reveal-all case). buildItemDetailHTML skips buildLoreFieldHTML for a generated spellbook. Authored spellbooks are unaffected — a DM can still give them lore and see it in the popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Compendium → Magic tab now has inner tabs along the bottom (Items / Spells / Spellbooks), mirroring the Editor's Magic tab. "Items" keeps the existing discovered magic-items view; "Spells" lists the spells the player has learned; "Spellbooks" lists the spellbook tomes they've obtained. With a DM's Reveal all on, Spells fills with the whole grimoire and Spellbooks with every tome the world defines, each flagged Undiscovered. Both new subtabs reuse the standard compendium-entry look and the delegated name-click / thumbnail-enlarge handlers on #compendium-panel: a spell name opens the spell detail popup, a spellbook name opens the item popup. The name-filter box and the DM Reveal-all toggle apply to whichever inner subtab is active. The inner-tab bar shows only while Magic is the active category. Field Guide documents the new inner tabs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Items-tab item cards now use the same layout as the Magic → Spellbooks cards: the portrait sits left with Details + Classes beside it in the top row, and the Description + Detailed Description fields drop to a full-width row (.item-card-stacked) beneath it — rather than stacked in the info column to the right of the portrait. Purely a reordering in buildItemCard; the fields and their editors are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The UI already gated meta-commands in handleDMMetaCommand, but the underlying mutation functions trusted their caller. Add defense-in-depth guards so none of them can change game state for a non-DM, even if reached by another code path: - applyDMMetaCommand() returns false immediately unless player.isDM - applyDMMetaDirective() returns [] (applies nothing) unless player.isDM - dmMetaViaGM() returns before calling the GM unless player.isDM Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When a "//" directive isn't matched by the deterministic parser and an API key is set, the free-form request is now handed to the GM as a DEBUG DIRECTIVE translation — no narration, no story turn, no in-fiction refusals. The GM sees the current character state plus the valid alignment/spell/skill/room names and returns a compact JSON directive the engine applies via applyDMMetaDirective(): base stats, HP/MP/max, coin, XP/level, fame, alignment, status effects (with stat modifiers + timed durations), learn/forget spells & skills, add items, and teleport. Each applied field prints a precise confirmation beneath the GM's one-line note. So open-ended asks work: "// give me a flaming greatsword and poison me for an hour", "// max out my stats and make me legendary". Recognized commands still resolve locally (no GM call); with no API key an unrecognized line points at "// help". The applier reuses the same engine helpers as the parser and normal turns, so results stay consistent. Field Guide documents the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A "//"-prefixed line is now a DM-only out-of-character directive that mutates the live game state directly — no story turn, no GM call, no time passing. Checked before the single-"/" Field Guide route, and refused (with a notice) for non-DM players. Supported instantly, in forgiving phrasing (set/delta/bump all parse): - Attributes: // str +2 · // set dex 14 · // raise con by 3 - HP/MP: // set hp 50 · // mp +5 · // set maxhp 200 · // heal · // kill - Coin/XP/fame: // gold +100 · // xp +150 · // level up · // set fame 500 - Status effects: // add status poisoned con -2 for 30 · // remove status X · // clear statuses (carry the same stat modifiers + timed durations the GM can assign, flowing into effective stats and expiring on the world clock) - Spells/skills: // learn spell fireball · // forget spell light · // learn skill lockpicking (skills force-learn past the class gate) - Alignment: // set alignment Lawful Good (validated) - Teleport: // goto <room id or name> - // help prints the full reference in the story panel Each directive echoes and prints a one-line confirmation of what changed, then refreshes the sidebar, character sheet, and spell views and saves. Field Guide documents the family under Part II. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Explain that going over WT capacity makes the character Encumbered — an amber Statuses condition passed to the GM that hinders or fails physically demanding feats until the load is lightened. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When carried weight exceeds carry capacity the player is now "encumbered". This is derived from inventoryWeight() vs maxCarryWeight() (never stored in player.status, so it can't drift out of sync with the pack): - isEncumbered() / encumbranceOverRatio() helpers alongside the weight utils. - An amber "Encumbered N/M" chip leads the status list in both the sidebar Statuses block and the Character sheet's conditions section. - buildSystemPrompt() adds a Carry weight line; when over capacity it flags ENCUMBERED and instructs the GM to hinder or fail physically demanding feats (jumping, climbing, sprinting, swimming, squeezing through gaps) with an in-fiction explanation that the player must lighten their load first. Chip clears everywhere the moment weight drops back within capacity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Every item now carries a numeric `weight` (DEFAULT_ITEM_WEIGHT = 1), plumbed through the Item constructor, makeItem, catalogItemShape, applyItemSpec, and both GM item-authoring schemas (the item-edit directive and the addItem loot field), and shown in the item editor card + detail popup. Weight serializes with the item and survives save/restore. Adds a WT (weight) vital beneath the XP bar on both the Character block (sidebar) and the Character > Profile Vitals section. Its current value is the cumulative inventory load (sum of weight x quantity) and its maximum is derived from STR and CON via effectiveStat (round(STR*2 + CON)), so buffs/debuffs shift capacity. The bar is a partial fill that grows/shrinks as items are picked up and dropped, caps at 100%, and turns red when over capacity. Helpers: itemWeight, inventoryWeight, maxCarryWeight, fmtWeight; a bronze .bar-wt (.over red). Updates the Field Guide (vitals, sidebar Character block, item fields). Verified (19 Playwright checks): weight default/override, inventory sum, STR/CON-derived max, sidebar + Profile bars beneath XP, grow-on-pickup / shrink-on-drop, over-capacity red cap, STR-buff raises max, item card + popup show Weight, and save/restore persistence. No page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Post-processes GM narration (narrator/ambient messages) to hyperlink any
mention of a spell or spellbook name, so a click opens its popup — spells
via showSpellPopup, spellbook tomes via showSpellbookItemPopup.
- storyLinkTargets() is an extensible registry of {name, onclick}: today
every spell and every spellbook-type catalog item; more item types
(e.g. possessed/known items) can be added here later and get linked
automatically.
- linkifyStoryEntitiesHTML() walks TEXT NODES of the message DOM only,
never touching tags/attributes or text already inside a link/code, so
existing markup and links are preserved. Case-sensitive whole-word
match (proper-noun mentions link; lowercase common words like "light"
don't), longest names first ("Lightning Bolt Spellbook" beats
"Lightning Bolt"). The linkified HTML is stored in messageLog, so links
survive story re-renders. Applied in addMsg for narrator/ambient only;
system messages already carry their own explicit links.
Verified (13 Playwright checks): spells + spellbooks linked; lowercase
common words and partial words (lighthouse/Lightning) not matched;
longest-name-wins nesting; existing <a>/attribute text untouched; an
end-to-end narrator message renders a working link that opens the popup;
system messages keep single (non-nested) links. No page errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe story lines about reading a spellbook (readSpellbook) and generating one (generateSpellbookForSpell) now render the spellbook name and each spell name as clickable links: the spellbook opens its item popup (showSpellbookItemPopup) and each spell opens its spell popup (showSpellPopup). Adds spellStoryLink / spellbookStoryLink helpers and a .story-spell-link style (keeps the bold gold look; underlines on hover). The inline onclick is preserved in messageLog's stored HTML, so the links survive story re-renders. Verified (7 Playwright checks): the read message links the tome + every taught spell (learned and already-known); clicking the tome link opens its item popup and a spell link opens the spell popup; the generate message links both. No page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The enlarge button used a 🔍 emoji, which renders in its own colors and so didn't match the gold ⬆/♻ glyph buttons beneath the portrait. Replaces it with an inline SVG magnifier using stroke="currentColor", so it inherits the button's themed color (and the gold hover) exactly like the sibling buttons. Updates the Field Guide's mention of the icon. Verified: the magnifier's stroke color equals the Upload/Regenerate buttons' color; no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Spellbook editor cards now show a collapsible "Teaches" section above the Prompts section, listing a chip per spell the tome inscribes. Each chip is a hyperlink that opens a spell detail popup styled after the existing item popup (buildSpellDetailHTML mirrors buildItemDetailHTML's title/image/labelled-field markup: School, Level, MP Cost, Target, Effect, Description), shown via showSpellPopup in the shared popup container. Updates the Spells design doc and Field Guide. Verified (8 Playwright checks): the Teaches section is a collapsible <details> above Prompts, lists a hyperlinked chip per taught spell, and clicking a chip opens the item-popup-styled spell popup with the spell's labelled fields. No page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The new spellbook-items-* elements were missing from the shared editor CSS selector lists (toolbar position, scrolling view, and the GM edit box + input/button/output), so the Spellbooks subtab's GM request box rendered unstyled and mispositioned. Adds #spellbook-items-toolbar, #spellbook-items-view, and #spellbook-items-edit(-input/-btn/-output) to those rules so the tab matches the Items/Spells subtabs. Verified: the Spellbooks edit box and toolbar now share the Spells tab's exact geometry and styling (position, border, background, padding). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Gives the Magic > Spellbooks cards a tome-specific layout: the portrait and the Details + Classes share the top row, the Description and Detailed Description run full-width beneath, then the existing Portrait prompt and Lore sections. The standard Items/Flora/Fauna/Magic cards are unchanged. Refactors the shared card pieces into itemCardParts, keeping buildItemCard identical and adding buildSpellbookItemCard; renderItemKind uses the spellbook builder only for the "spellbooks" kind. Adds a .item-card-stacked rule for the full-width description row. Updates the Spells design doc and Field Guide to describe the layout. Verified (7 Playwright checks): portrait left of the details column; Details + Classes (no description) in the top-row info column; a full-width row with Description then Detailed Description below it; Prompts + Lore after; and the standard Items card unchanged. No page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Items of type "spellbook" are now excluded from the Editor > Items tab (catalogItemsForEditor's default branch drops them, mirroring how magic items are handled). Adds a third inner subtab, "Spellbooks", at the bottom of the Editor > Magic tab that looks and behaves exactly like the Items tab but shows only spellbook-type items. It reuses the shared item-editor machinery: a new ITEM_EDITOR_KINDS 'spellbooks' entry, renderSpellbookItems, its filter/clear/collapse/expand helpers, an EDITOR_IO.spellbooks adapter for Export/Import, an ITEM_EDIT_KIND_META entry, and dmEditSpellbookItems. The item-edit GM directive gains a spellbook branch (type "spellbook", classes ["book"], a teaches spell id/list from the world grimoire, an evocative title) and "spellbook" is added to the type enum. applyItemSpec now preserves a spec's `teaches`, so GM-authored/imported tomes teach. Updates the Spells design doc and Field Guide (Editor tab table, the Magic editing section) to describe the new subtab and the Items-tab exclusion. Verified (10 Playwright checks): spellbooks are absent from the Items tab; catalogItemsForEditor routes them correctly; the Magic tab shows an Items/Spells/Spellbooks tab row; the Spellbooks panel activates with the same toolbar/filter/GM box and lists only spellbook items as item cards; the filter narrows; EDITOR_IO export lists them; and applyItemSpec keeps teaches. No page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the DM Editor's Magic > Spells cards, the Spellbooks field is now a collapsible section (a details.item-prompt panel like Portrait) with the tome chips listed inside its body, sitting below the Portrait section. The player Character > Spellbook card keeps its inline field. Refactors the shared chip/Generate logic into spellbookChipsHtml, used by both buildSpellbookField (player inline) and the new buildSpellbookSection (editor collapsible). Updates the Spells design doc and Field Guide accordingly. Verified (9 Playwright checks): the editor section is a toggleable <details> labelled Spellbooks with chips inside, below Portrait, no longer inline; a no-tome spell still shows note + Generate within it; and the player card's inline field is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the DM Editor's Magic > Spells cards, the Spellbooks field now renders as a full-width row below the collapsible Portrait section, instead of inside the item-card-info column beneath Details. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the room cards, push the interiors-count tag ahead of the region tag so it renders to the left of the region chip (after the "interior" tag). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The spell card's Generate button now appears only when the logged-in player is in DM mode (buildSpellbookField checks player.isDM). The Spellbooks field itself and its tome chips still show to everyone; only authoring a new tome is a DM affordance. The DM Editor's Magic > Spells cards (buildSpellDefCard) now carry the same Spellbooks field — tome chips plus, since the editor is DM-only, a Generate button. Generating from either surface re-renders both the player Spellbook and the editor Spells cards so the new tome's chip appears wherever it's shown. Updates the Spells design doc and Field Guide to note the DM-gating and the editor field. Verified (10 Playwright checks): non-DM sees the field/chips but no Generate; DM sees Generate on the Character card; every editor Spells card has the field; a no-tome editor card shows Generate; and generating from the editor creates the tome and refreshes the editor card to a chip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Spellbooks are now the only acquisition path: the free "Learn" button is removed from the Spellbook cards, so a spell can be gained only by reading a spellbook (readSpellbook). Each spell card gains a "Spellbooks" field: a chip per world tome (type "spellbook") whose `teaches` list includes that spell, each a hyperlink that opens the tome's item popup. When no tome teaches a spell, the field shows a note and a Generate button that authors a spellbook for that spell — GM-titled when a key is set, else a plain fallback — drops it into the player's pack, and registers it into the catalog, so the card, Profile, and pack all update. The Character > Profile tab gains a "Spellbooks" section beneath Magic, listing the tomes the player carries with what each teaches, linking to the item popup. Supporting: catalogItemShape now preserves `teaches` (so generated/ inline tomes keep teaching); new helpers spellbooksTeaching, buildSpellbookField, showSpellbookItemPopup, uniqueItemName, requestSpellbookForSpell, generateSpellbookForSpell. Updates the Spells design doc (rev. 2 — sole path, card field, Generate, Profile section; proposal I reframed to reader-gating) and the Field Guide's Magic + Profile sections. Verified end-to-end (12 Playwright checks): no Learn button, per-card Spellbooks field, chips open the item popup, Generate creates a tome (catalog + pack) that then reads to learn the spell, Profile section beneath Magic with teaches-lines and links, and save/restore persistence. Syntax clean; design doc renders without overflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Introduces a "spellbook" item type (carrying "book" in its classes) that teaches spells. A spellbook's `teaches` field names the spell id — or a list of ids — it inscribes; a Read action on the carried item learns every taught spell the reader doesn't already know and prints one summary. Unlike a skill book, a spellbook is NOT consumed — it stays in the pack as a permanent reference. - makeItem preserves an array `teaches` (a spellbook can teach several spells); a lone id is still stored as a trimmed string. - learnSpell gains an opts.silent so a multi-spell read announces once. - New readSpellbook / readSpellbookAndClose + spellbookTeaches helper; the item detail popup shows a "Read — learn <spells>" button for the unknown spells (and an "already know" note otherwise), re-rendering in place since the book isn't consumed. - The default world's flavor Spellbook item becomes a real spellbook (type "spellbook", classes ["book"], teaches light/detect_magic/ frost_lance). Updates the Spells design doc (rev. 2) to reflect the new acquisition path — marking proposal II (sourced learning) shipped, revising §04, the integration table, the build order, header, and footer — indexes it in Designs/README.md, and adds a spellbook note to the Field Guide's Magic section. Verified end-to-end (17 Playwright checks): array/lone teaches, learn unknown + skip known, not-consumed, one summary line, re-read handling, graceful unknown-spell no-op, and save/restore persistence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Pollinations note claimed the token is "used only when generating character portraits." In fact Pollinations is the default Image AI provider (and the fallback when another provider has no key), so it paints all in-game art — character portrait, item images, and room banners — and every request already appends the token when set, falling back to the free keyless tier otherwise. The lone exception is the Character portrait Gallery, which is always Nano Banana Pro because its variations combine the portrait with a text prompt. Rewrites the dialog note to say this, and tidies two stale "portraits"-only comments on the pollinationsKey / saved-key declarations. No behavior change — the code already worked this way. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds Designs/character-spells.html — a design document for the Spells system in the same look, feel, and structure as character-skills.html (shared stylesheet, numbered sections, proposal cards). It documents the shipped grimoire — the world-editable catalog, the spell record and its five effect kinds, MP-gated casting, class starting loadouts, the Spellbook/Profile/sidebar surfaces, and the DM Magic editor — and then lays out seven proposed improvements to bring magic to parity with skills: class/level/stat gating on learning, scroll/tome learning that consumes the source, per-spell mastery ranks, a spellCast manifest that resolves offensive spells, a casting attribute and spell DC off effectiveStat, school/target semantics, and an MP economy, with a suggested build order. Indexes it in Designs/README.md and cross-links it from the guide's "Editing magic & skills" section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
On the room cards, push the "interior" tag ahead of the region tag so it renders to the left of the region chip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Swaps the order within the Rooms toolbar's filter row so the name filter comes first and the region select sits to its right. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Each Spellbook card now shows a big icon in a left column of its body, with the description, effect, and action buttons to its right. When the spell has painted art the icon is a 68px cropped portrait (click to enlarge); otherwise it's the spell's emoji rendered large in a bordered tile. The small header thumbnail is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a Designs/ directory holding the Character Skills design document (reconciled to the shipped build: all six decisions locked, skill checks free in Phase 1) plus a short README indexing it. Updates guide.html (the in-game Field Guide) to match what the game now does: - New player sections: Spellbook & magic (learn/cast/forget, MP, cropped portrait thumbnails) and Skills (book-learning, class gates, GM-rolled checks against live attributes, the DC ladder, xp/levels, the check manifest, and the 5% unskilled floor). - Character Profile: four subtabs, Alignment, the portrait enlarge button, and the Magic/Skills/Treasure glances. - Story-panel tools (Dice bag, Voice input, Make a Book, text size) and the Settings > Dice > Show Dice Roll Video option. - DM Editor: the full fifteen-tab list (adding Magic, Skills, Environment, Races), a new "Editing magic & skills" section, the book/magic item types and the book teaches field, and the Rooms region filter. - GM change-language + Appendix A: the skillChecks, stateChanges alignment, and stateChanges skillLearned fields. Glossary gains Skill, Proficiency, Spell, Alignment, and teaches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Spellbook cards now show a cropped thumbnail of the spell's portrait (object-fit: cover, small rounded square) in place of the emoji icon when the spell has painted art; spells without art keep their emoji. The DM Editor Rooms tab gains a Region select at the top of its toolbar, defaulting to "No Region" (every room). Choosing a region filters the cards to that region, and the existing name filter then searches within the selected region. The region list is derived from the rooms' own region fields and self-heals if a selected region disappears. Room Export (listSpecs) respects the region filter too, matching its "respecting the current filter" tooltip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Adds a Skills tab to the DM Editor mirroring the Magic > Spells editor: a card per world skill (icon, name, stat + class-gate tags, editable description with GM generate, read-only details), a name/stat filter, Export/Import, and Collapse/Expand. A GM authoring box lets the DM create or revise skills in plain language via requestSkillEdit. Editing a description writes through to world.skills and refreshes the player's Skills views; applySkillSpec creates or edits skills in place, matching by id or name; importSkillsFromSpecs round-trips the id-keyed export. Fixes normalizeSkillRecord clobbering a record's id to the internal wrapper key, which caused newly authored skills to be keyed under "__one" on export instead of their real id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Wire the skills system into the GM turn loop:
- buildSystemPrompt now feeds a Character Skills dossier — each known skill's
governing stat, CURRENT modifier (training + status folded in), level, and
proficiency — the exact numbers the GM needs to roll a check.
- New skillChecks response field (array of { skill, dc, roll, outcome }) with
a rule telling the GM to roll d20 + mod + proficiency vs a DC, report every
check an action triggers, keep narration consistent, and honor the 5% floor
for unskilled actions + the class gate.
- applySkillChecks recomputes each check from the engine's authoritative
modifiers (honest numbers even if the GM's arithmetic drifts), derives the
outcome, awards skill xp on success (critical +2, else +1), and prints a
per-turn manifest above the narration.
- stateChanges.skillLearned lets the GM teach a skill in-fiction (gate-honored).
Verified in a browser (13 checks): prompt dossier + schema + 5% rule; the
manifest render with pass/miss/critical; authoritative totals; xp awarded to
successes only; unknown-id ignore; and skillLearned with class gating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomPhase 1 + player-facing surfaces of the character skills system:
- world.skills (id-keyed catalog seeded from SKILL_CATALOG) + player.skills
(map id -> {level,xp}); normalizeSkills, serialize/restore, backfill.
- Per-skill leveling: proficiency = level+1 (L1 +2 … L5 +6); grantSkillXp
advances levels (5×level xp per level, soft-capped at L5).
- Checks read the ability modifier off effectiveStat(), so training bonuses
and status buffs/debuffs apply automatically (skillAbilityMod).
- New 'book' item type carries a 'teaches' skill id (preserved through
makeItem/serialize/restore). readBook(): honors the class gate, learns the
skill, and consumes the book on success (knowledge retained on the card);
gated/known reads don't consume. A Read action appears on carried book
item popups.
- Character > Skills tab: a card per learned skill (icon, name, tier·level,
stat + live modifier, proficiency, class gate, description, xp-to-next bar).
Profile sheet gains a Skills glance mirroring the Spells one.
Verified in a browser (16 checks): seeding, book reading with class gating
+ consumption, the Skills tab cards, live status-modifier reflection,
xp/level-up, the Profile glance, and save/restore persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomRemove the 'muted' attribute and explicitly play the dice-roll clip unmuted at full volume. The roll is user-initiated (clicking a die), so browsers permit sound. If a browser still blocks unmuted playback, it retries muted so the clip still plays. Verified in a browser: the video plays with muted=false, volume 1.0, and audio is actually decoding (webkitAudioDecodedByteCount > 0 / mozHasAudio). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Drop the dimmed overlay behind the dice-roll video so the main app window is no longer shaded while it plays. The overlay is now transparent and click-through (pointer-events:none); only the video shows, with no box-shadow or border-radius. The video keeps pointer-events:auto so clicking it still skips (the click bubbles to the overlay's skip handler), and the rest of the app stays interactive underneath. Verified in a browser (7 checks): transparent overlay, click-through container, interactive video with no shadow, playback intact, and click-to-skip still revealing the result. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
When the 'Show Dice Roll Video' setting is on, rolling a die from the Dice Bag now plays Videos/D20.webm centered on the screen over a dimmed backdrop; the roll result is revealed in the narration when the clip ends (or is skipped by clicking). When the setting is off, the result prints immediately as before. Robust against edge cases so the result is ALWAYS revealed: a missing/ undecodable file (video onerror), blocked autoplay (play().catch), a clip with no 'ended' event (6s safety timeout), and rapid re-rolls (the prior clip finishes and reveals before the next starts). The muted, playsinline video is layered above the app modals. Verified in a browser (11 checks): overlay wiring + source path, setting off = immediate result / no overlay, setting on = overlay shown + result deferred, the real webm decoding and playing, click-to-skip revealing the result, and natural end auto-revealing it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Settings popup gains a 'Dice' section with a single 'Show Dice Roll Video' checkbox, wired through the existing get/setSetting persistence (key: showDiceRollVideo, default on) and reflected on open via syncSettingsControls, matching the other settings toggles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A 🎲 icon button next to the Voice-input button opens a popup (bottom-right of the story view, above the control row) holding the major polyhedral dice — D4, D6, D8, D10, D12, D20 — each drawn as a clickable SVG silhouette with a hover lift/highlight. Clicking a die rolls 1..N and prints the result to the narration (e.g. 'Rolled a D20 — result: 17'). The popup toggles from the button (active state while open) and closes via its ✕; it stays open while rolling so several rolls can be made in a row. Verified in a browser (14 checks): button placement left of Voice input, popup open/close + above-the-row bottom-right positioning, all six dice as SVG icons, hover effect, single roll prints one narration line, results stay within 1..N across many rolls per die, and no page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
A microphone toggle button now sits just left of the Make-a-Book button on the Story tab. When toggled on it dictates the player's next move into the main #cmd-input box via the browser's SpeechRecognition: each finalized phrase is appended (space-joined) so the player can review and press Enter. - Continuous recognition that auto-restarts after pauses while the toggle is on; clicking again stops it. - Active/listening visual state (gold highlight + gentle pulse), aria-pressed. - Graceful handling: unsupported browsers show a system note and never toggle on; blocked mic permission stops and notifies. Verified in a browser with a mock SpeechRecognition (13 checks): placement left of Make-a-Book, on/off toggle + visual state, recognition start/stop, transcript appended to the input, auto-restart on end, submit flow, and the unsupported-browser fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character > Profile portrait's action row gains a third button — a 🔍 magnifying glass, shown only when a portrait is set — that opens the shared image lightbox (#item-image-modal) with the portrait at full size. Reuses the existing modal via a small openCharacterPortraitModal() helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The quick Character popup now has a Consumables section between Inventory and Magic, listing inventory items of type 'consumable' as the same draggable/clickable tiles. Those items are split out of the Inventory grid (which already excluded magic items), so each appears in exactly one section. Empty state shows 'No consumables.' Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Magic > Items tab's GM request directive now instructs the GM on
naming: magic items are notable and deserve distinctive names — a unique
proper name ("Firebrand", "Widow's Kiss") or a "[Object] of [Power]" form
("Ring of Invisibility", "Amulet of Warding") — rather than mundane generic
names like "Sword" or "Magic Potion". Encourages creativity that hints at
the item's power/provenance while staying consistent with the world's tone,
and to reflect the magic in the description and lore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomBring the DM Editor > Magic > Spells cards to full parity with the Item cards' media: - Portrait column with the same look/behavior as item cards: the painted image with upload (⬆) and regenerate (♻) controls, or a Generate button with a status line when empty. Reuses the shared item-card-media markup and image plumbing (paintImageFromPrompt, itemMediaStatusEl). - A collapsible Portrait prompt section (textarea + ✨) whose ✨ asks the GM to author an image prompt from the spell's name + description. The directive tells the GM this is a MAGIC SPELL and to depict the spell EFFECT (arcane energy / elemental force) rather than an item or scene. - Spell records now carry image + prompt fields (normalizeSpellRecord), persisted with world.spells. New handlers: generateSpellImage / regenerateSpellImage / uploadSpellImage / generateImageForSpell / setSpellDefPrompt / suggestSpellPrompt / spellImagePromptDirective. Verified in a browser (12 checks): media column + empty Generate state, Portrait prompt section, prompt persistence, the magic-spell directive, image display + upload/regenerate controls, media width matching item cards, and image/prompt surviving save/restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Spellbook's toolbar and card list used tight, asymmetric edge insets (~4px left, 8px right) so its content hugged the panel edges — visibly different from the DM Editor card tabs. Match the editor spacing: toolbar left 24px / right 16px, view sides 24px, bottom 20px, and a 4px scrollbar (down from 8px). The filter box and cards now sit 24px from the left, identical to the Editor Items tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The World editor tab, unlike every other inner-tabbed editor tab (Map,
Magic, Environment, Art), did not re-render its active inner panel when the
tab was (re)entered — switchEditorTab('world') only called
renderRegionsStitch(), never switchWorldInnerTab(activeWorldInnerTab).
As a result, the Regions panel kept whatever it last rendered. If it first
rendered before the world's region data was live, or the world was rebuilt
by the cross-window sync (syncWorldFromStorage) or a boot resume — both of
which call switchEditorTab(activeEditorTab) — the Regions tab kept showing
the 'No regions yet' empty state even though world.regions.list was
populated (which is why the Generate button's confirm modal correctly
reported existing regions). It only appeared once renderRegions was
re-triggered another way (e.g. re-clicking the inner Regions tab).
Fix: on entering the World tab, also call switchWorldInnerTab(
activeWorldInnerTab) so the active Chunks/Regions inner panel re-renders
from current world data — matching the Map/Magic/Environment tabs.
Verified with a regression test that reproduces the exact symptom (fails
before the fix, passes after) across both the re-entry and cross-window
sync paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThree changes to the quick Character popup (opened from the sidebar portrait): - Title now shows the character's name instead of the literal "Character" (set from player.name in renderCharQuickPopup). - Clicking the portrait dismisses the popup and jumps to the Character > Profile tab (cqOpenProfile), with a pointer cursor + hover affordance. - The popup (and its spawned item-detail popup) now live at #app level instead of inside #view-story, so — like the sidebar NPC/item popup — it floats over the main panel and stays visible regardless of which tab is active. Repositioned to sit just left of the sidebar (top:62px to clear the header row). Verified end-to-end in a browser (10 checks): #app-level placement, name title, portrait-click navigation + close, and persistence across the Story, Maps, and Journal tabs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a moral Alignment to the player character:
- player.alignment defaults to 'True Neutral' (constructor + save/restore
backfill).
- The valid alignments live in world data (WORLD_DATA.alignments), now a map
of alignment name -> { description } populated with the standard nine
alignments and a GM-facing description of how each is judged. world.alignments
is normalized (normalizeAlignments), serialized, and restored with the world
(older saves/imported worlds seed from the built-in set).
- The GM reads the current alignment and the full valid set (with descriptions)
in its system prompt, and may shift the player's alignment over time via a new
stateChanges.alignment field — applied through setPlayerAlignment, which
validates case-insensitively against world.alignments and ignores unknown
names. Guidance tells the GM to drift by one step only, on a durable pattern
of conduct, never a single act.
- The alignment is shown at the top of the Character Profile sheet (identity
block, below Fame), with its description as a tooltip.
Verified end-to-end in a browser (17 checks): default value, world set +
descriptions, sheet display/placement, valid/invalid/case-insensitive GM
changes, live re-render, system-prompt wiring, and save/restore persistence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiomThe quick Character popup (opened from the sidebar portrait) now shows the character portrait at the top of its body, above Inventory/Magic/Wealth — the painted portrait cover-cropped when one is set, else a glyph placeholder, mirroring the sidebar Character block and Character sheet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Add a Magic tab to the DM Editor with two inner subtabs, mirroring the Environment tab's inner-tab pattern: - Items: identical in look and behavior to the Editor > Items tab, scoped to items of type 'magic'. The Editor > Items tab now excludes magic items (they live here), alongside the existing plant/animal split. Full parity: filter, collapse/expand, import/export, and GM authoring box, all wired through the shared item-card pipeline (new 'magic' kind in catalogItemsForEditor / ITEM_EDITOR_KINDS / EDITOR_IO / ITEM_EDIT_KIND_META and a requestItemEdit branch). Shared item-card image handlers now refresh every item subtab (renderAllItemKinds) so a magic-item card updates in place. - Spells: a full editor over the world grimoire. Spells now live in editable, persisted world data (world.spells), seeded from the built-in SPELL_CATALOG and serialized/restored with the world (older saves backfill via normalizeSpells). Cards mirror the Item cards: read-only detail rows plus an editable Description with GM ✨ generate, a GM authoring box (create/edit any field), import/export, filter, and collapse/expand. spellById/allSpells now read the active grimoire, so the player Spellbook and Profile reflect DM edits. Verified end-to-end in a browser (23 checks): the Magic Items subtab shows only magic items and the Items tab excludes them; the Spells subtab lists, filters, edits, creates, imports, and persists spells through save/restore; and player-facing spell views track the edited grimoire. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Introduce a first-pass magic system: - SPELL_CATALOG: a grimoire of 10 spells (id, name, icon, school, level, mpCost, target, effect, description). Effect kinds: heal, restore, buff, damage, utility. - player.spells: the ids the character knows, seeded per class (CLASS_STARTING_SPELLS) in the Player constructor and rehydrated on load. - learnSpell / forgetSpell / castSpell: casting validates known/alive/mana, spends MP, and applies the effect (heal/restore adjust HP/MP; buff routes through the status system; damage/utility are narrated for the GM to resolve), with feedback in the story panel. - New Character > Spellbook tab: a filter box + Known/All scope + Collapse all / Expand all toolbar (mirroring the editor tabs) over a grid of collapsible spell cards. Known spells offer Cast/Forget; unknown ones (in All scope) offer Learn. - Character > Profile Magic section's Spells subsection now lists known spells (click to open the Spellbook) instead of the placeholder. Verified end-to-end in a browser: casting deducts mana, healing restores HP, learning adds spells, filter/collapse work, and known spells persist through a save/restore round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The Character > Profile sheet now has a Magic section (between Inventory and Treasure) with two horizontally-arranged subsections: Items and Spells. Items lists inventory entries of type 'magic' as the same clickable detail cards used by the Inventory list; Spells shows a placeholder pending a spell data model. The two columns stack on narrow panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
The quick Character popup opened from the sidebar portrait now shows a Magic section between Inventory and Wealth. Magic-type inventory items are split out of the Inventory grid into their own section, rendered as the same square, draggable-and-clickable tiles used for Inventory (they reuse the existing cq-tile click/dragstart handlers). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFzHCC6ujWLjpzyFPmoiom
Clicking the sidebar Character portrait now opens a non-modal popup at the top-right of the Story view instead of jumping to the Character Profile tab. The popup shows just two sections — Inventory and Wealth — each as square item tiles (the item image or its glyph, with a count badge). Tiles are draggable AND clickable; clicking one opens that item's detail popup just to the LEFT of the character popup, mirroring the map room→detail popup pairing. - Inventory tiles resolve to player.inventory and reuse the shared showItemPopup path (so the on-the-fly item-portrait generation and cross-popup dedup all apply). - Wealth shows gold/silver/copper coin tiles; clicking a coin shows a currency detail body directly (no image generation). - The popup stays in sync with inventory/wealth changes while open (updateSidebar re-renders it), and closing it dismisses its item detail popup too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Resuming a saved session (Continue, or boot auto-resume) rebuilt the game UI but never reset the active tab, unlike a fresh startGame which always switches to Story. So logging out while on an Editor subtab (e.g. Races) and then resuming a DIFFERENT save left that subtab active still showing the PREVIOUS save's content, since restore doesn't re-render the Editor. Only a full browser refresh cleared it. restoreGameState now resets activeEditorTab to its default and switches to the Story tab as part of its view rebuild — matching startGame — so no stale tab lingers and a later Editor visit re-renders fresh from the resumed world. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Gallery generation is image-to-image (it feeds the current portrait back in), and Nano Banana (Gemini) is the provider whose image-to-image reliably works with an inline portrait. Limit the Settings > Gallery AI provider list to just Nano Banana: - The provider select now offers only Nano Banana. - getGalleryProvider validates against GALLERY_PROVIDERS (nanobanana); any other/legacy id falls back to it, and setGalleryProvider ignores non-allowed ids. - resolveGalleryProvider simply returns the (validated) provider — a missing key surfaces as a clear "add a key" error rather than a silent swap to another provider. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Switching the Gallery AI provider to Nano Banana and generating a variation failed with "could not read the source portrait to vary from" whenever the current portrait was a remote URL (e.g. a Pollinations image): imageToInlineData's fetch() was CORS-blocked and returned null, so Nano Banana had no input image. imageToInlineData now falls back to a canvas read when fetch fails — it loads the image crossOrigin (image CDNs send Access-Control-Allow- Origin: *), draws it to a canvas, and exports the pixels as PNG base64. Only if every strategy fails does it return null, so callers degrade gracefully instead of erroring the whole generation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
When Pollination is the selected Gallery AI provider and a web-hosted source portrait is available, switch its request to the `kontext` image-to-image model, passing the current portrait via the `image=` query param so the result is a true variation rather than a fresh generation. Pollination is now flagged supportsImageInput. Because kontext can only fetch an http(s) source, an inline data:-URI portrait (uploaded, or produced by Nano Banana) can't seed it; those cases gracefully fall back to text-to-image with a one-line notice in the log explaining why. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Gallery generation resolved to any image-capable provider, so choosing Pollination for Gallery AI silently used — and logged — Nano Banana. Now the selected Gallery AI provider is always used (with the standard keyless → Pollination fallback only when a non-Pollination choice has no key): an image-capable provider (Nano Banana) still sends the current portrait as an input image for an image-to-image variation, while others (Pollination / Higgsfield) generate from the prompt alone. Either way the Logs name the provider actually used. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
New Settings › "Gallery AI" section (same provider list as Image AI) chooses the provider used specifically for the Character Profile portrait-gallery variations. imageToImageProviderId now follows the Gallery AI selection (defaulting to Nano Banana, the image-capable provider) instead of the Image AI one, falling back to any image-capable provider if the chosen one cannot take an input image. The choice persists independently of Image AI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Dropping a Gallery image on the portrait no longer forces portraitExpanded to false — the portrait keeps whatever size it was (expanded stays expanded, normal stays normal), only its image changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Gallery thumbnails are now draggable, and the Character Profile portrait is a drop target: dropping a thumbnail on the portrait sets that image as the character portrait (saved + re-rendered). The portrait highlights with a dashed gold outline while a thumbnail is dragged over it, and the drag is scoped to in-app gallery drags so it does not hijack unrelated drags. The dragged image is held in a module var (data URIs are too large to round-trip through dataTransfer), with the gallery index on the event as a fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
A new collapsible "Gallery" section on the Character Profile (just above Background) shows small thumbnails from a new player.gallery field. At the bottom, an image-prompt input + Generate button paints a VARIATION of the current character portrait: the prompt and the portrait are sent together to an image-capable AI (Nano Banana / Gemini), which now accepts an input image so it can do image-to-image. The result is stored in the gallery and shown; thumbnails enlarge on click and can be removed. Adds image-to-image support to the Nano Banana provider (an inlineData input part), imageToInlineData(), a supportsImageInput provider flag, and paintImageVariation()/imageToImageProviderId() to route variations to a capable provider (with a clear error if none is configured). The gallery serializes with the save and backfills on older ones; the section keeps its open/closed state across the frequent profile re-renders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Items of type "treasure" are now collected into a new player.treasure trove (kept apart from the usable inventory) for the player to admire, and each one gained raises the character's hidden Fame — a flat amount per unit plus a value-scaled bonus. On pickup or a GM addItem, a treasure-type item is routed to the trove (stacking a same-named one) instead of the inventory; ordinary items are unaffected. A new "Treasure" section on the Character Profile (after Inventory) lists the trove with the same card look as Inventory (gilded names), and each entry opens its detail popup (which paints a portrait on the fly). The GM is told that type "treasure" files a prize into the trove and boosts Fame. The trove serializes with the save and rehydrates/backfills on restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Reorder the Character Profile sections so Attributes sits directly beneath Vitals (ahead of Statuses and Factions), keeping the two stat blocks together. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
A book-icon button now sits at the bottom-right of the Story panel, just left of the +/- text-size controls. It binds the playthrough into a novel-formatted, illustrated document and hands it to the browser's print dialog (→ Save as PDF). The book draws its prose from the story log: each room scene opens a new chapter (title + inline banner + the room description), and the GM narration, combat, and ambient beats between them become justified paragraphs with drop caps (ambient set in italics). Player command echoes and UI notices are dropped so it reads like a novel, not a transcript. An illustrated appendix — The People, Beasts & Foes, Relics & Curiosities — gathers the people, creatures, and items encountered, with their images from the Compendium and inventory. A cover page carries the character's portrait, name, class, and the in-world date. Printing waits for the book images to finish loading first. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Editor Map tab now has a bottom inner-tab bar (matching the World, Art, and Environment tabs) with two panels: "World" holds the existing realm/room map, and "Regions" is a placeholder for now. switchMapInnerTab toggles the active panel/tab and (re)draws the realm map when World is shown; switchEditorTab renders the active inner panel when the Map tab opens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The on-the-fly portrait generation was wired only into showStoryItemDetail, so opening an item from an NPC card (or the sidebar, room, editor, map, classes, or compendium popups) showed the glyph and never generated art. Route every item popup through a shared showItemPopup(popupId, it) that opens the popup AND calls ensureItemPortraitForPopup, and add the same call to the wrapper openers (editor/map) and the Compendium path (which now surfaces the resolved item via compendiumDetailBodyFor). So an imageless item paints its portrait on the fly wherever it is viewed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Character Profile now shows a "Fame" row — the character's public renown as a label (Unknown → Locally Known → Recognized → Notable → Renowned → Famous → Legendary). It is derived from a new hidden, GM-only player.fameValue (never shown to the player), or a label the GM assigns explicitly. fameValue rises over time on its own: completing a quest beat awards fame (with a bonus for finishing a quest), and hoarding treasure credits fame as PEAK wealth crosses each milestone (so spending never costs fame). The GM can also nudge it via stateChanges.fameDelta or set the label via stateChanges.fame. Each tier carries a persuasion bonus that is added to positive NPC reputation deltas alongside the CHA bonus, so a more famous hero wins others over more easily; the system prompt reports the label, the hidden value, and the bonus to the GM. A story notice fires when the Fame label changes. Fame persists with the save and backfills on older ones. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Races tab had an Import/Export group beside its Collapse/Expand
buttons; add the same to the other editor tabs with object cards — NPCs,
Monsters, Rooms, Classes, Items, Flora, Fauna, Encounters, and Factions —
so each object type can round-trip its JSON.
A generic driver (editorExportType / editorImportTypeFile) plus per-type
adapters in EDITOR_IO handle each store: export downloads the currently
LISTED objects (respecting the tab filter) as { <key>: … }; import merges
them back into the running world, updating a same-id/same-name object
rather than duplicating (mirroring the Races behavior) and re-rendering the
tab. Entities import through the existing placement path (update every
same-named being, or create in its location, falling back to the starting
room); rooms/items/classes/encounters/factions merge into their stores.
Imports accept the wrapped form, a bare array, or a bare id-keyed object,
and report added/updated counts in the tab output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXBump .msg-room .room-title from 15px to 18px so the room name reads larger at the top of each room scene in the story narrative. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The .env-inner-tab button selector was missing from the inner-tab button style rules (base, :hover, .active) — only .world-inner-tab and .art-inner-tab were listed — so the Environment panel's Flora/Fauna tabs rendered as unstyled default buttons. Add .env-inner-tab to all three rules so they match the World and Art inner tabs (uppercase display font, gold active underline, dim/gold hover). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
When an item popup opens for an item with no picture, the portrait is now
synthesized on the spot: it first ensures an image prompt exists — asking
the GM to author one from the item's details if the field is blank — then
paints the portrait, stores it on the item (and its type/compendium so it
is not regenerated), and swaps it into the popup. A small pulsing status
message ("Writing a prompt…" / "Generating portrait…") shows in the portrait
space while it runs, and the flow bails out silently if the popup is closed
or shows a different item by the time the image is ready.
Adds ensureItemPortraitForPopup(), requestItemImagePrompt(), and a shared
itemImagePromptDirective() (also used by the Items-editor ✨). showStoryItemDetail
kicks it off after opening the popup. Missing prompt + no GM key leaves the glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXPreviously a play-with-ambient beat only played a sound if a clip had been pre-generated in the editor; otherwise it fired silently. Now, when such a beat triggers without a stored clip, the engine synthesizes one on the spot: it first ensures a soundPrompt exists — asking the GM to author a short text-to-sound-effect description from the beat + room if the field is blank — then generates the clip via the Audio AI provider, stores it on the act (so it saves with the world and is not regenerated next time), and plays it. Adds ensureAmbientSoundReady() and requestAmbientSoundPrompt(); requestRoomAmbient now awaits the former before playing. Missing keys (Audio AI / GM) are logged and the beat simply plays no sound. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On a page refresh the restored story could land above the bottom: the single synchronous scroll fired before the restored messages images and fonts finished laying out, and #narrative's scroll-behavior: smooth turned the scroll into a ~1s animation. Add scrollNarrativeToBottomSoon(): it jumps instantly to the bottom (bypassing the smooth-scroll) and keeps re-pinning across a short animation-frame settle window so late layout growth cannot leave the view short. It yields the moment the reader deliberately scrolls up (detected as scrollTop dropping below the last set value, which content growth alone never causes), and a window load listener catches images that finish after the frame window. The restore path now calls it instead of a lone scroll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Ambient beats (room ambient beats and NPC ambient behaviors) were printed as plain narrator text, indistinguishable from the main story. Give them a dedicated "ambient" message type rendered in italics (.msg-ambient) so they read as background flavor. Speech within an ambient beat is still brightened (addMsg now highlights the ambient type too), and the type serializes with the message log so it survives a restore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
A small −/+ button group pinned to the bottom-right of the Story view lets the player scale the narrative text up and down. It applies to #narrative only (via a --story-font-scale CSS variable used as zoom), so the change is confined to the Story tab and leaves the sidebar, editor, and other views untouched. The chosen size is clamped to a sensible range, remembered across sessions in the player settings, and the buttons disable at the min/max bounds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The per-time-of-day atmosphere editors in the Rooms card were two-row textareas. Swap each for a single-line text input (value carried in a value="" attribute, resize disabled, trimmed padding) so the six mood slots read compactly. The roomSetAtmosphere handler is unchanged (still reads el.value) and the ✨ GM-suggest button stays inline to the right. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
An upstream change moved the "You notice" line onto a dedicated --younotice color variable. Update the assertion to match; the intent (a brighter tone than --text-dim) is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The "/Exits:" chips in the Story tab used a bespoke rounded-pill style. Restyle .exit-chip to mirror the sidebar .exit-badge: small uppercase gold-dim outlined tags (2px corners) that brighten to gold with a gold-glow fill on hover, so exits look consistent in both places. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The "/" Field Guide reply is a JSON object whose "html" value is an HTML fragment. When the model left double-quotes in HTML attributes unescaped (e.g. href="guide.html"), strict JSON.parse failed with "Expected \x27,\x27 or \x27}\x27 after property value", so no answer was shown. Add parseFieldGuideResponse: it tries strict JSON first, then falls back to a lenient extraction that recovers "remark" and the trailing "html" value even when inner double-quotes are unescaped (html runs to the last quote before the closing brace). Also steer the model to use single-quoted HTML attributes so the JSON stays valid in the first place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The exits after a room description were plain underline-on-hover links. Make each direction a small gold-outlined pill chip that fills in on hover, turning every exit into a clear tap target. Space-separated instead of comma-joined, and the trailing period is dropped now that the directions read as buttons rather than prose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The floor-items line rendered in var(--text-dim), a muted brown-gray that was hard to read against the dark background. Switch it to the normal body text color (var(--text)) so noticed items read clearly, matching the brightness of the surrounding room description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Typing a line that starts with "/" in the Story input now asks an out-of-character question about the app/UI instead of taking a game turn. A dedicated (non-GM) Field Guide persona fetches guide.html same-origin, extracts the sections most relevant to the question by keyword scoring, and answers with curated, sanitized HTML styled to match the app. The answer appears in a non-modal popup pinned to the upper-right of the Story tab, with a friendly remark above it and a link to the full guide, so the player can keep it on screen while they act and close it when done. - handleSend routes "/"-prefixed input to handleFieldGuideCommand before the in-world player echo, so no game turn is spent - extractGuideSections / selectRelevantGuideSections pull and rank guide content; sanitizeGuideHtml allowlists tags/attrs before innerHTML - showFieldGuideAnswer / closeHelpPopup drive the #help-popup - graceful fallback to the full guide when no API key is set Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Each "sound" room-ambient act gains a checkbox (left of its sound-prompt input on the room card) — playWithAmbient. When set and a clip has been generated, that clip plays via howler.js alongside the ambient beat's text as it fires in play (requestRoomAmbient). Generated ambient sound clips are now stored on the act (act.sound) and saved with the world, so they survive save/reload (▶ play and the play-alongside both use the persisted clip). normalizeAmbientAct carries sound + playWithAmbient; setRoomAmbientPlayWithAmbient toggles the flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Beneath each per-time Sounds prompt (Rooms card → Prompts → Audio → Sounds), two buttons: "Generate Audio" synthesizes the sound from that prompt via the Audio AI provider (Settings › Sound AI) and plays it immediately through howler.js; "Play" replays the last-generated clip. The clip is kept in memory only (never serialized). Guards on a blank prompt and a missing ElevenLabs key; errors surface via generateSoundFromPrompt's logging. timePromptSub gained an optional extraActions slot so only the Sounds category adds these buttons (Music/Banner are unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
A "sound" room-ambient beat now carries a soundPrompt (a text-to-sound- effect description, distinct from its narrative prompt). On the room card it appears as an editable child row beneath the sound beat, with a tiny ✨ generate button and a ▶ play button. Generate synthesizes the clip via the Audio AI provider (Settings › Sound AI → generateSoundFromPrompt), falling back to the ambient's narrative prompt when soundPrompt is blank, then plays it immediately via howler.js; ▶ replays the last-generated clip. The clip is kept in memory only (never serialized). Errors surface through generateSoundFromPrompt's logging. soundPrompt is normalized/persisted on the act, and the room-edit GM directive notes the GM may author it for a sound beat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
New Settings › Sound AI section with a provider <select> (ElevenLabs for now). All sound generation routes through generateSoundFromPrompt → generateSoundWithProvider → the chosen provider, mirroring the image dispatch. The ElevenLabs client targets its Sound Effects API (/v1/sound-generation) with the stored ElevenLabs key and returns the audio as an audio/mpeg data URI. Start/success are logged as 'ai' lines (naming the provider, and the subject if given); failures log the service's raw response as a collapsible row, like image + GM responses. Adds getSoundProvider/setSoundProvider/resolveSoundProvider, SOUND_PROVIDERS, soundError, and audioBufferToBase64; the settings popup reflects the saved sound provider on open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Below a selected region's description, the detail panel now shows a wide banner image (a 🗺️ placeholder when none is set) with Generate and Upload buttons beneath it, then an editable image-prompt field with a tiny ✨ GM-suggest button below it. Generate paints from the prompt (falling back to one derived from the region's name/description) into region.bannerImage; Upload stores a downscaled data URI; ✨ asks the GM for a prompt built from the region's description. Data: regions gain bannerImagePrompt (threaded through buildRegionsFromList + normalizeRegions, persisted). The region-plan GM directive now asks the GM to author a bannerImagePrompt per region at creation, from its description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Each floor item listed in a room's "You notice:" line now shows its emoji icon to the left of its name (in an un-underlined notice-icon span); items without an icon render just their name as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Races toolbar gains an Import/Export pill group to the left of the
Collapse/Expand group. Export downloads the currently listed races
(honoring the name filter) as { races: { "<id>": {…} } } JSON via the
shared file-save helper. Import opens a file dialog and merges races from
a JSON file into the world — accepting the { races }, bare id-keyed, or
array shapes, creating new races and merging same-id ones (all fields,
portraits included, preserved). Core split into racesExportObject/
importRacesFromData for testability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXupdateRealmCalendar now evokes one ambient beat for the current room at
the new time of day whenever the time-of-day label changes (bypassing the
chance roll), so a place that was quiet at dawn bursts to life at evening
the moment the hour turns; the chance/interval timers then carry on. The
shared evokeRoomAmbientOnEntry helper gained a reason label used in the
log line ("entering" vs "the time change").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXEntering a room now always evokes one ambient beat for the current time
of day, bypassing the chance roll, so a lively place greets the player
immediately; the per-beat chance/interval timers then carry the ongoing
ambience for the rest of the stay. Wired via evokeRoomAmbientOnEntry at
the end of describeRoom (the canonical room-entry moment).
Ambient beats are logged under the "ambient" category at every stage:
evaluation (skipped/triggers in tryRoomAmbient), the on-entry evocation
("evoked on entering"), the GM handoff, and the produced beat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXWhen an image generation fails, the provider now attaches the service's raw response (body text, parsed JSON, or the attempted URL) to the thrown error, and paintImageFromPrompt logs it as a collapsible row — the same way GM text responses are logged — so failures (quota, auth, malformed output) can be diagnosed from the Logs. Falls back to a plain line when there's no readable body. Adds imageError()/readImageResponseBody() helpers; the Pollination, Nano Banana, and Higgsfield generators now carry raw context on failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Rooms gain an ambient field: background beats grouped by time of day
(dawn…midnight), each { type, prompt, chance, interval }. While the
player lingers in a room during a matching time of day, a timer rolls
each beat's chance and, on success, asks the GM for one short ambient
line (overheard song/laughter for "speech", an environmental "sound",
or a bit of described "action") — so a tavern sings at night but sits
quiet at dawn. It's a standalone GM call that prints as narrator text
without touching game state, and it reflects the room's atmosphere.
- Data: normalizeRoomAmbient/normalizeAmbientAct; wired into the Room
constructor, addRoom, applyRoomSpecToRoom, and serialized with rooms.
Tolerates a legacy flat array (acts naming their own time(s)).
- Runtime: setupEncounters registers a timer per (time bucket, act);
tryRoomAmbient gates on room + current time of day + chance; then
requestRoomAmbient makes the GM call and prints the beat.
- The Rooms-tab GM directive documents the ambient schema and tells the
GM to author fitting ambient when it CREATES a social/atmospheric room.
- Room cards show a read-only Ambient section listing the beats by time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe race-edit handoff now instructs the GM to always include "lore" (2-4 sentences of hidden history/significance, revealed only once unlocked) and "loreKey" (one concrete in-world unlock condition) for every race it CREATES, and only when changing them on edits. Both fields are added to the allowed field list and to raceFieldPatch, so returned values persist on the race (starting locked); they surface through the card's existing Lore section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
paintImageFromPrompt now takes an optional subject name and includes it in its "Generating/Generated a … image for \"<name>\" via <provider>" AI log lines, so the Logs show WHAT each image was for. Threaded the name through the callers — races, factions, NPCs/monsters, items, encounters, room banners, the player portrait, and the Compendium/Art regenerate. Omitting the subject keeps the previous unadorned line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Each race now returns a detailed description plus a verbose portraitPrompt, so a batch like "create 4 races" overran the 2000-token cap and the JSON was cut off mid-object. Raise max_tokens to 8000 for the race-edit call, matching the headroom the ambient/world-gen calls use. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
After a GM race edit, requestRaceEdit collects the ids of newly-created races and hands them to a new autoGenerateRacePortraits() pass. It paints a portrait (in the background, sequentially, with a GM status note) for each created race that arrived with a portraitPrompt and has no image yet, re-rendering its card as the art lands — so a GM-authored race shows up with a portrait without the DM pressing Generate. Races without a prompt, or that already have a portrait, are skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The race-edit handoff now instructs the GM to always include a "portraitPrompt" for every race it CREATES — a vivid head-and-shoulders portrait prompt describing the people's characteristic features in the world's style — and to include it on edits only when changing it. portraitPrompt is added to the allowed field list and to raceFieldPatch so a returned prompt is stored on the race (and drives its portrait Generate without further input). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
requestRaceEdit already ran with buildSystemPrompt() as the system prompt (which carries the prologue, rules, and canon), but the world's tone and theme fields were never injected. Add a WORLD CONTEXT block to the race-edit directive with theme, tone, a prologue excerpt, and a canon excerpt, plus an explicit instruction to design races that fit this specific setting — mirroring the per-card description generator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Race cards now mirror the Factions/Items card design: a portrait media column with Generate / Upload (and Regenerate + Upload once an image is set), a collapsible Prompts → Portrait subsection for the image prompt with a ✨ GM-suggest, and the shared DM-only collapsible Lore section. Data: normalizeRace gains portrait, portraitPrompt, lore, loreKey, and loreUnlocked. The shared compendium lore/prompt machinery is wired for the new "races" category (compendiumTypeContext, applyCompendiumLoreField → applyRaceTypeField, compendiumGenerateLore, compendiumSuggestPrompt), and dedicated race portrait handlers (generate/regenerate/upload + raceImagePrompt/generateImageForRace) mirror the faction ones. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
New Editor › Races tab, modelled on the Factions tab, with the same
shared widgets: a name filter (with clear button), Collapse all / Expand
all, one collapsible card per race, and a bottom GM request box that
creates, edits, or removes races from a natural-language instruction
(strictly scoped to race data).
Each race card shows an editable Description and Detailed Description
(each with a ✨ GM-generate button) plus a Details section (lifespan,
homeland, traits, race id).
Data: world.races is an id-keyed map { name, description,
detailedDescription, traits[], lifespan, homeland }, normalized by
normalizeRaces/normalizeRace, seeded in the built-in world (Humans, the
Moorborn, the Hollow), serialized with the world, and restored on load.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe per-time atmosphere rows were always expanded, adding a tall block to every Room card. Wrap them in a collapsible <details> (the app's standard .item-prompt summary/caret pattern), collapsed by default, so the moods are tucked away until the DM opens the section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The per-time atmosphere moods were reachable only through nested collapsible accordions, with the ✨ suggest button below each box. The Rooms card now renders one directly-visible row per time of day (dawn…midnight): a small time-of-day label above a labelled textarea, with a tiny ✨ GM-suggest button inline to its right — the same .item-desc-edit layout used by the item/monster description fields. roomSuggestAtmosphere now resolves the open textarea via .item-desc-edit (the button moved out of the old .item-prompt wrapper). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Rooms gain an atmosphere property — a short mood phrase per time of day (e.g. "tense and gloomy" after midnight, "lively and raucous" in a tavern at evening). It's GM-eyes-only steering, not shown to the player. The current room's atmosphere for the current time of day is injected into buildSystemPrompt (present when set, omitted when blank), so it colors how the GM evaluates actions and how NPCs here speak, and it is also woven into the ambient-behavior handoff so overheard speech and ambient beats reflect the room's mood. The DM Rooms editor card gains a collapsible Atmosphere section with one per-time subsection (edit + ✨ GM-suggest), mirroring the banner/audio prompt sections. Data model: atmosphere is normalized to six slots (normalizeRoomAtmosphere), seeded/restored by addRoom, queried via Room.getAtmosphereFor, and persisted with the room. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
In the story tab's "Present:" line, each entity's name is now tinted by their reputation with the player: green for positive standing, red for negative, deepening in saturation the more extreme the value. A neutral (near-zero) reputation resolves to a legible light tone so it stays readable against the dark background rather than fading out. Adds reputationNameColor(rep) — clamps to ±100 and maps magnitude to HSL saturation/lightness — applied inline on each present-link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The room popup (map, NPCs, monsters, quests, regions, and the editor map) now shows a Region field. When the room's region matches a defined region, the name is a hyperlink that opens a new region detail popup floating to the left of the room popup, mirroring the faction popup pattern. The region popup shows the region's bannerImage (a placeholder map graphic for now), name, and description. Regions gained a bannerImage property (empty placeholder) that is seeded by buildRegionsFromList and preserved by normalizeRegions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Factions subview (#editor-sub-factions) was missing from the position:relative group that anchors each editor tab's absolutely- positioned corner toolbar. As a result the Factions filter box and collapse/expand buttons anchored to a higher ancestor and rendered on top of the editor tab bar instead of inside the tab. Add #editor-sub-factions to that group so its toolbar sits inside the tab like every other tab. Add a regression assertion in tests/test_factions_editor.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The login "Edit World" notice ("Editing … in a separate window") lingered
after the detached editor window was closed. Popups fire no reliable close
event, so poll the opened window's `closed` flag and clear the notice once
it's gone (same-origin, so win.closed is readable). Only clears if that
notice is still showing, so it can't wipe a later message.
Extend tests/test_login_edit_world.js: the notice stays while the window is
open, clears once it closes, and doesn't clobber an unrelated message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXSince world-draft edits now persist across "Edit World" re-opens, add an explicit way to start over: a "Reset from Saved Game" control in the draft editor's World › Chunks panel. It discards the draft's edits and reloads a fresh copy of the saved game's WORLD. Only the world is replaced — the saved game and its character are never read or written by the draft editor (the draft is a standalone copy), so resetting the world you're editing never touches your playthrough. - resetDraftFromSavedGame(): rebuild the live world from the saved game's snapshot, persist it as the draft, re-render; guarded by a confirm modal (destructive) and only runs in the draft editor (IS_DRAFT_EDITOR). - The control is draft-only (hidden unless body.draft-editor, which the draft editor now stamps). - Extend tests/test_login_edit_world.js for the markup, gating, and reset wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Editing a world via the login "Edit World" button (openSavedWorldInEditor)
re-seeded the world draft from the saved game EVERY time it opened, so any
edits made in a prior session — new regions, room→region assignments,
region renames/descriptions — were silently discarded on close/re-open.
Seed the draft from the saved game only when no draft by that name exists
yet; if one already exists, keep it so its edits persist (and note that
we're "Resuming your edits"). The draft stays decoupled from the live game
("your saved game is untouched").
Update tests/test_login_edit_world.js: add a no-clobber regression (an
edited draft survives a re-open) and give the "Edit this save" case a
distinct world name so it still exercises fresh seeding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe selected region's detail panel now shows an editable name input and a description textarea. Editing persists and re-renders so the change shows everywhere (map label, chip, room dropdowns). - setRegionName renames the region and re-tags every room filed under the OLD name (case-insensitive) to the new name, so no room is orphaned by the rename; a blank name is rejected. setRegionDescription updates the description. Both save and re-render (and refresh the Rooms cards if that tab is open). - CSS for .region-name-edit / .region-desc-edit. - Extend tests/test_regions_editor.js: the fields are wired to their setters, rename re-tags matching rooms (and leaves others alone), a blank name is rejected, and the description sticks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Regenerating the region map produces new region names, so every room's previous region assignment would dangle (point at a region that no longer exists). _doGenerateRegions now clears every room's region back to "" on a successful regeneration, and logs how many were reset. The DM re-files rooms via each room's dropdown in the room panel. Extend tests/test_regions_editor.js to assert regeneration installs the new region set and empties every room's region. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Each room in the World › Regions room-list panel now has a region <select> below its name — "No Region" (the default) plus one option per region. Choosing a region sets that room's region and re-files it under the matching filter immediately; "No Region" clears it. - renderRegionRoomsPanel renders buildRoomRegionSelect(room, regions) per row; the room name stays the clickable link to the room popup, the dropdown sets the region. A room carrying a region not in the list (an authored/custom name) is preserved as its own selected option. - setRoomRegion(roomId, name) writes room.region, saves, re-renders the panel, and refreshes the Rooms cards if that tab is open. - CSS: the whole-row click affordance moves to .rr-room-name; add .rr-room-region select styling. The No-Region detail hint now points at the dropdown. - Extend tests/test_regions_editor.js for the dropdown (options, default, pre-selection, assign-and-refile, clear). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Worlds now carry a short "rules" paragraph — how reality works here (is there magic? is it grounded in earthlike physics? what is and isn't possible?) — that shapes both generation and every turn of play. - Data model: world.rules (defaults ''), wired through the World constructor, serializeWorld, rebuildWorldFromSnapshot, and the world-expansion digest. The built-in world ships a rules paragraph. - Per turn: buildSystemPrompt injects a "World Rules — How Reality Works" section (marked BINDING), before the Prologue, whenever rules are set; omitted when blank. - Generation: requestWorldGeneration takes an authored rules paragraph (binding) or asks the GM to author one, and the schema now includes a "rules" field; generateNewWorld stamps it onto the world. requestPrologue is rules-aware so the prologue stays consistent. Region expansions feed the rules too. - World Editor: a "World Rules" textarea (above Prologue); collected by collectWorldEditorFields and restored by populateWorldEditorFields (falling back to the world's own rules). - Tests: extend test_world_framing.js (round-trip + digest + generation) and add test_world_rules.js (system-prompt injection, WORLD_DATA, form collect/populate, gen/prologue directives). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Replace the standalone Flora and Fauna editor tabs with a single
"Environment" tab (between Items and Art). Flora and Fauna become inner
subtabs inside it, with the tab bar aligned at the BOTTOM — matching the
World tab's Chunks/Regions and the Art tab's Missing/Review pattern.
- Markup: a new #editor-sub-environment wraps the (unchanged) flora and
fauna panels as .env-inner-panel bodies with a bottom .env-inner-tabs
bar; all inner ids (flora-view, fauna-view, *-edit, etc.) are preserved
so the existing render/edit handlers work untouched.
- CSS: .env-inner* joins the shared world-inner/art-inner inner-tab
styling; the panels get position:relative for their absolute corner
toolbars.
- switchEditorTab swaps the flora/fauna tab+subview toggles for
environment and renders the active inner panel; a legacy
switchEditorTab('flora'|'fauna') call now redirects into Environment.
New switchEnvInnerTab(sub) toggles the Flora/Fauna inner panels.
- Update tests/test_flora_fauna.js for the nesting (Environment tab,
bottom inner tabs, inner-tab switching, legacy redirect).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXClicking a room in the Regions room-list now opens the shared room-detail popup positioned to the LEFT of the room-list panel (overlaying the map), reusing the app's existing room popup machinery. - Add #regions-room-popup (a .room-popup-body host) inside the Regions panel; it joins the shared popup CSS group and is offset to sit just left of the 264px room panel (right: 296px). - Room rows are now clickable (role=button, hover affordance) wired to showRegionRoomPopup(id) → showEntityRoomPopup(id, 'regions-room-popup'). - Close the popup when the region selection changes or the map is regenerated, so it never shows a room from a stale filter. - Extend tests/test_regions_editor.js: room rows wire to the opener, the popup element/positioning exist, opening fills + shows it, and changing the selection closes it (harness querySelector now caches). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Regions editor now has a right-side room list panel that filters the
world's rooms by the selected region, plus a "No Region" chip (to the
left of the region chips, identical in look) that lists rooms whose
region property is unset.
- Two-column layout: the map/chips/detail on the left, a scrollable room
panel on the right (wraps below on narrow widths). The panel header
shows the active filter + a room count; each row shows the room name
and its region tag.
- Selection model gains a REGION_NONE ('__none__') sentinel — the "No
Region" filter. Selecting a region (map polygon or chip) lists rooms
whose region name matches it; "No Region" lists rooms with a blank
region; nothing selected shows a prompt.
- regionSelectionRooms() / renderRegionRoomsPanel() do the filtering;
matching is case-insensitive on the region name.
- Extend tests/test_regions_editor.js: the No Region chip precedes the
region chips, the panel filters by region and by no-region, headers +
tags render, and No-Region selection marks no map polygon selected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe DM Editor › World › Regions tab now generates a map of the world's
continent divided into named, selectable regions. A Generate button asks
the GM to name the regions from the world's prologue/lore; the engine
lays them out and draws an interactive SVG map. Regenerating when a map
already exists prompts a confirm modal first.
- Geometry engine (pure, deterministic): a seeded PRNG + convex-hull
landmass outline + Lloyd-relaxed Voronoi partition, clipped per cell so
the regions tile the continent exactly. regionMapGeometry(count, seed)
and buildRegionsFromList(specs, seed) turn a GM region list into the
persisted shape; the map is a pure function of (count, seed) so it is
stable across reloads.
- Data model: world.regions { seed, viewBox, outline, list:[{ id, name,
description, color, polygon, label }] } via normalizeRegions, wired
into the World constructor, serializeWorld, and rebuildWorldFromSnapshot
(empty for saves that predate it).
- GM handoff: requestRegionEdit() asks for 3–7 region names+descriptions
grounded in the prologue/lore/tone (no coordinates — the engine draws
the map); dmGenerateRegions() confirms before overwriting an existing
map, _doGenerateRegions() builds + persists + renders.
- UI: renderRegions() draws the SVG (per-region polygon with hover
highlight + click-to-select, name labels, coastline), a chip row, and a
selected-region detail panel; selectRegion() toggles selection; the
Regions inner tab renders on show.
- Add tests/test_regions_editor.js (geometry determinism/tiling,
normalize round-trip, persistence, render markup, select/confirm,
GM parse).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXImage-generation log lines now identify which provider was consulted, so the Logs alone show what generated each image and when. paintImageFromPrompt logs the provider on the start line, a new success line, and the failure line; the label reflects the provider ACTUALLY used, so a keyless provider that falls back to Pollination is logged as Pollination (with a note explaining the substitution) rather than mislabeled as the selected one. Extract resolveImageProvider() (the post-fallback provider id) and imageProviderLabel(); generateImageWithProvider logs the substitution with both provider names. Update tests/test_image_provider.js and tests/test_ai_log.js to assert the provider appears in the log lines (start, success, failure, and fallback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The player's own portrait (the Character sheet's "paint my portrait" request and the ♻ Regenerate button) called buildPortraitUrl() directly, so it always used Pollinations regardless of the Settings › Image AI choice — the one generation path that ignored the provider selection. Route it through paintImageFromPrompt() like every other generated image (NPC/monster portraits, item images, room banners, faction emblems), so selecting Nano Banana Pro or Higgsfield now applies to the character portrait too. paintImageFromPrompt already loads the image before it resolves, so a failed/rate-limited request still leaves the existing portrait untouched and reports the error. Extend tests/test_image_provider.js: the character-portrait branch no longer builds a Pollinations URL directly, and painting the portrait with a non-Pollination provider selected produces that provider's image. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Refresh the git-history snapshot: 591 commits across 9 days, adding the July 8th entries (Character factions section, the Editor→Factions "Add to Player" button, the field-guide update, and the item-catalog seeding on class/NPC/room edits). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
When the GM creates a class, NPC, monster, or room and gives an entity
an item that isn't in the world's item catalog yet, the loadout/floor
reference used to dangle (resolving to "Unknown Item"). Now the GM can
return the new item definitions in a top-level "items" object alongside
the edit, and the engine seeds them into ITEM_CATALOG before resolving
any { ref }, so the reference resolves.
- Add registerCatalogItemsFromChunk(chunk): additive, id-keyed (or
array) catalog seeding that never overwrites an existing id and skips
minor items; extract the shared catalogItemShape() normalizer.
- Call it in requestClassEdit / requestEntityEdit / requestRoomEdit
before applying specs; surface a "new item(s)" count in the log and
return value, and refresh the Items editor when any are added.
- Teach the class, being, and room directives the "define any new item
you reference" rule (inline or via the top-level items object), and
list the existing catalog ids in each.
- NPC/monster edits now accept an "inventory"/"addInventory" field so a
being can actually be given items (applyNpcSpecToEntity + directive).
- Add tests/test_gm_new_items.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXBring guide.html in line with the current game: - Factions (new player section): reveal-based discovery, the shared faction popup, the Character-sheet chips, the NPC-popup Factions field, the Compendium Factions tab, and the Journal Factions timeline. - Editor: bump the subtab count to thirteen and add rows for the new Factions and Art tabs; add an "Editing factions" section and an "Art tab" section (Missing/Review, batch Generate → Stop → Confirm). - Compendium (player + DM): list Factions among the subtabs and the per-type art/prompt and per-entry Lore kinds. - GM: note the login model picker (Sonnet 5 default / Opus 4.8), add the faction dossier + reveal rule, and document factionReveal in the change-field reference. - Small fixes: CON on being cards, Journal placeholder list, glossary Faction entry, and TOC links for the new sections. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Each faction card in the DM Editor › Factions tab now has a bottom toolbar with a single right-aligned gold "Add to Player" button that adds that faction to the current player's memberships (upsert by ref, so it won't duplicate). Once the player belongs to the faction the button renders disabled as "Added ✓"; with no active player it is disabled with an explanatory hint. Adding refreshes the Character sheet's faction chips if that tab is open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Character › Profile sheet now has a "Factions" section listing a clickable
chip per faction the player belongs to (its display name + the player's role).
Clicking a chip opens the shared faction detail popup in the upper-right corner
of the Character panel (showFactionDetailFromLink's no-host fallback).
- Player gains a `factions` list ([{ref, role, reputation}], like entities),
normalized/backfilled on load; only refs that resolve to a world faction are
shown/linked, deduped by ref; no memberships shows an empty line.
- The faction popup's outside-click dismiss now also excludes .char-faction-chip
so a chip click opens (rather than instantly closing) the popup.
Test: new test_char_factions.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe Art tab now has bottom-aligned inner subtabs — "Missing" and "Review" —
mirroring the World tab's Chunks/Regions inner tabs for visual consistency. The
existing missing-art dashboard (toolbar, cards, and Generate bar) moves into the
Missing panel; Review is a placeholder for now.
- Generalized the World tab's .world-inner* bottom-inner-tab CSS to also cover
.art-inner* (grouped selectors) so both look identical.
- New switchArtInnerTab('missing'|'review'), mirroring switchWorldInnerTab;
returning to Missing re-renders the list.
Tests: new test_art_inner_tabs.js; loosened two CSS-selector regexes in
test_world_inner_tabs.js for the now-grouped selectors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe batch re-render after each card ran renderArt(), which re-applied the global Collapse-all state — so a card the DM had expanded to watch got re-collapsed when its generation finished. renderArt now takes an opts.preserveOpen flag that skips the global collapse re-apply, and the batch loop snapshots each card's open/collapsed state before the re-render and restores it after. A card the DM expanded stays expanded; a collapsed one stays collapsed. Plain renders (filter typing) still honor Collapse-all as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
During a batch run, each card now updates in place as it finishes — the tab re-renders (scroll preserved) so the just-generated card shows its new portrait prompt and image instead of the empty "Generate" state. buildArtRoomCard now renders a room's banner when present (it previously only ever showed the empty state). Generated cards are KEPT on the tab (via artGeneratedKeys) for review rather than dropping off immediately. When the batch finishes, the button becomes a green "Confirm"; clicking it dismisses the now-arted cards and clears the Filter-by-name box. The button thus cycles Generate → Stop (while running) → Confirm (when done). Test: extend test_art_generate_all.js for the keep-for-review + Confirm cycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
collapseAllArt/expandAllArt only toggled the DOM, with no persisted state, so a filter-triggered re-render rebuilt cards in their default expanded state — after Collapse all, typing in the filter re-expanded the matching cards. Track an artCollapsed flag (set by Collapse/Expand all) and re-apply it at the end of renderArt, so the collapse setting survives re-renders including filter typing. Test: extend test_art_tab.js for the persisted collapse state across filtering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
A bottom toolbar pinned to the Art tab holds a single gold Generate button, justified right. Clicking it batch-generates art for every card currently shown (so cards filtered out are skipped), one at a time from the top: - For each card, if it has no image prompt the GM writes one first, then the image is painted from it. Both steps are logged to the Logs tab and shown in the status bar. - The card being processed gets an animated header indicator (pulsing header + spinner). - Clicking again stops the run after the current card; the button reads "Stop" (outlined) while running and resets to "Generate" when done/stopped. Implementation reuses the shared compendium plumbing (compendiumTypeContext / compendiumSuggestPrompt / paintImageFromPrompt) directly so no re-render happens mid-run and the on-screen card order stays put. renderArt and the batch share a single artMissingLists() source so the drawn cards and the processed list can't drift. Test: new test_art_generate_all.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Art tab toolbar previously held only the collapse/expand group. Add a "Filter by name" box to its left (keeping the upper-left free for the tab's informational text), matching the filter boxes on the other editor tabs. - New artFilter state with setArtFilter / clearArtFilter (using the shared syncFilterClearBtn), and renderArt now filters all three missing-art groups (Characters & Monsters, Rooms, Items) by name. - The intro line notes when a filter is active, and shows a no-matches message instead of the all-done message when a filter simply matches nothing. Test: extend test_art_tab.js for the filter box + filtering behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Each faction card in the DM Editor now exposes the hidden "reveal" condition as a small (3-line) textarea with a ✨ Generate button, styled exactly like the Description fields (reuses the shared buildDescEditRow helper). - setFactionReveal persists edits to faction.reveal by id. - generateFactionReveal asks the GM to author a concrete, earnable in-fiction reveal condition (rule 13c) from the faction's data + world canon, reflecting it into the open textarea. Test: extend test_factions_editor.js for the Reveal field wiring + handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
In the Journal subtab timelines (Quests and Factions), the entry date moves out of the body and becomes a right-aligned left column, sitting to the LEFT of the dot/line marker. Restyled a little larger (12px) and gold, with a fixed column width so every entry's dot lines up down the timeline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Opening the faction popup from a faction link that isn't inside an NPC popup (e.g. an Editor NPC/Monster card) now places it in the top-right corner of the surrounding panel/tab — matching where the other editor popups appear — instead of anchoring it beside the clicked link. The in-NPC-popup behavior (float to the left of that popup) is unchanged. Test: extend test_npc_popup_factions.js for the top-corner fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On the DM Editor NPC (and Monster) cards, each faction in the Factions field is now a hyperlink that opens the shared faction detail popup. An unknown/dangling ref (no matching world.factions entry) stays plain text since there's nothing to open. showFactionDetailFromLink gains a fallback: when the link isn't inside an NPC popup (as on an editor card), it anchors the faction popup to the clicked link — to its left, flipping right when there's no room, clamped to the viewport. Test: update test_npc_factions_field.js for the linked names. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On the faction detail popup (opened from an NPC popup's faction link), drop the Detailed Description, Lore, and Unlock sections — leaving the portrait, alignment, description, and regions. Add a gold "Compendium" button at the bottom that closes the popup and jumps to that faction's card in the Compendium › Factions tab (via goToCompendiumEntry). Test: extend test_npc_popup_factions.js for the button wiring and the removed sections. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Entities carried only STR/DEX/INT/WIS/CHA — the CON (Constitution) attribute the player already has was missing. Add it across the board. - Entity model gains `con` (defaults to 8); reEntityObj backfills it for older saves/imports. - Authored an appropriate CON for all 12 default-world entities (e.g. Aldric the blacksmith 16, Skeleton King 16, Town Guard 14, Villager 10, frail Herbalist 9), grounded in each being's nature. - The NPC & Monster editor cards render CON alongside the other attributes. - CON is also surfaced in the GM dossier stat line and made settable via the entity-edit spec; the entity-edit and world-expansion directives now include con in the stats shape so the GM authors it too. Test: new test_entity_con.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The NPC detail popup now lists the factions the being belongs to. Each is a hyperlink that opens a faction detail popup positioned just to the LEFT of the NPC popup, showing the faction's portrait and details (alignment, description, detailed description, regions, and — for a DM — its lore). - buildNpcDetailHTML gains a "Factions" field (buildNpcFactionsFieldHTML). Non-DM players only see factions they've discovered (revealed, per rule 13c); a DM sees every membership. Unknown/undiscovered refs are skipped. - New #faction-detail-popup element (at #app level, position:fixed) with shared popup styling; showFactionDetailFromLink measures the host NPC popup's rect and floats the faction popup to its left, so it works in every context the NPC popup appears (story, map, compendium, editor). - Dismissed via its close-X, an outside click, Escape, or when a new entity popup opens. Test: new test_npc_popup_factions.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The DM Editor › Factions cards now carry the same bottom-of-card sections the
Items / NPC cards do: a "Prompts" section with a "Portrait" subsection holding
the faction's image prompt, and the shared DM-only Lore section (hidden lore,
unlock condition, unlocked toggle, GM Generate).
- New id-based setFactionPrompt (persists portraitPrompt) and suggestFactionPrompt
(delegates to the shared compendium prompt handoff, reflecting the result into
the open Portrait textarea).
- Lore reuses buildDmLoreSectionHTML('factions', name, faction), routed by name
through applyFactionTypeField, exactly like the editor Items card.
Test: extend test_factions_editor.js for the Prompts/Portrait + Lore sections
and the setFactionPrompt handler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe Compendium › Factions card now carries the same DM tooling the Items / People / etc. cards do: a collapsible Prompt section (the faction's image prompt), a collapsible Lore section (hidden lore, unlock condition, unlocked toggle), and ⬆ Upload / ♻ Regenerate controls beneath the portrait — all wired through the existing shared compendium machinery. - Faction schema gains portraitPrompt, lore, loreKey, loreUnlocked. - compendiumTypeContext resolves the 'factions' category (portraitPrompt + portrait); applyCompendiumLoreField dispatches to a new applyFactionTypeField; buildDmLoreSectionHTML / compendiumGenerateLore / compendiumSuggestPrompt gain a faction noun/subject and a heraldic-emblem image style. - generateImageForFaction now prefers the DM-authored portraitPrompt (falling back to the derived prompt) so the editor and Compendium stay consistent. - Extracted compendiumPortraitActionsHTML / compendiumPromptSectionHTML helpers, reused by both renderCompendium and the faction card. Tests: extend test_compendium_factions.js; update test_world_factions.js for the new schema fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Compendium now has a Factions subtab listing a card for every faction the player has discovered (i.e. revealed in play — one that has a Journal › Factions entry). It is a live view over factionJournal × world.factions, so there is no separate discovery bucket to keep in sync. - Reuses the shared Compendium name filter and the DM-only "Reveal all" toggle unchanged: with Reveal all on, a DM also sees every world-defined faction not yet revealed, dimmed and badged "Undiscovered". - A card shows the faction's portrait (shield placeholder when none), name, description, and alignment; the DM additionally sees the hidden reveal condition, never shown to a normal player. - New COMPENDIUM_TABS list drives subtab activation (categories + factions); the factionReveal handler refreshes the tab live when it's open. Test: new test_compendium_factions.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The faction-edit directive previously listed "reveal" as an optional field, so a GM-created faction often had no reveal condition and fell back to play-time guesswork. Instruct the GM to ALWAYS author a concrete, earnable reveal condition (grounded in the world) for every faction it creates, while leaving existing reveal conditions untouched on edits unless asked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Factions were auto-discovered the moment the player entered a room with a
member — a Journal entry appeared on sight. Replace that with a reveal the
GM decides through conversation/gameplay, gated by each faction's new
hidden "reveal" condition.
- normalizeFaction now carries the GM-only "reveal" string (the in-fiction
condition for revealing the faction to the player).
- buildSystemPrompt hands the GM a "Factions" dossier: every faction a
present being belongs to, flagged REVEALED or HIDDEN with its reveal
condition — so the GM knows what's still secret and how it's earned.
- New "factionReveal" GM response field + rule 13c: the GM sets it the turn
the player genuinely earns knowledge of a hidden faction; the engine logs
the Journal › Factions entry once and announces it in the story.
- describeRoom no longer auto-records faction encounters.
- recordFactionEncounter is refactored onto a shared recordFactionMet, and
a new revealFaction(ref, entityName) drives the GM path.
- The Factions editor GM box can author/preserve the hidden reveal
condition (factionFieldPatch + directive).
Tests: new test_faction_reveal.js; updated test_faction_journal.js and
test_world_factions.js for the renamed default faction ("The Guards") and
the new "reveal" field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXA GM edit that targets an entity by name (e.g. "add the Town Guards to the guards faction") only applied to the FIRST matching being, because requestEntityEdit resolved the target with findEntityByName (first match). When two NPCs share a name (the default world has two "Town Guard"), the second one was left untouched. Add findEntitiesByName(name) returning ALL live entities with a matching name, and have the apply loop update every match, reporting "Name (×N)" when more than one is affected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The DM Editor NPC/Monster cards now display a "Factions" field beneath the Reputation field, listing the factions the being belongs to — each resolved to its display name (from world.factions) with its role and standing in muted parentheses, e.g. "Guards (gatekeeper, trusted), Merchants Guild". An unknown ref falls back to the ref; a being in no factions shows "None". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
When the player first encounters a faction (via a being belonging to it), a story note now appears — "◈ Faction discovered: Guards." (or "Factions discovered: A, B" for several at once) — styled like the ambient "… is now nearby" notifications. recordFactionEncounter returns the newly-met faction names so describeRoom batches them into a single note; already-known factions never re-announce. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The first time the player encounters a being belonging to a faction, a timeline entry is recorded in the Journal's Factions subtab introducing that faction — once per faction (the first member met triggers it). recordFactionEncounter runs from describeRoom (the canonical "you see it now" moment) over each present entity's factions list; renderFactionJournal draws the entries in the same dot/line timeline shape the Quests subtab uses (date, faction name, title, descriptive text). The factionJournal is session state, persisted with the save (snapshot/restore/new-game reset, and spliced through the detached editor like the other play-state). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The NPC/Monster editor GM handoff now supports a per-entity "factions" field, so "add these NPCs to the guards faction" adds the faction ref object to each affected entity's factions list. applyNpcSpecToEntity merges the incoming memberships by ref (mergeEntityFactions): a new ref is appended with the usual defaults (role "member", reputation "liked"), and re-listing an existing ref updates only the fields the spec provided — existing memberships are preserved. The entity-edit directive documents the field and lists the world's faction ids for valid refs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Entities (NPCs/Monsters) gain a "factions" list of memberships, each
{ ref, role, reputation } where `ref` is a faction id (a key in
world.factions). normalizeEntityFactions coerces the list: role defaults to
"member" and reputation defaults to "liked" when blank/missing, a bare
string is treated as a ref, and ref-less entries are dropped. The Entity
constructor defaults it to [], makeEntity carries it from the entity's
data/catalog, and reEntityObj normalizes/backfills it on resume. It
serializes with the save as an own field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXFollowing the data-shape change (factions is now an object keyed by id, like
the item/entity catalogs), reworked all faction handling to match:
- normalizeFactions builds an id-keyed map, preserving authored ids and
tolerating a legacy array (id derived from each faction's name slug);
normalizeFaction normalizes one faction. World/serialize/rebuild use it,
defaulting to {} and backfilling {} for older saves.
- The Factions editor references factions by id (data-faction-id): render,
card handlers, collapse set, and the toggle listener all key by id;
findFactionById replaces findFactionByIndex.
- applyFactionSpec(id, spec) creates/merges by id; removeFactions deletes by
id (name fallback). requestFactionEdit's GM directive now asks for
factions as an id-keyed OBJECT (create = new id, change = existing id,
remove = ids), and parsing accepts the object form (array tolerated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe DM Editor "World" tab used "regions" as its internal moniker, colliding
conceptually with the world-chunk/Regions functionality it contains. Renamed
the outer tab's ids — etab-regions → etab-world, editor-sub-regions →
editor-sub-world, and switchEditorTab('regions') → switchEditorTab('world')
(with the matching sub === 'world' checks). The inner "Regions" subtab
(wtab-regions / world-inner-regions) and the region/chunk merge machinery
(regions-view, renderRegionsStitch, etc.) are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe Collapse all / Expand all buttons (and individual card toggles) on every DM Editor tab now persist across page reloads. The per-tab collapse sets (NPCs, Monsters, Rooms, Classes, Encounters, Items/Flora/Fauna, Factions) initialize from localStorage (SAVED_EDITOR_COLLAPSE_KEY) at load and are saved by saveEditorCollapse() on every change — the bulk buttons, the shared card-toggle listeners, and a new Factions toggle listener. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The "Editing … in a separate window. Your saved game is untouched." note set by Edit World / Edit this save was never cleared, so it persisted in the login DOM and reappeared whenever the login overlay was shown again (e.g. after logout). populateLoginScreen now clears it, so it only appears right after an explicit Edit World / Edit this save action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Factions editor tab now has the standard card toolbar — a "Filter by name" box (with clear) and Collapse all / Expand all — plus a GM request box at the bottom. The filter narrows the cards while preserving each faction's true index as the edit handle; collapse state is tracked by faction name. The GM box (dmEditFactions → requestFactionEdit) authors, edits, and removes factions from a natural-language instruction, STRICTLY scoped to faction data — it declines anything outside faction management. Helpers: applyFactionSpec (create/merge by name) and removeFactionsByName. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Expands the faction object schema with portrait, quests, and tasks fields (alongside name, description, detailedDescription, regions, alignment); normalizeFactionList coerces the new list fields and carries portrait. Populates the default world's "Guards" faction with the full schema. Adds a "Factions" tab to the DM Editor (after Encounters) that lists a card per world faction. Each card reuses the shared editor card controls: a portrait column with generate/upload/regenerate (painted from the faction's own details) and editable Description + Detailed Description with ✨ GM generate — mirroring the Items/NPC cards. Factions are referenced by index into world.factions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The DM Editor › World tab now has a bottom-aligned inner subtab bar. The existing world-chunk merge panel moves into a "Chunks" inner tab, and a new "Regions" inner tab sits next to it (placeholder for the forthcoming Regions editor). switchWorldInnerTab toggles the active inner panel + tab; the bar is styled as a bottom bar (border-top indicator) beneath a scrollable body. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Adds a world-level `factions` list, each faction being
{ name, description, detailedDescription, regions: [<region labels>], alignment }.
A normalizeFactionList helper tolerates authored data with capitalized keys
(Name/Description/Alignment) and coerces regions to a trimmed string array;
the World constructor defaults it to []. The field round-trips through
serializeWorld / rebuildWorldFromSnapshot, backfilling [] for saves that
predate it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXWhen a detached editor is opened for a game save (Edit World / Edit this save), the save's character name is now carried in the URL (?player=<name>) and prefixed to the detached window title — e.g. "Aldric — Eldoria — World Draft Editor" — so the DM can tell which save's world is being edited when worlds share a name across saves. The per-save window name also includes the character so distinct saves open distinct windows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Each row of the login load-save menu now has an "Edit this save" button to the left of the Delete button. It opens that specific saved game's world in the standalone detached draft editor — the same flow as the login "Edit World" button, but for any save picked from the menu rather than only the active one. The Edit World logic was refactored into a shared openSavedWorldInEditor(snap) helper used by both entry points. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The player-turn handoff logged the user's input inline in the log message, bloating the line for long requests. It now records the request as a COLLAPSIBLE detail (like the GM's raw response line right below it), so the line stays compact while the exact user input is inspectable on demand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
With the new Edit World button the login button row had four buttons and would wrap. The row is now nowrap and each button flexes to an equal share (flex: 1 1 0, min-width: 0) with reduced font size and padding, so API Keys / Import Game / New World / Edit World stay flush on a single line and never wrap onto a new line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Adds an "Edit World" button after "New World" on the login screen, enabled only when a resumable game save exists. Pressing it takes the saved game's world verbatim, stashes it as a named world draft, and opens the detached draft editor (?detach=editor&draft=<name>) on it — exactly like importing a world from a local file: a standalone editing session decoupled from the live playthrough (edits persist to the draft, never to the saved game). The button's enabled state is kept in sync in refreshNewGameHint alongside the other login controls, and a disabled style is added for the secondary login buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Entities gain a "region" string field for a forthcoming geographic feature, defaulting to "". The Entity constructor documents/defaults it, makeEntity carries it from the entity's data/catalog, and reEntityObj backfills it to "" for saves/imports that predate the field. It serializes with the save as an own property. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The login name field is now disabled while a compatible save is being continued (New Game unchecked) — it shows the saved character's name and can't be edited. Checking New Game frees it for a fresh character; unchecking re-locks it and restores the saved name. A new syncLoginNameLock mirrors syncLoginClassLock and runs from refreshNewGameHint, so it re-applies on every login refresh and New Game toggle. The New Game checkbox now defaults to CHECKED when there is no compatible save to continue (seeded from the synchronous resumable cache in populateLoginScreen, and re-affirmed by refreshResumableCache). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The login name field was only filled from SAVED_NAME_KEY, which is written solely when a new game is started with a typed name — so it could be stale or absent relative to the actual save, leaving the field empty on resume. refreshResumableCache now reads the character name from the save snapshot itself (the authoritative source) and sets the login name field to it, so clicking "Continue Your Journey" resumes the last save with its own character name shown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The ambient behavior GM call capped max_tokens at 400. The GM answers with its full response-format JSON object (only "narration" populated), which overran that budget and truncated the JSON mid-string, so JSON.parse failed with "Expected ',' or '}' after property value". Raised the budget to 1500 so the object always closes cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Ambient behaviors authored directly on an NPC/Monster (e.g. the Old Gatekeeper's comment) were never evaluated: makeEntity did not carry the `ambient` array from the entity's data/catalog onto the runtime Entity, so entity.ambient was undefined and setupEncounters registered no timer — nothing was ever calculated or logged. makeEntity now populates entity.ambient (cloned per instance so each entity owns its behavior objects, whose identity keys the oncePerPresence markers), the Entity constructor documents/defaults the field, and reEntityObj backfills it to [] for saves/imports that predate it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
New characters enter the game with isAlive = true. The flag flips to false the moment HP reaches 0 and back to true if HP is later restored above 0. A refreshPlayerAlive() helper derives the flag straight from HP and is called at every HP mutation (applyStateChanges' hpDelta, the level-up HP gain) and on load (also backfilling saves that predate the field). The death check now reads the flag. isAlive is an own field, so it serializes with the save. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Ambient behaviors were only evaluated for encounter-owned actors. Now an NPC or Monster that carries its own `ambient` array in its data object is also processed each cadence: setupEncounters registers a real-time timer per timePeriod/oncePerPresence entity behavior, and a new tryEntityAmbient rolls that behavior's chance while the entity is present in the player's room, handing successful beats to the GM. statusChange behaviors on an entity fire via onEntityStatusChanged (now handling entity-owned actions in addition to encounter spawns). Every ambient calculate/handoff step is now logged under the AMBIENT log type (previously the skip/trigger rolls logged as "encounter" and the GM handoff as "gm"), matching how encounters surface their own evaluation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Character › Equipment main column (equip-main) now centers the body figure + slots both horizontally and vertically in its panel. It uses a flex column with auto margins on the first/last child rather than justify-content:center, so a figure taller than the panel stays scrollable from the top instead of clipping. The right-hand inventory list is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On the DM Editor NPCs (and Monsters/Art) tab, each entity card now has a collapsible "Inventory" section above the Prompts section. It lists the entity's inventory as individual chips; each chip is clickable and opens that item's detail popup in the card view's room-popup slot, using the same position and design as the item popups elsewhere in the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Restore the visible SVG-rendered equipment slot boxes (framed dark boxes with type icons) as the drag-and-drop targets, and use the new body silhouette images as the figure background: Images/FemaleEquipmentBackground.png for Female characters and Images/MaleEquipmentBackground.png for male and other genders. The slots ring the body over the image; mix-blend-mode: lighten merges the image's near-black background into the dark panel so only the body (and its gold rim) shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Replace the hand-drawn SVG body + slot boxes on the Equipment tab with the committed artwork (Images/FemaleInventory.png for Female characters, Images/MaleInventory.png for male/non-binary/other/unspecified). Each PNG is the full screen — body, slot frames, and icons — so the interactive slots become transparent drop-zones laid over the drawn slots (aligned by percentage), invisible until an item is dragged over them. Drag-and-drop and the "future update" acknowledgement are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Restructure the Equipment tab into two columns: the paper-doll figure on the left and the inventory as a vertical list in a fixed-width column on the right. The list scrolls independently (overflow-y: auto) when the items exceed the available height, so a large inventory no longer pushes the layout. Each item is a full-width row (icon · name · qty) that stays draggable onto the slots. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Build the Equipment subtab: a gender-appropriate body silhouette (female for Female characters; the male body for male / non-binary / other / unspecified) ringed by the design's 13 equipment slots — head, amulet, weapon, sidearm, shield, gloves, boots (L), central armor, clothing, bracers, ring, boots (R), shield (R) — each an empty framed slot with a monochrome placeholder glyph. Inventory items render as a draggable tray beneath the figure; slots are HTML5 drop targets that highlight on drag-over. Per the request, the actual equip mechanic is deferred: a drop only acknowledges the target (names the item + slot) and changes no state. renderEquipment runs on tab switch and stays in sync when the inventory updates while it's visible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Reorder the DM Editor tab bar so Quests sits immediately after Map, ahead of NPCs. Only the tab button moved; the subviews are shown/hidden by class, so their DOM order is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The room popup opened from a quest beat's Location chip now behaves like the NPCs/Monsters tabs: its Items and Present names are clickable links that open the item/NPC detail popup to the LEFT, and the visible-exit list is omitted. Register the Quests popup in ROOM_POPUP_COMPANION (companion: quests-detail-popup) and open locations via showEntityRoomPopup so linkContents + omitExits apply, the room is tracked for link resolution, and the shared delegated .popup-link handler drives the companion. NPC/ monster/item chips still render their detail in the primary slot (and now close the companion first). Added the companion element + CSS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On the DM Editor › Quests tab, each beat's referenced Locations, NPCs, Monsters, and Items are now clickable chips that open the matching detail popup — the room popup for a location, the entity popup for an NPC/monster, the item popup for an item — in a top-right Quests-tab popup slot, reusing the same builders/popup behavior used elsewhere. buildWorldNameIndex now separates enemy entities into a "monsters" bucket so beats list Monsters distinctly from NPCs. questRefClick resolves the clicked name (room by name, entity via findEntityByName, item via questFindItemByName) and shows it via showEntityPopup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Register a new "AMBIENT" log category (pink, #e58fc0) alongside the other log types; it shows in the Logs Filter menu and is colour-coded like the rest. When an ambient beat is produced, log a descriptive line under this type — who did what, to whom, where (e.g. "<NPC> comments to <target> in <room>", "<NPC> says a line aloud", "<NPC> performs a <action>") — with the produced narration as the detail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The "comment" ambient's target is now resolved from the room's living occupants — "player" (default), or an NPC/monster present in the room, matched by name then type (resolveAmbientTarget). A comment only fires while its target is co-present, and ambientTargetProfile grounds the remark in the target's details (player: class/gear/status; entity: type/level/description/status). oncePerPresence is now tied to NPC↔target co-presence rather than a player action: the marker records which target the behavior fired against, keeps rolling on cadence until it actually triggers, and re-arms once that target leaves the room (ambientRearmStale) so a later meeting can fire again. The player relocating still resets everything. Docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Clarify oncePerPresence semantics: while the NPC is present, the normal interval + chance keep rolling; the behavior is retired for the rest of that presence only once it ACTUALLY triggers (a passing roll), not on the first evaluation. A failed roll leaves it eligible so it keeps rolling on cadence until it fires once. The clock resets when the player leaves and returns (resetAmbientPresence), re-arming it for a fresh presence. Move the once-per-presence marker from tryAmbient (pre-roll) into evaluateAmbient's success branch; update the code + GM directive docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Add a new ambient behavior type "comment": an NPC makes a remark directly to a target (default: the player) after observing them. The GM directive now grounds the remark in the target's observable details — for the player: class/level/gender, summary, carried gear, active statuses, and vitals — via ambientTargetProfile, and asks the GM for one line spoken straight to the target. Also add a new intervalType "oncePerPresence": rolled on the interval cadence but at most once per NPC per continuous presence with the player. tryAmbient marks the chosen speaker so it won't re-roll that behavior until the presence ends; resetAmbientPresence clears the markers when the player relocates. The encounter-shape GM directive documents the new type, target field, and interval type. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Register a new "AI" log category (purple, #b18fd6) alongside the existing
GM/ROUTINE/ENCOUNTER/SAVE/ERROR/SYSTEM types — it shows in the Logs
Filter menu and is colour-coded like the others.
Emit a simple 'ai' log line whenever an AI generation kicks off:
- image generation (paintImageFromPrompt) logs the image kind
(portrait / item image / banner) and the selected provider;
- audio-prompt generation (roomSuggestAudioTimePrompt) logs the
time-of-day and whether it's music or ambient sound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe DM Editor › Items cards now show an editable "Classes" field — the item's kind tags (e.g. sword, one-handed weapon) as a comma-separated input. Edits parse into a trimmed, de-duplicated array via setItemClasses and write through to the catalog type and every live copy by name. applyItemTypeField now hands each target its own copy of array values so the catalog type and live copies don't share one array instance (scalars are unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Add a "Image AI" section to the Settings popup with a provider dropdown
(Pollination / Nano Banana / Higgsfield). The choice persists in the
player settings and drives which service generates every in-game
portrait, item image, and room banner.
All image generation already funnels through paintImageFromPrompt; it now
routes to the selected provider via a small registry + dispatcher. Each
provider has a generate(prompt, shape) that invokes its own service with
the key saved in the login API Keys dialog:
- Pollination: keyless/tokened GET image URL (unchanged behavior).
- Nano Banana: POST to the Gemini image model, returns the inline
base64 image as a data: URI.
- Higgsfield: POST a text→image job and poll it for the result URL.
When a non-Pollination provider is selected without a key, generation
falls back to Pollinations so images still render.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXThe report's top stat row now includes a "Lines of code" box: the total line count across the tracked source files (.html/.js/.css), with the generated report itself excluded so the figure doesn't inflate on each regeneration. A sub-line shows how many files that spans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Refresh the point-in-time commit report to include the login model picker, world/class/New-Game defaults, copyright notice, and the editor NPCs/Monsters two-panel room popup work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The room popups opened from the Current/Home Location links on the DM editor's NPCs and Monsters cards now match the editor Map subtab's room popup: the visible-exit list is dropped and the Items/Present names are clickable links that open an item/NPC detail popup to the LEFT of the room popup — consistent with the World map and the editor map. Each subtab gets a companion #…-detail-popup positioned left of its room popup. A small ROOM_POPUP_COMPANION registry drives showEntityRoomPopup (which now applies linkContents + omitExits and remembers the room per popup) and a delegated .popup-link handler per room popup that resolves the clicked item/NPC and shows it in the companion. Closing a room popup also closes its companion. The Art tab room popup is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Add a centered, muted "Copyright 2026 Brave You Worlds" line beneath the Begin/Continue button on the login overlay. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On the login screen, the "New Game" checkbox now defaults to checked when there's no compatible saved game to continue, so a first-time (or post- reset) player lands on a ready-to-start fresh game. When a resumable save exists, it still defaults to unchecked so the screen offers to continue. refreshResumableCache sets the checkbox from the resumable-save flag it computes. It only runs when the login screen is (re)shown — boot, logout, menu refresh — so it never overrides a choice the player is making. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The New-Game world dropdown started on a disabled "Choose a world…" placeholder that forced an explicit pick. It now defaults to the built-in "Default" world whenever there's no prior remembered selection (and falls back to Default if a remembered world was since removed), so a world is always selected. populateWorldSelect also ensures the Class dropdown is populated from the default world when it falls back to it and the options aren't already built. onNewGameToggle now returns its async populate/stage chain so callers can await the staged selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
When a compatible saved game exists, the login Class dropdown is now set to the saved character's class and disabled, so a returning player can't accidentally change class before continuing. The dropdown only becomes editable when "New Game" is checked (or when there's no save to resume). refreshResumableCache now caches the saved character's class and the saved world's classes alongside the resumable flag. refreshNewGameHint drives a new syncLoginClassLock() that, while continuing, rebuilds the options from the saved world's classes, guarantees the saved class is present, selects it, and disables the control — and frees it otherwise. onNewGameToggle re-asserts the lock after the world-driven option rebuild. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The login Class dropdown was showing hardcoded HTML options (Warrior/ Rogue/Mage/Ranger with terse descriptions) instead of the game world's actual classes. The New-Game world picker already rebuilt the dropdown via rebuildLoginClassOptions when staging a world, but the initial login render never did, so the static markup leaked through until the player touched the picker. Call rebuildLoginClassOptions from populateLoginScreen so the dropdown reflects the built-in default world's classes on load, and continue to let the New-Game world picker override it with the chosen world's classes when a fresh game is staged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Add a Model dropdown to the login/setup screen, beneath the API-key info note, letting the player choose which Claude model powers the game. The options are the current Opus and Sonnet model ids, the choice persists across refreshes in localStorage (tlr_model), and every Claude API call now reads the chosen model via getSelectedModel() instead of a hardcoded id (defaulting to Sonnet, and falling back gracefully if a stored id is no longer offered). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Rooms-card generate/suggest buttons didn't surface a bottom status-bar message the way other GM actions do. Add setGmStatus/clearGmStatus to all three: - Banner prompt ✨ → "GM is writing the <Time> banner prompt…" - Audio prompt ✨ → "GM is crafting the <Time> <music|sound> prompt…" - Banner image Generate/Regenerate → "GM is painting the <Time> banner…" Each clears back to Ready on success and on error/abort. test_room_audio_prompts.js now captures the status during the audio suggest and asserts it returns to Ready, plus source checks that all three handlers set + clear the status bar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Expand the Room card's Prompts section with a new collapsible "Audio" alongside
"Banner". Audio holds two subsections — "Music" and "Sounds" — and each holds the
same six per-time-of-day collapsible prompt subsections as the Banner section.
- Data: Room gains nested audioPrompts { music:{…6 times}, sounds:{…6 times} },
with normalizeRoomAudioPrompts ensuring the shape (empty slots stay '', since
there's no single base to fan out). Wired into the Room ctor, addRoom, and
reRoomObj (backfills older saves).
- UI: refactor the per-time subsection into a shared builder reused by Banner and
by Audio › Music/Sounds; wrap Banner + Audio together under the Prompts section.
- Handlers: roomSetAudioTimePrompt (save one music/sounds slot, by room id) and
roomSuggestAudioTimePrompt (GM writes a music/ambient-sound prompt for that time
of day, keyed to its mood). Broaden the nested first-child CSS reset.
New test_room_audio_prompts.js covers the data shape + restore, the
Prompts›Banner›Audio›Music/Sounds nesting and per-time wiring, and the
set/suggest handlers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXNPC editor cards gain a collapsible (default-collapsed) editable Detailed Description directly beneath their existing read-only Description, sharing the entity description field/handlers already used by monsters — the short Description on NPC cards stays read-only. - Refactor buildDescEditFields to expose a standalone buildDetailedDescSection (the collapsible editable Detailed Description), reused by the Monsters card and now appended to the NPC card. - No data change: Entity already carries detailedDescription, and setEntityDescription / generateEntityDescription are entity-agnostic (by uid). New test_npc_desc.js covers the NPC layout (read-only Description kept, collapsed Detailed Description below it), edit + GM generate, and the popup; update the monster test's NPC assertion to the sharper "short Description stays read-only". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On monster cards, the Detailed Description is now a collapsible <details> section (default collapsed) instead of an always-open field, so the card stays compact until the DM expands it. Add an opts.collapsibleDetailed flag to the shared buildDescEditFields helper (the Items card keeps its flat layout). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Bring the Items-tab description controls to the Monsters tab. Monster (enemy- type) editor cards now show an editable Description and Detailed Description, each a textarea with a tiny ✨ button that asks the GM to (re)write it from the creature's own data (name, kind, classes, HP, appearance, current text) plus the world's theme/tone/prologue/canon. - Extract a shared buildDescEditFields() helper and refactor the Items card to use it (no behaviour change there). - Entity cards branch on type: enemy-type cards render the editable fields; NPC cards keep their read-only description line for now. - setEntityDescription / generateEntityDescription resolve the monster by uid and write the entity TYPE + every live copy by name (applyEntityTypeField), mirroring the item description editor. - The entity detail popup (buildNpcDetailHTML) now shows the Detailed Description after Description. New test_monster_desc.js covers the monster card's editable fields + generate wiring, type-wide propagation, GM writes for both variants, the no-API-key guard, the popup display, and that NPC cards stay read-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Add a new openly-visible "detailedDescription" alongside the existing short "description": - Data: Item, Entity, and Room each gain a detailedDescription field, threaded through makeItem/makeEntity/addRoom, registerInlineItem, the dropped-item clone, and reItemObj/reEntityObj/reRoomObj restore normalization (older saves default to ""). - Items editor card: the read-only Description line becomes an editable Description + a new Detailed Description, each a textarea with a tiny ✨ button. setItemDescription saves to the catalog type and every live copy by name (applyItemTypeField); generateItemDescription asks the GM to (re)write the short or detailed description from the item's own data plus the world's theme/tone/prologue/canon, then drops it into the field. - The item detail popup now shows the Detailed Description (after Description). New test_item_detailed_desc.js covers the data field (build + restore), the card's editable fields and generate wiring, type-wide propagation, GM writes for both variants, the no-API-key guard, and the popup display. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Now that each card's prompt controls sit inside a "Prompts" section, drop the redundant "Prompt" suffix: the Portrait Prompt subsection (entity/item/encounter cards) reads "Portrait", and the Banner Prompt subsection (room cards) reads "Banner". Update the affected test assertions to match the shorter labels (and to target the prompt-label span specifically, since the room card also has a "Banner" section header). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Wrap each editor card's prompt controls in an outer collapsible "Prompts" section so they're grouped under one heading: - Entity (NPCs/Monsters), Item, and Encounter cards nest their "Portrait Prompt" inside "Prompts". - Room cards (Rooms tab and the Art-tab room card) nest their "Banner Prompt" (with its per-time subsections) inside "Prompts". Purely structural — a shared wrapInPromptsSection() helper plus nested-section CSS; the inner prompt sections and their wiring are unchanged. The Compendium's "Prompt" section (a different label/surface) is left as-is. Tests updated: entity/item cards nest Portrait Prompt under Prompts (test_editor_lore); the room card nests Banner Prompt under Prompts (test_room_banner_card). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The room card's banner is now a carousel over the six time-of-day slots. The DM cycles with ‹ / › chevrons; the viewed slot shows its OWN image (no cross-time fallback, so empty slots read as empty), labelled with its time of day (e.g. "🌆 Dusk 5 / 6"). The Generate / ♻ Regenerate and ⬆ Upload beneath act on THAT slot only — Generate paints from that time's prompt into room.bannerImages[time]. - setRoomBannerSlot writes one slot (preserving its gif); the all-slots setter stays for the Compendium Places / Art paths. - buildRoomBannerBlock renders the carousel from a per-room viewed-index (roomBannerCarousel, defaulting to the world's current time of day); carried in an id'd host so roomBannerCarouselStep repaints it in place (no full re-render, so scroll and expanded prompt subsections survive). - roomBannerGenerateForTime / roomBannerUploadForTime act per slot, by room id + time key, and sync the Places compendium thumbnail when editing the current time's slot. New carousel CSS. The Rooms card's old all-slots Upload/Regenerate are replaced by these per-slot controls. New/updated tests cover the carousel nav (wrap-around), the viewed slot's own-image display, per-slot targeting, and single-slot writes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
A room now carries a separate banner prompt for each of its six time-of-day
slots, and the card's Banner Prompt section holds a collapsible subsection per
time of day (🌅 Dawn … 🌙 Midnight), each with its own textarea and ✨ GM-suggest.
- Data: add room.bannerPrompts { dawn … midnight } and normalizeRoomBannerPrompts,
which copies the single base bannerPrompt into any empty slot — so a room that
only ever had one prompt fans it out to all six (the DM can then tailor each).
Wired into the Room constructor, addRoom (honours room-data bannerPrompts),
reRoomObj (backfills older saves), and applyRoomFlesh (re-seeds a fleshed stub).
- UI: the single Banner Prompt textarea becomes six nested collapsible
subsections, one per time of day, labelled with its time-of-day icon.
- Handlers: roomSetBannerTimePrompt (save one slot, by room id) and
roomSuggestBannerTimePrompt (GM writes a prompt for that specific time, keyed
to its lighting/mood via buildBannerPromptForTime). New nested-subsection CSS.
The top banner image controls (Upload/Regenerate) are unchanged for now — a
natural follow-up is per-slot image generation driven by each time's prompt.
New test_banner_time_prompts.js covers the backfill, per-slot save, and restore
normalization; test_room_banner_card.js updated for the per-time wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knXRender the banner image, its art controls, and the Banner Prompt section directly beneath the card header — above the description and the rest — so a room's art is the first thing the DM sees. Add a test assertion pinning the banner ahead of the Description section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
A room's prompt drives its banner art, not a portrait, so label the section "Banner Prompt" instead of "Portrait Prompt" (the NPC/Item cards keep their "Portrait Prompt" label). Update the test accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Bring the Rooms editor cards to parity with the NPC / Monster / Item cards, which show a portrait plus upload/generate controls and a collapsible prompt. - Each room card now renders its banner art (the current time-of-day frame), full-width, with Upload (⬆) and Regenerate (♻) icon buttons beneath it — or a Generate / Upload placeholder when the room has no art yet. Below sits a collapsible "Portrait Prompt" section holding the room's bannerPrompt with a ✨ GM-suggest button, and the existing art-slot summary line. - All controls reuse the shared 'places' Compendium handlers (compendiumUpload / compendiumRegenerate / compendiumSetPrompt / compendiumSuggestPrompt), which already paint a WIDE frame, write across all time slots, and sync any discovered Places compendium entry — so the Rooms tab and the Compendium Places tab edit banner art through one code path. - Add reRenderRoomsIfActive(), called by compendiumRegenerate/Upload, so a banner action taken from a room card refreshes the Rooms tab in place (mirroring reRenderArtIfActive). New wide-banner CSS (.room-card-banner). No data-model change: rooms already carry bannerImages + bannerPrompt. New test_room_banner_card.js covers the image/placeholder branches, the wired controls, and the refresh hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Compendium's People/Monsters/Places/Items entries already carry a collapsible, DM-authored Lore section (hidden lore + unlock condition + "unlocked" toggle + GM Generate). Bring the same section to the DM Editor's NPCs, Monsters, and Items tabs. - Extract the section's markup into one shared buildDmLoreSectionHTML(category, name, obj) helper and refactor the Compendium to use it (no behaviour change). - Render it on each editor card: the Items card keys off the item's compendium category (items/plants/animals/magic); the NPC/Monster cards use people vs. monsters by entity type. All controls route through the existing category- dispatching setters (compendiumSetLore/Key/Unlocked, compendiumGenerateLore), so an edit writes to the type + every live instance by name. - Gate inclusion on isDM (the editor is DM-only, but this matches the Compendium's defensive pattern and keeps the section out for players). The lore data attributes already exist on Item and Entity, so no data-model change was needed. New test_editor_lore.js drives all three editor renderers, the shared helper, a type-wide edit, and the non-DM case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Update the Field Guide for the two features added this session: - Maps (Part I): the World-map room popup's Item/Present names are now clickable, opening a detail popup beside the room popup (two-panel inspect, like the DM editor), and the popup omits the exit list. New screenshot map-room-detail.png. - Compendium lore (Parts I & II): the collapsible DM Lore section — hidden lore, unlock condition, "unlocked" toggle, and GM Generate — now spans People, Monsters, and Places, not just items. Generalized the player-facing "a thing's own lore" note, added a "Per-entry Lore" bullet and a note distinguishing it from the Lore subtab, and cross-linked the beings/rooms editor sections. New screenshot compendium-lore.png. Screenshots were captured from the running app (headless Chromium). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The Items tab has long had a collapsible, DM-authored Lore section (hidden history + an unlock condition + an "unlocked for the player" toggle), backed by lore/loreKey/loreUnlocked on item objects. Extend the same system to the People, Monsters, and Places tabs. Data model: - Entity gains loreKey + loreUnlocked (it already had lore); makeEntity reads them inline or from the catalog type. - Room gains lore + loreKey + loreUnlocked; addRoom seeds them from room data. - reEntityObj / reRoomObj backfill the new fields on older saves and imports. Compendium: - The collapsible Lore section now renders for every catalogued kind (people, monsters, places, and the item-backed items/plants/animals/magic), labelling itself per category (character / creature / place / item). - New applyEntityTypeField + applyRoomTypeField, and an applyCompendiumLoreField dispatcher, so the lore setters and the GM "Generate" write to the right catalog/type plus every live instance by name. Generate's directive is now category-aware (THE SUBJECT, not THE ITEM). Display: - A shared buildLoreFieldHTML powers the Lore field in the item, NPC/monster, and room detail popups: shown to the player once unlocked, always shown to a DM (flagged "locked — DM view") with the unlock hint. Tests: new test_being_place_lore.js covers the data model, dispatchers, per-category rendering, detail-popup gating, GM generate, and save/restore normalization; update test_comp_lore_gen for the generalized directive header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Refresh the point-in-time snapshot via tools/gen-progress-report.js so it includes today's World-map room-popup work. 450 commits across 7 days. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
Two refinements to the World-map room popup and its item/NPC detail popup, bringing both in line with the DM Editor → Map tab: - Drop the visible-exit list from the World-map room popup (opts.omitExits). Exits are already drawn as lines between room nodes, and every other room popup in the app omits them, so the popup now reads identically to them. - Give the room popup the standard 264px width (remove the narrow 246px override it inherited from being an entity-detail element). With the room popup at 264px, the detail popup at right:290 leaves the same 12px gap as the editor's #editor-entity-popup — fixing the too-wide spacing. - Route a map-icon badge's detail to #map-detail-popup and anchor it in the far-right corner (hiding the room popup first), exactly like the editor; add the .far-right variant and clear it when the detail is opened from a room-popup link instead. Update test_map_room_links.js for the new call signature and cover exit omission, the standard width, the 12px/far-right positioning, and badge routing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
On the player Maps → World tab, clicking a room name already opens that room's popup on the right. Its Items and Present (occupant) names were plain text; make them clickable links that open the corresponding item or NPC detail popup to the LEFT of the room popup, mirroring how the DM Editor → Map tab already behaves. - Parameterize buildRoomPopupHTML/showEntityRoomPopup with a linkContents option so the player map renders .popup-link spans (data-item-idx / data-npc) while the NPC/Monster card location popups — which have no companion popup — keep plain text and no dead links. - Add a #map-detail-popup element (and CSS) positioned just left of the room popup, reusing the shared entity-popup styling and wide-NPC variant. - Route .popup-link clicks inside the room popup to showMapItemDetail / showMapNpcDetail, resolved against the popup's room. Reset/close the detail popup when the room popup changes, a map icon is clicked directly, the popup closes, or the map subtab switches. Extend test_map_room_links.js to cover the new call signature, the left detail popup, clickable-content rendering, and the click routing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ajXqrgB6UQN8p9EBM5knX
The editor subtab bar sets overflow-x:auto so its many tabs can scroll horizontally. Per CSS, when one axis is auto the other's `visible` computes to `auto`, so the 1px of vertical overflow from the tabs' `top:1px` nudge triggered a stray vertical scrollbar. The row only holds tab headers, so pin overflow-y:hidden to clip that pixel instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
On the player Maps tab (World overview and interior subtabs), the room name inside each node is now a hyperlink that opens the room detail popup — consistent with room-name links elsewhere. The box itself stays inert since the current location is already highlighted. The DM editor selection-mode map is unchanged (its boxes select rooms). - renderMapInto tags player-map name <text> with .map-node-name (pointer + gold hover/underline); editor selection mode omits it. - initMapBadgeClicks gains a name-link branch, sharing the pan-drag guard with the existing badge handler. - Adds test_map_room_links; guide Maps section clarifies the interaction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The "Development log · radiantone/thelostrealms" line now links the repo name to https://github.com/radiantone/thelostrealms (new tab). The <code> inherits the anchor color so it keeps the gold-dim → gold hover. Regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each day is now a <details> whose header (<summary>) toggles it on click, with a ▸ caret that rotates when open. An "Expand all / Collapse all" control sits above the day list (a tiny inline setAllDays() flips every details.day). Regenerated the report. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Checks in the generator that builds progress-report.html from git history,
using repo-relative paths (git -C <repo-root>) so it runs from anywhere:
node tools/gen-progress-report.js
Re-ran it to refresh the checked-in report to the current history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFprogress-report.html is a self-contained companion doc (like the Field Guide) styled in the game's palette and typefaces (Cinzel / Fira Code / EB Garamond, gold-on-dark). It lists every commit grouped by day, newest first: summary tiles (total commits, days active, span, busiest day), then per-day sections with each commit's subject, short hash, author, and an expandable "details" for the full message body. Merge commits are tagged and dimmed. Generated from `git log` as a point-in-time snapshot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A book-icon button now leads the right-aligned toolbar cluster (guide → Save → Export → blocks → settings → toggle). It opens the Field Guide (guide.html) in the same minimal-chrome popup as the login screen's guide button (openGuideWindow), so the guide is reachable mid-game. - #guide-btn joins the shared toolbar icon-button style and now carries the margin-left:auto that starts the cluster; Save Game moves to margin-left:4px. - Icon is the same inline open-book SVG (currentColor) as the login button. Tests: test_save_button asserts the guide button exists, opens the guide, and sits left of Save Game; its margin assertion updated for the new cluster lead. Suite 35/35. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The rightmost toolbar button (▶ Hide sidebar) hugged the sidebar border because only inter-button margin-left gaps existed. Give it a matching 4px margin-right so its gap to the edge equals the 4px spacing between every other toolbar button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Completes the save trio: Save Game (internal browser save), Export Game (current playthrough → local JSON file), Import Game (load a local JSON save and continue it). - Export Game: a download-icon button in the toolbar, right of Save Game. It writes the full session snapshot (buildGameSnapshot, extracted from the save path so save and export share one shape) to a "<character>-<world>- save-<date>.json" file via the existing downloadJsonFile helper, and flashes the button to confirm. No-op outside a live game. - Import Game (renamed from "Import World"): importWorld now detects the file's shape — a full saved-game snapshot (player + world + rooms) is made the active save and RESUMED via restoreGameState (continue), while a world-export envelope / bare world still stages for a fresh Begin. So the one button both continues exported saves and plays exported worlds. - User-facing "Import World" strings on the login side now read "Import Game" (the World Editor's own Import World button is unchanged). Tests: new test_export_game.js (button placement/wiring, snapshot shape, export blob content, export→import continue round-trip, importWorld routing); test_save_button selector assertions relaxed for the shared rule. Guide + README updated. Suite 35/35. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
compendiumGenerateLore now sets a one-line status ("GM is writing lore for
<item>…") on the bottom status bar via setGmStatus, cleared with
clearGmStatus when done. Because the status bar is global, the player still
sees the GM is working even if they switch away from the Compendium tab
while the request is in flight (the on-button "Generating…" text alone
disappears with the tab).
Tests: test_comp_lore_gen asserts the status shows during the call and
returns to Ready afterward. Suite 34/34.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFEach saved-game row in the login "Load a saved game" menu now has a trash
button. Clicking it swaps that row into an inline confirm ("Delete
Character — World?" with ✓ / ✗) rather than a browser popup; ✓ deletes,
✗ cancels. Only one row confirms at a time, and closing/reopening the menu
discards any pending confirm.
- deleteSavedGame(key) drops the snapshot (kvDel) and its index entry; if it
is the currently-active playthrough (activeSaveKey matches), it also clears
SAVED_STATE_KEY so a refresh won't revive it, and refreshes the login
Continue state. Deleting any other save leaves the active slot untouched.
- Rows are now a flex container (load label + trash); the menu renders via a
sync renderLoadSaveMenu() driven by _loadSaveList + _confirmDeleteIdx.
- Icons are inline SVG (currentColor): trash tints red on hover, ✓ red, ✗ gold.
Tests: test_load_save extended (trash wiring, confirm shows/cancels/deletes,
snapshot+index removal, active-save clearing vs. non-active untouched).
Verified in-browser. Guide + README updated. Suite 34/34.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFInside the DM-only Lore section (Items/Flora/Fauna/Magic compendium entries), a right-justified "Generate" button now sits beside the "Unlocked for the player" checkbox. It asks the GM to author BOTH the item's hidden lore and its unlock condition (loreKey) in one call. compendiumGenerateLore() seeds the GM with the world's canon so the lore reads as native: theme/tone framing, the prologue/backdrop, the world lore summary, the geography (rooms + regions), and the notable occupants, plus the item's own name/type/kinds/description. It writes both fields to the item type and every instance (applyItemTypeField) and reflects them into the open Lore textareas without re-rendering, so the section stays put. No-op (with a tooltip hint) when there's no API key. Tests: new test_comp_lore_gen.js (button markup + right-justification; GM directive carries geography/occupants/canon/prologue and requests both lore+loreKey; both stored; API-key guard). Verified in-browser. Suite 34/34. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
After a rest/wait/sleep that crosses into a new time-of-day, the room scene is now reprinted with its "You notice / Present / Exits" lines — even when the room's prose is the same at the new hour (the common single-description case, which previously showed only "It is now afternoon."). NPC routines can move people in or out during a jump, so the refreshed Present/Exits lines help the player re-orient. updateRealmCalendar now takes a fromTimeSkip flag; applyStateChanges passes true after a deliberate skip. On a jump crossing a boundary it always reprints the full scene (title+time, description, detail lines). Ordinary once-a-second time passage is unchanged: it still only reprints when the description itself differs, otherwise a quiet "It is now …" note — so normal play doesn't get spammed with Exits lines as time drifts by. Tests: new test_timeskip_room.js (jump reprints scene + Exits for a single- description room; a natural change stays a quiet note). Verified in-browser. Suite 33/33. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In the Art tab, pressing a portrait/banner Generate button with an empty
prompt now first calls the GM prompt generator (as if the ✨ button beneath
the prompt were pressed), then generates the image from the new prompt —
instead of showing "add a prompt first".
- artTabVisible() gates the behavior so the shared card Generate handlers
keep their original behavior on every other tab (Items, Flora/Fauna, NPCs,
Monsters, Places compendium).
- Wired into all three Art card kinds: items (generateItemImage →
suggestItemPrompt), entities (generateNpcPortrait → suggestNpcPrompt), and
rooms (compendiumRegenerate('places',…) → compendiumSuggestPrompt).
- The prompt call is invoked with a null button so it doesn't fight the
Generate button's busy state; the button shows "Writing a prompt…" then
"Painting…". If the GM can't produce a prompt (or no API key), it falls
back to the existing "add a prompt first" message.
Tests: new test_art_autoprompt.js (auto-writes then paints; no-op when a
prompt exists; no auto-call outside the Art tab). README updated. Suite 32/32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFAfter a save, endStorageStatus keeps the bar's pulse/shimmer for a ~500ms "linger" and only cleans up on its own timer. But setStatusBar (which prints "Ready"/"Thinking"/"Typing") changed the text WITHOUT dropping the 'saving' and 'storage-busy' classes — so when a turn's "Ready" landed during that window, the text read "Ready" yet kept pulsing/shimmering. setStatusBar now clears the leftover storage indicator (the text 'saving' pulse, the bar 'storage-busy' shimmer, and the pending linger timer) whenever no save/load is actually in flight (_storageOps === 0). An in-flight save still keeps its indicator, and the normal post-save linger on the "Saving…" text is unchanged. Tests: new test_status_bar.js reproduces the lingering case and asserts it clears, while confirming the indicator persists during an active save. Verified in-browser. Suite 31/31. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A disk-icon button now sits in the panel toolbar, just left of the
show/hide sidebar-blocks (☰) button, letting the player snapshot on demand
rather than relying only on the automatic per-turn save.
- manualSaveGame() calls saveGameState() and briefly flashes the button
("Game saved"); it's a no-op outside a live game.
- The button starts the right-aligned toolbar cluster (the margin-left:auto
moved onto it from the sidebar-blocks wrapper), sharing the existing icon-
button styling. Icon is an inline SVG using currentColor (gold theme).
Tests: new test_save_button.js (placement, styling, save-writes-snapshot,
flash, and the no-game guard). Verified in-browser (button left of ☰; click
writes a snapshot). README updated. Suite 30/30.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFThe game kept a single save (SAVED_STATE_KEY, auto-resumed on boot). Now
every save is also MIRRORED into a saved-games library — one entry per
world + character — so the player can switch between playthroughs in
different worlds.
- SAVED_GAMES_KEY index ({key, character, world, savedAt}) + per-playthrough
snapshots under 'tlr_save:<character> <world>'. kvGet/kvSet reuse the
IDB-then-localStorage fallback.
- mirrorSaveToLibrary() runs after each successful save (best-effort, never
fails the primary save); upserts by world+character so replays update in
place instead of duplicating.
- Login: a gold disk button beside the Name field (SVG, currentColor) drops
a menu beneath the input listing saves as "Character — World". Picking one
copies its snapshot into the active slot and resumes it via the existing
restoreGameState path (honoring the DM checkbox); an unresumable snapshot
reports a load error instead of throwing.
Tests: new test_load_save.js (library mirror/list/get/upsert, menu render +
labels, show/hide, load-into-active, empty state). Verified in-browser
(play → save mirrors → menu lists it → load mounts the game). Guide + README
updated. Suite 29/29.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFThe explanatory text under the Claude API Key field is now hidden by default and revealed by a small circle-"i" button beside the "Claude API Key" label. Keeps the login form compact while the guidance stays one click away. - The note (#api-key-note) starts hidden; the info button toggles it. - toggleApiKeyNote() flips visibility, aria-expanded, and the button's active (bright-gold) state. - The icon is an inline SVG circle-i using currentColor, so it follows the gold theme (dim → gold on hover/when open), matching the sound/guide icons. Tests: new test_api_key_info.js (markup + toggle behavior). Verified in-browser (note hidden by default, reveals/hides on click). Suite 28/28. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Color emoji carry their own fixed palette, so CSS color can't theme them. Swapped the 🔇 and 📖 emoji for tiny inline SVG icons (speaker and open book) drawn with stroke="currentColor", so they inherit the buttons' existing gold theme — var(--gold-dim) at rest, var(--gold) on hover, and full gold when sound is on. toggleLoginSound now swaps between currentColor "on"/"muted" speaker SVGs instead of emoji text. Tests: test_guide_button updated to assert the SVG/currentColor icons. Verified in-browser (both icons compute to --gold-dim; toggling sound brightens to --gold). Suite 27/27. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A small 📖 button now sits just right of the sound toggle on the login screen. It opens the game guide (guide.html) in a minimal-chrome popup window (popup=yes, sized and centered, named "tlr-guide" so repeat clicks reuse the one window instead of stacking duplicates). - New .setup-guide-btn style mirroring the sound toggle's look. - openGuideWindow() opens guide.html and focuses the popup. Tests: new test_guide_button.js (markup, placement after the sound toggle, and the window.open call). Verified in-browser (popup loads guide.html, title "The Lost Realms — Field Guide"). README backfilled with the recent character-subtabs and login-world-select test rows. Suite 27/27. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The world chosen in the login New-Game picker now persists (SAVED_NEWGAME_WORLD_KEY, '__default__' or a saved-world name). On a later New Game it pre-selects and re-stages that world instead of the forced placeholder — so the player doesn't re-pick every time. - onNewGameWorldChange() stores the chosen value. - populateWorldSelect() selection priority: current in-memory value, else the remembered value (if still in the library), else the placeholder. A remembered world that was since deleted falls back to the placeholder. - onNewGameToggle() applies the resulting selection (stages it) after populating, so the pre-selected world is ready for Begin. - The first-ever choice is still forced (no remembered value yet). Tests: test_login_world_select covers persistence, pre-select-on-reopen, re-staging, and the removed-world fallback. Verified across a real page reload in-browser. Guide updated. Suite 26/26. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Checking "New Game" now reveals a "World" dropdown on the row beneath the checkboxes. It lists an always-present "Default" (the built-in starter world) plus every world saved from the World Editor (the SAVED_WORLDS_KEY library — authored worlds, not saved games). A leading disabled placeholder forces an explicit choice, and Begin is blocked on a fresh game until one is made. - populateWorldSelect() builds the options (placeholder + Default + sorted library names); primed on login load and refreshed whenever New Game is toggled on so newly-saved worlds appear. - onNewGameToggle() shows/hides the row; unchecking New Game drops any staged world and restores the built-in one (keeps the Continue path clean). - onNewGameWorldChange() stages the pick: "Default" → built-in; a saved world → load its envelope and stage it, updating the login title, tagline, version, and Class list. - Refactored applyImportedWorldText into a shared stageWorldForLogin() core (reused by both the Import World button and the picker) plus a new resetLoginToDefaultWorld(). - startGame() enforces the choice on a New Game and resolves the selection into the world it seeds; a file import still counts as a valid choice. Tests: new test_login_world_select.js (markup, options, row toggle, staging, Default reset, Begin gating). Verified in-browser. Guide updated. Suite 26/26. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Adds a third Character subtab (Profile / Equipment / Skills), mirroring the
existing subtab pattern: a chartab-skills button, a character-sub-skills
subview, and a switchCharacterTab('skills') toggle. For now it shows a
styled empty-state placeholder ("No Skills Yet") since the skills system is
still to come — reusing the app's shared empty-state look via a new
.character-placeholder rule added alongside the journal placeholders.
Tests: new test_character_subtabs.js (markup, wiring, single-active-subview
toggling, Profile re-render). Verified in-browser. Full suite 25/25.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFEncounters store their portrait on enc.image (Encounters editor), but a spawned entity's portraitImage() only consulted its own conversation/ compendium images and the compendium-by-name lookup — never the encounter it came from. So a "Villager" spawned by the "Villagers" encounter showed a blank portrait in its detail popup even when the encounter had one set. Add Entity.encounterPortrait(): for an encounter-spawned entity (tagged _encounterName), look up its encounter and return enc.image. Use it as the final fallback in portraitImage(), so it applies wherever an entity's portrait is shown (detail popups, sidebar, editor cards) and stays live with encounter-image edits. The entity's own portrait still takes precedence; untagged entities are unaffected. Tests: test_encounter_portrait covers the spawn→popup fallback, precedence, the no-image case, and non-encounter entities. Verified in-browser with the built world's Villagers encounter. Full suite 24/24. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The editor subtab CSS is written as enumerated id-lists (e.g. #npcs-view, #items-view, …). The new Flora/Fauna ids were never added to them, so those subtabs fell back to unstyled defaults: the GM request box lost its dark background/border/padding and full width, the toolbar wasn't positioned, and the card view lacked the top padding that clears the corner toolbar (hence the off margins/spacing). Added #flora-* and #fauna-* to every affected rule: the -edit container, -edit-input (+ :focus), -edit-btn:disabled, -edit-output:empty, -toolbar, and -view (+ its themed scrollbar rules). Computed styles for Flora/Fauna now match Items exactly; verified in-browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Two new DM Editor subtabs sit beside Items and manage the same item catalog, sliced by the game's existing compendium routing: - Flora — plant-type items (Compendium "Flora") - Fauna — animal-type items (Compendium "Fauna") Items now shows everything that isn't a plant or animal (magic included, since there is no separate Magic editor subtab). The three subtabs share one machinery: catalogItemsForEditor(kind) splits the catalog, renderItemKind()/dmEditItemKind() drive rendering and the GM edit box, and requestItemEdit(instruction, kind) biases new entries toward "type":"plant"/"animal" and scopes the roster to the tab's slice. Each tab keeps its own name-filter and collapse/expand (over the shared card set). The Compendium's Plants and Animals tabs now display as "Flora" and "Fauna" (via compendiumDisplayName), while the internal category keys stay plants/animals so saves, discovery, and itemCompendiumCategory are unaffected. The item card's "Compendium:" field and the "No matches" hint use the display name too. Empty-state labels updated. The Editor tab row now scrolls horizontally (themed) rather than clip when many tabs are present. Tests: new test_flora_fauna.js (24 checks); README + guide updated. Verified in-browser (tab order, subtab switching, Compendium labels). Full suite 24/24. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Items already carried a free-form `lore` field, but it was never surfaced to the GM at play-time nor to the player. This closes the loop and gates the reveal behind earned discovery. Data model: - Item gains `loreKey` (a GM-facing note on how the player unlocks the lore) and `loreUnlocked` (revealed-to-player flag). Threaded through the constructor, registerInlineItem, makeItem, applyItemSpec, the positional drop-path Item(), and the save/load backfill. - applyItemTypeField()/unlockItemLore() keep lore, loreKey and loreUnlocked consistent across the catalog template and every live instance by name (same-named items are the same item). GM integration: - buildSystemPrompt emits a GM-eyes-only "Item Lore" dossier for items in the room or inventory that carry lore — hidden text, locked/unlocked state, and (while locked) the unlock condition. - New response field `itemLoreUnlock` + rule 13b instruct the GM to unlock an item's lore only when the player genuinely satisfies its loreKey (close examination, research, an expert NPC, a skill/stat check). The addItem schema now accepts loreKey; authored lore starts locked. - The turn handler flips the item everywhere, shows a gold "Item lore unlocked" note linking to the item, and refreshes open views. Player/DM surfaces: - The item detail popup shows lore only once unlocked; a DM always sees it, flagged "locked" with the unlock hint while the player hasn't earned it. - The Compendium gains a DM-only Lore editor on item-backed categories (lore text, unlock condition, and an "Unlocked for the player" toggle). Tests: new test_item_lore.js (28 checks); guide.html documents item lore for Player/DM/GM; README updated. Full suite 23/23. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Save World now stores the authored world envelope in a browser saved-worlds library (IndexedDB, key tlr_editor_worlds), keyed by the world's name — a browsable list of worlds the DM has authored, distinct from saved games and from the edit-drafts store. Export World (new ghost button, left of Import World) takes over the former Save behavior: validate and download the versioned envelope to a file that Import World on the login screen can play. - SAVED_WORLDS_KEY + loadSavedWorlds/saveSavedWorld/getSavedWorld/ listSavedWorldNames helpers (localStorage fallback when IDB is absent) - buildEditorWorldEnvelope() extracted and shared by both paths - saveNewWorld() -> async library save (rejects blank/"Untitled Realm"); exportNewWorld() -> download - Export World button wired between Save World and Import World - guide.html: document the library-vs-file split - tests: test_new_world covers both paths; fix test_world_draft for the earlier Edit->"World Editor" button rename Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Align #regions-stitch with its sibling inputs and the app palette: use var(--bg-panel) (was var(--bg-raised)) with a gold focus border to match #regions-brief/#regions-input, and theme the option list + selected-row highlight (dark-panel options, gold-dim selection with dark text via the WebKit gradient trick) instead of the browser-default light dropdown and blue selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
#regions-view and the #regions-input world-chunk textarea used the default browser scrollbars. Apply the same thin themed scrollbar used across the other editor views (scrollbar-width: thin + 4px webkit track/thumb on var(--border)). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
New regions and fleshed rooms now stay tonally, visually, and thematically consistent with the world's authorial intent — previously the expansion prompt defaulted to a generic "dark fantasy" tone, omitted the art style, and never sent the prologue or theme. - Persist tone, artStyle, and theme on the world object (World constructor + serializeWorld + rebuildWorldFromSnapshot), so they survive save/serialize/rebuild across play, import, and draft-edit. generateNewWorld stamps them onto the generated world from the editor form (prologue was already on the world). - buildWorldDigest now carries prologue + theme (tone/artStyle were already read but were empty because the fields didn't exist). - requestWorldExpansion and requestRegionStubs feed theme + full prologue; fleshRoom feeds theme + a trimmed prologue excerpt to keep the per-room call lean. tone/artStyle were already woven into the directives. Adds tests/test_world_framing.js (persistence round-trip + digest + directive wiring). Full suite 22/22. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Per correction, the World Editor's Import World is unrelated to the login play-import: it does not build/instantiate the world or convert shapes. It simply reloads a file previously written by Save World. - Save World now embeds the editor form inputs (name, theme, tone, scope, art style, prologue) into the saved envelope under an `editor` block (optional metadata; older/foreign files just won't carry it). - parseWorldForEditor returns the world VERBATIM (no itemCatalog→items aliasing) plus the editor block; importWorldToEditor drops the saved world JSON into the box unchanged and repopulates the form via populateWorldEditorFields (selects only set to a real option; falls back to the world's own name/prologue when no editor block is present). - Validation notes are still surfaced; the hard build check remains on Save. Rewrites tests/test_world_import_editor.js for the verbatim + round-trip behavior and updates the guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Beside Save World (darker/ghost variant), an Import World button opens a
file dialog and loads a previously saved world JSON back into the editor's
World JSON box, then surfaces validation notes so errors are revealed.
- parseWorldForEditor(text): accepts the export envelope or a bare world
object; normalizes the serialized export shape (itemCatalog/entityCatalog)
to the editor's World-constructable shape (items/entities) so Save/Edit
can rebuild it; returns { world, notes } via validateGeneratedWorld, or
{ error } for bad JSON / non-world files.
- importWorldToEditor(): file picker (showOpenFilePicker with the
pickWorldFileViaInput fallback), loads the normalized JSON into the box,
and reports clean vs. "check: …" notes to the save-output line. The hard
build check still runs on Save.
Adds tests/test_world_import_editor.js and documents the button in the guide.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFLore is the one Compendium tab that fills by earned knowledge rather than encounter, so cover it explicitly in all three parts: - Player: a "How Lore unlocks" note — earned through real conversation and investigation (trust/reputation, right questions, piecing clues), not freely-given facts; gold "Lore uncovered" notice + one-time scaling XP; people and items both yield lore. - DM: a "Lore, as a DM" note — lore entries have no art/prompt controls and aren't authored here; seeded world-canon lore is behind-the-scenes GM fuel that only surfaces via play; Reveal All lists canon lore as Undiscovered. - GM: a "Discovery vs. Lore" note — compendiumDiscover (cataloguing first encounters) vs loreDiscover (hard-won knowledge only), the not-for cases, reputation gates, and the ~5 / ~10–20 / ~30–50 XP scale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Build 100+ room worlds by laying down cheap room stubs and generating full detail on demand: - Rooms carry a persisted `stub` flag (addRoom), preserved through serialize/rebuild. - requestRegionStubs asks the GM for a large skeleton (8–16 stub rooms + exitPatches, no detail) and merges it through the hardened mergeWorldChunk (each room marked stub). A "Stub a large region" button in the Regions tab drives it, reusing the stitch selection. - fleshRoom → applyRoomFlesh fills one stub in place: installs any new catalog entries (reusing existing ids), replaces description/banner prompt and the room's people/items (dropping unresolved refs), and clears the stub flag. Exits and visited state are preserved. Mutation is gated to stub rooms only — every other room stays additive. - DM-only "Flesh out with GM" button + a stub badge on stub room cards in the Rooms tab. Adds tests/test_stub_flesh.js (stub round-trip + applyRoomFlesh behavior); marks P2 done in the design doc. Full suite 20/20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Make repeated world expansion coherent and cheap at scale:
- buildWorldDigest(world, stitchIds): DM-selected stitch rooms in full
(name, description, and their currently-used exit directions so the GM
patches a FREE direction), plus a compact digest of every other room
(id + name + one-line) and existing catalog names for reuse. Bounded and
capped (default 4 stitch rooms), defaults to the start room.
- requestWorldExpansion now consumes the two-tier context, accepts multiple
anchor ids, uses a dedicated bounded system prompt (no longer the full
per-room system prompt), instructs reuse of existing NPCs/items via {ref}
to avoid duplicates, and raises max_tokens 4000 → 16000.
- Regions tab: a "Stitch onto" multi-select (renderRegionsStitch, populated
on tab open, preselects the current room) feeds the stitch set;
dmGenerateRegion passes the selection.
Adds tests/test_world_digest.js and marks P1 done in the design doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFRework the world-chunk merge so growing a world stays valid no matter how
many chunks are merged:
- Collision policy: rooms/quests always skip; catalog ids
(items/entities/classes) skip by default and are replaced only when the
DM confirms via opts.overwrite. Adds planWorldChunk() (collision preview)
and an overwrite-confirm prompt in the Regions-tab paste merge.
- Name dedupe: a new catalog id whose name matches an existing entry is
dropped and any {ref} to it is rewired to the existing id.
- Referential auto-repair: dangling new-room exits and unresolved {ref}s
are dropped, exitPatches onto missing rooms skipped — all recorded as
non-fatal findings instead of silent corruption or errors.
- Reachability: newly-added rooms with no path from the start room are
reported (kept, not removed) via a shared reachableRoomIds() helper.
Extends the summary with per-collection skips + a findings[] array;
formatMergeSummary surfaces them. Adds tests/test_world_merge.js (27
assertions) and updates DYNAMIC_WORLD_LOADING.md + the design doc (P0 done).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFResolve the §7 open questions: skip-and-report default with an interactive overwrite-confirm path in the editor (in-play paths skip only); auto-repair-and-report findings; two-tier expansion context (full-detail DM-selected stitch rooms + digest for the rest); DM-only Flesh Out for v1; fleshing mutates only stub-flagged rooms. Updates §4.A/§4.B/§4.C, the §5 context schema, and converts §7 to resolved decisions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Audits the existing seed → Edit-to-draft → expand/merge loop
(requestWorldExpansion, mergeWorldChunk, draft persistence, BFS map
layout) and pinpoints the merge-robustness gaps: silent catalog id
overwrite, no room-exit/{ref} referential-integrity checks, no
reachability/orphan detection, no name-dedupe, no post-merge validation,
and the 4000-token expansion cap.
Proposes: a chunk integrity contract + collision policy + reachability
report surfaced in the Regions tab (P0), a compact world digest for cheap
coherent expansion at scale (P1), and stub-then-flesh lazy generation for
100+ rooms (P2). Includes contracts, a phased plan, open questions, and a
testing strategy. Review-only — nothing implemented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFThe full generated world JSON (rooms, entities, items, classes, quests, each carrying image prompts) was being truncated at 8000 output tokens on larger worlds, producing incomplete/invalid JSON. Give the GM generous headroom — claude-sonnet-4-6 supports up to 128K output tokens, and the response still stops at end_turn, so this is a ceiling, not a target. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
While the GM generates a world, show a monospace HH:MM:SS clock next to the status line that ticks up from 00:00:00, so the user sees how long the generation is taking. It computes from a start timestamp (accurate across delayed ticks), reveals on start, and freezes on the final duration when generation finishes (success or error). A second Generate run resets it. Add tests/test_worldgen_timer.js (formatting, tick, freeze, restart) and fill in the README test table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Per the intended behavior, an instance edit in the Art tab should fill the catalog type's (Compendium's) portrait and prompt only when the type does not already have one — a convenience for the DM to backfill MISSING art across instances and the Compendium from a single tab, without overwriting art or prompts deliberately set on the type. syncEntityTypeFromInstance now fills ENTITY_CATALOG compendiumImage / portraitPrompt only when blank; setCompendiumEntryImage → renamed backfillCompendiumEntryImage and reverted to fill-if-blank (also used by the item paths). Extend tests with a no-clobber case (existing type art + prompt preserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Compendium resolves people/monsters from the shared TYPE — the
ENTITY_CATALOG object (portraitPrompt) and the discovered snapshot
(imageUrl) — while the Art tab and NPC/Monster editors edit the live
entity INSTANCE. For catalog-backed monsters the instance is a distinct
object, so portrait/prompt edits showed in the editor but never in the
Compendium.
Add syncEntityTypeFromInstance(ent, {image, prompt}) which pushes a
deliberate instance edit up to the ENTITY_CATALOG type and overwrites the
discovered entry's image. Call it from generatePortraitForEntity,
uploadNpcPortrait, setNpcPrompt, and suggestNpcPrompt.
Rename syncCompendiumEntryImage → setCompendiumEntryImage and make it
overwrite (a deliberate re-generate should replace the shown image);
use it for the item paths, whose catalog/prompt were already in sync.
Update tests/test_art_sync.js to exercise the monster catalog-type
propagation and the overwrite behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFUpdate .we-field label and #we-result-label from 10px/gold-dim to 14px/gold so the World Editor's section titles read at the same size and color as the Character sheet's section labels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When a portrait or item image is generated (or uploaded) in the DM Art tab, populate the matching Compendium entry's imageUrl if it was blank, so the Compendium reflects the new art. Entities that gain a portrait then drop out of the Art tab on the next render via the existing reRenderArtIfActive hooks. Also fix the Art tab location links: #art-room-popup was missing from the shared room-popup positioning/style rules, so clicking a location did not reveal the popup. Add it to both selectors. Add tests/test_art_sync.js covering the sync behavior, the Art-tab drop, and the popup reveal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Art panel wasn't scrolling because #art-view lacked the overflow/flex treatment the other editor views get. Add #art-view to the shared scroll rule (overflow-y:auto; flex:1) and the thin scrollbar styling (4px, --border thumb, transparent track; plus scrollbar-width:thin for Firefox on all editor views), and make #editor-sub-art a positioning context so its corner toolbar anchors. Add a top-right corner toolbar with Collapse All / Expand All that toggles the open state of every card in the tab (entities, rooms, items are all <details class="npc-card">). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Logs tab gets a small toolbar: a "Filter ▾" drop-down with a colour-coded checkbox per log type (GM, ROUTINE, ENCOUNTER, SAVE, ERROR, SYSTEM) that shows only the checked types, and a "Clear" button that empties the buffer. This tames the chatty SAVE logs while keeping everything available for real debugging. The choice persists in localStorage and applies to both the live-append and full re-render (hidden-type entries stay buffered, just unshown). The 800-entry cap (LOG_MAX) still bounds the buffer; the visible list is the checked types drawn from those entries. Unknown categories are never hidden. Menu closes on outside-click / Escape, mirroring the sidebar-blocks menu. Includes tests/test_log_filter.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a "save" log category (orange, #e0863a — distinct from ENCOUNTER gold and ROUTINE green) and emit save events from the game-save path: a "Saving game…" line with the snapshot size when a write begins, and "Game saved." on success. A failed write still logs an error (red) and no false "Game saved." is emitted. Includes tests/test_save_logs.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Check in the higher-value unit tests built during development, made repo- portable (they read ../text_adventure.html relative to tests/ rather than an absolute workspace path). Each extracts the game's inline <script>, runs it under a small DOM mock, and asserts engine behavior plus rendered markup — no build step, no npm install, Node built-ins only. Includes a runner (node tests/run.js) that runs every test_*.js and prints a pass/fail summary (non-zero exit on failure), and a README describing how they work and what each covers. Coverage: IndexedDB persistence, world drafts + Edit isolation, the Art tab, Art Style, world-concept Suggest, minor items, Places banners, encounter portraits, monster location links, DM-on-resume, compendium reveal/backfill, journal subtabs, and the New World editor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A review dashboard under the DM Editor that gathers, in one place, everything still without art so the DM can see the gaps, review the available prompts, and create the images: - Characters & Monsters with no portrait (entity.compendiumImage blank) - Rooms with no banner at all (room.hasAnyBanner() === false) - Items with no picture (catalog item.image blank) Each entry reuses the very same controls as its home tab — the NPC/Item cards (Generate / Upload / editable Portrait Prompt / ✨ suggest) and, for rooms, the Places-compendium banner handlers (Generate / Upload / editable Banner Prompt / ✨) — so generating here writes to the same live data everywhere. A count and a Refresh sit at the top; when nothing is missing it shows an all-done message. The tab has its own room-detail popup for the entity cards' location links, and it auto-refreshes after an art action while it's the visible editor subtab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Save World now sits at the left of its row (Edit stays right-justified via the spacer), and Back to Login is left-justified on the row below — the two align along the left edge. Pure markup layout change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Move "Save World" up onto the same row as "Edit" (both right-justified beneath the World JSON box), and drop "Back to Login" onto its own row directly below Save World. Pure markup layout change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Beneath the World JSON box, add a right-justified gold "Edit" button that opens the just-created world in the detached editor for hands-on editing — without ever touching the player's saved playthrough. Newly created/edited worlds are kept in their own IndexedDB store (SAVED_WORLD_DRAFTS_KEY), keyed by world name and separate from the player's game save (SAVED_STATE_KEY). Edit validates the current world JSON, saves it as a named draft, and opens a detached window with ?detach=editor&draft=<name>. That window boots in a new "draft editor" mode: it loads the named draft and stands up a throwaway DM author around it (never reading the player's save), and its saves route to the draft store (saveGameState → _saveWorldDraftEdits), not the game state. Cross-window sync from the player's session is disabled in this mode so a running game can't overwrite the draft world, and vice versa. Draft storage mirrors the state layer (IndexedDB with a localStorage fallback); a listWorldDraftNames() helper is included to feed the planned "load a previous world" dropdown. As with normal Detach, this requires serving over http(s) — file:// isolates storage between windows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Beneath the Theme & Premise box, add a gold Suggest button that asks the GM to
invent a fresh world concept — an evocative name plus a 2-3 sentence theme &
premise — from the currently selected Tone (requestWorldConcept → { name,
theme }), then fills both fields. Mirrors the existing Create Prologue / art-
style Suggest patterns and reuses the shared we-output status line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFThe New World generation schema authored a portraitPrompt for every entity and a bannerPrompt for every room, but items got no image prompt — so generated items came out with nothing to paint from. Add a "prompt" field (a single-object still-life prompt, the item analog of an entity's portraitPrompt) to the item schema, and extend the art-style clause to cover item prompts too, so a freshly generated world has image prompts for items, entities, and rooms alike. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Below Tone and Scope on the New World editor, add an "Art Style" field: an
input box with a "Suggest" button beside it. Suggest asks the GM to distil a
single one-line visual style for the world's generated images from the world's
name, theme & premise, tone, and prologue (requestArtStyle → { artStyle }),
then fills the field. The chosen style is threaded into world generation so the
GM composes every room bannerPrompt and entity portraitPrompt in that style,
giving the world cohesive art. No style given = no change to the prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFWire 18 real, captioned screenshots into the guide as themed figures, stored as external files under Images/guide/ and referenced relatively (lazy-loaded). Coverage spans all three parts: the title screen, API-keys dialog, main play screen, character sheet, Compendium, Journal, Maps, and Settings for the Player; the Editor (map + the directly-editable Classes tab), a being card, items, rooms, encounters, the DM quest view, the World subtab, and the New World editor for the DM; and the engine Logs tab for the GM. The captures are real game state, taken by driving the app into a fresh session (the opening room is described locally, so no live AI call is needed). A small figure style (framed, captioned, responsive) matches the guide's theme. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Apply the game's scrollbar treatment globally to the guide — 4px wide, --border thumb on a transparent track with a 2px radius, plus Firefox's thin scrollbar-width — so the page, the sticky table of contents, and the scrollable code/table blocks match the app's look. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The sticky table of contents gains a humble search box. Typing matches every
section ("page") of the guide against its title and body — title matches ranked
first — and shows a compact result list (grouped by part) in place of the TOC.
Clicking a result, or pressing Enter, jumps to that section's anchor exactly as
if navigated directly, then closes the search and restores the TOC. Arrow keys
move the highlight, Escape and the ✕ button clear. The index is built once from
the rendered sections; no dependencies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFA self-contained, book-length HTML guide styled in the game's palette and typefaces, with a sticky table of contents, scroll-spy navigation, callouts, reference tables, and a back-to-top control. Three parts: - Player: login/setup, saving & resuming (IndexedDB), the screen and sidebar, the play loop, the character sheet, stats/combat/reputation, the accelerated world clock and calendar, the Compendium, Journal, Maps, settings, images, and the file:// caveat. - Dungeon Master: becoming a DM, the nine Editor subtabs, editing beings/items (incl. inline "minor" items) and rooms, encounters, quests, the Compendium's type-level art/prompt controls and backfill, growing the world via chunks + exitPatches, the New World editor, import/export, and the detached editor. - Game Master: the model and browser API call (with the direct-key caveat), the assembled system-prompt dossier, the rules it plays by, the full structured-change vocabulary, authoring directives, image generation, a turn end-to-end, and robustness notes. Plus appendices: a change-field reference, reputation/time tables, a glossary, and a colophon noting it is a living document. Content was verified against the source; fonts fall back gracefully offline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When creating rooms, not every object a room logically contains deserves a
catalog template + Compendium entry. Add a "minor" flag on items so the GM can
place incidental room dressing — utensils, plates, props, small puzzle/quest
bits — as real, interactable items that are deliberately kept out of the item
catalog and the Compendium, so they don't clutter the player's collection.
A "minor": true item authored inline in a room's "items" array is fully
playable (examine/take/use) but registerInlineItem skips catalog registration
for it and describeRoom skips Compendium discovery. Normal inline items and
catalog refs are unchanged (still catalogued/discovered). The flag is plumbed
through the Item constructor, makeItem, the drop-split, and reItemObj
(backfilled false on older saves/imports), so it survives save/restore.
The world-expansion (room-authoring) directive now instructs the GM to use
inline "minor" items for one-off flavor and local interaction, reserving
catalog templates + { "ref" } for items that recur, travel between places, or
matter to the world.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFThe full game snapshot (base64 portraits/banners + a long story log) routinely
blew past localStorage's ~5 MB quota, forcing the old save path to trim story
history to fit. The snapshot now lives in IndexedDB, which has a far larger
quota and stores the JSON directly. Small scalars (API keys, name, DM/logged-in
flags, sidebar/settings prefs) stay in localStorage — tiny and read
synchronously at boot.
Storage layer: idbOpen/idbGet/idbSet/idbDel plus loadStateRaw/saveStateRaw/
deleteStateRaw. If IndexedDB is unavailable (private mode, some file://
contexts) every helper transparently falls back to localStorage. A returning
player's existing localStorage save is migrated into IndexedDB once on first
read, then removed to free its quota.
Because IndexedDB is async, the four choke points (saveGameState,
restoreGameState, readSavedSnapshot, hasResumableSave) and their callers
(startGame, syncWorldFromStorage, boot) are now async; boot runs in an async
IIFE and the detached-editor view is sequenced after the awaited resume. The
login screen reads a synchronous resumableSaveCached flag (primed by
refreshResumableCache at boot and logout) since it can't await on every toggle.
Cross-window sync: IndexedDB has no storage event, so each save fires a tiny
localStorage "ping" key that the existing storage-event listener now watches to
re-hydrate the world in a detached editor. The unreliable beforeunload save is
replaced by a visibilitychange(hidden) + pagehide save (with beforeunload kept
as a best-effort last kick); the Continue re-save is now awaited so the role
choice is persisted before proceeding.
While a read/write is in flight, the bottom status bar shows an animated
attention state ("Saving game… do not close the browser." / "Loading your
saved game…") with a sweeping gold shimmer overlay and pulsing text, restoring
the prior status when done and reference-counting concurrent operations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFThe Places subtab now offers the same DM portrait upload/regenerate and collapsible editable prompt section as the other Compendium subtabs. A place's "type" is the live room: its prompt lives on bannerPrompt and its portrait is the room banner, set across all six time-of-day slots (gifs preserved) via setRoomBannerAllSlots. Banners paint in a wide landscape frame (buildBannerUrl / paintImageFromPrompt wide flag), and the GM suggest prompt is framed as a wide establishing shot of the location. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
On the NPC and Monster editor cards, the "Current location" and "Home location" values are now clickable links (when they name a real room) that open a floating room-detail popup — mirroring the entity-chip popups elsewhere. - Add a room popup element inside the NPCs and Monsters subviews (each already position:relative) and register them in ENTITY_POPUP_IDS. - buildRoomPopupHTML(roomId) renders read-only room detail (name, banner, id, description, exits, interiors, items, occupants); showEntityRoomPopup opens it via the shared showEntityPopup, targeting the active subview's popup. - renderEntityCards renders the two location values as .place-link links (kvRows gained a raw/HTML value flag); off-screen/unknown locations stay plain text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The ACE editor and the hidden #we-json textarea could drift: generation wrote only the textarea (ACE kept showing stale content, and an ACE edit would then overwrite the generated world), and openWorldEditor re-created ACE on every open. - Create ACE once, lazily (worldEditorAce) via ensureWorldEditorAce(); resize it on open so it lays out after the overlay is shown. - Add getWorldEditorJson()/setWorldEditorJson() as the single source of truth; route generateNewWorld (success + salvage-raw) through setWorldEditorJson and saveNewWorld through getWorldEditorJson, so ACE and the textarea stay in sync. - Graceful fallback: if ACE isn't available (CDN blocked/offline), un-hide the #we-json textarea and hide #editor so the JSON box is always editable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The collapsible prompt section on the NPC/Monster, Item, and Encounter cards in the DM Editor is now labeled "Portrait Prompt" — it drives the portrait, and leaves room for other prompt types on these objects later. The Compendium's own prompt section is unchanged (out of scope). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Encounter cards now carry an optional portrait and image prompt (new image / prompt fields on the encounter objects; persisted with the world): - A portrait media column (reusing the item-card media styling) with the same controls as other portraits: an empty state offering Generate + Upload, and, once imaged, ⬆ upload / ♻ regenerate icons (click the image to enlarge). - A collapsible, editable Prompt section with a ✨ button that asks the GM to write an image prompt from the encounter's name and the entities it uses, drawing on each entity's description for guidance. - Generate/regenerate paint from the Prompt via pollinations.ai and store the result on the encounter, mirroring entity-portrait behavior. A GM encounter edit (upsert by name) now preserves the DM-authored image and prompt instead of wiping them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A standalone CSS theme (ace-lostrealms.css) that styles ACE's DOM classes with the game's own palette (gold/tan on near-black, with the green/blue accents brightened for code legibility). Scoped under a `.ace-lostrealms` class so it overrides ACE via pure CSS — no theme module needed: include the file and add the class to the editor container. Covers the editor surface, gutter/active line, cursor/selection, guides + bracket matching, the full syntax token set, and the autocomplete/search popups and scrollbars. Not wired into any editor yet (the app's JSON areas are plain textareas); this is a reusable drop-in for whenever ACE is mounted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Save World routed its status to #we-output up by Generate World — far above
the Save button at the bottom — so a fallback download (no File System Access
API, e.g. Firefox/file://) or any error looked like "nothing happened."
- Add a dedicated #we-save-output status line beside the Save button; route
all saveNewWorld feedback (busy/success/error) there.
- downloadJsonFile now reports its method ('picker' vs 'download') so the
message distinguishes a real Save dialog from a browser download ("saved to
…" vs "downloaded as … to your browser's downloads"), and add a .catch so a
failure is always surfaced.
The save-dialog path (showSaveFilePicker) already fired on secure contexts;
this makes the outcome visible everywhere.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF- Add a "Prologue" textarea under Tone/Scope in the New World editor; its text is fed to the GM as canon and used as the world's prologue when generating (requestWorldGeneration now takes/uses opts.prologue). - Restyle the generate row: Generate World is right-justified (.we-spacer), with a new "Create Prologue" button left-justified on the same row. - Create Prologue (createPrologue/requestPrologue) asks the GM to draft a prologue from the World Name, Theme & Premise, and Tone. If Name/Theme are blank, the GM invents them first and returns them so blank fields are backfilled (user-entered values are preserved), then writes the prologue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Replace the vestigial "Starting World Seed" login field (it was read but never used) with a full New World flow reached from a new "New World" button beside Import World. The New World editor is a standalone page that works with the GM to forge a complete world from a theme brief: - Inputs: World Name (optional), Theme & Premise (the old seed idea, now the GM's thematic guidance), Tone, and Scope — kept uncluttered. - Generate World asks the GM (requestWorldGeneration) to author a complete WORLD_DATA-shaped world and drops it into an editable JSON textarea. - Save World validates the (possibly hand-edited) JSON, builds it into a live World to prove it loads, serializes it to the portable export envelope, and downloads it — a file that re-imports cleanly from the login screen. - A logout button returns to the login screen. Supporting refactors: extract downloadJsonFile + worldJsonFilename (shared by Export World and Save World) and let serializeWorld take an explicit world so the editor can serialize its build without touching globals. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Journal tab now has five subtabs — Quests, Faction, Profession, Legend, and Tasks — mirroring the Character/Editor subtab pattern. The existing journal timeline moves under "Quests" (the default); the other four show styled placeholders for features to come. - switchJournalTab(sub) toggles the active tab/subview and renders the timeline when Quests is selected; switchTab opens whichever subtab is active; goToJournalBeat forces Quests (where entries live). - Reuse the shared subtab bar/tab/subview CSS and the journal panel/empty-state styling for the new subviews. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
- Sound toggle: a tiny speaker icon on the checkbox row (right-justified via margin-left:auto) unmutes/mutes the login background video. Browsers block autoplay with sound, so the video starts muted and this click turns it on; the icon reflects state (🔇/🔊). The video now pauses when entering the game (so no lingering audio) and resumes on logout — via new playLoginVideo / pauseLoginVideo / toggleLoginSound helpers. - Logo: the icon.svg emblem now sits to the left of the world title, in a centered flex row. It's a sibling of the <h2> (whose text is set via textContent), so title updates don't wipe it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a muted, autoplay, looping full-bleed video (Videos/loginBackground.mp4) behind the login form. A dark wash (moved to #setup-overlay::before) sits over it so the form stays readable, and the form box is raised above both. The previous still image is kept as the overlay's fallback background — shown while the video loads or if it can't play (e.g. the file is missing). logout resumes the video when the overlay is shown again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Fix: unchecking (or re-checking) the Dungeon Master box on the login screen had no effect when resuming a saved game — the Continue path called restoreGameState(), which restored player.isDM from the snapshot, and returned before the checkbox was ever read. restoreGameState now takes an optional dmOverride: when the player Continues from the login screen, its DM checkbox is applied to the resumed character (before DM visibility and every DM-gated render), and the choice is persisted (checkbox key + a re-save of the snapshot) so a later boot auto-resume keeps it. Boot auto-resume passes no override and keeps the saved role. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Every request from an Ask-the-GM bar now shows a gold "GM is …" busy line on the bottom status bar for the duration of the request, then resets to "Ready" when it completes. - setStatusBar now accepts an optional custom message; add setGmStatus / clearGmStatus helpers. - Wire all Ask-the-GM handlers: the character sheet request (portrait / background / summary, with the message inferred from the request so the ♻/✨ buttons and phrasings like "paint me a portrait" read specifically — "GM is painting a new portrait...") and the DM editor tabs (NPCs, monsters, rooms, classes, items, encounters, quests, region authoring). Each sets its message on submit and clears it in its finally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give entities and items a free-form "lore" string — object-specific history/provenance/significance, distinct from the reputation-gated profile history and from world-level lore. - Add lore to the Item and Entity constructors, plumbed through makeItem/makeEntity (catalog ref inherits; inline overrides win), registerInlineItem, applyItemSpec, and applyNpcSpecToEntity. Persists as an own field; reItemObj/reEntityObj default it to "" for older saves/imports. - GM guidance: every creation directive (world-expansion, encounter-edit, NPC/Monster editor, Item editor, in-play addItem loot, Room-editor inline items) now asks the GM to author lore consistent with the world's prologue, existing lore/canon, and the object's own name and description. The per-entity GM dossier in buildSystemPrompt now surfaces each present creature's lore (GM eyes only). - Seed lore for the whole cast (all 12 entities) and the story-significant items (Ancient Crown, Forbidden Tome, Bone Wand, Sapphire Pendant, Bog Iron, Strange Herb, Old Parchment), all tied to the Shattering, the barrow, the Border Skirmish, and the Herbalist's Cure. Mundane gear is left without lore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Make the Character sheet's portrait and background controls consistent with the entity/item cards, driving the sheet's own Ask-GM round-trip: - Background: add a right-aligned ✨ button beneath the text, mirroring the prompt sections. It asks the GM to write a background, exactly as if the player typed "generate a background" into the Ask-GM box. - Portrait: replace the single "Upload" label with the shared upload (⬆) + regenerate (♻) icon-button row used on other portraits. ♻ repaints the portrait as if the player typed "paint me a portrait". submitCharacterRequest() now takes an optional preset instruction so the buttons reuse the exact same GM flow without touching the Ask-GM input; generateCharacterBackground()/regenerateCharacterPortrait() wrap it with per-button loading state. Removed the now-dead .char-portrait-upload CSS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Extend every GM authoring path so newly created entities and items come with a meaningful "classes" taxonomy, and surface those labels so the GM can reason about them during play. Authoring directives now request 1-3 kind labels (distinct from `type`): - World-expansion (new entities + items) - Encounter-edit (newly invented entities) - NPC/Monster editor edit - Item editor edit (disambiguated from the player's *character* classes, which share the word "classes") - In-play loot (addItem) and Room-editor inline item specs Apply paths updated to persist the field: applyItemSpec and applyNpcSpecToEntity now read/normalize `classes`; makeItem/makeEntity and the world-chunk merge already carry it. Dropping a stacked item no longer strips classes (and now preserves image/condition/prompt too). Gameplay use: buildSystemPrompt lists each present creature's classes in its stat line, and a new rule tells the GM to judge action feasibility by kind — a humanoid can be disarmed/dismembered (e.g. cutting the arm off the Skeleton King), an incorporeal spirit cannot; constructs don't bleed, beasts can't be reasoned with, etc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give entities and items a free-form list of kind/category labels (distinct from `type`, the single mechanical/behavioral slot) so creatures and objects can be grouped and reasoned about by kind — e.g. a Bog Wraith is ["undead","spirit"], a Giant Spider ["arachnid","beast"], a Skeleton King ["undead","humanoid"], and an Iron Sword ["sword","one-handed weapon"]. - Add classes to the Item and Entity constructors, plumbed through makeItem/makeEntity (catalog ref inherits; inline overrides win) and registerInlineItem. - Add normalizeClassList(): accepts an array or a comma/semicolon string, trims, drops blanks, and de-dupes case-insensitively into string[]. - Persist automatically (own instance field); reItemObj/reEntityObj default it to [] so older saves and imports rehydrate cleanly. - Seed the example data (Iron Sword, Giant Spider, Bog Wraith, Barrow Shade, Skeleton King) to demonstrate the taxonomy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Gate the Compendium prompt backfill on the type prompt having been blank before the update, matching the image backfill. Seeding prompt-less instances now happens only the first time a type gets a prompt; later edits to the type prompt no longer propagate to instances — an instance prompt may already have driven a generated image, so refining the type prompt must not silently change it. Applies to both compendiumSetPrompt and compendiumSuggestPrompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Mirror the image backfill for prompts: when the DM edits or GM-suggests a Compendium type's image prompt, give any live instance of that type that has no prompt of its own the same text as a reasonable default. Instances that already carry a prompt keep it, and clearing the type prompt never blanks instances. - Entities inherit into portraitPrompt; item instances into prompt. - Wired into both compendiumSetPrompt and compendiumSuggestPrompt. - Add backfillInstancePromptsFromType() alongside the image backfill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When the DM creates a Compendium type's image for the FIRST time (the type had no image before) via Generate/Regenerate or Upload, backfill any live instance of that type whose portrait is still blank with the new image, as a reasonable default. Instances the DM already gave their own portrait are left untouched, and later regenerations (type already imaged) do not reseed. - Entities: match by name, seed blank compendiumImage on live NPCs/monsters in the world (what the editor NPC/Monster tabs display). - Items: the editor Items tab edits ITEM_CATALOG directly so it is already in sync; also seed blank live item instances scattered in rooms, NPC inventories, and the player's pack. - Add allWorldItemInstances() and backfillInstancesFromType() helpers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Treat each Compendium entry as a general TYPE (one "Town Guard"), not a specific live instance. A type's image prompt and canonical portrait now live on the shared CATALOG object (ENTITY_CATALOG for people/monsters, ITEM_CATALOG for items/plants/animals/magic), so editing or generating them re-skins the type rather than one live creature. - Add a DM-only collapsible, editable "Prompt" section to Compendium entries (people/monsters/items/plants/animals/magic), mirroring the editor tabs, with a ✨ button that asks the GM to write an image prompt. - Rework Upload / ♻ Regenerate to write the type's prompt/image on the catalog object and sync the discovered snapshot, decoupled from any single instance's own portrait. - Replace compendiumEntryContext with compendiumTypeContext; add compendiumSetPrompt and compendiumSuggestPrompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Below Compendium images, show DM-only Upload + Regenerate buttons for categories backed by a live source: People/Monsters (the entity's compendiumImage) and Items/Plants/Animals/Magic (the catalog item's image). Regenerate paints from the source's editor-set prompt; both sync the discovered entry's snapshot so the change shows immediately. Places (room banners) and Lore have no single portrait, so no buttons. Extracts a shared downscaleImageFileToDataUrl helper reused by all uploaders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Same-named NPCs/Monsters collided because the editor card actions resolved by name (findEntityByName returns the first match), so generate/upload/ edit always hit the first entity. Add a stable per-instance uid to Entity (persisted, rehydrated on restore/import) and route the editor card actions — generate/regenerate/upload portrait, prompt edit + suggest — and the collapse state through the uid via a new findEntityByUid. Name-based identity is unchanged everywhere else (GM edits, compendium, story links), where name is the intended reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When an NPC/Monster has no portrait, show an "Upload" button below "Generate" that opens a file dialog and assigns the chosen image to the entity's portrait (compendiumImage), reusing the existing uploadNpcPortrait handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Relocate the World-tab "Export World" backup row from the top of the subtab to below the world-chunk textarea and Merge/Clear buttons, and flip its divider to a top border to read as a separator from the content above. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Generate and upload now set only ent.compendiumImage (dropping the location-keyed conversationImages writes), and the NPC/Monster editor card reads compendiumImage directly for its portrait. conversationImage() falls back to compendiumImage so in-game conversation still shows a generated/ uploaded portrait. This keeps the portrait consistent everywhere from one attribute instead of updating only the card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add an Import World button beside API Keys that loads a world exported by the World tab's backup and stages it so the next Begin starts a fresh game on that world instead of the built-in one. Validates the export envelope (or a bare world object), rebuilds the login title/tagline/version and the class dropdown from it, and forces New Game. Uses the File System Access API's Open dialog where available, else a hidden file input. Imported games are flagged in their save so they resume past the built-in world-version gate (the snapshot is self-contained); the gate still guards non-imported (built-in) saves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add an Export World button on the World (regions) subtab that serializes the entire live world (rooms, NPCs, items, classes, lore, quests, encounters, catalogs) to a JSON file wrapped in a versioned envelope. Uses the File System Access API for a real Save dialog where available and falls back to a normal download otherwise. Extracts serializeWorld() so the export and the session save share one shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
restoreGameState set player.loggedIn in memory but never wrote the persisted SAVED_LOGGEDIN_KEY flag, and startGame only sets that flag on its New Game path (the Continue path returns early after restore). So after a logout (manual or inactivity) + Continue, the flag stayed false and every subsequent refresh — and the detached editor — failed to auto-resume even though the save was intact. restoreGameState now reaffirms the flag on any successful resume (skipped in the detached editor so a second view can't revive a logged-out session). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Make the game installable so it (and the detached Editor) can run in a standalone window with no browser address bar. Adds a web manifest (standalone display, maskable icon), an SVG app icon, and a network-first service worker (fresh online, offline fallback, installability). The service worker is registered only over secure origins (skipped under file://). Requires serving over http(s)/localhost and installing the app once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Running from a file:// path makes browsers treat each page as an isolated origin, so localStorage can fail to persist or be shared between windows — which silently breaks save/resume and the Editor's Detach window. Detect file:// and show an advisory note on the login screen (and in the detached window) explaining the cause and the fix (serve over a local web server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Make a failed auto-resume explain itself instead of silently showing the login screen: restoreGameState now records why it bailed (no save / corrupt / version mismatch / rebuild error) and the login screen shows that reason. The detached editor's "no session" message shows it too. Also make saveGameState quota-resilient: if the snapshot exceeds browser storage (data-URI images + a long story can blow past ~5 MB), retry with a trimmed story so world + character still persist, and warn loudly instead of losing the save silently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Right-clicking the Editor tab (DM only) opens a context menu with a "Detach" item that launches the same file in a minimal second window (?detach=editor), scoped to just the Editor with no page scroll — a second view of the same session, e.g. on another monitor. The detached window resumes the shared saved session and shows only the Editor. Cross-window sync via the localStorage `storage` event keeps the live world in step both ways; the detached window only ever persists world data (play-state is spliced from the main window's latest save) so it can't roll back play, and its idle timer never logs the session out. Refactors the world rebuild out of restoreGameState into a shared helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Lift the card background and border and use full gold for the card titles so the API Keys dialog reads less dim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add right padding to the API Keys dialog body so the cards don't hug the vertical scrollbar when the list overflows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add three more optional provider-key cards to the API Keys popup and persist them on this device alongside the Pollinations token. Refactors key handling into a single API_KEY_FIELDS table that drives pre-fill, save-on-close, dialog-sync, and resume, so each key is one table entry plus its card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Mirror the NPC card controls on the Items editor cards: beneath an item's image, add an icon-only row with an upload button (downscales to 512px, stores on the catalog item) and a ♻ regenerate button that repaints the image from the prompt. Beneath the Prompt textarea, add the tiny ✨ button that asks the GM to author an image prompt and drops it into the textarea in place. Refactors the item image generation into a shared helper reused by the empty-slot Generate button and the regenerate button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Beneath an existing portrait, add a small row of icon-only buttons: an upload button (works like the Character-sheet uploader, downscaling to 512px and storing on the entity) and a ♻ regenerate button that requests a fresh portrait from the prompt, replacing the current one. Refactors the generation core into a shared helper reused by the empty-slot Generate button and the regenerate button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Beneath the Prompt textarea (right-aligned) add a small dim icon button that asks the GM to author a portrait prompt for that NPC and drops it straight into the textarea in place (persisting it), without re-rendering the tab so the open Prompt section stays put. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Float the Attributes block to the right within the vitals column, just after the Vitals label so it aligns near the Current-location row. The vitals grid and the now full-width Description flow to its left and wrap beneath it, so Description can use the full card width. The float is contained within the vitals column (a flex item), so it does not affect the Profile/Routine/Abilities/Inventory sections below. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Place Description and Attributes beneath Vitals & Location inside the right-hand column beside the portrait, so the whole vitals/description/ attributes block sits to the right of the portrait. Profile and the remaining sections stay full-width below. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give the portrait frame a definite portrait aspect ratio and fill it with object-fit: cover so the image fills the bordered box completely (and the box has a solid top edge). The top row already aligns children to the top and the Vitals label has no top margin, so Vitals & Location now lines up with the portrait's top edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Place the Vitals & Location section to the right of the portrait in a two-column top row, enlarge the portrait, and size the portrait frame by width with auto height so the border hugs the image at its own aspect ratio instead of overshooting a fixed square. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
NPC/Monster editor cards now show the entity's current portrait. When none exists, they show the same placeholder + "Generate" button as the Items tab, which paints a portrait from the NPC's prompt via pollinations.ai (loaded before commit; errors surface inline). The generated portrait is stored as the compendium thumbnail and a conversation close-up for the NPC's room, so it appears in the editor, Compendium, and in-game conversation. Each card gets a collapsible, editable Prompt section, and the NPC-edit GM directive now asks for a portraitPrompt (also merged by applyNpcSpecToEntity). Adds a portraitPrompt field + portraitImage() resolver to Entity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking the image in an item detail popup now opens the same lightbox used by the Items editor cards, showing a larger version. Wired in the shared buildItemDetailHTML, so it applies everywhere the item popup appears (editor, map, story, compendium, sidebar, classes, rooms). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The room-card item/entity chips opened #editor-entity-popup, which lives inside the Map subview — hidden while the Rooms tab is showing, so the popup never appeared. Give the Rooms subview its own #rooms-entity-popup (mirroring the Classes tab) and route the chips to it, so item and NPC detail popups are visible on the Rooms tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In the DM Editor's Rooms tab, the item and entity chips on each room card are now clickable and open the corresponding item/NPC detail popup (the same editor entity popup used elsewhere). Chips are keyed by their index in the room's items/entities array; entity chips resolve from the room's own home-entities list so the popup matches what the card shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking an item's image in the DM Editor's Items tab now opens a centered lightbox overlay showing a larger version of the image. Click the backdrop or the ✕ to close. Reuses the app's modal-overlay styling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Apply the app's thin 4px scrollbar styling (transparent track, --border thumb) to the item card Prompt textarea, replacing the browser default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When an item has no image, its card's media column now shows a "Generate" button instead of an empty slot. Clicking it paints an image from the item's prompt via pollinations.ai (square frame), loading the URL before committing so a rate-limited/failed generation leaves the item imageless and surfaces the error on an inline status line. On success the image is stored on the catalog item and persisted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each item card in the DM Editor's Items subtab now has a collapsible "Prompt" section at the bottom holding the item's free-form image/description prompt, editable by the DM. Edits persist to the item catalog (and the save). Adds a `prompt` field to items (Item, makeItem, registerInlineItem, applyItemSpec) and lets the GM author it via the Items request box. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Apply the app's thin 4px scrollbar styling (transparent track, --border thumb) to the setup box, replacing the browser default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Cap the setup box height and let its contents scroll internally when the browser window is shorter than the box, and pad the overlay so the box never hugs the window edges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Change the login screen description to "A world of ancient mysteries and hidden treasures." Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Change the login screen description to "A world of ancient mysteries, lurking dangers and hidden treasures." Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Pollinations key was only saved inside startGame, so entering it in the API Keys dialog and refreshing (without starting or resuming a game) lost the value. The dialog now behaves like a settings panel: it persists the key to localStorage on close and re-syncs the input from storage on open, so the key survives a refresh regardless of game state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Replace the Pollinations key field on the login form with an 'API Keys' button that opens a popup dialog holding one card per optional key (Pollinations to start). The input keeps its id, so pre-fill, persistence, and the portrait call are unchanged — this just tidies the login form. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Before sending portraitPrompt to the image service, portraitPromptWithGender prepends a gender descriptor (Female/Male/Non-binary) when the prompt doesn't already convey any gender — so gender is guaranteed present, not just requested via the GM directive. If the prompt already conveys a gender (GM or player intent), it's left untouched; Unspecified/Other force nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a Pollinations API key field to the login screen (persisted in localStorage like the Claude key, pre-filled on refresh, loaded on resume). When set, buildPortraitUrl appends it as a URL-encoded token query param (portraits load via <img>, so a header isn't an option); when blank, the keyless tier is used as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Collapsing/expanding a sidebar block by clicking its header now saves the state to localStorage (tlr_sidebar_collapsed) and re-applies it on load, so collapsed blocks stay collapsed after a refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Restructure the item card body into two columns — item info on the left, the image in a right-side column that stretches to the full height of the card — instead of the image sitting in a section at the bottom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
New 'Items' editor subtab matching the other tabs: a filter box + Collapse all/Expand all toolbar, one collapsible card per ITEM_CATALOG entry showing all item properties (type, value, condition, icon, description, image, compendium routing, id), and a GM request box focused on creating/editing items. applyItemSpec merges specs into ITEM_CATALOG (match by id or name, value clamped >= 0); requestItemEdit is constrained to item data only and declines out-of-scope requests. Persists with the world save. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Every inline item (no catalog ref) is now registered into ITEM_CATALOG via registerInlineItem, called centrally from makeItem — so class loadouts, room floor items, and GM loot all become catalog-managed. Class loadouts authored via the Classes tab are normalized to catalog refs, so the catalog is the single source of truth for the item. Items thus appear in the catalog-driven Compendium Reveal All and are ready for DM management. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Reveal All built its item universe only from ITEM_CATALOG, so inline items the GM authored in a new class's starting loadout (not catalog refs) were missing. allCompendiumEntriesFor now also scans class startingInventory (resolved via makeItem, routed by type, deduped by name), and compendiumDetailBodyFor falls back to class loadouts so the revealed item's popup opens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Encounters tab now matches the other editor subtabs: a filter box (top-left) + Collapse all/Expand all group (right-justified) in a floating toolbar, and each encounter is a collapsible <details> card (caret, per-card collapse persisted by name). renderEncounters filters by name and honors the collapsed set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The starting-inventory chips on Classes cards are now clickable links that open the item's detail popup (a dedicated #classes-entity-popup for the subview, sharing the standard popup styling). showClassItemDetail resolves the loadout spec by its startingInventory index (kept on the chip) via makeItem and shows buildItemDetailHTML, matching item popups elsewhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a 'Starting inventory' section to each class card in the Classes editor, resolving the loadout specs (catalog refs / inline) to item chips (icon + name + quantity), with a 'None' hint when empty. The loadout is authored via the GM request box; this surfaces it on the card. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a GM request box + Apply button to the Classes tab (matching the other editor subtabs). dmEditClasses hands the instruction to a class-only GM directive: it may create/modify classes' name, description, base stats, and starting inventory, and must decline anything outside that scope (quests, rooms, NPCs, story, player state). applyClassSpec merges specs into world.classes (base stats clamped >= 1, new classes seeded from defaults) and saves with the world. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Classes filter box + collapse/expand tools disappeared because the toolbar is position:absolute but #editor-sub-classes wasn't in the position:relative rule (unlike the NPCs/Monsters/Rooms subviews), so the toolbar had no containing block. Added it to that rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add #classes-toolbar and #classes-view to the shared editor CSS so the Collapse/Expand tools are right-justified (space-between toolbar) and the view padding/margins match NPCs/Monsters/Rooms. Also persist per-card collapse state for class cards (keyed by data-class-name). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
- Each WORLD_DATA class now defines baseStats (hp/mp/str/dex/int/wis/cha/ con). The Player constructor reads them via classBaseStats(), falling back to DEFAULT_CLASS_STATS for a class/world without baseStats. Class stats save/restore with the world (already carried on world.classes). - New DM Editor 'Classes' subtab, styled like NPCs/Monsters/Rooms: a filter box + collapse/expand toolbar, one collapsible card per class showing its description and an editable table of base stat defaults. Editing a field writes to world.classes[class].baseStats and saves; new values apply to characters created afterward. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add player.gender (default Unspecified) with a dropdown on the Character Profile tab (Unspecified/Female/Male/Non-binary/Other) the player can change freely; the choice persists with the save. Gender is included in the GM system prompt and the character-edit directive so generated backstory/summary/portrait reflect it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The in-world digital clock now displays 12-hour H:MM with an am/pm suffix (e.g. 12:00 am, 9:05 am, 2:30 pm) instead of 24-hour time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A tiny 'Upload' button beneath the Character-sheet portrait opens a file picker; the chosen image is downscaled (max 512px) to a JPEG data URI, set as player.portrait, reflected in the sidebar, and saved. Non-image files are ignored; canvas failure falls back to the original image. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Show the in-game time as a zero-padded HH:MM digital clock just left of the time-of-day indicator in the top-right header. Updated each second by updateRealmCalendar alongside the existing label/icon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The in-world clock runs at 24x real time and keeps advancing while the player reads/types, so a request formed at (e.g.) Midnight can arrive with the clock drifted into Morning. Previously hoursUntilTimeOfDay returned 0 when already inside the target window, so the skip did nothing and the GM reported it was 'already morning'. It now advances to the NEXT occurrence of that window instead. Rule 11a updated so the GM narrates it as time genuinely passing rather than a no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking any Compendium entry now closes the sidebar item popup (only) first, so it doesn't linger underneath the compendium popup — they share the same top-right region. Other popups are untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Opening an entity in a detail popup now closes any OTHER popup already showing that same entity (e.g. an item open in the sidebar and then clicked in the Compendium). showEntityPopup tags each popup with the entity key parsed from its title and closes duplicates; different entities may still show in separate popups. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking an item in the Character Profile tab's Inventory now opens the shared item detail popup (resolved from player.inventory) instead of jumping to the Compendium. Uses a data-char-item hook + a delegated handler on #character-view, mirroring the sidebar item links. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking the Character-sheet portrait now doubles its size (192x232) and clicking again shrinks it back, with a smooth transition. The expanded state is stored on the player (portraitExpanded), so it is saved with the world state and survives a refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The NPC/People popup portrait is now clickable: click to expand it from half to full width and click again to shrink it back, with a smooth width transition (mirrors the story banner behavior). Delegated handler scoped to the wider NPC popup image only; item popups are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In the wider NPC/People popup, the portrait now takes 50% width (the detail fields flow full-width beneath it), keeping the popup shorter. Scoped to .entity-popup-wide so item popups are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
NPC/People detail popups now render wider (336px) than item popups. buildNpcDetailHTML emits a marker; showEntityPopup toggles a .entity-popup-wide class on the popup (removed again when the same popup later shows an item). An ID+class CSS rule applies the wider width to every NPC-capable popup, but not the items-only second compendium popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each Compendium subtab now has a DM-only toggle (right side of the filter row) that reveals every entry defined for the world — including those not yet discovered in play — dimmed and marked 'Undiscovered'. Lets a DM review the full catalogue without playing through discovery. The universe per category mirrors auto-discovery routing (entities split people vs monsters; items route to items/plants/animals/magic; places are rooms; lore is the world lore list), merged with discovered entries by name. Popups resolve undiscovered entries from the catalogs. Non-DM players never see the toggle or the extra entries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add an optional WORLD_DATA.mapBackground field carried on the World object and persisted across refreshes. The map backdrop (player Maps tab + DM editor map) is now painted from a --map-bg-url CSS variable that applyMapBackground() sets per world on load, falling back to the built-in default when a world omits one — so loading a new world can change its map background. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Replace the fixed 15-minute idle window with a player-configurable value set via a +/- number stepper in the Settings popup (whole minutes, clamped 1-240, default 15). Changing it persists to localStorage and reschedules the active countdown immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When 'Logout after Inactivity' is enabled, the player is returned to the login screen after 15 minutes with no interaction. Any mouse/keyboard/ touch/scroll activity resets the countdown (throttled). The timer starts on login/restore and when the setting is toggled on, and stops on logout or when the setting is turned off; a stale timer never logs out if the setting was disabled in the meantime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Settings popup now has a 'Logout after Inactivity' checkbox. Its state persists in localStorage (tlr_settings) and is reflected onto the control whenever the popup opens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A ⚙ button next to the sidebar-blocks button opens a floating top-right Settings popup that shares the styling and orientation of the other detail popups. Blank for now (titled 'Settings'); closes via its × button, the gear toggle, click-away, or Escape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Reorder the sidebar so the Magic block sits directly beneath Inventory (before Items) instead of at the bottom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
New collapsible 'Magic' sidebar block (empty placeholder for now), following the existing section pattern. It is automatically picked up by the sidebar-blocks visibility menu and collapse logic since both derive from data-section/data-body. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking the portrait in the sidebar Character block now opens the Character tab and selects its Profile subtab, with a pointer cursor, hover highlight, and tooltip to signal it is clickable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A new dropdown button (☰) beside the Hide sidebar button lists every sidebar block (Character, Wealth, Inventory, Items, People, Exits) with a checkbox. Unchecking a block hides it (kept in the DOM so its content keeps updating); checking shows it again. Choices persist in localStorage and are re-applied on load. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The sidebar Character block now displays the same painted portrait shown on the Character tab (shared player.portrait), beside the name/title/ class, with a glyph placeholder when none is set. Painting a new portrait refreshes the sidebar as well. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The 'create a portrait' request now actually loads the generated image before committing it. If the image service fails (e.g. a rate limit), the error is surfaced on the request output line and the existing portrait is left unchanged; the portrait is only replaced when the image loads successfully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The enlarged banner now resolves its static/gif sources live from the room's current data at click time (keyed by data-room-id and data-banner-time) instead of baking the gif URL into the stored HTML. This makes gifs added to the world data take effect on already-rendered banners, and a migration recovers room id + time from the image alt text so banners persisted before this feature also swap to their gif. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When a room banner has a distinct, non-empty gif source for the current scene, clicking to enlarge (full width) swaps the image to the gif, and clicking again to shrink restores the static image. Banners with no gif (or an empty gif string) keep their existing static-only behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Rewrite all 13 rooms' bannerImages time-of-day slots from bare URL
strings to the { static, gif } object shape, moving the existing URL
into 'static' and leaving 'gif' empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFEach time-of-day banner slot now holds two possible image sources — a still 'static' image and an animated 'gif' — instead of a single URL. Legacy string slots and old saves are normalized on read, so display is unchanged (static preferred by default); getBannerImageFor takes an optional kind to opt into the gif. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Places (rooms) entries with no image of their own now fall back to the live room's banner art (entry id mirrors the room id). The .compendium-thumb img is object-fit:cover in a fixed square, so the wide banner is auto-cropped to a thumbnail — no manual thumbnails needed. Resolved live at render, so banners generated after discovery still show; bannerless rooms keep the placeholder icon. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Drops the ⤓ export-story button, its CSS, and the exportStory() function now that the persistent message-log/save mechanism covers story continuity. The sidebar-toggle button takes over the right-alignment (margin-left:auto) at the end of the tab bar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The portrait directive now tells the GM to craft the image prompt from the character's details (class, summary, and background) and to honor any instruction about which of those to base it on — e.g. "create a portrait using only the character summary" builds the prompt from the summary alone. The current summary and background are already passed to the GM as context. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Restore now shows the game (hides the login overlay, enables input) at the very top of the UI-rebuild pass, before any fragile render call — so if a later render throws, it's logged but the player is already in the game rather than bounced back to login. Moved renderNarrativeWindow out of the core restore try into that best-effort pass for the same reason. Also mirror the restore-failure reasons (version mismatch / core throw / view-rebuild throw) to the console so the cause is visible when a resume does bail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The existing character sheet (+ GM request bar) now lives under a "Profile" subtab, with a second "Equipment" subtab (blank placeholder for now). Reuses the editor/map subtab styling; switchCharacterTab toggles the subviews and re-renders the sheet when Profile is shown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
An ambient behavior of a non-"speech" type (e.g. "dance") logged "Ambient type not supported" and did nothing. Ambient beats are now handled generically: requestAmbientBehavior asks the GM for one short atmospheric beat — a spoken line for "speech", or a brief described action for any other type. The encounter-edit directive notes the type can be any short behavior label. Removes the unsupported-type error path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
These type tabs are item-like (their entries are routed from item types), so their entry titles are now clickable links that open the shared item detail popup — preferring a live item, falling back to the compendium record — just like the Items tab. renderCompendium marks them clickable and compendiumDetailBodyFor resolves them as items. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
startGame now calls switchTab('story') before describing the opening room, so
a new game always lands on the Story tab regardless of which tab was active on
the login screen / previous session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFstartGame reset the compendium to a hardcoded { people, places, items,
monsters, lore } object, omitting the newer plants/animals/magic buckets.
Auto-discovering a plant-type item then hit compendium.plants (undefined)
in compendiumDiscover and threw, so the item landed in neither Items nor
Plants. The reset now derives its buckets from COMPENDIUM_CATEGORIES, and
compendiumDiscover defensively creates a missing bucket before use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFItem auto-discovery now routes by the item's own type: a plant/animal/magic item is catalogued under its dedicated tab (Plants/Animals/Magic) instead of the general Items tab. Compendium entries record the type, and the Items tab also filters out any plant-typed entry as a guard. Ordinary items are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Three new compendium categories (plants, animals, magic) with their own tab buttons, meta (icons/empty text), and buckets — behaving like the existing tabs (rendering, name filter, popups where applicable). The GM's compendiumDiscover directive now lists these categories (flora; non-hostile wildlife vs. enemy monsters; spells/artifacts/arcane phenomena). Restore now backfills any missing category arrays so older saves stay safe, and the tab bar wraps so 8 tabs fit on narrow panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A filter textbox above the compendium entries (with the shared clear-X button) narrows the active subtab's entries by a case-insensitive name substring; a non-matching query shows a "No matches" state. The filter resets when switching subtabs so each category opens unfiltered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The sidebar People block NPC names are now clickable links that open the shared NPC detail popup (in #sidebar-entity-popup) instead of being plain text/jumping to the Compendium. Names carry data-people-npc; a delegated handler on #entities-list resolves the entity (current room, then any room) and renders it with buildNpcDetailHTML. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The sidebar Items block (current-room floor items) now opens the shared item detail popup instead of jumping to the Compendium, matching the Inventory block. Links carry data-room-item; a delegated handler on #room-items-list resolves the item from the current room and shows it in #sidebar-entity-popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The sidebar Inventory items now open the shared item detail popup instead of jumping to the Compendium. Links carry data-inv-item and a delegated handler on #inventory-list resolves the item and shows it in a new #sidebar-entity-popup that floats over the main panel just left of the sidebar (anchored to #app so it clears the sidebar's own scroll region), reusing the shared popup styling and buildItemDetailHTML builder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Rule 15a and the playerStatusChanges field note now tell the GM that a status can also change HP or MP (a heal, or damage taken), applied via the existing stateChanges.hpDelta/mpDelta — one-time changes to the current pool that are NOT part of the reversible status "effects" array and don't undo on expiry (e.g. a "wounded" status alongside hpDelta -8, "refreshed" with mpDelta +10). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Status badges (character sheet + sidebar) now use a green border/dot when the status is a net-positive buff (statusIsPositive: effect deltas sum > 0), and keep the red affliction styling otherwise (debuffs, neutral, or plain labels). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Rule 15a now illustrates a positive buff — "rested" after sleeping, granting a slight CON +1 / STR +1 for a couple of in-world hours. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
formatGameCountdown now renders whole minutes ("14m", "2h 05m"), rounding up
so a status with under a minute left still reads "1m" while active.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFTimed statuses now display a ticking countdown (e.g. "⏳ 14m 05s") next to the badge in both the Character sheet Statuses section and the sidebar Character block, formatted in remaining in-world time. Each countdown carries a data-expires attribute; updateStatusCountdowns refreshes them in place on the per-second calendar tick (right after the expiry sweep), so no full re-render is needed for the numbers. Indefinite (no-duration) statuses show no timer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The sidebar Stats cell showed the base score with the status modifier only annotated in brackets, so a debuff didn't lower the visible number (while the character sheet's attribute card correctly showed the reduced effective score). statCellHTML now displays the effective value (base + training + status), keeping the (+trained) and [±status] annotations beside it, so the two views agree and a debuff visibly reduces the stat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Statuses are now objects { label, effects:[{stat,delta}], expiresAtGameMs }.
The GM's playerStatusChanges "add" entries may be a plain label or an object
with "effects" (stat buffs/debuffs) and "durationMinutes"; rule 15a documents
this (e.g. "out of breath" → CON -2 for ~15 min after jumping jacks).
The engine sums active effects into player.statusMod (folded into
effectiveStat, shown as [±n] on stat cells and a dashed "status" tag on
attribute cards), and auto-expires timed statuses against the in-world clock
via a per-tick sweep (plus an immediate sweep after rest/wait time skips),
reversing their effect and posting a "Recovered" notice. Legacy string
statuses normalize to objects; restore recomputes mods and quietly drops any
that expired while away. Character sheet + sidebar badges show effect summaries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFRule 15 now lists "tired"/"out of breath" after physical exertion such as jumping jacks or a hard sprint, so the GM applies exertion conditions too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Mirrors the Stats subsection: a collapsible "Statuses" subsection inside the Character block lists the player's active conditions as compact badges (or a muted "None." line when empty). updateSidebar populates it each turn, so GM-assigned afflictions appear here alongside the character sheet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Adds a "playerStatusChanges": { add:[...], remove:[...] } field to the GM turn
contract, plus a directive (rule 15) telling the GM to apply a temporary
condition when the player's action would logically cause one ("poisoned" after
drinking poison, "bleeding" after a wound, "stunned", "dizzy", "sick", etc.)
and to clear it once resolved (cured, bandaged, rested off, worn away).
The turn handler applies these via applyPlayerStatusChanges (add with
case-insensitive dedupe, remove by label), refreshes the Character sheet's
Statuses section, and shows a brief "Afflicted:/Recovered:" system notice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFPlayer now has a `status` array (default []) holding short condition labels the GM can assign during play (e.g. "feeling dizzy", "crippled", "sick", "stunned"). A new Statuses section on the character sheet (between Vitals and Attributes) shows them as chips with a count badge, or a muted "No active conditions." line when empty. Blank entries are filtered; text is escaped. Persists with the rest of the player state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Testing done; restore the intended trailing-window and reveal-batch sizes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The visible content bounced when older messages were revealed because two things fought the manual scrollTop compensation: the container's scroll-behavior:smooth animated the compensating jump, and native scroll anchoring (overflow-anchor:auto) shifted scrollTop too. Disable native anchoring on #narrative and turn off smooth scrolling for just the compensation assignment, so the reveal is an instant, jump-free adjustment — the scrollbar moves down while the on-screen content stays put. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Temporary small values so the windowed reveal is easy to exercise on an existing playthrough; will tune upward after testing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Story panel's full history now lives in a messageLog array (the source of truth); only a trailing window of the most recent messages is mounted in the DOM. A top sentinel + IntersectionObserver reveals older batches when the reader scrolls up (preserving their viewport by compensating scrollTop), and returning to the bottom trims the window back down. Window/batch sizes are tunable constants (NARRATIVE_WINDOW=60, NARRATIVE_REVEAL_BATCH=30). addMsg now records to messageLog and mounts/trims; save persists messageLog (with a legacy narrativeHtml-blob parse on restore); export builds from the full log; new game resets it. Delegated #narrative click handlers are unaffected. Late-image re-scroll is now gated to when the reader is at the bottom so a load in revealed content can't yank them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Rooms edit path ignored a room's items entirely: applyRoomSpecToRoom had no item handling and the GM directive never listed items as editable, so "add an Iron Sword to the Village Square" was reported as done while the item was silently dropped. Now the room spec supports "addItems" (append to the floor) and "items" (replace the floor list), each accepting a catalog ref or an inline item spec, instantiated via makeItem; the directive documents both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
New "Rooms" subtab mirrors the NPCs/Monsters tabs — the same collapsible cards (keyed by room id), the corner filter + Collapse-all/Expand-all toolbar, and a GM input box — but lists one card per room. Each card exposes the room's data: region, base description, per-time-of-day descriptions, exits and hidden exits (direction → destination), floor items, home entities, containment (interiorOf / interiors), banner artwork/prompt, and the room id. The GM box creates or modifies rooms: existing rooms are updated in place (name, description, region, merged descriptions/exits/hiddenExits); brand-new rooms are installed and stitched via mergeWorldChunk (exits + exitPatches). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Label-only change; internal id and wiring are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Label-only changes; internal ids and functions are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Rooms now carry a "region" string (default ""), threaded from room data via addRoom, for later grouping of rooms into named geographical regions on the world map. It serializes with the rest of the room state for refresh-resume. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each filter textbox now has an inline X button at its right end that clears the filter text, restores the full list, and refocuses the box. The button only appears while the filter has text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each tab's toolbar now spans the top with a left-justified "Filter by name" textbox and the collapse/expand tools on the right. Typing filters that tab's cards by a case-insensitive substring match on the entity name; a non-matching query shows a "no match" message. The two filters are independent and persist across re-renders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The NPCs subtab now lists only non-enemy entities. Enemy-type entities move to a new "Monsters" subtab with identical look and behavior — the same cards, collapse/expand-per-card, the corner Collapse-all/Expand-all toolbar, and a GM input box for creating/modifying entities. Rendering and editing are generalized: renderEntityCards drives both tabs (npcEntities / monsterEntities filters, separate collapsed-state sets and toggle listeners); requestEntityEdit(instruction, kind) and dmEditEntities(kind) share all machinery, with per-kind wording and default type (new monsters default to "enemy"). Edits re-render both tabs so a type change moves an entity across the divide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A small floating toolbar in the upper-right corner of the NPCs subview holds a segmented button group: Collapse all and Expand all, which fold or unfold every NPC card at once. Collapse all fills the collapsed-name set; Expand all clears it; both re-render. The toolbar group is structured to take more tools later. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each NPC card is now a <details> whose <summary> is the title bar — clicking it collapses/expands the card. A caret in the bar rotates to show state, and collapsed cards drop their head divider so only the title shows. Expanded/ collapsed state is tracked per NPC name (npcCardsCollapsed) and kept in sync by a capturing 'toggle' listener, so it survives the tab's re-renders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
New "NPCs" subtab lists every entity in the world as a card exposing all its properties for the DM: description, vitals & location (current/home), full attributes, reputation, the complete behind-the-scenes profile (occupation, appearance, personality, mannerisms, demeanor, speech style, history, motivation, secrets), the daily routine (prose summary + per-time-of-day table), abilities, inventory, and any per-spawn personalization. A GM input box at the bottom is restricted to creating new NPCs or modifying existing ones — writing descriptions, routines, and occupation/profile text. requestNpcEdit hands the instruction to the GM as a standalone (non-story) call; updates merge onto the live entity in place, new NPCs are placed and homed in a named room and registered in the entity catalog. Persists via saveGameState and refreshes the sidebar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In the Items subtab, if the clicked item is already displayed in either the primary or secondary popup, do nothing instead of opening a duplicate. Each popup tracks its item (case-insensitively) and clears it on close. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In the Items subtab, the first item selection fills the primary detail popup; while it stays open, further selections fill/update a second popup shown just to its left, so two items can be compared side by side. Every other category (People) keeps a single popup. Both popups close when switching subtabs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking a People or Item entry title in the Compendium now opens the shared NPC/Item detail popup (top-right of the compendium view) — the same popup and styling used on the maps and in the story. People/Item titles resolve to the live entity/item so the popup reflects current state (reputation, health, quantity), falling back to a minimal view built from the compendium record when nothing live matches. Places/Monsters/Lore titles are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
A resumed session restores the saved narrative HTML verbatim, so item/NPC names printed before the popup change still carried the old onclick="goToItem/goToPerson" and kept jumping to the Compendium. Rewrite those legacy links to the data-attribute form on restore so the delegated handler opens the shared detail popup instead. The migration is idempotent and no-ops on HTML without legacy links. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The clickable item and NPC names in a room's detail lines previously jumped to the Compendium tab. Now they open the shared item/NPC detail popup (top-right of the story view) — the same popup and styling used on the DM editor map and player maps — reusing whatever catalog/compendium image the entity already has. Enemies/monsters are now linkable too. Adds a global attrEsc helper so entity names are safely escaped in the data attributes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Style .modal-body with the thin scrollbar used elsewhere so the Prologue popup (and other modals) no longer show the default browser scrollbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a tiny 'Prologue' link under the world tagline on the login screen. It opens a modal (reusing the existing modal styling) showing WORLD_DATA.prologue (paragraphs preserved), with a Close button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a 25-word ceiling to the summary directive (both the summary action and the summary produced with a background rewrite), keeping the one-sentence (two if necessary) guidance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Update the Character-sheet GM directive so both the summary action and the summary produced alongside a background rewrite are one sentence in most cases, two only if truly necessary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Drop the bordered .char-editable box styling from the summary — it now reads as plain dim flavor text like the old class description, brightening on hover and showing only a subtle ring/tint while being edited. Background keeps its box. Click-to-edit behavior is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Turn the top-of-sheet class description into an editable, persisted player.summary (seeded from the class description at game start). Generalize the click-to-edit code (beginEditCharField/endEditCharField, charFieldEditing guard) so summary and background share it. The Character GM request box now also supports rewriting the summary, and when it rewrites the background it returns an updated summary too so the two stay consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The section titles (Background, Vitals, Attributes, Inventory) all use .char-section-label — bump it to 14px/700 in full gold (from 10px/600 gold-dim) so they read as prominent headings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a GM request bar at the bottom of the Character sheet, restricted to
two intents: paint/replace the portrait, or rewrite the background text.
requestCharacterEdit sends the request to the GM (standalone call) which
returns { action: portrait|background|decline, portraitPrompt, background,
message }. Portrait requests turn the GM's image prompt into a live image
URL via buildPortraitUrl() (isolated so the backend can be swapped later)
and set player.portrait, replacing the placeholder; background requests set
player.background; anything else is declined with the GM's message. Both
persist via the session save.
Note: MCP connectors (e.g. Higgsfield) aren't reachable from the browser
Anthropic API, so portraits are realized at runtime via a keyless
text-to-image URL service; buildPortraitUrl is the single swap point for a
future server-side Higgsfield proxy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFNew 'Background' section (below the header) showing a placeholder until the player writes one. Click the text to edit it inline (contentEditable); clicking outside (blur) saves it to player.background and exits edit mode, restoring the placeholder if left empty. A bgEditing guard stops the periodic Character-sheet re-render from clobbering an in-progress edit; the text persists via the existing session save (player is serialized). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Restructure the sheet header into a left-aligned row: a portrait box in the top-left (uses player.portrait if ever set, else a glyph 'No Portrait' placeholder) with the identity block (name/title/level+class/class blurb) beside it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
renderMapsTab set #map-subpanel.innerHTML, which wiped out the #map-entity-popup child, so clicking a World-map icon found no popup element and showed nothing. Move the dynamic subviews into an inner #map-subviews container (now the innerHTML target) and keep the detail popup as a sibling of it under #map-subpanel, so it survives rebuilds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When an item/NPC detail is opened directly from a map icon on the DM editor map (not from the room popup's links), anchor it to the far-right corner instead of the left-of-room-popup slot — hide the room popup to free the corner and add a .far-right override. Opening from the room popup's links still uses the left slot (the override is cleared). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Item and NPC icon badges on every map (player world/interior maps and the DM editor map) are now clickable and open the item/NPC detail popup. The popup markup + styling is unified via shared builders (buildItemDetailHTML / buildNpcDetailHTML) and a generic showEntityPopup, so item and NPC popups look identical everywhere, reusing catalog/compendium/portrait images with a glyph fallback. - Badges carry data-badge-kind/room/idx/name and a map-badge-click class. - Player maps: a top-right #map-entity-popup over #map-subpanel, opened by a delegated badge click (ignores pan-end); closed on map-subtab switch. - Editor map: badge clicks open the existing entity popup (and no longer select the room). The room popup's Item/NPC links reuse the same builders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Quests is DM-only, so relocate it under the Editor tab alongside Map, Encounters, and Regions. Remove the standalone Quests main tab/panel and its switchTab wiring; add an 'etab-quests' subtab and 'editor-sub-quests' subview (with the quest timeline, the GM quest-edit bar, and the confirm modal), rendered via switchEditorTab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Brighten the badge text, value, and border to gold on mouse hover. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Absolutely-center the badge within the status bar (position:relative on the bar, left:50% + translateX on the badge) so it stays centered regardless of the left status text and the right-aligned calendar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a 'Map Version: <n>' text badge to the bottom status bar, populated from WORLD_DATA.version on load and kept in sync with the live world's version during play. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add WORLD_DATA.version (1.0.0), stored on the World instance and in the save snapshot. A saved session now only auto-resumes when its stored version matches the built-in version; a mismatch (world was updated) drops to the login screen instead of resuming a stale world. Add a 'New Game' checkbox to the login dialog. The login now doubles as a Continue/New choice: with a compatible save present, leaving it unchecked continues that game (button reads 'Continue Your Journey'); checking it discards the save and seeds a fresh world from the built-in WORLD_DATA. A contextual note explains which will happen. Logout now keeps the snapshot (clears only the logged-in flag) so Continue is available from the login; a normal in-play refresh still auto-resumes as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Quest beats now carry a 'rewards' list of item references, each { ref:
<itemCatalogId>, quantity?, note }. The note is GM-only guidance on how the
reward is obtained (handed over by an NPC, found in a chest, etc.).
- QuestBeat/Quest gain the rewards field (default []); every WORLD_DATA
beat seeded with the property, with the three climactic beats given real
rewards.
- Quests tab shows a '🎁 Rewards' line at the bottom of each beat (after
locations/NPCs), resolving each ref to its catalog item name + note.
- questSummary feeds each beat's rewards + notes to the GM, and a new rule
10a tells it to deliver a beat's rewards when it unlocks — into inventory
or the room as the note fits — described naturally, once.
- The GM quest-editor schema and applyQuestEdit carry rewards through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFAdd an instruction box at the bottom of the DM Quests tab. requestQuestEdit hands the DM's instruction to the GM with the current quests (each beat's unlocked/journaled state flagged); the GM returns quests to upsert by id and quest ids to remove. Applying upserts quests (replacing beats, carrying over unlocked state + date for beats whose id persists) and deletes removed quests, then re-renders Quests/Journal/Compendium and saves. Because edits can touch beats the player already unlocked (shown in the Journal), computeQuestEditImpact flags any unlocked beat that would be removed or changed; if any, a confirmation modal lists them and the edit only applies on confirm (removals then drop from the Journal/Compendium, which re-derive from beats). Non-destructive edits apply immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In the DM editor room popup, Item and Present-NPC names are now clickable. Clicking one opens a second detail popup to the left of the room popup: - Items show name, image (from the item's catalog image, falling back to its compendium entry, else the icon glyph), type, quantity, condition, value, and description. - NPCs show name, portrait (conversation close-up → compendium thumbnail → compendium people/monsters entry image, else a type glyph), type, reputation (non-enemies), health, status, occupation, and description. Links use a delegated handler on the popup keyed by item index / NPC name. The detail popup resets when the room popup reopens or closes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The popup's 'View interior' button kept pushing the same room onto the breadcrumb path on every click. Hide the button once you're already inside that building (it's the current drill focus), and guard editorMapDrillInto against re-pushing the current focus (it just refreshes the popup instead). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The character view used the default native scrollbar. Match the rest of the app: 4px webkit scrollbar with a transparent track and border thumb, plus scrollbar-width:thin for Firefox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Replace the static hint text (and the separate 'Realm' back button) with a clickable breadcrumb trail: 'Realm › Building › …'. Drilling into a room pushes it onto a navigation path (editorMapPath) and appends a crumb; clicking any earlier crumb (or 'Realm') jumps straight back to that level. The current level is shown non-clickable. The bar passes clicks through to the map except on the crumb links themselves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The first click re-renders the map (replacing the clicked SVG node), so the browser never saw two clicks on the same element and the native dblclick never fired. Detect a double-click by timing instead: a second click on the same room within 400ms drills into its interior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Exits are already drawn on the map, so listing them in the popup is redundant. Keep the Hidden Exits section (which connections are secret isn't obvious from the map lines). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Since single-click now selects a room and opens the detail popup, rooms with interior levels lost their drill-in. Add a dblclick handler on the editor map viewport: double-clicking a room that has interiors drills into its interior rooms/levels (the popup's 'View interior' button still works too). Update the overview hint to mention it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The popup body showed the default native vertical scrollbar. Match the rest of the app: 4px webkit scrollbar with a transparent track and border thumb, plus scrollbar-width:thin for Firefox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
New 'Character' tab (right of Story) rendering a read-only sheet from live player state: identity (name / title / level+class / DM flag) with the class blurb, vitals (HP/MP/XP bars), the six attributes as cards showing effective score, D&D-style modifier, and any earned training bonus, the derived persuasion/fortitude bonuses the engine uses, wealth (gold/silver/ copper), and the full inventory with each item linking to its Compendium entry. Re-renders on open and, while open, on every sidebar refresh so it stays live as play changes things. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each encounter card in the Encounters tab now ends with a collapsible 'Ambient behaviors' section (native <details>, so it survives re-renders) listing every ambient behavior: type, trigger (Every Ns for timePeriod or 'On status change' for statusChange), chance, optional target, and the GM prompt — each in a readable card. Only shown when the encounter defines ambient behaviors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The '◈ ... is now nearby' line was too dim (text-muted). Switch it to gold-dim at 12px to match the app's other ◈ status indicators. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The editor map no longer highlights based on the player's position. Room boxes are now selectable: clicking one highlights it and opens a floating, right-justified room-detail popup (room title, banner image, id, description, exits, hidden exits, interior relationships, items, and current occupants) with a close button. The popup floats over the map so it stays pannable/zoomable; clicking another room re-points the popup. Buildings with interiors are drilled into via a 'View interior' button in the popup rather than by the click. renderMapInto gains a selection mode (opts.selectedId) that drives the editor highlight and suppresses the 'you are here' marker; the player world map is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The rebase auto-merged two additions of intervalType onto the same ambient object (a defensive timePeriod and the intended statusChange), leaving a duplicate key. Keep the intended statusChange and drop the duplicate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Ambient behaviors now honor an intervalType field. 'timePeriod' (the default when absent) keeps the current behavior: rolled on a real-time timer every 'interval' seconds while the NPC is present. 'statusChange' is event-driven — evaluated only when the encounter NPC's status changes (via a routine shift or a GM statusChange), so a change of state can prompt a remark/action instead of a clock tick. Refactor the roll/dispatch into a shared evaluateAmbient(); the timer path picks a random present spawn, the new onEntityStatusChanged() fires statusChange behaviors for the specific NPC whose status changed. Wire the hook into applyNpcRoutines and the GM statusChanges handler. setupEncounters registers timers only for timePeriod behaviors. Encounter-editor GM prompt documents the field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Return the map hint to the top-left corner and drop the zoom/reset controls just beneath it. Brighten the hint text from muted to gold-dim so it's actually noticeable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Put the level legend back in the bottom-left where it was, and move the zoom/reset controls to the top-left instead. Shift the map hint just right of the controls so they don't overlap. Bottom-right of every map panel is now free for upcoming UI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Free up the bottom-right of every map panel for upcoming UI. The zoom/ reset controls move to bottom-left as a fixed-width column hugging the left edge; the level legend shifts just to their right so both stay in the bottom-left without overlapping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add an instruction box pinned below the Encounters document. A DM types a natural-language change (add or modify an encounter); requestEncounterEdit hands it to the GM (standalone call, outside conversation history) with the current encounters, entity ids, and room ids, and the GM returns the full definition of each encounter to add/change plus any new entity templates. The engine upserts encounters by name (matching names replace, new names append), merges new entities, restarts the encounter timers, re-renders the tab, and persists the change. Enter submits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add full session persistence. saveGameState() snapshots the live game to localStorage (player, world rooms/NPCs/items with all mutations, merged regions, compendium, quests/lore, conversation, game-clock epochs, and the story panel HTML); restoreGameState() rehydrates it, re-attaching class prototypes so Entity/Room/Quest/World methods work again. On load, if the persisted loggedIn flag is true, the login screen is skipped and the saved session is restored — same location, inventory, world state, and story. State is saved after each turn, on time-of-day transitions, on encounter spawns, on region merges, on quest-beat changes, and once more on beforeunload so a refresh captures the latest state. The UI rebuild is a best-effort pass so a render hiccup can't discard a valid restore. Logout clears the snapshot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a loggedIn flag to Player (false at the login screen, true once a character enters the game via startGame, back to false on logout), persisted across refreshes in localStorage (tlr_logged_in). Add a logout button in the header to the right of the time-of-day field. Logging out stops the encounter/ambient timers, clears the logged-in state, and returns to the login screen (repopulating the saved fields). Convert populateLoginScreen from an IIFE to a reusable function so logout can refresh the login form, and clear the narrative on start for a clean slate when logging back in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
On a time-of-day transition, compare the room's description for the new
hour against the previous one. If it's unchanged (most rooms, which use a
single base description), skip reprinting the title/banner/description/
detail block and just echo the new time of day ('It is now afternoon.').
The full scene is still shown when the description actually differs at the
new hour (e.g. a square that empties after dark).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFrequestWorldExpansion asks the GM to author a new region as a JSON world chunk (stitched onto the player's current room via exitPatches, optionally with new NPCs/items/lore/quests/encounters) and feeds it straight into mergeWorldChunk. It's a standalone GM call outside conversation history, uses the tolerant JSON extractor, and never throws. Two entry points, both DM-only: - Editor > Regions tab: a 'Generate with GM' brief input that authors, shows, and merges a region, plus the existing manual paste-and-merge. - 'expand the world <brief>' command in normal play, which locks input and shows the typing indicator but advances no story. Factor formatMergeSummary/tryPrettyJson helpers shared by the tab handlers. Add DYNAMIC_WORLD_LOADING.md documenting the chunk format, merge semantics, exitPatches stitching, and both usage paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Refactor the constructor's room-build loop into a reusable World.addRoom method so rooms can be instantiated into a live world at runtime. Add mergeWorldChunk(chunk): merges a WORLD_DATA-shaped JSON subset into the running world in place — extends the item/entity/class catalogs, appends lore/quests/encounters, installs new rooms, and applies exitPatches to stitch new rooms onto existing ones both ways. Merging is additive-only (existing rooms/quests are skipped, never overwritten) and returns a summary of what was added/skipped. Because the GM prompt and maps/compendium read from world.* on demand, merged content becomes discoverable immediately; the UI refresh runs as a decoupled best-effort pass so a render hiccup never reports the committed merge as failed. Expose it via a DM-only Editor > Regions subtab: paste a JSON chunk and merge it into the live game, with a result summary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When the player draws an encounter-spawned NPC into actual conversation (the GM sets speakingNpc), mark that entity engaged. tryAmbient now skips engaged NPCs, so their idle ambient speech stops once a real exchange has begun and no longer talks over the conversation. The flag lives on the entity instance, so a fresh spawn after despawn starts chatty again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When an encounter is flagged personalize: true, each freshly spawned entity is fleshed out into a unique individual: a standalone GM call (outside conversation history, so it never races a player turn) invents the NPC's real name, gender, history, trade, interests, and any local lore/rumor they'd know, and stores it on the entity's new personal field. That text is fed back into the GM dossier so the spawn roleplays as a distinct person and can surface the local lore they know through conversation and loreDiscover. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Register a real-time timer per ambient behavior on each encounter. While the encounter's entities are present in the player's room, each behavior rolls its chance on its interval; on success a 'speech' behavior asks the GM (via a standalone call outside conversation history, so it never races a player turn or nudges the story) for one spoken line and prints it as a single overheard remark. No turn is consumed and no state changes apply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Save the DM checkbox state to localStorage on game start and restore it when the login screen loads, matching the existing name and API-key persistence behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Connecting lines were drawn center-to-center, so they ran into the room blocks. Add boxBorderPoint() to stop each endpoint at the node's rectangle border along the line's direction, so a connection touches the edge of each room box instead of extending inside it. (Vertical up/down stubs already started outside the box and are unchanged.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The room-connecting edges on every map used the near-invisible --border color; switch them to gold (slightly thicker) so connections read clearly, keep vertical links gold-dashed, and brighten hidden/secret editor connections to full red. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Drilling into a structure (or going back, or switching levels) previously kept the overview's pan/zoom — with auto-centering disabled if the DM had panned — so the interior rendered off-view. Now re-enable auto-centering (and reset zoom on drill-in/back) before re-rendering, so the focused interior/level is framed in the center of the viewport. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
On a map that doesn't include the player's actual room (e.g. the World map hides interior rooms), highlight the containing parent room instead when it is on that map — so being upstairs in the inn still shows the inn lit on the World map. Maps that do render the player's room (interior structure maps) still highlight it directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Fix overlapping interior rooms on the DM Editor map. The editor overview now shows only non-interior rooms (like the World map); buildings that contain interiors render as clickable nodes (dashed gold outline). Clicking one focuses that structure and shows its rooms one vertical level at a time — reusing structureLevels — with a clickable level legend to switch floors and a "← Realm" back button to return. A drag/pan is no longer mistaken for a node click (via view._moved). Editor focus resets on new game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a gameLog opts.collapsible mode: lengthy details render hidden behind a clickable message line (caret ▸/▾) that expands/collapses on click, toggled by a delegated handler on the static #logs-view. Log every raw GM response with it (as a "GM raw response (N chars)" line) so the full data can be inspected on demand. The parse-error log no longer duplicates the raw text since it now has its own line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The GM occasionally wrapped its JSON in prose (e.g. a leading sentence), which
broke JSON.parse. Add an emphatic CRITICAL output-format directive to the
Response Format section (entire reply must be one valid JSON object, first char
"{" and last "}", no prose outside it, no code fences, self-validate before
finishing). Also add a tolerant client parser: extractJsonObject strips code
fences and pulls the outermost balanced {...} (string/escape-aware) so a stray
prose prefix/suffix no longer fails the turn. On failure the log now includes
the raw response for diagnosis.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFAdd narration rule 2b instructing the GM to always wrap spoken NPC dialogue in straight double quotes (never single or curly quotes), reserving the apostrophe for contractions/possessives. The dialogue highlighter only matches double quotes (single quotes are ambiguous with apostrophes), so enforcing this restores the quoted-speech brightening. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a "Lore" subtab under Compendium for history/knowledge the player extracts
from NPCs (and items) through genuine conversation. The GM emits a new
"loreDiscover" field { id, title, text, xp } (rule 13a) when the player draws
out a real, hard-won piece of lore; the engine records it in compendium.lore
(deduped by id) and awards its XP once. XP scales with extraction difficulty
(~5 minor, 10-20 a real secret, 30-50 a guarded/pivotal revelation).
Refactor the level-up logic into a shared awardXp() used by both per-turn
xpGain and lore rewards. Lore entries render with an "+N XP" badge and the
discovery notice links to the entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFGenerate a faded old cartographer's map of the region (hand-lettered place names like Ashfen Moor, Northern Road, Village Square, The Rusty Flagon, plus hills, moor, barrow, watchtower and a compass rose) via Higgsfield / Nano Banana Pro in the room banners' art style and palette, and apply it as the .map-view / #editor-map-view background behind a dark wash so it reads as dim wallpaper while the overlaid node markers stay readable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Generate a subdued, desaturated medieval architectural wallpaper (weathered stone-and-timber wall with a faint repeating heraldic motif) via Higgsfield / Nano Banana Pro using the room banners' art style and color theme, and apply it as the #setup-overlay background behind a heavy dark gradient wash so the image reads as dim wallpaper and the login form stays readable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Reposition the interior-map level legend to the bottom-left corner. Center each map on its content's centroid so the first/starting area appears in the middle of the viewport instead of the upper-left corner. Views carry an autoCenter flag (default on) that re-centers on render and reset; manual panning turns it off so the player's chosen position is respected. Centering is skipped while a viewport has no size (its tab hidden) and applied once it's shown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Within an interior map (e.g. the inn), rooms are now split into stacked levels by up/down exits: the parent is level 0, moving up/down shifts +1/-1, and cardinal moves stay on the level. A structure subtab renders only the visited rooms on the current level — the level of the player's room while inside (so moving up/down switches layers), or the last level viewed otherwise. A level legend (revealed floors, highest at top, current highlighted) is shown on multi-storey structures. structureLevels() computes the per-room level (including hidden exits); levels persist per structure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The World map now excludes interior rooms (those with interiorOf) — only outdoor/parent rooms appear. When the player enters a room that contains interiors (e.g. the inn common room or the barrow entrance), a new Maps subtab named after that parent room appears, whose map reveals only that structure's rooms — the parent plus its interiors — progressively as each is visited (hidden connections included so secret interior rooms like the vault lay out once discovered). Maps subtabs are now built dynamically: renderMapsTab() rebuilds the bar and per-subtab viewports when the subtab set changes and renders the active one. Pan/zoom state is per-subtab (mapViewFor), map container styling moved from ids to classes to support multiple viewports, and controls are key-based. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Establish an interiorOf / interiors relationship for rooms contained within a larger structure. Interior rooms name their top-level containing room via "interiorOf"; the parent lists every room within it (possibly several transitions deep) via "interiors". Wired for the two structures in the world: the Rusty Flagon (inn_common_room ⊃ inn_upper, inn_room) and the barrow (barrow_entrance ⊃ barrow_chamber, hidden_vault). The World/Room engine reads these into room.interiorOf (null default) and room.interiors ([] default). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Set .api-key-note font-size to 10px !important so it isn't overridden by the setup box's styles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Set the login API-key note to 10pt font and brighten its color from muted to full text color on mouse hover (with a smooth transition) for readability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Save the API key to localStorage on game start and pre-fill the login screen's key field from it on load, so the player needn't re-enter it every refresh. Storage access is guarded for private/blocked contexts, and the key-field note now accurately states the key is saved in this browser's local storage on the device and only ever sent to Anthropic's API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Save the character name to localStorage when a game starts (only when actually entered, not the "Traveler" default) and pre-fill the login screen's name field from it on load, so a refresh back to the login page keeps the last name. Storage access is guarded for private/blocked contexts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give the Villager a routine whose slots set only a time-of-day status (no location), so once an encounter spawns a villager somewhere, the routine system gives it an activity fitting the hour (busy by day, winding down in the evening, hurrying home uneasily after dark) without relocating it from where it was placed. spawnEncounter now applies routines to fresh spawns so the status appears immediately rather than only on the next room entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Rename the "World Map" tab to "Maps" and move the existing map panel into a "World" subtab inside it, mirroring the Editor/Compendium subtab pattern (shared subtab-bar styling, switchMapTab renders the active subview so the SVG sizes correctly). Leaves room to add more map subtabs later. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking a Compendium entry's image thumbnail now toggles it between the default 72x72 and a larger 220px view for more detail. Only real image thumbnails are clickable (placeholders aren't); handled by a delegated click listener on the static compendium panel so it survives entry re-renders, with a smooth size transition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Consolidate the redundant item image fields: the generated image URL now lives in the single "image" field (the previously-empty placeholder), and the separate "url" field is removed. Item Compendium discovery (room items and starting inventory) now passes the item's image as the entry's thumbnail imageUrl, so Items entries show the generated art. Thumbnails keep the existing 72x72 box with object-fit: cover, so the browser resizes the 1024px image. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Generate an image for every item with Nano Banana Pro (nano_banana_pro, 1:1) via Higgsfield from each item's prompt, and store the resulting CloudFront image URL as a "url" field on all 21 WORLD_DATA.items entries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give all 21 WORLD_DATA.items a "prompt" field: a close-up still-life image prompt for the item, sharing the room banner prompts' exact style spine (oil painting, visible brushwork and rich impasto, muted earthy palette with selective warm highlights, "The Lost Realms" tone, no text or watermarks), with the wide banner framing swapped for a single centered object study. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
despawnEncounters now emits an encounter-category log entry when it actually removes spawns, recording the trigger (onPlayerExit / onTimeChange), the count, the scope (single room vs all rooms), and a per-room breakdown of which entities (and their encounter) were removed. No log when nothing matches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Make the Logs tab DM-only (hidden by default, revealed by applyDMVisibility) so its internal engine details don't break immersion for players, and move it to the end of the tab bar, after the Editor tab. The view-logs panel is moved to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a "Logs" main tab backed by a timestamped, category-coloured engine log (ring-buffered). A gameLog(category, message, detail) helper records events and live-appends when the tab is open. Instrumentation: GM handoffs (main turn + NPC-tag consult, with the response's active fields), NPC routine changes (location/status moves), encounter processing (chance gate, location match, time-of-day probability rolls, occurrence, and spawns — with the numbers), and caught errors (message + stack trace) at every catch site plus GM-JSON parse failures. Categories are colour-coded: gm=blue, routine=green, encounter=gold, error=red, system=neutral. Each line is prefixed with an HH:MM:SS.mmm stamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Move the DM Editor's full-realm map under a new "Map" subtab and add an "Encounters" subtab beside it. The Encounters subtab renders a formatted, read-only document of every WORLD_DATA encounter — name, chance/interval/ despawn metadata, entity chips (resolved to display names), and a per-location table of time-of-day probabilities. switchEditorTab toggles the subviews and renders the active one (the map renders once its subview is visible so the SVG sizes correctly). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Encounters can now specify a "despawn" label the engine understands: "onPlayerExit" clears the encounter's spawns from a room when the player leaves it, and "onTimeChange" clears them across all rooms at each time-of-day change (the label set is open to more values later). Spawned entities are tagged with their despawn mode; despawnEncounters(trigger, roomId?) removes matching spawns (scoped to one room for exit, all rooms for time change) while leaving fixed NPCs untouched. Villagers use onTimeChange so the crowd re-rolls each period. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add "chance" (0-100 overall likelihood) and "interval" (seconds between evaluations) to encounters, and implement the engine: on each encounter's interval, roll chance; if it passes and the player is in one of the encounter's eligible "where" locations, look up the probability for the current time of day and roll it; if that passes, instantiate the encounter's entity refs into the room so they appear to the player (sidebar + map refresh and a brief arrival notice). An already-present guard prevents the same encounter from stacking duplicates in a room. Villagers set to chance 50, interval 60s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give encounters a "where" field: a list of { location, when } objects, where
"when" is a list of { time, probability (0-100) } entries describing how likely
the encounter is at each time of day in that location. Populate the Villagers
encounter with logical village-area spots (village square, market row, inn,
forge, south gate) weighted by time of day.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFAdd WORLD_DATA.encounters: a list of encounter objects, each with (for now) a "name" and an "entities" list of entity references into WORLD_DATA.entities. Add a common "villager" npc entity and a "Villagers" encounter that references it. The World instance exposes this.encounters (stored as-is for now; more fields and behavior will come later). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Relying on the GM to compute timeSkipHours for a named time of day was inherently approximate — "wait until midnight" could under-count and land in Dusk. Add a "timeSkipUntil" response field (a time-of-day label); the engine resolves it with hoursUntilTimeOfDay() against the authoritative windows and advances exactly enough to land inside the target bucket. The GM now just names the target and no longer does the arithmetic; timeSkipHours remains for relative waits/rests. Rules 11/11a and the schema updated accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Make the "◈ Compendium updated." message a link that jumps to the exact entry that was just added, via a new generic goToCompendiumEntry(category, id) helper covering all four categories. Uses the .exit-link style so the underline appears only on hover, matching the exit labels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Remove the player-facing prologue preface shown on login. The prologue field remains in the world data and is still fed to the GM prompt; how/when to surface it to the player will be decided later. The prologue styles are kept for future reuse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add an "Editor" tab (after Quests, DM-only, hidden for non-DMs) whose panel shows a fully-revealed map of every room — including rooms reachable only via secret exits, whose hidden connections are drawn dashed — with the same item/entity content badges as the World Map and its own independent pan/zoom. Refactor the map renderer into a shared renderMapInto(view, roomIds, opts): renderMap() draws visited rooms on the World Map surface, renderEditorMap() draws all rooms (includeHidden) on the Editor surface. Pan/zoom is generalized to per-view state (MAP_VIEWS.world / .editor) so the two maps never move each other. The Editor map refreshes wherever the World Map does (for a DM) and resets on new game. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
"Wait until dusk" was landing in Evening because the GM prompt never provided the exact in-world hour (only the date and a time-of-day label) and never stated this world's hour windows. Using everyday intuition (dusk ≈ early evening ~18:00), the GM computed a timeSkipHours that lands in Evening (17:00–20:00) instead of Dusk (20:00–22:00), which in this world comes AFTER Evening. Add the current clock hour and the full time-of-day windows to the prompt, and a rule 11a instructing the GM to compute timeSkipHours from the current hour and those windows (target the start of the intended window, +24 if it's tomorrow) rather than intuition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
WORLD_DATA had both "tagline" and "loginDescription" with identical text, but only loginDescription is used (it populates the login-screen subtitle); World.tagline was stored and never read. Drop the redundant tagline field and its unused World property. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add WORLD_DATA.prologue: a multi-paragraph account of the realm's current state centered on the starting village, threading the Shattering lore and the Border Skirmish / barrow / herbalist quest premises into present conditions. The World exposes this.prologue; it is shown to the player as a framed opening preface before the first room, and included in the GM system prompt as the atmospheric baseline and a springboard for developing new lore, people, items, places, monsters, and quests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Introduce WORLD_DATA.classes, keyed by class name, where each class object has a name, a description, and its startingInventory (the per-class loadout that previously lived in the top-level startingInventory field). The World instance now exposes this.classes, and the Player constructor reads its loadout from world.classes[class].startingInventory. Loadouts resolve identically to before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Mirror the item-catalog refactor for NPCs/enemies: all 11 entity definitions
now live once in WORLD_DATA.entities (keyed by id) and each room references
them via { ref: "id" } in its entities list. The two identically-named Town
Guards become town_guard_1/town_guard_2. The World constructor installs
data.entities into ENTITY_CATALOG before building rooms, and a new
makeEntity(spec, roomId) resolves refs (with inline overrides), defaulting an
entity's home location to the referencing room. Entity inventories now build
via makeItem too. Presence stays location-driven and byte-identical to before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFAdd the "image" (empty — no close-up art supplied yet) and "condition" fields to all 21 catalog entries in WORLD_DATA.items, so the new fields are present in the world data itself. Starting conditions are drawn from the pristine/good/average example palette to fit each item's description (e.g. Mug of Ale "average" for slightly stale, Sapphire Pendant "pristine"); the GM can change these during play. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Extend the Item object and makeItem() with two optional fields: "image" (URL to a close-up image of the item) and "condition" (free-form label such as pristine/good/average/rusty/broken). Both default to empty and can be set in the catalog, on a room/inventory reference, or by the GM; condition is meant to be decided or changed by the GM based on the item or in-game events. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The 12 items that existed only inside room definitions (Coin Purse, Strange
Herb, Mug of Ale, Iron Ingot, Wolf Fang, Old Parchment, Bog Iron, Locked
Chest, Ancient Crown, Bone Wand, Sapphire Pendant, Forbidden Tome) are now
defined once in WORLD_DATA.items and each room references them by id via
{ ref: ... } (Wolf Fang keeps its quantity of 2). No item data is duplicated
across locations and the catalog anymore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFExit badges in the sidebar Exits block now act like buttons: clicking one issues "go <dir>" via goExit() (the same path as the story-text exit links), and hovering brightens the badge (gold text/border with a faint glow) with a pointer cursor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Auto-scroll fired synchronously after appending a message, but NPC close-up portraits and room banners (loading="lazy") have zero height until loaded, so the scroll landed short and the loaded image pushed content below the fold. Now re-scroll to the panel bottom on each contained image's load/error, and always scroll the container bottom (not a specific message div) so a late image-load re-scroll can't jump the view up to an earlier message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When an NPC's conversation close-up is shown, lay it out with the portrait on the left and an info column on the right: a "Reputation:" field showing the signed numeric value in a larger, brighter-gold font followed by ", <tier>" (e.g. "+7, Warm"), and a "Description:" field with the NPC's one-line description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each direction in the room "Exits:" line is now a link; clicking it types "go <dir>" into the command input and submits it, letting the player travel with the mouse alone. The .exit-link style stays plain text and only shows the underline (with a pointer cursor) on hover. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Top line now shows only the character name. Beneath it a new player.title field (default "Adventurer", earnable later) renders as a smaller, dimmer subtitle. Below that, the class name is left-justified and the current level number right-justified on one line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Root cause of NPCs giving inconsistent directions (e.g. the Gatekeeper
placing the Inn west when it is east): the GM prompt only listed bare exit
direction words, never which room each direction led to, so it guessed.
Exits are now { to, description } (normalizeExits accepts the legacy string
form too). The GM prompt lists each exit as "direction → \"Destination\"
(id): description" and the World Map now shows direction→destination per
room, and new rule 3c requires answering location/direction questions from
this authoritative data instead of guessing. Map rendering reads exit.to.
Authored an exit description for every exit across all 13 rooms.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFReveal an NPC's conversation close-up only the first time they speak after the player enters/re-enters a room; suppress it on later lines while the player stays. A shownConversationNpcs set tracks who has been shown and is cleared on each room entry (describeRoom), so leaving and returning lets the close-up appear again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Change --speech from a light parchment to a lightened gold (#e3c46a) so spoken lines stand out more distinctly, aligned with the app's gold palette while staying softer than the bold heading gold. Applied in both the live and exported-story stylesheets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Wrap double-quoted spoken passages in GM narration in a .npc-speech span so dialogue reads a touch brighter than surrounding prose. Adds a --speech color (a lighter warm parchment tone within the existing palette) to both the live and exported-story stylesheets, so saved stories keep the effect. Handles straight and curly quotes; only narrator messages are transformed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Item names in the Inventory box and the room Items box now link to their Items-tab Compendium entry via goToItem(), using the same name-derived id as the "You notice" line. The new .item-link style keeps the text visually plain and only shows the underline on hover, so the boxes look unchanged otherwise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
WORLD_DATA now carries the complete seed for a game world: add "items" (the master item catalog) and "startingInventory" (per-class loadouts as catalog refs). The World constructor installs data.items into the active ITEM_CATALOG before building anything and exposes data.startingInventory on the instance; the Player constructor reads its loadout from the world instead of hardcoded class lists. Engine classes no longer embed any item data, so swapping in a different WORLD_DATA yields a fully different item set with no code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Introduce ITEM_CATALOG + makeItem() so item data lives in one place and is referenced by id from player starting inventory, room items, and GM addItem grants (eliminating the triplicated Health Potion definition). On new game, seed the Items Compendium with the player's starting equipment (Iron Sword, Health Potion, etc.) since those are never picked up from a room. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In a room's You notice: line, each floor item is now a link that jumps to the Compendium Items tab and scrolls to that item's entry (highlighting it). New goToItem() switches the main panel to Compendium and the Items subtab, then scrolls to compendium-items-<id> (id derived from the name like auto-discovery does). Mirrors the Present: NPC links. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
In a room's Present: line, each people-type NPC name is now a link that jumps to the Compendium People tab and scrolls to that NPC's entry (highlighting it). New goToPerson() switches the main panel to Compendium and the People subtab, then scrolls to compendium-people-<id> (the id is derived from the name the same way auto-discovery derives it). Enemies/monsters stay plain text since they aren't in the People tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Hovering any panel tab or Compendium subtab now highlights its title in full gold (was dim gold). The DM-only routine text on People entries brightens to the normal text color on hover for readability, with a short transition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Make the Stats subsection title a flex row with space-between so the 'Stats' text sits on the left and the state chevron on the right (moved the chevron after the text and dropped its now-unneeded margin). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Character box's Stats subsection now starts collapsed, and its title shows a tiny chevron that points down when expanded and right when collapsed. toggleSubsection mirrors the collapsed state onto the title so the chevron stays in sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
For DM players, People entries in the Compendium now show a 'Routine:' line with the NPC's internal routineDescription (gated on player.isDM; never rendered for a normal player). Removed the DM-only routine text (and its .entity-routine style) from the right sidebar People box, since it now lives in the Compendium. The observable per-NPC status line in the sidebar is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each Compendium entry's Location value is now a link (when that place has been discovered): clicking it switches to the Places subtab and smooth-scrolls to the matching place entry, briefly highlighting it. Compendium entries get a stable DOM id (compendium-<category>-<id>); the Location links via goToPlace() by matching the location name to a discovered Places entry, and falls back to plain text when none exists. Added place-link styling and reused the entry flash highlight. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each quest beat title in a People entry's Journal line is now a link:
clicking it switches to the Journal tab and smooth-scrolls to that entry,
briefly highlighting it. Journal entries get a stable DOM id
(journal-<questId>-<beatId>); journalEntriesForNpc (renamed from
journalTitlesForNpc) now returns { title, questId, beatId } so the link
targets the exact entry; goToJournalBeat performs the tab switch and
scroll. Added link and flash-highlight styling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFMake the beat->NPC association for the Compendium People 'Journal:' line GM-determined instead of a naive name scan: - Player-driven unlocks: the GM includes a questUpdate.npcs array (the NPCs the beat concerns, named or implied); the engine records them on the beat and marks it authoritatively tagged. - DM client-side unlocks (badge click or 'unlock the quest beat' command): since no GM turn occurs, the engine now consults the GM with a focused one-off call (tagBeatNpcsViaGm) to review the beat and return its NPCs. journalTitlesForNpc trusts a beat's tagged NPC set once determined, and falls back to scanning the entry for literal name mentions only until then (so the line isn't blank pre-consult / if a consult fails). Documented the questUpdate.npcs field and rule in the system prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
On the DM-only Quests tab, each beat's status badge (Locked / Discovered) is now clickable: clicking it toggles that beat between locked and unlocked via toggleQuestBeat(). Unlocking stamps the in-world date so the beat shows in the Journal; locking clears it. Refreshes the Quests panel, Journal, and Compendium People cross-references. The badge shows a pointer cursor and a hover highlight, and a title hint of the action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
For DM players, 'unlock the quest beat <name>' and 'lock the quest beat <name>' jump a quest beat straight to unlocked/locked so a quest thread can be tested without playing through its trigger. Matched case-insensitively against beat titles (or ids as a fallback). Handled client-side like the verbose meta-command — no GM call, no turn consumed — so testing is instant and reliable; unlocking stamps the in-world date, locking clears it, and the Journal, DM Quests panel, and People cross-references refresh. Non-DM input is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Below the Reputation line, People entries now show a 'Journal:' line listing, comma-separated, the titles of unlocked journal entries (quest beats) in which that NPC is tagged or mentioned. An NPC matches a beat if it's named in the beat's title/text, or listed in an optional explicit beat.npcs tag array (now supported on QuestBeat). The line is omitted when no unlocked entry references the NPC, and the People tab refreshes when a new beat unlocks so the cross-reference stays current. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give NPCs a compendiumImage field, fed as the imageUrl when an NPC is auto-discovered into the Compendium's People tab (falls back to the placeholder icon when empty). Set the Old Gatekeeper and Herbalist thumbnails to their close-up images. The existing .compendium-thumb box (72x72, object-fit: cover) keeps them at the same small size as the placeholders. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a player.verbose flag (default true) and a client-side meta-command: typing 'verbose on' or 'verbose off' toggles it without calling the GM or consuming a turn, with a system-message confirmation. The flag is fed to the GM via a new Response Verbosity section in the system prompt: verbose keeps the usual vivid detail, brief tells the GM to narrate concisely while still conveying all important information and setting every structured JSON field exactly as normal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Wire the Gatekeeper's conversationImages with his Village Square portrait (Images/OldGatekeeperVillageSquareTalk.png), shown when the player talks with him there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Prefix the conversation portrait with the NPC's name in a .room-title span. Since the portrait is rendered as a 'room' message, it inherits the same .msg-room .room-title treatment (display font, gold) used for the name above room banner images. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Exits detail line after a room description used --text-muted (#3d3830), the darkest palette color, making it hard to read. Switch it to --gold-dim (#7a6430) so it stays within the app palette while being clearly legible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give NPCs a conversationImages field: a map of room id -> close-up image URL, so the portrait can match where the NPC currently is. Entity conversationImage() resolves the current location's image, falling back to the NPC's default (home) location, else none. The Herbalist gets her Market Row close-up (Images/HerbalistInMarketTalk.png); other NPCs and other locations can be added later. The GM sets a new speakingNpc field (exact NPC name) whenever the player has engaged an NPC in conversation and that NPC speaks this turn; the engine then reveals that NPC's close-up in the narrative just above the GM's narration. Documented the field and a dialogue rule in the system prompt, and added .npc-portrait styling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Clicking a room banner image now toggles it between the default 50% width and full 100% width, and clickable banners show a pointer cursor on hover. - Image banners render with a "clickable" class (and a title hint); the no-art placeholder stays non-interactive. - A delegated click handler on #narrative toggles an "expanded" class on the clicked banner, so every banner in the log toggles independently. - CSS adds the pointer cursor, the 100% expanded width, and a smooth width transition. The exported story stylesheet also honors expanded width so a banner left expanded exports at full width (export stays non-clickable). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The time-of-day change message was gated on the banner image differing, so the description and the You notice / Present / Exits lines only appeared when the banner changed. Decouple them: on every time change the message now always shows the time-updated description plus the detail lines (which can change with the hour, especially who's present). The banner image is still included only when it actually changed, so identical art isn't reprinted each tick. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When the time of day changes in place and the room's banner + description are re-shown, also append the You notice / Present / Exits lines, since a new time of day can change what's there — especially who's present, as NPC routines move characters in and out. Factor those three lines out of describeRoom into a shared roomDetailLinesHTML(room) helper (recomputed from the room's live contents) and use it in both room entry and the time-of-day change message, so the two always render identical, up-to-date detail lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a statusChanges field to the GM response: an array of
{ entityName, status } the GM sets whenever the player's action changes an
NPC's activity or condition (waking a sleeper, provoking, calming,
knocking out, interrupting). The engine updates that NPC's live status,
which the sidebar People box shows via the end-of-turn updateSidebar.
Mark such a change as an override so applyNpcRoutines won't immediately
overwrite it on the next room entry or sidebar refresh; the override holds
until the next time-of-day change, when the NPC resumes its normal
routine. Documented the field and a rule in the GM system prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaFUpdate his routine so every time-of-day slot places him in the village square (he no longer opens the south gate at dawn, drinks at the inn, or goes home at night). His evening status is now a randomized set — still watching, dozing off, or fast asleep on his bench — so it varies from one evening to the next. To support that, applyNpcRoutines now accepts an array of possible statuses for a slot and picks one at random, but only when the NPC first enters that time-of-day period, so the choice stays stable through the evening instead of flickering on every sidebar refresh or room re-entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The time-of-day change handler already re-applied routines and called updateSidebar, but describeRoom (the room-entry view) refreshed only the map, not the People box, and read present entities without first ensuring routines were applied for the current time. Make room entry self-contained: apply the current time-of-day routine at the top of describeRoom and call updateSidebar after describing, so entering an area at a new time always shows exactly who is present then. Together with the existing time-change refresh, the People box now updates dynamically both when time passes and when the player enters a room. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give each townsfolk NPC a daily routine: per time-of-day, where they are (a room id, or null for off-screen/home) and what they're doing (status). applyNpcRoutines() reads this at game start and on every time-of-day change, reassigning each NPC's location (so presence, which is already location-driven, shifts over the day) and status. The four fixed monsters/guardians have no routine and stay put. Now the world repopulates naturally: the market empties of its herbalist at night, the forge goes cold, the gatekeeper heads home while the guards keep a tenser watch, and the shady traveler slips out after midnight. Also adds a hidden per-NPC routineDescription (prose), surfaced to the Game Master in the dossier (with the NPC's current status) and to the human DM in the sidebar, but never shown to a non-DM player. The GM rules now say to portray each NPC per their current status and routine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The room-entry description was already computed from the current in-world hour, but the time-sensitive rooms only had dawn/evening/dusk/midnight variants, so morning (08-12) and afternoon (12-17) fell back to the generic daytime base description. Because the in-world clock runs fast, a player could enter a room during those buckets and see the base line even when expecting a time-specific one. Add morning and afternoon variants for all nine time-sensitive rooms (village square, market row, inn common room, south gate, forge, northern road, watchtower, moor, barrow entrance). Every time-of-day bucket now has a bespoke one-liner, so getDescriptionFor never falls back to the base for these rooms and the description always matches the current time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a location property to the Entity class (the room id an NPC currently occupies), set at world-build time to the room the NPC is defined in, plus a homeLocation anchor. Presence in a room is now derived from this property via World.entitiesInRoom(roomId) rather than static room.entities membership: the sidebar, GM system prompt, room description, map badges, and entity removal/kill lookups all resolve who is present by location. This lays the groundwork for the planned daily-routine system: moving an NPC between rooms becomes a matter of reassigning its location, and homeLocation records where to send it back. Global by-name lookups (reputation changes, compendium, NPC-name gathering) still scan the entity registry, so they find an NPC wherever it currently is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
When the in-world time of day changes, the banner update previously showed each room's single base description, which could contradict the new time (e.g. a bustling market shown at midnight). Author time-of-day description variants for the rooms whose scene changes with the hour (market, gate, forge, roads, moor, barrow entrance, inn common room and upper landing), following the existing village_square pattern. getDescriptionFor() already prefers a time-specific variant, so the one-liner shown on a banner change (and on room entry, and in the GM prompt) now reflects the current time instead of the daytime default. Genuinely time-invariant interiors (private room, barrow chamber, hidden vault) keep their neutral base description. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
The Lost Realms · Copyright © 2026 Brave You Worlds, LLC. All rights reserved.
This report is Game Content under LICENSE-CONTENT, not software under LICENSE:
it has no Change Date and does not become open source on any future date, the way the engine does.
Free to read, quote and build against; selling it, redistributing it, or using it in anything you take
revenue from needs a separate licence —
COMMERCIAL-LICENSE.md · licensing@braveyouworlds.com