radiantone/thelostrealmsThe prerequisite gate closed one half of this door. The other half was the hard class gate: Spellcasting belongs to caster classes alone, and a Warrior could still choose it as their starting skill. Every other road to that skill already refused them — learnSkill, the point buy, the level-up draft, and reading a book each check it — and creation was the one place with no lock on it, exactly as it had been for prerequisites. The rule now goes through skillClassLocked, which did not exist. The test it encodes was written out five separate times as `!skillEligible(sk) && skillHardGated(sk)`, once in each of those four paths and once in the item popup's Read note, so adding creation as a sixth copy would have made the drift worse rather than better. It is one named function that all six now call, and the test asserts the inline form appears nowhere — a change to what "hard gated" means no longer has to find five sites and remember creation. The distinction the rule turns on is hard versus soft, and getting that wrong in the generous direction would have been worse than the bug. A Warrior may still begin with Lockpicking: it names Rogue, but it is soft-gated, so it is learnable off-class at the usual reduced proficiency, and it was already learnable that way after creation. Refusing every off-class skill here would have shut most characters out of most of the roster to fix one skill. Both directions are asserted, and the fixtures find a hard-gated and a soft-gated skill off the live table rather than naming Spellcasting and Lockpicking, so a world that gates differently is covered by the same file. The badge names the character's own class — "Not for a Warrior" — rather than the classes that may hold the skill. The card underneath it already prints that roster in its skill-gate chip, so repeating it would say the same thing twice in one square inch; and Spellcasting lists seven classes, which is a badge that wraps past the card it labels. What the card does not say is which side of the gate this character stands on, and that is the half worth the space. The tooltip has the room, so it names every class that may learn it and says "may ever learn it, at creation or after" — a prerequisite is a floor that can be climbed and a class gate never is, and the two must not read alike when both arrive as a dimmed card. test_book_class_gate.js pinned the old inline expression at two of the five sites. Its assertions now ask those sites to CALL the helper and check the hard-only property once at the definition, which is stronger than it was: the property could previously have been true at one call site and false at the next. Sixteen sabotages were each caught by a distinct named assertion, the first of them being the pre-fix implementation, which fails on seven counts.
Two more from looking at it, and both were the same kind of fault as the last two: a class that was not applied, rather than a rule that was not written. THE REMOVE BUTTON. It carried .rm-door-btn, which looks like the class those little ✕ controls use in a room card — but .rm-door-btn sets a font size and a padding and nothing else. The colour, border and hover all come from .npc-tool-btn beside it, which I had dropped. Alone it fell through to the user-agent stylesheet and rendered a white box on a dark panel. The test now checks the invariant rather than this one button: .rm-door-btn sets no theming of its own, and every use of it in the file pairs it with the themed base. A size modifier used alone is white wherever it appears. THE SCROLL. Every other view in this strip nests .world-profile-inner inside a .world-profile wrapper, and it is the WRAPPER that owns overflow-y and the app's 4px scrollbar; the inner element is only the 900px measure and the padding. Currency had the inner and not the outer, so the panel did not scroll — it CLIPPED. With one currency that is invisible. With three, the third is simply unreachable, and the tab looks like it lost the currency the DM just added. Browser-verified with three currencies: 950px of content in a 645px panel, scrolling, thumb rendering in the app's thin style. Removing either fix fails the tests by name.
Two faults, reported from looking at it. The panel had no padding at all, and several fields rendered with the browser's own white box on a dark page. The padding one was a missing class rather than a missing rule. Every other view in this tab group — Calendar, Profile, Login — hangs its content on .world-profile-inner, which is where the 20px/24px padding, the 900px measure and the 18px stack gap all live. #currency-view was a bare div, so it got none of it. It carries the class now and the block-level margins the renderer was setting for itself come out, since the shared gap does that job and two of them stack. The white boxes were a selector scoped to the wrong thing. Only .cur-den-row's controls were themed, so .cur-name — the currency title at the head of each card — fell through to the user-agent stylesheet and rendered white-on-dark. The rule names the CARD now rather than the row, so a control added to a card later is dressed by default instead of joining a list nobody re-reads. That is the same shape as the --accent bug: something that looks wired and is not. While in there: --border-soft was written with a var() fallback and is not a token this app defines. The fallback worked, but a var() naming a token that does not exist is exactly how --accent got in and then quietly killed a whole declaration — a reader goes looking for it, and a later edit drops the fallback. It uses --border, which exists. Two legibility fixes the screenshot argued for. The colour picker showed raw hex in the one column with no room for it, standing where a label should be, while the dot beside the field already showed the colour; the swatches are named now (Gold, Silver, Copper, Ice, Amethyst, Jade, Rust, Bone), and a colour an imported world chose that is not one of ours is kept as its own option — without that the select would open on "—" and the next edit to any other field in the row would save that over the author's colour with nobody touching it. And the live sample, which is the one line that answers "did I get the worths right", was the dimmest thing on the card; it is --text-dim with the figure in gold.
Currency P2. The tab has been an empty placeholder since the World strip was laid out; it renders now. Rename the built-in coins, change what one is worth, add a denomination, add a currency that is not coin. Every edit commits through normalizeCurrencies and saves immediately, like the calendar editor beside it, so there is no draft state to fall out of step with the world and no apply button whose absence quietly discards work. Most of the interest is in what it refuses, and whether it says so. A worth of zero or less is refused out loud with the old value kept — unrefused, normalizeCurrencies simply DROPS the denomination and the author watches a row vanish with no explanation. The base currency has no Remove button at all rather than one that always argues, since every price in the world is quoted in its unit. The last denomination of a currency cannot go, because a currency with none is not money. Two denominations sharing a worth is a warning rather than a refusal: a crown and a sovereign may be the same money by different names, but the rollup will only ever quote one of them and the author should know. And removing a denomination the player is holding says what it cost them, because money evaporating from a save is the kind of thing blamed on the last five other changes. The guard for P3 is written now: a currency an NPC still accepts is not removed, and the NPCs are NAMED rather than counted, since "3 NPCs" does not tell a DM which shop just closed. It reads a field P3 adds and is inert today — written early so P3 cannot forget it, and so the finder already looks in both places an entity lives rather than only the catalog. BROWSER-VERIFIED, AND IT FOUND A BUG THE TESTS COULD NOT. The worth refusal wrote its message and then re-rendered the panel, which in a real DOM destroys the #currency-output node it had just written into — so the edit reverted with no explanation, which is precisely the failure the refusal existed to prevent. A mock that hands back the same element object however often innerHTML is rewritten cannot see that. The renderer takes the message now, so the order cannot be got wrong again, and the mock has been taught that rewriting a container empties what was nested in it. Re-introducing the old order fails three assertions. Also fixed on the way: a new denomination was offered a worth of one step below the smallest, which for the built-in coinage is copper at 1 and left no room — the row arrived colliding with copper, was quoted by neither, and read as the button having done nothing. Twelve sabotages. One of them originally caught only the error message and not the refusal itself: the "is the base currency still there" check was carried entirely by normalize re-seeding a missing base, so it passed with the guard cut out. It asserts that nothing is committed at all now.
Currency P1: the model and the names. A world that renames nothing plays identically — that is the safety property this phase is built around, and the first assertions in the test are about nothing else. A currency is a family of denominations, each with a worth in the base unit. Gold, silver and copper become three denominations of ONE currency rather than three fields, which is why change can be made between them; a world may rename them, and may mint a fourth. Every renderer and the change-maker walk that list worth-descending instead of dividing by a hard-coded 100, so a world that adds a crown worth 500 sees prices quoted in crowns, payouts credited in crowns and purchases making change in crowns with no other edit anywhere. itemValueText and copperToCoinText are each one line now, into one formatter; they used to write out the three names and the three letters for themselves, which is two copies of one roster and exactly how the equipment slots and the item types both went stale. THE PURSE IS A MAP, keyed by denomination, so a second currency has somewhere to live. `gold`, `silver` and `copper` survive as accessors over it, defined on Player.prototype. That is not tidiness avoidance: about forty sites read those fields, every save in existence stores them, and the GM's own stateChanges and the DM console name them by hand. As accessors they cannot drift from the map because they ARE the map — and reInstance is Object.assign(Object.create(Player.prototype), snap), so Object.assign invokes the setters and an old save migrates itself on the way in. There is no migration function because there is nothing left for one to do. Exchangeability is deliberately NOT the rate. Every denomination carries a worth including the ones that are not coin, because that is what lets a gem merchant price a sword without a second price list; whether anyone will convert it is a separate flag, defaulting off for anything but the base. Conflated, gems are coins wearing a costume and a gem-only merchant is one extra click on the way to the nearest stall. Three test fixtures were reading coin off plain objects that never carried the accessors, so a purse holding forty gold reported as empty; they go through purseCount and playerWealthCopper now. One of them sliced the Player constructor by a fixed 9000 characters, and a comment added here pushed famePeakWealth past the window — the order assertion then failed while reporting an order that was never wrong. A window is not a scope. purseCount also gained a legacy read for plain objects in the old shape, since silently reporting a full purse as empty refuses every purchase with no trace of why. Eleven sabotages. The last one caught nothing at first: the negative-purse guard was asserted through purseCount, which floors at zero itself, so removing the guard changed nothing the test could see while the negative went on to the save file. It asserts the stored value now.
Editor → World → Currency has been an empty placeholder since the tab strip was laid out, with a note saying this is where a world's coinage would be defined. This is the design for what lands there. The observation the whole thing rests on is that there is only ever ONE number. item.value is copper, and so is a quest purse, a starting purse, a sell band, a flora discovery award and every price the GM is ever asked to name. Nothing in the engine has needed to know what money is called. So this is a naming layer over that number, plus one new question — will this merchant take that? — asked at exactly one point. Kept apart, the change is small; merged, every price in the file grows a currency argument. A currency is a family of denominations, each with a worth in the base unit. Gold, silver and copper are not three currencies but three denominations of one, which is precisely why change can be made between them and why spendCopper can break a gold piece; gems are a second currency, which is why change cannot be made from a gem to a copper. That framing is what makes the rest fall out rather than be decided. Every denomination carries a rate, including the ones that are not coin, because that is what lets a gem merchant price a sword without a second price list. Exchangeability is a SEPARATE flag, and conflating the two is the mistake that hollows the feature out: if a rate implies conversion, gems are coins in a costume and a gem-only merchant costs the player one extra click on the way to the nearest stall. Three decisions resolved with the owner before writing any of it — one price scale rather than per-item prices per currency; the purse becomes a map of denomination → count rather than currencies living as inventory items; a per-currency exchangeable flag rather than always or never. Three left open and recorded with their leanings: what a merchant pays you IN when he accepts several, whether prices show in the merchant's currency or the base, and whether a world should be allowed more than one non-exchangeable currency. Four phases, with the model provable by tests before any of it is reachable from a tab.
The award was rescaled to 10-40 last commit and the thresholds were left at 5/10/15/20, so the whole Novice→Master ladder was 50 xp and a single Formidable critical outpaid it. That made the tiers decoration. A tier costs 20/40/60/80 now — 200 total, about ten Hard successes or twenty Easy ones — which is enough to be a climb without being a grind. The important part is the factoring rather than the numbers. SKILL_CHECK_XP_STEP says what a check is WORTH; SKILL_LEVEL_COST_STEPS says how many of them a tier COSTS, and the threshold is the product. They are separate questions and folding them together is precisely what produced the last commit's problem: raising the award changed pacing as a side effect, silently. Priced in steps, rescaling the award now leaves pacing exactly where it was, and pacing moves only when someone moves it on purpose. The test asserts the product rather than the four numbers, because pinning the numbers would not have caught the factoring being lost. Pacing is world-tunable. `skillLevelCostSteps` on a world overrides the built-in pace — a long grim campaign and a light one do not want the same ladder — read through one accessor so nothing else has to know a world may have an opinion. It is clamped to 0.5-20 and refuses zero, negatives and non-numbers, because a tier that costs nothing loops grantSkillXp forever rather than levelling anything. A world field has THREE touchpoints — the constructor, serializeWorld, and rebuildWorldFromSnapshot — and the middle one is the trap: miss it and the setting is authored, saved perfectly, and gone on the next reload with nothing at all to see. That is the same shape as the door that nearly evaporated through normalizeExits, so it is a test and not a comment. Removing the field from either the serializer or the restore fails it by name. No editor UI for it yet, deliberately: playtesting decides whether worlds should carry their own pace before it earns a control.
One xp is not a legible reward when the player is reading it off a manifest line, and a four-value scale starting at 1 has no room between its rungs — a partial rounded to zero on everything below Hard. The step is 10 now, so a success runs 10/10/20/30/40 across the GM's published ladder and a partial is worth 5 to 20 rather than 0 to 2. The scale lives in one constant. SKILL_CHECK_XP_STEP is what beating the easiest check pays, every rung of the DC ladder is another one of them, and the ceiling is derived from it rather than typed: SKILL_CHECK_XP_CAP is a critical (double) on the hardest published rung, which is 8 steps. Typed separately, the old 6 would have clamped every rung above Easy to the same number and flattened the ladder into a flat rate again with nothing to see — the test asserts the cap IS the ladder top for exactly that reason. WHAT THIS DOES TO LEVELLING, stated because it is a real consequence and not a rounding detail. Skill level thresholds are unchanged at 5/10/15/20, so the whole Novice→Master ladder is 50 xp and a single Moderate success now covers a fifth of it. A Formidable critical pays 80, which exceeds the entire ladder. Skills will effectively max out in a handful of checks. If the intent was bigger numbers at the current pacing rather than faster pacing, skillXpToNext wants scaling by the same factor (5*level → 50*level); that is one line and is deliberately NOT done here, because doing it would have cancelled the change that was asked for. The tests were measuring the wrong things and the new numbers exposed it. xpNow() summed cumulative skill xp, but grantSkillXp ZEROES the remainder at SKILL_MAX_LEVEL, so every award of 50 or more read back as exactly 50 — a 55-xp override and a capped 80-xp award both reported 50, which looked like the override being ignored and the clamp being wrong and was neither. Awards are read off the manifest line now. The expected rungs were also derived from "DEX 20 (+5), prof +2", which are the reported character's stats and not the probe's: the probe rolls DEX 17 (+3), so a roll assumed to be a critical graded as a success and the assertion compared against a ladder value for an outcome that never happened. Both are derived from the engine now. And one regex anchored on `\b` inside a template literal, where it is the backspace character rather than a word boundary. Four sabotages against the scale, each caught with the ladder printed in the failure.
The Keys tab told an operator what a key IS ("Sound generation key") and, since the names became links,
where to make one. Neither answers the question somebody with a budget and ten cards is actually asking:
which of these do I need, and what stops working without it. Each provider name now carries a row of
chips naming the jobs the game gives it — Nano Banana with six of them beside Higgsfield with one is that
answer, in the space of a heading rather than a paragraph.
The tags live on MANAGED_KEYS in vault-core.js, next to keysUrl, for the reason written there: a table of
ten providers inside admin.html is right on the day it is written and wrong the first time one of them
changes job, with nothing on the page to notice. The admin page reads each card's own list out of the
payload it already fetches, so a provider added to the roster arrives with its description attached and a
provider whose role changes is corrected in one place.
They are a shared vocabulary rather than free text per provider. Images recurring across five cards is
the point — it is what lets two providers claiming the same job be seen to claim the same job — and the
failure mode of that is a stray "Portrait" beside four "Portraits", which breaks nothing and quietly
stops matching. The test normalizes case and plurals to catch exactly that.
A card with no tags renders no row at all. That is a custom descriptor's key, whose purpose the vault
genuinely does not know; an empty pill row would be a claim that it has none.
The chips sit between the name and the note rather than in the head row, which the configured/not-set
badge shares — squeezed in there they wrap the status pill onto its own line on a narrow window. They are
set in the page's monospace metadata face and deliberately quieter than that badge, which reports live
state while these are a fixed description and must not compete with it for the eye.
server/test/test_provider_chips.js covers the roster, the renderer and the placement; eighteen sabotages
were each caught by a distinct named assertion. The no-copy-in-the-page check strips comments before it
scans for tag literals — the CSS comment explaining the nowrap rule names the longest tag as prose, and a
check that fails on its own explanation is one somebody deletes rather than trusts.The award was `outcome === 'critical' ? 2 : 1`, a literal written out at both sites that grade a check, and the two had already drifted: a partial earned 1 in applySkillChecks and 0 in the identify path. One ladder, two copies, two answers, which is the equipment-slot failure wearing a different hat. There is one skillCheckXp(dc, outcome) now and both call it. The flat rate paid nothing for difficulty, and the critical rule inverted it. Because a critical is total >= DC+10, a rogue with DEX 20 and proficiency +2 auto-crits any lock under about DC 17 and can essentially never crit a Formidable one — so grinding trivial checks trained a skill twice as fast as attempting hard ones. The award is priced off the DC now, against the GM's own published ladder (Easy 8 through Formidable 25), so a success runs 1/1/2/3/4 across it and a Formidable success is worth four Easy ones. The crit rule is deliberately unchanged: under a DC-priced award its inversion mostly cancels, since a hard check pays more per success whether or not it ever crits. The GM may also price a check itself, because the DC does not know that the guard's footsteps are coming back down the hall and the GM does. It is clamped to 0-6 and logged against what the ladder would have paid, since progression a model can move has to be visible in the DM log or a fast-levelling character has no explanation. The prompt offers the field and tells it to omit the field normally, and names the misuse it is most likely to be put to — a reward for good writing, which this is not, because skill levels are permanent. Two things worth recording. The first draft paid ZERO for beating an Easy check: 1 + floor((8-10)/5) is 0, and a success that earns nothing reads as the engine having failed rather than as the check having been trivial. Base is floored at 1. And a replace_all put the new "xp" field on the abilityChecks schema as well, which grants no skill xp at all — an offer the engine would have ignored in silence. The test measures CUMULATIVE skill xp rather than prog.xp, which was the fixture fault behind three failures that had nothing to do with what they claimed: grantSkillXp spends xp on level-ups, so a 6-xp award leaves a residual of 1 and "a Formidable check outpays an Easy one" failed at 1 vs 2. The literal-count assertion was also matching the comment that explains the old literal, so it could not have passed however clean the code was. Seven sabotages, seven distinct named failures.
Speed Reading could be taken as a starting skill by a character with no Spellcasting. The skill's own description is what a trained caster's eye does, and its declared prerequisite is Spellcasting, but the Pick a Skill dialog gated on one question only — do you already have it — and nothing downstream re-checked. A warrior who chose it simply had it. The gate is skillPrereqsMet, which is the same function the skill tree and the point-buy path read through canAcquireSkillWithPoints. That was the point of not writing a local check: creation and levelling cannot now disagree about the same skill, and a prerequisite a Dungeon Master adds on a skill card is enforced at creation with no edit to the engine. It is a floor of two parts, prerequisite skills and a character level, and both apply — a skill gated to level 3 was equally out of reach of a level-1 character and equally unenforced. What makes this work at creation at all is that the class grants are already in player.skills by the time the dialog opens, seeded by setPlayerClass. So the rule is not "no advanced skills at creation" — a mage, who begins with Spellcasting, can still take Speed Reading, and blocking them would have been a different bug in the same place. It is "nothing you have no footing for", and the test asserts both directions. Gating the cards alone would have introduced a worse fault than the one being fixed. openSkillPick decides whether the step is shown at all, and it was filtering on the same "not already held" question; left that way, a world where every remaining skill is gated would open a dialog with no selectable card and a Confirm that could never enable — a dead end, in a flow the player cannot abandon. It now asks the same helper the cards do, so that world skips the step and finalizes. Locked skills are still shown rather than hidden. Hiding them would leave the player wondering whether the world has a healer's craft at all; showing them dimmed, with a badge naming the skills they need, names the road to it instead. The badge says the names rather than "prerequisites unmet" because at creation there is exactly one thing to do about it — choose differently — and the name is what makes that a choice. The full reason also rides on the cell as a tooltip, since the badge can wrap. The dialog's lead now says why some cards are dim, which a grid of silently greyed cards does not. The rule is checked in three places and that is deliberate. Omitting a locked card's onclick is the affordance; pickSkillSelect re-checks because it is a global reachable by name and a starting skill is permanent; confirmSkillPick re-checks because that is where a pick becomes part of the character. tests/test_skillpick_prereqs.js covers it, and reads its fixtures off the live skill table rather than naming skills, so a gated skill authored later is covered the day it exists and a roster that loses its last gated skill fails loudly instead of leaving the file vacuous. Seventeen sabotages were each caught by a distinct named assertion; the first of them is the pre-fix implementation, which the file fails on fifteen counts. test_stat_allocation.js was picking "the first skill the character lacks" as its starting pick, which now depends on where the gated skills sit in the roster; it asks for a pickable one instead.
Reported from play, as two symptoms with one cause. A door created with "+ door" in the Wine Cellar, named "Cellar Door", locked, keyed and given a pick DC of 25, was still "a door" with no description from the Rusty Flagon side. And the GM, standing on that side, narrated prying at a "sealed trapdoor" against a DC of 20 — a number that exists nowhere in the world. The twin is copied from the PLACEHOLDER at creation: addDoorToExit makes a nameless closed door, calls ensureDoorPairs, and only then opens the editor. Saving wrote this side and shared the state, and nothing else crossed. The cosmetic half of that was the smaller half. `locked` with method `none` normalizes to SEALED, so the far face was not merely unnamed — it was a sealed door with no key and no pick DC. The GM read that face, took the word from it, and had nothing to roll against, so it invented both the check and its difficulty. Neither number nor noun was a hallucination in the loose sense; both were the honest reading of a record the author never meant to write. So the editor grows "Same door on both sides", and it defaults to what the author almost certainly means rather than to a fixed answer. Two faces stay legal — §10-H, and a bolt on one side only is a real thing — so this is a default, not a rule. It defaults on when the faces already match and when the far side is still the untouched placeholder, and off when the far side has been given its own name or lock, where the hint says in as many words that ticking it will overwrite. Every door authored before this commit is in the placeholder state, so opening one and saving repairs it. The second half is the dossier. An unpickable lock said nothing about picking, and silence is not "no picking" to a model — it is a blank the model fills, which is how DC 20 got invented. A locked door now carries either the pickable line with its DC or an explicit GM-only "NOT pickable; do not invent a DC for it". A player who fails an invented roll has been told something false about what they can do. One sabotage caught nothing on the first pass: the placeholder fixture had both faces starting identical, so `matched` was carrying the assertion and the placeholder branch could be deleted without any test noticing. It has its own fixture now — authored face, untouched twin, faces deliberately unequal — which is the reported case exactly.
It offered the whole item catalog, sorted so that keys floated to the top, which put the four things that could actually be a key behind everything else in the world. The magic-item picker already filters through isMagicItem for exactly this reason; this is the same rule applied to the field that needed it first. With one exception, and the exception is the point. A lock may already name an item that is not typed "key" — authored before this filter existed, or by a GM that reached for a sigil, a shard or a signet. Filtering that out would empty the select, and the author would then either save a door whose key had silently changed or be refused a save over a field they never touched and could not see. So whatever the door already points at stays in the list, selected, and labelled "not a key" so the oddity is visible rather than merely tolerated. The test for that was failing for a reason that had nothing to do with the app: the DOM mock's select did not reflect its options into `.value`, so `.value` kept whatever an earlier block had assigned. The mock now does what a real select does — the option marked selected, or the first one. That makes several existing assertions mean what they claim. The retention assertion also checks the save was ACCEPTED and not just that the key survived, because dropping the item empties the select, an empty key select is refused at save, and a refused save leaves the key intact too — the same visible result from a different bug.
Four things shipped to text_adventure.html since the guides were last synced (243591f, 16 Aug) and none of them had reached either document: the Region Builder (a per-region authoring brief, GM-written rooms/beings/items/quests/encounters/lore scoped to one region, and Export/Import Region as a portable file that deliberately carries no dungeons), Factions joining the Art tab's Missing/Review batch-art buckets, the NPCs/Monsters/Fauna filter now matching what a being is carrying rather than only its name, and a new "//" directive letting a DM ask the GM to recolour the app's own sixteen CSS variables (distinct from a world's art style). The DMG's World › Regions section gets the bulk of the Region Builder writeup, since that's where the authoring detail belongs; the Field Guide gets a shorter companion section under Growing the world, plus a new OOC-table row and note for the recolour directive alongside the existing debug-family notes. The Filter-by-name and "full debug family" summary lines pick up one clause each rather than a rewrite. While touching the Art tab section, corrected a claim that predates all of this: the DMG said Review was a future-features shell, but it has been a working art gallery for some time (renderArtReview, its own popup, refreshed live as art is generated) — Missing's grouping list was also years stale, naming only three of the nine buckets artMissingLists actually returns. Both are now described as they work. The Players Handbook needed no changes — all four features are DM/authoring-only, and the handbook already defers that ground to the Field Guide and DMG rather than duplicating it. region-files.html, authored-mechanics.html and native-app-login.html remain proposals with nothing built, per Designs/current-status.html, so they stay out of the guides. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LyGjG54RbhZcdX8vcSxHv
Reported from play: a door authored "Cellar Door" and described as "a large cellar door with iron bands" was narrated as "the trap door". The obvious suspicion was that the name and description never reached the GM. They did — both were on the dossier line, exactly as authored. They were in the wrong shape, in two ways that compound. The exits list and the doors list were two paragraphs describing one way out, and the exits list comes first. The GM read `down → "Wine Cellar" — Down to the wine cellar.`, formed its picture of a hatch in a floor, and met the door as a separate fact further down with nothing tying the two together. So the exit line now names the door standing in it, and says whether it is barred or passed through — the binding is made where the picture is formed rather than after it. The door line itself read `Cellar Door (id: door_village_square_down)`: a name beside a handle, in the shape of another handle, which is an easy thing to treat as an engine label rather than as the words to use in prose. The name is quoted now, the description is prefixed "looks like:" so it reads as what the player is looking at rather than as one more field after a middle dot, and the section says outright to call each door by the name given. That last one matters more than it looks: the previous instruction was "narrate them, never contradict them", and a synonym contradicts nothing. Calling a cellar door a trap door is fully compliant with the old rule and still tells the player about a door that is not there. The rule now names that exact wrong answer, because an abstract instruction is easy to satisfy abstractly. Five sabotages, five distinct named failures. The open-door case is asserted separately: an open door is still named on its exit line but described as passed through, since reporting it BARRED would have the GM refuse a way the engine allows — the same contradiction in the other direction.
"Opens by" offered a key or nothing. It now offers an enchanted item the player must be CARRYING and a spell the player must KNOW, and a locked door grows a Pickable tick with the Lockpicking DC beside it. Both new methods are references, not strings, for the reason keyItem already was: two items may share a display name, so a lock naming "Rod of Ways" opens for whichever convincing replica the player happens to be holding. magicItem is an ITEM_CATALOG id and spell is a grimoire id, and naming one that does not exist is logged as an authoring error rather than quietly answering "they haven't got it" — that would be a door nobody can open with no trace of why, which is the family of failure this whole system is repairing. The magic-item picker is filtered through isMagicItem so it lists the four things that could plausibly be a lock's answer instead of the whole catalog, and the spell picker reads the ACTIVE grimoire rather than SPELL_CATALOG, since a world's own spells are exactly the ones its own doors would be warded by. Picking is deliberately ORTHOGONAL to the method, which is where doors part company with containers. A container's `pick` is one of the mutually exclusive CONTAINER_LOCK_METHODS, so choosing it throws away which key the lock wanted; a door that wants a key may also be worked open by a good enough rogue, and saying so needs a flag rather than an alternative. The two defaults are now one constant — DEFAULT_PICK_DC, formerly named for containers — because two names for one number is how a door's ordinary lock ends up DC 15 while a chest's is DC 14, a difference nobody authored and nobody could find. The engine cannot roll a Lockpicking check, so it does not pretend to. The DC is published to the GM on the door's dossier line, marked GM-only exactly as the container trap line is, and both unlockDoor and openDoor are accepted on a pickable lock without the opener because by then the GM has rolled. That is the container contract, and refusing openDoor there would have put the engine's refusal underneath narration that had already committed — the contradiction the door gate exists to prevent. Every such opening is logged with the DC, so a DM reading the log can tell a rogue from a bug. Sealed stays outside all of it: the checkbox is hidden there, a stale tick is cleared on save, and unlockDoor on a sealed door remains the GM's call, per the resolved decision D. The player is told what the lock answers to and never told the DC. Whether a lock looks workable is the GM's to narrate off the dossier; a number in the story text is the engine talking out of character and also hands over information a rogue is supposed to earn. Two tests here were passing for the wrong reason and are worth recording. buildWorld rebuilds ITEM_CATALOG, so fixtures seeded once at the top were read back empty by every assertion after the first world — labels came back as raw ids, which read as four separate engine bugs. And the door-line assertions matched the first bullet in the whole system prompt, which is a lore hook about a barrow. Sabotaging openDoor's pickable branch then broke nothing at all, because that path was live and unasserted; it has its own pair now.
It was built out of .we-field and .we-row, which belong to the World Editor's full-page authoring form. Those are sized for a page: 14px uppercase labels, 13px controls, generous padding, and no margin of their own because the page's own container does the spacing. Dropped into a modal, which supplies no gap, the result was oversized text with the fields flush against one another. The Ability editor's .ability-ed-* vocabulary is what every other modal editor in this file wears — 11px display labels, 12.5px monospace controls, and a 12px margin under each field, so the spacing travels with the field instead of depending on where it was put. Adopting it wholesale rather than tuning the .we-* numbers means the door editor now moves with the rest of them, and it added no new sizing rules at all. One thing that vocabulary genuinely lacks is a hint placed UNDER a control. The Ability editor inlines every hint into its label, which works there because those fields are full width; the door editor's State / Opens by / Key row would have wrapped three labels to different heights and left the three selects misaligned. So .ability-ed-hint-block is a block-placed hint at the same size and colour as .ability-ed-hint — a rule rather than an inline style, since inline styling is how the two field vocabularies came to diverge here in the first place. The test pins the choice: no .we-* classes inside the door modal, the fields and rows are the shared ones, and the block hint is a rule that matches its inline sibling. Reverting a single field to .we-field fails it by name.
Doors P1 gave the engine a door it would refuse passage through, and an editor to make one by hand, but no prompt had ever heard of them. A feature only a human can author is a feature the Game Master will never use, and the Rooms tab GM box, the "//" room addition, world generation, world expansion and the region build are all places where "put a locked door on the north exit" is the obvious thing to ask for. The schema is one shared const, DOOR_AUTHORING_GUIDE, interpolating DOOR_STATES and DOOR_METHODS rather than listing them. That is not tidiness. The equipment-slot roster was pasted into two prompts, both went stale the moment a slot was added, and gear authored for the new slots was silently unequippable; the item types went the same way later. A const helps nothing on its own, though, so the test discovers the roster of prompt sites from the source — any function with a template line naming the "exits" field must interpolate the guide — and fails until a new one either carries it or is added to the exemption list. requestRegionStubs is the single exemption: it asks for name, description and exits only and spends its length refusing every other detail, so a door schema there would invite exactly what the rest of that prompt is trying to prevent. The region's second pass carries it instead. The far side is completed in code, not asked for in the prompt. A model writes the door on the exit it was thinking about and the return exit comes back a bare opening, which leaves the same door shut from one room and absent from the other — the player walks around it and nothing reports a problem. So ensureDoorPairs now runs at the end of a chunk merge, after the Rooms tab writes exits directly, and over a world rebuilt from a snapshot. That last one needed the helper to take a world rather than reading the global, because a world being loaded is not the live world yet; it is rebuilt and only then assigned, and pairing after the fact would be a turn late on the first move through the door. A rule that only lives in the prompt is a request.
An exit without a `door` behaves exactly as it always has — that is the compatibility test for the whole feature, and it is asserted. With one, the way can be shut, locked by key, or sealed. THE GATE IS THE POINT. The engine does not move the player; the Game Master does, by returning moveToRoom, applied after checking only that the room exists and the party is not below ground. No adjacency test, nothing consulting the exit taken. A door told to the GM and nowhere else is therefore not a door, and the comment two blocks above that line already said why about dungeons: a refusal that only lives in the prompt is a request. So the refusal is the engine's, at that one point. AND IT IS SAID, TO BOTH. The GM has just narrated the player stepping through; declining silently leaves them reading a paragraph about the cellar while standing in the corridor — the BUG-017 / BUG-021 / BUG-036 family exactly. The player gets the door's own words, the GM gets a note telling it not to narrate a passage that did not happen, and the DM log records which door held. doorRefusal returns the SENTENCE or null, never a boolean, so a caller cannot have the answer without the reason. THE HAZARD THAT WOULD HAVE EATEN THE FEATURE. Rooms serialize wholesale, so a door reaches the save and the export for free — but every exit comes back through normalizeExits on load, and that function rebuilds an exit from a fixed pair of fields. Left alone, a door was authored, saved perfectly, and gone on the next reload with nothing to see. Named there now, and tested through a real serializeWorld cycle rather than only through the normalizer. TWO RECORDS, ONE ID, per decision §10-A. A door authored on one exit gets its twin on the far room's return exit automatically, copying the side it was made from — identical is the normal door, and an author wanting two faces edits one afterwards. Only `state` is kept in step, written to every record sharing the id, because a door standing open is open from both rooms and there is no coherent world in which it is not. A room with no return exit is left alone: that is a one-way drop, and legal. THE KEY IS THE ENGINE'S, per §10-C. openDoor on a locked door is REFUSED unless the player is carrying the key the lock names by catalog id — a GM that could simply declare it open would make the key decorative. closeDoor, lockDoor and unlockDoor round out the set, each naming a doorId, because the engine cannot read "she turns the key" out of prose and guessing would be the heuristic-over-prose trap. The dossier tells the GM each door with its ID — a GM told a door exists but not what to call it can describe it and never open it — in the weather dossier's stance: engine-owned, narrate it, never contradict it. A room with no doors gets no section. The exits bar marks a shut way dashed and dimmed rather than removing it, because a badge that vanishes is indistinguishable from a wall and the player is entitled to know which. Verified in the browser on the built-in world: a locked oak door on the Village Square's north exit refuses a narrated walk-through and says so to both; the GM's openDoor is refused while the key is absent; with the Brass Key in the pack it opens on both sides at once; and the walk then succeeds into Market Row. Sabotage-checked in four directions, one of which had to be redone — cutting the whole gate block broke the function rather than removing the gate, so the results read inverted until it was neutralised precisely instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Decision C from Designs/doors-and-barriers.html, the container half. A lock gains `keyItem` — an
ITEM_CATALOG id — beside the `keyName` string that was previously its only link to its key. Two catalog
entries may carry the same display name, and a lock naming "Iron Key" then opens for whichever one the
player happens to be holding. That is a lock with a duplicate.
Kept ALONGSIDE keyName rather than replacing it: every world in existence spells its keys as strings, and
the string is still what the player is told when no catalog entry names it. A lock authored before the
reference existed normalizes with an empty one rather than having it invented — guessing which of two
same-named entries was meant is the bug, not the fix.
Two shared helpers, written once because doors will call the same pair rather than a second copy that
drifts. `lockKeyLabel` resolves a reference to the catalog entry's CURRENT name, so renaming an item in
the editor renames it everywhere a lock mentions it — which a stored string cannot do; the three display
sites that read lock.keyName directly now go through it, or a renamed key would follow in one place and
go stale in the others. `playerHasKeyFor` answers whether the pack holds it, by ref first and by name only
for worlds that predate refs. A reference to a catalog entry that does not exist is REPORTED as an
authoring error, because silently it is a lock nobody can ever open and nobody can find out why.
The instance half of C turned out to be already done — another session had shipped `it.ref` on makeItem
and a backfill on restore, with tests, in two commits. So this is the half that was outstanding.
MY OWN TEST CAUGHT A BUG IN MY OWN CODE, which is the part worth recording. playerHasKeyFor originally
tried the ref and then FELL THROUGH to the name check, which defeated the entire purpose: the fixture
holds the wrong "Iron Key" and it opened the lock anyway. When a lock names an item, that item is the
only thing that opens it, and the fallback belongs only to locks that name no item at all.
DELIBERATELY NOT DONE, and asserted so it cannot drift in unnoticed: containers still let the GM
adjudicate the key. There is no engine-side container key check today — keyName was only ever displayed,
and the code says so where it refuses ("Beating one is the GM's adjudication... arriving as
containerChanges"). Flipping that to engine-enforced is a behaviour change to a shipped system rather
than a refactor, so this change gives the lock an unambiguous reference and one shared checker, and the
checker records in a comment that containers do not call it yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvRev. 2. Each decision is recorded with its argument rather than its conclusion alone, because a decision without its reasoning gets re-litigated by whoever reads it next — and three of these overturned what the document was drafted with, for reasons the draft had missed. A — TWO RECORDS SHARING AN ID, not one shared object. The draft argued that mirroring state across two records is a fragile invariant, which was true and about the wrong field. The two sides of a door are genuinely different things: the lock plate may be on the inside only, the button reachable from one side and not the other, and one side may be a bookcase nobody has found while the other is plainly a door. A single record expresses that only with a per-side sub-object for nearly every field, which is two records wearing a disguise. So `state` is shared and written to both at once; hidden, lock, discovery, description and art are per side. This is now the load-bearing shape of the whole document, and it resolved H for free — two records already carry two faces. G — hiddenExits STAYS, and stays separate. The draft wanted to migrate it so a world could not say one thing two ways. That would have lost a distinction the fiction makes: a hidden EXIT is a way you did not notice, a hidden DOOR is an obstacle you have not found. The first has nothing to open, and folding it in would force every overlooked passage to invent a permanently-open door — a lie in the data bought for tidiness in the schema. They compose instead. D — the GM judges an authored condition. The draft wanted an explicit directive as a guard against an over-eager GM. The condition is prose the engine cannot parse, and inventing a parser for "roll the boulder aside" is the heuristic-over-prose trap this project keeps stepping out of. Recorded with the consequence that is not a contradiction: the engine still has to be TOLD, so the GM judges and then says so — and the residual risk is now a thing to watch in play rather than a thing designed against. C added a correction bigger than the question: a lock names its key by CATALOG ID, not by the free string containers use, because two items may share a display name and a lock that opens for whichever one the player happens to hold is a lock with a duplicate. Recorded honestly rather than papered over: item instances carry no catalog id, so the runtime check still compares names somewhere — what the id buys is unambiguous AUTHORING, and containers should follow. F was sharpened rather than confirmed. The draft called a door "a narrative blockade the graph cannot see", which was true of the world before this document and false of the world after it. A door is mechanical: on an exit, naming its key by id, joining two rooms the map already knows. The evaluator can trace it exactly, so a keyed door whose key lies behind itself is a provable soft lock rather than a suspected one. B and E confirmed: closing is in scope (with the pursuit question deliberately left to combat), and a barrier is not an item but must be examinable — which §04 already provides. Phasing gained P3.5 for closing and stopped promising a hiddenExits migration that is no longer wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two routes to it, both deliberate acts by the player rather than something the screen does at them. EXAMINING IT IN TEXT is primary — the player spent a turn on it, so they get picture, description and state in the door's own words — and it is primary for a mechanical reason as well as a fair one: it works when nothing can match the door's name, which turns out to matter. CLICKING ITS NAME in the story opens the same popup, and is a convenience over the first route rather than a replacement, so the player who types is never worse off than the player who clicks. State is shown in prose — "shut", "locked", "standing open" — not as a field name. A player reading `state: locked` is reading the engine rather than the world. THE FRICTION THIS TURNED UP, and it is the reason the section is longer than "add doors to the linkifier". linkifyStoryEntitiesHTML already walks GM narration, matches known names and wraps each in a link to its popup, and says of itself that it is extensible; adding doors looks like one line. It is not. The match is case-sensitive and whole-word ON PURPOSE — its own comment says "so proper-noun mentions link, but lowercase common words like 'light' don't" — and spells and tomes are proper nouns while A DOOR IS NOT. "the iron-banded door" is all lowercase and will never match; loosen the rule to catch it and every occurrence of the word "door" in any paragraph becomes a link, including doors in other rooms and doors that are only a figure of speech. So the leaning is to link where the ENGINE wrote the text — the room description, the exits list, the refusal line when a door holds — because there the engine knows exactly which door it is naming and needs no matching at all. GM narration is a bonus, linked only when the name is distinctive enough to be safe. A missed link costs a click; a wrong one opens the wrong door's picture. And the visibility gate is restated where it bites: a hidden door is not linkified, has no popup, and is not examinable. Asking about a bookcase that has not been searched must be indistinguishable from asking about any other bookcase, or the absence of a refusal becomes the tell. P2.5 is ordered to match — the examine route first, then the engine-authored links, then GM-narration linking last and optional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Three fields on the door record, and the first of them collides with something that already exists — which is the part worth writing down, because otherwise the two get filled in interchangeably by every author and every model. AN EXIT ALREADY HAS A `description`, and in the built-in world it holds "South leads to the South Gate, the fortified way out onto the Ashfen Moor." That is where the exit GOES. The door's description is what stands IN THE WAY — the oak, the iron, the moss on the boulder. The doc states the split as a rule: the exit answers "where does this lead", the door answers "what am I looking at". It matters most in the boulder case, where the exit's description must not be shown at all, because saying where it leads gives away that it leads anywhere. `imagePrompt` and `image` follow the record rather than inventing a noun. The codebase already spells this same pair three ways — image/prompt on items, portrait/portraitPrompt on entities and dungeons, bannerImage/bannerImagePrompt on regions and rooms — and a door should not add a fourth; nested under `door`, the plain pair reads correctly without a prefix. `ignoreArtStyle` comes along because every other paintable object in the game carries it. Three consequences the art forces the design to answer. The picture needs a HOME, so the doc proposes the examine popup that regions and items already use. It needs a SHAPE, and portrait is right for a door while a boulder is squarer — which is the one place `kind` earns anything mechanical, worth flagging since §03 otherwise insists it is pure flavour. And it inherits the REGION's Art Style for free, because room-scoped art already resolves region before world, so a frozen region's doors look frozen with nobody authoring it twice. It also forced a new open decision. A bookcase from the library is blank stone from the passage: does a door have one face or two? The secret case resolves itself — the unfound side is hidden, so there is nothing to paint — leaving only the plain door that genuinely differs from each side. §10-H leans to one face, reaching for two doors sharing a lockId when an author really wants both, because a second face doubles every art field for a case that may be rare. Phasing gained P2.5 for the art, deliberately AFTER hiding: a door's picture must never be shown for a door the player has not found, and that rule belongs to P2. P1 carries `description` from the start, since a door the player cannot see the point of is worse than no door. Renumbering the sections took two goes and the first was wrong in a way worth noting: search-and-replace shifting 05..11 collided with the §05 heading I had already written by hand, producing two 06s and no 05. Redone by walking the h2s in document order, which cannot collide, with the cross-references then fixed by what they point at rather than by arithmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Editor › World gains a Settings inner tab at the right-hand end of the bottom strip, holding one collapsible section per group of settings. The first group is Electron, and the first setting in it is "Settings Visible" — a placeholder, which the hint under it says out loud. The reason this is a new store rather than another section of the app's Settings dialog is lifetime. That dialog belongs to whoever is sitting at this browser and lives in localStorage; these belong to the world, so they ride through serializeWorld into an exported world file and through rebuildWorldFromSnapshot on reload. A world handed to somebody else should arrive with the Dungeon Master's choices about it, and should not arrive carrying their inactivity timeout. Both persistence paths name their fields explicitly, and the reload path bypasses the World constructor entirely, so each of the three needed its own line — a field missed in any of them is a setting that vanishes with nothing to show for it. WORLD_SETTING_GROUPS is the only declaration of what a setting is: every key's default, its type, and by omission what is not one. normalizeWorldSettings coerces whatever it is handed into exactly that shape, for the reason normalizeWorldPalette does — the value arrives from a save or an imported world file, either possibly written by a different build. An undeclared key is dropped rather than carried through, which is a real trade: a world saved by a newer build loses a setting this one cannot show. Carried through, the alternative is worse, because the editor would then hold settings it can neither display nor clear and the DM would have no way to see what the world was actually doing. The placeholder persists even though nothing reads it. A checkbox that forgets its own state the moment you leave the tab reads as a bug rather than as an unfinished feature — and the plumbing is precisely the part a second Electron setting would otherwise have to add from scratch, which is what asking for the section now buys. Two smaller decisions. The section is a native <details>, so the open/closed state is the browser's: no toggle handler, nothing to persist, and because this panel's markup is static rather than re-rendered the state survives a tab switch on its own. Its caret and summary chrome join the existing .npc-collapse-field selector group instead of being copied, so two carets cannot drift apart. Adding the tab also meant writing 'settings' into the two identical id rosters inside switchWorldInnerTab — a whitelist and a render loop — for the third time. They are now one hoisted WORLD_INNER_TABS constant. Disagreement between those two copies could produce both of the quiet failures test_world_currency_tab.js was written to catch: a tab missing from the whitelist fell through to `else sub = 'chunks'` and read as a dead tab, and one named in the loop with no button threw on a null getElementById and took the whole strip down. That half of the problem is now structurally impossible; that test was updated to check the single constant against the markup instead, including its order, and to fail if a second roster literal ever reappears beside it. tests/test_world_settings_tab.js covers the tab; twenty-seven sabotages of the implementation were each caught by a distinct named assertion, and the three that broke by throwing rather than by returning a wrong value are run through a helper that reports them under the property they broke instead of letting a stack trace replace the whole file's output.
The palette dropdown could edit sixteen colours and reset them, but a look a DM had spent time
tuning could only ever exist inside one save. There was no way to carry it to a second world, to a
different install, or to hand it to somebody else. Export and Import now sit in the menu foot above
Reset all, with a line underneath reporting what happened.
The file is the world file's own envelope: { "palette": { "dark": {…}, "light": {…} } }, the same key
under the same shape a world JSON already carries. That was the whole reason not to invent a
palette-only format, which would have been no smaller and would have interchanged with nothing. As
it stands an exported palette pastes straight into a world file, and Import accepts a whole world
file as a source without the DM cutting it down first — along with a bare { dark, light } map and the
legacy flat { "--bg": "#…" } shape normalizeWorldPalette already tolerates, for the reason
itemEditorImport gives: somebody handed a file should not have to know which of the three they were
given.
What gets written is the OVERRIDE map, not the sixteen colours as they currently resolve. Resolved
colours were the tempting choice — they read as "the palette" — but they bake :root's built-in
defaults in as overrides, which leaves Reset nothing to fall back to and leaves a light/dark flip
still showing the exporting theme's values. Overrides reproduce the same look in any world, because
the defaults underneath them belong to the app rather than to the world, and they still record which
colours the DM actually touched. A world with nothing customised is refused rather than written as
{}, since a file holding an empty object imports as "no colours found" and reads to whoever opens it
as a broken import.
The subtler decision is on the way in. An import replaces within a theme — choosing a file is asking
for that palette, not for a blend with whatever the world was already wearing, and a merge leaves the
leftovers underneath indistinguishable from the import so it can never be undone. But it only touches
the themes the file actually named, read off the raw parsed object because normalizeWorldPalette
always returns both keys and so cannot tell us. A file carrying only `dark` leaves the light
overrides standing. Replacing both would discard half a palette silently: the menu only ever draws
the theme in effect, so the loss would not surface until the DM flipped the theme, long after the
save was written.
Import is a <label> wrapping a hidden file input rather than a button that clicks one, because
programmatically clicking a file input is refused as a non-user gesture in some browsers and fails by
opening no dialog at all, with nothing in the console. The cost is the box model a <button> brings
free, which is what the .palette-io-row rules put back.
tests/test_palette_io.js covers it; sixteen sabotages of the implementation were each caught by a
distinct named assertion, including the two that would otherwise be silent — a merge instead of a
replace, and an import that wipes the theme the file never mentioned.A design document for exits that can be shut, locked, hidden, or blocked by an authored obstacle. Grounded against the engine as it stands rather than sketched, because the grounding turned out to be the whole argument. THE FINDING THE DOCUMENT IS BUILT AROUND. The engine does not move the player — the Game Master does, by returning moveToRoom, which is applied after checking only that the room exists and the party is not in a dungeon. No adjacency test, nothing that consults the exit taken. So a locked door enforced by telling the GM about it is not a locked door, and the codebase already says so in the comment guarding that very line: "a refusal that only lives in the prompt is a request." Every phase below is arranged around making the refusal the engine's, and around saying it to BOTH the player and the GM — a silent refusal after the GM has narrated the player stepping through is the BUG-017 / BUG-021 / BUG-036 family exactly. AND THE PROPOSAL IS DELIBERATELY UNORIGINAL. Three partial models already exist and none of them knows about the others: containers ship key/pick/button/sealed locks with hidden buttons and traps; the dungeon crawler ships locked doors, secret walls, free-text lock IDS shared across doors, and switches that throw every lock of a name; overworld exits ship half of one idea in hiddenExits + revealExit. So the design takes the container lock record verbatim, takes the dungeon's lock id and switch verbatim, and attaches them to an exit. A DM who has locked a chest has already learned how to lock a door. The load-bearing pair is `hidden` versus `hidesExit`, and the boulder is why they are separate: the boulder is visible in the room while the east exit it controls is not. A barrier is not a second type — same record, different noun — because a separate type duplicates every field and drifts, and the honest difference is one word in the description plus which lock method the author reached for. The one genuinely new method is `condition`, free text the engine cannot parse, which is also the one place the GM must be trusted; §09-D leans to an explicit openDoor directive for the same reason sellItem became one field rather than two. Seven open decisions with leanings, four phases, P1 being the gate and the plain door — the phase that answers whether an engine-enforced door feels right in play before any of the mechanisms are built. §09-F may be the most valuable part: World Evaluation already models a `path` lock for "a narrative blockade the graph cannot see", which is precisely a door, so a keyed door whose key is behind itself is a soft lock provable without anyone playing to it. Chrome copied from region-files.html per the house convention; every class it uses resolves against that style block, which took two corrections — the table wrapper is `tablewrap`, not `tblwrap`, and the footer takes no class. Row added to Designs/README.md beside Containers, whose lock model it borrows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A design doc, proposed and not built, for the gap between a rule the Game Master is told and a rule the world enforces. The question it starts from is whether a new mechanic could simply be new line items in the world prompt. The answer turns out to be "partly, and further than expected": the schema is already a mechanics vocabulary the ENGINE consumes, not just prose the GM reads — skills with a stat and a DC, abilities with appliesTo/waives and a signed bonus, item effects on onEquipped/onUse/onHit, weather conditions that impose a status, quests as a DAG. Anything expressible as a named condition, a signed modifier, a stat delta, a duration, a trigger or a gate is authorable today with no code at all. The ceiling closes in three places, and naming them is most of the document. A new NOUN: STAT_KEYS is six stats and the pools are hp and mp, so a magazine, an essence budget or a contamination track has nowhere to live. A new VERB: ABILITY_KINDS is exactly modifier and passive, so a resource spend or an opposed roll cannot be said. A new MOMENT: three item triggers, and no onEnterRegion, onNightfall or onFire. The apparent escape hatch — appliesTo.tags, documented as a free-form trigger — is not one: nothing engine-side matches a tag against world state, so the GM decides, which is the boundary in a single field. Two measurements already in this folder settle whether prompt text is enough. Thieving & Sneaking's sixty-four turns, in which every theft worked mechanically and almost none of them cost anything, because ownership is not modelled. And Weather's authored effects, handed to the GM as "the INTENDED effect, not a verdict" — this design half-built, with the doc's outstanding phase saying the engine still applies no effect. Authored as data, adjudicated as prose. The genre section is the heart of it, and it exists because a fantasy rule cannot show that a vocabulary is fantasy-SHAPED. Guns are nearest: RANGED_SUBTYPES already draws damage on DEX, so a revolver is a crossbow the world describes differently, and the one missing thing is a magazine — a resource bound to an item instance, a moment that spends it, and a refusal at zero. Cyberware needs a spend-down budget that is not a stat, slot CAPACITY rather than one item per slot, and an "installed" state beside carried and worn; it is closer than it looks, because applyClassEquipOps already lets an authored class edit the equipment doll with validated ops. Vehicles are deliberately scoped OUT and the reason is written down: a vehicle is a change to where the player IS, so currentRoomId becomes an indirection every surface reads through, and two-thirds of this machinery does not help. The proposal itself is a mechanic descriptor — validated data, never code, following the argument descriptor-schema.js already settled for providers — over named resources, a published trigger registry, and consequences including "say". The rule that makes it worth building is that the engine resolves and the GM narrates the outcome, not the other way round. Phase 0 is a census that is explicitly allowed to conclude the answer is no, which makes this the second document in the folder to reserve that after Dungeon VR. Both indexes updated, per the set comparison rev. 5 introduced: 33 documents on disk, all of them in README.md and all of them named on the status page.
"// make this world's interface frozen and blue" now authors a world palette. The directive schema gains "palette" (the sixteen editable CSS variables), "paletteTheme" (which of light/dark it is for, defaulting to the one in effect), and "resetPalette" to return to the built-in look. The roster is INTERPOLATED from PALETTE_VARS, never typed out. This is the third feature to be bitten by a hand-written list — the equipment slots and the item types both went stale that way — and here a variable named in the prompt that the engine does not know is silently dropped. Each variable's LABEL goes with it, so a model asked for a frozen palette knows what it is colouring rather than guessing from a CSS name. Everything else in this directive changes the fiction; this changes the app the fiction is read in, so it is held to the palette editor's own rules rather than trusted. Only variables that editor offers, only values toHexColor accepts, and anything else dropped AND SAID — a colour the DM asked for that never arrived is the kind of thing they would otherwise blame on the model. It rides with the save like the rest of the palette, so it never touches the login screen or the built-in world definition. Two things the prompt has to say and would be wrong without. That this is the APP's colours and not the world's art style — those are different things and "recolour the world" could mean either. And that the result must stay READABLE: a model handed sixteen colours and no constraint will set --text and --bg to the same value. The "//" router also had to learn that recolouring is an instruction, or "make the UI frozen" reads as a question and goes to the Dungeon Master's Guide instead of changing anything. Two test corrections worth recording, both cases of the test asserting something the code never claimed. It refused rgb() and short hex as "not a hex" — but toHexColor deliberately takes both and normalizes them, because the palette editor's own field does, and making the GM path pickier than the human one would have been a regression dressed as validation. And the theme-separation check asserted that nothing was painted after writing the other theme, which passed whatever the code did: applyWorldPalette re-applies the CURRENT theme's map, and the current theme was empty in that fixture. It now gives the theme in effect a palette first, so the assertion has something it could disturb — and fails when the two maps are conflated. The guard it was supposedly protecting turned out to be a no-op, and its comment now says so rather than claiming correctness it does not carry. Verified in the browser: ten colours applied, the whole app frozen blue, gold #c9a84c to #7fd4ff, and the one bogus variable refused and named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Gap 4 of the region-generation audit, closed as a decision rather than as work. A dungeon's `location` is free text — its own comment says "a region, a room name, a direction from somewhere known" — so which dungeons belong to a region has no answer the engine can compute. The only way to answer it today is to match that prose against region names, which is a heuristic laid over authored text: the thing this project keeps removing rather than adding, because the two systems end up fighting each other. Giving a dungeon a real `region` field is the fix. It waits on the Dungeon Builder, which is still in flight, and it is a schema change to another editor tab rather than part of this work. Recorded in three places, because an absence nobody explained reads as an oversight and gets "fixed" by someone guessing: Designs/region-files.html §09.3, where the doc had already anticipated the blocker; a comment in buildRegionFile where a reader would look for dungeons and not find them; and an assertion that the file carries none, so adding them later has to be a deliberate act that changes this line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The tests for the region-generation audit, and the honest account of a commit boundary I got wrong: the Art Style work landed in the previous commit, whose message describes only the prompt gaps. It is described here instead of rewriting pushed history. ART STYLE is the one place a region's brief reaches past authoring, and deliberately not into the GM prompt — which is the line that matters. A snow region and a desert region SHOULD look different; the Game Master still narrates one world. Wired at the three places that paint something belonging to a region: a room's banner, the region's own banner, and its map. Blank inherits the world's, so a world that never sets one paints exactly as it did before — that is what makes it safe to switch on for worlds that already exist. The test checks both halves: the region's style wins for its own scenery, and it is still absent from buildSystemPrompt. Painting is not narrating. The prompt gaps are pinned too — the slot roster interpolated, the FULL taxonomy rather than the brief one, the flora guide, quest and encounter schemas with beats kept inside the region, the climate line, and the token ceiling sized off the room target. One assertion needed rewriting after sabotage passed it. Checking that the climate line EXISTS matched the string in its own const declaration, so a build that defined climateBit and never concatenated it sailed through — the string lives in the const either way. It asserts the concatenation now, and fails with "defined but unused" when the join is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Gaps 1, 2, 5 and 6 of the region-generation audit, plus two bugs the widened guard found elsewhere. GEAR THAT CAN BE WORN. The region build's item schema named no equipment slots at all, so every sword and helm a region authored came back with none, normalizeEquipmentSlots had nothing to keep, and the gear arrived silently unequippable — the exact failure test_equipment_slot.js exists for. It now carries the full item schema with the roster INTERPOLATED, plus loreXp on an absolute scale when lore is being generated (omitted, every hook pays the same flat default, which is not a scale but an absence), plus the flora guide, and the FULL item taxonomy rather than the brief one — the brief rendering exists to keep the per-turn prompt cheap, and this is a one-shot authoring pass where the vocabulary is the point. QUESTS AND ENCOUNTERS were merged by mergeWorldChunk all along and asked for nowhere. Both now have their own schema, with every quest beat's location required to be inside the region — a quest that sends the player somewhere unauthored strands them. Encounters are what make crossing a region feel like crossing somewhere inhabited. THE CLIMATE is told to the model. A region wired to Alpine should be authored snowbound; without it the GM writes the place it imagines and the weather then contradicts it every turn. THE TOKEN CEILING was a flat 32K against world generation's 128K. A twenty-room region with beings, items and quests does not fit, and comes back truncated — which reads as the GM writing a broken chunk rather than as running out of room. Sized off the room target now, clamped to the measured ceiling. AND THE GUARD THAT SHOULD HAVE CAUGHT ALL THIS. test_equipment_slot.js checked that any prompt MENTIONING equipmentSlots interpolates the roster — so it demanded correctness wherever slots were asked for, and said nothing whatever about a prompt that mints items and never mentions slots. That is precisely what the region build was, which is why it passed. The rule is now "a prompt that mints catalog items says where they can be worn", with flora named as the one honest exception rather than pattern-matched out. Widening it found two more, neither reported: requestWorldExpansion's item catalogue and requestRoomEdit's inline floor items both mint gear and named no slots. Same one-line fix, same bug, live in the app until now. It also had to judge over a NEIGHBOURHOOD rather than a single line — a prompt is concatenated template strings, so a schema routinely spans several, and asking the line itself reported a prompt that names slots on the very next line as naming none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Climate joins Description at the top of the dialog, and Description becomes editable — the two fields the Regions tab owns, offered here too so authoring a region does not mean alternating between two screens. THE SAME FIELD IN TWO PLACES, not a copy, and that is the whole reason it is safe to offer twice. Both write through the region record; neither is a REGION_BRIEF_FIELDS entry, because listing them there would have the normalizer and the commit treat them as brief keys and fork each into a second value that drifts from the one on the tab. Each edit re-renders the Regions tab behind the dialog, or closing the builder would reveal a panel still showing what the region used to say. Climate goes through setRegionClimate rather than assigning the field here. That function owns the slug normalization weatherClimateForRegion reads back, and it refreshes the Climates tab's "which regions stand on this climate" note — which would have gone stale precisely when the change was made from this dialog rather than from the tab. The two pickers now share one regionClimateOptionsHtml. The interesting part of that list is not the loop but the stale-climate rule: a climate the world no longer defines is kept as a visible "no longer defined" option rather than silently re-filed, and a second copy of the loop is exactly what loses that. A world with no climates at all says so and disables the select, since an empty one looks broken rather than empty. Description was read-only two commits ago, with a note saying where it was edited. Making it editable is the better call and the note goes with it: it existed to explain a restriction that no longer applies. The test asserts the field is NOT read-only, with the reason, so the reversal is recorded rather than looking like drift. Verified in the browser: both edited from the builder, and the Regions tab behind agrees on both — same description, same climate id, with the climate's own description showing as the hint under the select. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A read-only Description at the top of the dialog, carrying the region's own description from Editor > World > Regions. Everything below it is written to fit this place, and being told what the place IS should not require closing the dialog to go and look at the tab behind it. Deliberately NOT a REGION_BRIEF_FIELDS entry. It lives on the region record, not in the brief, and listing it there would have the normalizer and the commit treat it as a brief key — forking it into a second description that drifts from the one on the Regions tab. So it is its own block above the generated fields, and the test asserts the separation rather than trusting it: committing the form leaves reg.description untouched and puts no `description` in the brief. Re-read on every render, so an edit made on the Regions tab is current the next time this opens rather than whatever it said the first time. Styled like an inheriting field — dimmed, italic — because it is the same idea: text the dialog is showing you rather than text you are writing here. It says where it IS edited, and an empty one says so in its placeholder instead of presenting a blank box that invites typing into a field that cannot accept it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Below every field: "Inherit from World" on the left, a ✨ on the right. INHERIT IS A STATE, not a one-off copy, because that is what the label says. Checked stores BLANK — which is already how a brief defers to the world — and displays the world's text read-only and dimmed, so the DM can see what they are inheriting rather than an empty box claiming to have inherited something. The alternative, copying the text in, would turn an inheritance into a snapshot: the region would stop tracking and a later edit to the world's tone would silently leave every region on the old one. The test proves it tracks by changing the world and watching an inheriting field move, and by re-checking a claimed field and getting the world's answer AS IT IS NOW rather than the one it left. Unchecking SEEDS the field with what it was inheriting rather than blanking it. The DM has just said they want their own version, and the most useful start for their own version is the one they were reading; a box that empties itself the moment you claim it is a worse tool. Two fields have no world analogue — scope and beings, both generation inputs the World Builder never stores — and their row is still drawn, disabled, saying why. A row present on six fields and missing on two reads as a rendering bug rather than as a fact about the world. A world field that is itself blank disables the box too, since an enabled box that inherits emptiness looks broken. THE ✨ writes one field for this region from the world and from whatever the DM has already written, so asking for a Prologue after setting a Tone gets a prologue in that tone. The field being asked for is left OUT of what the model is shown — handing it its current value invites a rewrite instead of an answer. Each field carries its own wording for what to ask, held with the field so a new one arrives with its own rather than a generic one. Plain text back, not JSON: it is one field, the whole reply is the value, and an envelope round one string is a parse that can fail for nothing. THE BUTTON GAP was reported as tight and measured as OVERLAPPING by 2px. Each button carried its own absolute `right`, so the space between them was the difference of two hand-measured numbers — and Add GROWS when its label becomes "Adding…", so any value chosen against the idle width is wrong against the busy one. They share one flex row now; measured at a clean 10px idle and 10px busy. One note on the test, because it was wrong in a way worth recording. The inherit assertions first read element values off the mock DOM — whose innerHTML is a plain string, so rendering never creates the inputs and .value returns whatever an earlier block last assigned to that id. They read a stale value from three blocks up and reported it as the world's tone. They parse the rendered markup now, and exercise commit through the elements the code actually reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The test for the feature, and one bug it found in it. regionBuildRoomTarget matched extra-large with `\bx?\s*large`, where the optional x made plain "large" match first — so every large region was quietly built at 16 rooms rather than 14. Found by writing the table out and reading what came back. THE ASSERTION THAT MATTERS is the one about the GM. This feature exists because a DM wants per-region framing while authoring, and it is permitted because that framing never reaches play. So the test fills every field of a region's brief with a marker, stands the player IN that region — the case most likely to leak — and asserts the marker appears nowhere in buildSystemPrompt. It also asserts the prompt is 150K characters, so the absence is a boundary rather than an empty string. Verified by leaking it deliberately: the assertion fails and names the field. The rest is the shape of the thing. The Build button is armed only by a real selection, and "No Region" does not arm it. An open builder holds the region it opened on rather than following the map. Ownership in the exported file follows the design doc's rule — a template used by one region travels, one used by two stays, and what stayed is reported. Imported rooms are re-tagged for the region they land IN, or a file cut from a differently-named region scatters its rooms into one that does not exist here. Each verified by re-introducing it. Confirmed in the browser against a two-region world: Build disabled with nothing selected and with the "No Region" bucket, enabled on a region; the builder opens on Saltmarsh; a brief typed into the form survives to the region record; the file carries kind, version, base stamp, both rooms, the brief, and the four border exits leaving the region; and a 152,790-character system prompt contains none of it. A full round trip applies Saltmarsh's file into Highfell: Highfell keeps its own name, takes the brief, and gains both rooms re-tagged to itself. NOT exercised: the GM call itself. Build Region needs a live model and would write into a real world, so its prompt and merge path are asserted statically and the round trip through mergeWorldChunk is covered by the import test, but no generated region has been read yet. That is the first thing to try on the next playtest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Editor > World > Regions gains a Build button left of Add, enabled only with a region selected — it opens the Region Builder on one particular region, so with nothing chosen there is nothing to open. Disabled rather than hidden, because a control that vanishes teaches nothing about why. "No Region" does not arm it either: that is the bucket of unassigned rooms, not a region, and it has no record to author. THE BRIEF. A region now carries tone, theme & premise, scope, art style, rules, prologue, narrative and beings — the World Builder's authoring fields, for one place. Declared once in REGION_BRIEF_FIELDS and read by the form, the normalizer, the generation prompt and the file format, so adding a field is one entry rather than four edits three of which get forgotten; the equipment slots and the item types both went stale exactly that way. AUTHORING CONTEXT, AND ONLY THAT. Designs/region-files.html argues tone/theme/prologue must stay world-global because "a GM shown only one region's worth of them writes a different world in each", and it is right about the GM — it is answering a different question. A DM wants to say this marsh is bleaker than the coast while writing it; the GM at play time still needs one coherent world. Both hold as long as these never reach buildSystemPrompt, so that is a property a test pins rather than a convention to remember. A blank field inherits the world's answer and the prompt says so, since a prompt that merely omits the world's tone reads as "this place has no tone" and gets one invented. BUILD REGION asks the GM for the region's rooms, beings, items and lore and merges them through mergeWorldChunk — the same load path as every other chunk, so every protection it has learned applies. It re-tags rooms with the region name whatever comes back: that one field decides whether any of this landed where the DM was building, and a chunk that omits it merges perfectly and leaves the region still empty. It reuses ensureDmAdditionPopulated too, so a template nobody referenced is placed rather than catalogued into nowhere. REGION FILES follow the design doc's envelope: a `kind` discriminator so a world file can never load as a region, a `base` stamp naming the world it was cut from, the region record with its brief, the contents in mergeWorldChunk's shape, and the border exits recorded rather than dropped — a region that forgot where its roads went could never be put back. Ownership follows the doc's rule rather than "whatever this region touches": a catalog entry used by exactly one region travels with it, one used by two or more stays with the world, and what was left behind is REPORTED, because that is exactly what a DM needs to know before handing the file to someone else. Two of my own faults, both caught by existing tests doing their job. The region build's summary counted catalog rows and called them beings — the BUG-038 dishonesty, one commit after writing the test that bans it; it counts what stands in the region's rooms now. And I put a built-in place name in two comments, which the no-leak test forbids below WORLD_DATA, correctly: a comment is where a copied example turns into a prompt. One test assertion was too broad and is now scoped to its claim. It banned the expression `if (a.entities) bits.push` outright as a proxy for "reports catalog rows as beings", and fired on the region-file import, which counts those rows and calls them "being template(s)" — which is what an import adds, and saying so is honest. It bans the wording now, and still fails when the wording returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A faction carries a portrait/emblem of its own — world.factions[id].portrait, painted from
.portraitPrompt — shown on its editor card and in the Compendium, exactly like a race's portrait. It was
missing from both Art tabs: no Factions section under Missing, no Factions group under Review.
What makes this one different from races, encounters, spells and skills is that none of the generation
machinery was absent. 'factions' is a Compendium category, and compendiumTypeContext has resolved it to
{ obj, promptKey:'portraitPrompt', imageKey:'portrait' } — the whole contract processArtCardGeneration
reads — since long before this tab existed, with compendiumDetailBodyFor answering for it too. So a
faction's art could always be painted from its own card, and could never be painted by the batch, because
the one list that never mentioned factions was the list of what is missing. Every part worked and one
list did not ask, which is the quietest shape this bug takes.
The change is therefore four lines of plumbing and no new generation code: a factions bucket in
artMissingLists, a section in renderArt (with its cards, its ordered pulse key and its share of the
total), the bucket in currentArtCards, and a group of cells in the Review gallery — read from
world.factions, the same source the Missing tab reads, so the two tabs cannot disagree about what this
world holds. The all-done message names factions among what it checked.
Four existing tests broke, all in the same way and all correctly: each hand-copied a roster of art
buckets, so adding one made them report a disagreement that was really their own staleness. Rather than
add a line to each, they now derive. The two order checks compare a sequence of NAMES taken from
artMissingLists itself; the two "give everything art" fixtures gained a guard, derived the same way,
that names the bucket the fixture forgot instead of letting a later assertion fail sideways.
Sabotage found something better than a passing test. Every one of those order checks compares two lists
that BOTH derive from artMissingLists, so none of them can see the drift they are named for: what the
pulse actually indexes into is the DOM, where orderedKeys[i] is matched against the i-th card, and those
cards come out in the order renderArt emits its SECTIONS. Moving a section without moving the batch left
every list-versus-list assertion passing. The new test reads the rendered section order out of #art-view
and compares that, which is the invariant the comments have been claiming all along.Typing "Hard Bread" in the NPCs filter now narrows the roster to whoever has it. That is the question a DM actually arrives with: they can see the list, so they are not searching it for "Innkeeper" — what they cannot see is who has the thing. Extended for Monsters and Fauna too, because they share one filter and a monster carrying a specific blade is the same question. That sharing matters more than it looks: EXPORT calls the same function, so a filtered export still matches the list on screen. Special-cased into renderNpcs, "export the listed NPCs (respecting the current filter)" would quietly have exported a different set than the DM was looking at. Containers are walked. An entity's inventory holds live Item objects and one of them may be a satchel with the bread inside; "in their inventory" plainly means that too, and stopping at the top level answers "no" to a question whose answer is yes. Depth-guarded rather than trusted — container contents are authored data, a cycle is a thing a DM can write by accident or an import can carry, and an editor filter that hangs on a keystroke is worse than one that misses a deeply buried crumb. filterEntitiesByName became filterEntities, with all six call sites. It no longer only filters by name, and a function whose name lies about its own behaviour costs more than a clumsy one. And the three placeholders say "Filter by name or carried item", with the fuller rule on hover. Without that the feature is only found by accident, and a DM who types an item name into the old box and sees an empty list concludes the item does not exist rather than that the filter never looked there. Verified against the built-in world through the real input: "Hard Bread" narrows seven NPCs to the Old Gatekeeper, who does carry it, and Export returns exactly that one. Sabotage-checked in four directions — inventory unsearched, containers unwalked, the depth guard removed, and the placeholder reverted. The depth-guard case is the interesting one: it fails cleanly on the cyclic fixture rather than hanging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Three changes to the tab, one of which is really about where documentation belongs. DELETE moves left of the room select. It acts on the TEMPLATE; the select and Place act on a copy of it. Trailing the row it read as the last step of "place this somewhere", which is the one reading that makes a destructive button easy to hit by reflex. A rule separates it rather than distance, so the row still reads as one group of actions. THE LEDE GOES. Three sentences explaining templates versus instances sat above the list, and a paragraph you re-read on every visit is one you stop seeing by the third — after which it is only taking the space the list wanted. What it said now lives in the Dungeon Master's Guide under Editor > Entities > Catalog, where a DM goes when they want an explanation rather than when they want the list: the three states the last column can report, why an encounter spawn is roomless on purpose, why Delete is sometimes absent, and that Import places nobody. The Entities crumb names the third subtab so the section is findable, since a section nothing points at is a section nobody reads. THE COUNT STAYS, because it is live state rather than explanation, and moves into the toolbar beside the filter — "13 templates · 1 used by nothing". Ruled off from the Unused toggle next to it: both are dim uppercase, and with only a flex gap they ran together as one string. The test asserts the ABSENCE of the lede rather than just its move. Moving prose out of a screen is only an improvement if it landed somewhere, so the guide's half is asserted too — otherwise this is a deletion dressed as an edit, and the next person confused by templates-versus-instances has nothing to read. And the tempting fix for any future confusion about this screen is to put a sentence back at the top of it. Verified by re-introducing each: Delete back on the right, the guide section deleted, and the count shown nowhere. Each fails its own assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Caught by the person who knew the world, an hour after the screen that got it wrong shipped. ENCOUNTERS reference a being template by id and spawn it into a room as the player plays -- tryEncounter calls makeEntity on each enc.entities[] ref -- so a template belonging to an encounter is roomless BY DESIGN. The stock world is exactly this case: "villager" is defined in no room and is the Villagers encounter's spawn, and it is perfectly correct. The new Catalog screen called it homeless, sorted it to the top as a problem, and -- the part that mattered -- offered a Delete that would have broken the encounter. The merge finding had the same blind spot and would have reported a correct world as faulty on every import of one. So the question is USED, not PLACED, and the screen now has three states rather than two: a template in a room says which rooms, one an encounter spawns says which encounter in the same weight rather than a warning badge, and only one that nothing references at all is flagged. The delete guard refuses both, not just the first. The lede counts "used by nothing" and the toggle reads the same way. Two lessons in the shape of it. A screen whose whole job is telling you what is unused has to know every way a thing can be used, or its one answer is wrong -- and wrong in the confident direction, since it also offers to act on it. And I built this from the room model alone because the room model was what the original bug was about; the encounter path was never in view. Worth remembering that a fix scoped to the report that prompted it inherits that report's blind spots. The test needed three repairs of its own to be worth anything here, all of the same kind -- an assertion that passed for a reason other than the one it named. appConfirm was left unstubbed, so a delete that reached it never resolved and "the template survived" was true whether the guard refused or the dialog merely hung; it always says yes now. The survival check then ran synchronously, one tick before the microtask that would have deleted it, so it still passed against no guard at all; it is deferred to the end. And deferred, it ran after later blocks had replaced the world out from under it, so it builds its own. Verified by removing the guard: it now fails with "Deleted Villager." printed as the evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Rev. 5 of Designs/current-status.html, read against main at 8489e91 — 354 commits after rev. 4 read it at 77faba2 on 6 August. The finding that reorders the page is not about any document's status. Every previous revision audited the documents it knew about, and none of them asked which documents exist. Rev. 5 lists the folder and compares three sets — the .html files on disk, the rows in README.md, the documents named on the page — and they disagreed. Five documents had never appeared on the index at all: class-gating, item-taxonomy, region-files, server-hosted-worlds and thieving-and-sneaking. Two of those five had no README row either, so nothing in the folder pointed at them. That is a different failure from the stale badges §06 has been collecting. A stale badge is a document arguing with itself, and a careful reader catches it. A missing document argues with nothing: every page it should have been on was internally consistent without it, and the more thorough the audit of the pages that do exist, the more confidently it reports a complete picture. The check costs one directory listing and it now runs first. One document had genuinely moved. Server-hosted-worlds has shipped phases 0-2 — the admin seam in /vault/config, server/world-store.js with three admin routes, a Publish button, the Worlds tab, and two test files — while the chip at the top of its own page still read "Proposed - design only, not built", and its README cell said the same. Both are corrected, and it moves from §02 to §03 with Phase 3 named as what remains. Every other "outstanding" claim rev. 4 made was re-verified against the source and all of them held: combat still slices one foe, weapon damage's retrofit still has no caller, weather still routes its effects through the prompt, and none of Server Vault's five Phase 4 pieces is present. Counts become 5 proposed / 15 built in part / 8 shipped with growth ideas / 3 finished, over 31 documents plus the index itself. §06 gains five rows and a recount: rev. 4's footer claimed sixteen markers while its own table held fourteen, and neither number was reproducible from the page, so rev. 5 counts the rows and says how — the same discipline the section exists to apply to everything else. §07 gains three entries. Native App Login is now the large body that is READY rather than merely large: its flow and federation are settled, so its next phase needs no decision first. Server-Hosted Worlds' Q1 and Region Files' central question turn out to be one question about scale that nothing has answered. And Thieving & Sneaking is not asking for work at all — it reports that stealing already succeeds and already costs nothing, which is a finding waiting for a decision rather than a plan waiting for a slot.
Two follow-ons to BUG-038, which was one path -- the "//" room addition -- authoring an entity template
and referencing it from no room, so the DM was told "1 being" and found nobody. That path got a net. These
are the holes it left.
EVERY OTHER PATH merges chunks the same way: world expansion, region layout, an imported world. So
mergeWorldChunk now reports an added catalog entry that no room references. A FINDING, not a repair --
down there the chunk's intent is unknown, and importing a catalog of gear to author from later is
perfectly legitimate; quietly scattering it through the world would be worse than saying nothing. Matched
by NAME rather than by ref, because makeEntity resolves a {ref} into a live instance and the reference
does not survive on it, so an inline being of the same name satisfies the entry just as well.
The summary line got its own clause instead of joining the adjustments list. Every other finding describes
something the merge REPAIRED, so "<where> <action>" is enough; this one describes something it deliberately
did not, and "grizel kept" reads as reassurance rather than as a report.
AND NOTHING IN THE EDITOR SHOWED THE CATALOG. Items have had one all along -- Editor > Items > Catalog
reads ITEM_CATALOG -- while both Beings rosters are built from allWorldEntities(), which walks rooms and
collects instances. A template in no room was therefore invisible in the world AND in the editor, and
uneditable besides, since a being's fields are written through a live instance. Editor > Beings > Catalog
is the third inner tab: every template, whether it is anywhere, and which rooms it stands in -- "placed"
is not an answer when a being is in three of them. The homeless sort first and are badged, an "In no room"
toggle narrows to exactly them, and each row can place a copy into a chosen room, which is the repair the
screen exists for. Delete is offered only while a template is in no room: removing a placed one would
leave live beings whose {ref} resolves to nothing, and makeEntity would rebuild them as "Unknown".
Import adds templates and places nobody, deliberately. The whole subject of this screen is that
cataloguing and placing are separate acts; an import that did both would be the confusion it exists to
expose. Anything that lands nowhere shows up here as such, which is the point.
switchEntityInnerTab is driven off a list now rather than a line per tab -- three hand-written pairs is
where one of them keeps saying `sub === 'npcs'` -- and the panel joins the shared toolbar and scroller
rules rather than carrying private copies, which is the lesson from the Items tab strip that never went
gold.
Two fixture faults found by sabotage-testing and worth recording. The harness exported ENTITY_CATALOG by
VALUE, and the World constructor reassigns it, so the test was inspecting a stale object -- a getter now.
And the sort fixture named its beings so that alphabetical and orphan-first order agreed, which meant the
orphans-sort-first assertion passed against a plain alphabetical sort; renamed so the two disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe entry the fix earned. It records the shape rather than the incident, because that shape has now produced three bugs in this ledger: the report commits, the engine quietly does something else, and nobody says so. This one is the sharpest version of it -- the report was not even wrong about its own subject, it was counting a different noun than the one the DM went looking for. A catalog row and a being in a room are not the same thing, and only one of them is what "add an NPC" meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported as: "// add a room and an NPC" answered "built 1 room, 1 being", and there was no being -- not in
the room, not in Editor > Beings > NPCs. Both halves were telling the truth about different things, which
is why neither looked wrong.
chunk.entities is a CATALOG of templates keyed by id. A being exists in the world only when some room's
own entities array REFERENCES one as {"ref":"<id>"} -- that reference is what World.addRoom puts through
makeEntity to produce a live entity. The GM authored the template and referenced it from nowhere. The
count said "1 being" because it counted catalog rows; the NPCs tab said nothing because allWorldEntities
walks ROOMS and collects instances. Catalog: one. World: none. Both accurate, and the DM told the
opposite of what they would find.
Nothing in the directive had said the reference was required. It described entities as "catalog templates
keyed by new id" and, in a separate rule, asked to "populate the place" -- which reads as two instructions
where it is one thing done in two places. The directive now says outright that defining and placing are
separate acts and both are required, and what an unreferenced template amounts to: in no room, invisible
to the player, absent from the editor's rosters, described but not added.
ensureDmAdditionPopulated is the net under that, and the sibling of ensureDmAdditionStitched -- which
exists because this same class of GM omission used to leave the new room unreachable. Any entity or item
the chunk authored that no room in the chunk references is placed in the PRIMARY room, the one the anchor's
exit patch points at, because that is the place the DM described. Not the first key in the object: key
order is whatever the model emitted. A being the GM did place is left where it was put, or the net would
duplicate it into the room you walk in through. Inline "minor" dressing never enters the catalog and is
never touched.
The reply now counts what is STANDING in the new rooms rather than what the chunk added to the catalog.
Those disagree in both directions: a template nobody placed was counted as a being that was nowhere, which
is the reported bug, and a being whose name matches an existing catalog entry is placed without adding a
row -- so it would have gone unmentioned in the other direction.
The rescue is logged rather than silent. The net makes the symptom disappear, and the log is the only
thing that still shows the GM omitting the reference -- worth knowing if it becomes a habit, since the
prompt fix is the real repair and this is the belt.
Verified by re-introducing five faults: the net removed from the pipeline, the net ignoring a being the GM
did place, placing into the first key rather than the connected room, the reply counting catalog rows
again, and the net moved after the merge. The first of those needed an assertion of its own -- every
behavioural check calls the function directly, so deleting the call leaves a perfect net that never runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvI pushed 124ff73 with this test red, having run the suite before the last edit rather than after it. The failure was mine and it was the same shape as the one that commit fixed elsewhere: the assertion matched trimNarrativeWindow() and the demotion call within 140 characters of each other, and the comment added above the demotion pushed them apart. Nothing about the behaviour changed. So it compares positions inside addMsg's own body instead — mount, then trim, then demote — which is what the assertion always meant and is indifferent to whatever prose grows between them. Checked against a sabotage that moves the demotion above the mount, which is the failure it exists to catch.
Reported as a nonsense complaint: "// add a room to the northwest" answered "The Village Square already has an exit north (-> Market Row). Pick a free direction." Nothing was wrong with north. The "//" intent-router asks the GM which direction the DM meant, and the roster it offered was typed into the prompt by hand -- north|south|east|west|up|down|in|out. Handed eight options, none of them the one asked for, the model picked the nearest, and the engine then correctly refused an exit that really was taken. The complaint named the direction the model chose rather than the one the DM typed, which is why it read as a bug about north. The engine has known all eight the whole time. DM_OPPOSITE_DIR carries the return exit for every diagonal and DIR_OFFSET carries their map vectors; only the prompts disagreed, and three of them did -- the router, the world-expansion prompt and the world-generation prompt, each hand-typed, each missing the same four. So no GM-authored world has ever placed a room diagonally either, on a map perfectly able to draw one. That is the larger half of this: the reported symptom was one command, the cause silently narrowed every authoring surface in the app to a four-point compass. All three now interpolate buildDirections(), whose source is DM_OPPOSITE_DIR itself, so a direction added to the engine reaches every prompt with no prompt edit. The router is also told plainly that the diagonals are real here and that the DM's named direction IS the answer: its "defaulting to a sensible free one" clause, unqualified, reads as licence to substitute whenever the named direction is taken, which is exactly what the DM saw. And the router's answer is matched rather than trusted, at the boundary where it is produced rather than in each of the three callers that key an exit on it. normalizeDirection accepts nw, north-west and North West, and returns nothing for a word the engine does not know, so an unrecognised answer falls to the caller's own default instead of becoming an exit key. That last part is a quieter version of the same bug: an exit written under a name nothing else reads builds the room, skips the return exit, and leaves the map with no vector for it. This is the house rule with a bug attached -- never hand-copy a roster into a prompt -- and it is the third time: the equipment slots went this way once and the item types after them. So the test carries a general guard rather than three specific ones: any literal run of compass words anywhere in the file fails it, which is what stops a fourth prompt repeating this. Verified by re-introducing the reported bug verbatim, by removing the normalization, and by dropping the diagonals from the engine's own table; each is caught by its own assertions. Confirmed against the room in the report: The Village Square has north taken and northwest free, so the guard that fired will now pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported: immediately after reloading, a banner is being weathered and no ring shows in its corner. syncWeatherBannerSpinner puts the ring on a room's MOST RECENT banner, and it is re-asserted after every path that rebuilds one — renderNarrativeWindow, renderPinnedRoom, refreshCurrentRoomBannerElement all call it, each with a comment saying why. addMsg did not, and addMsg is the one path that MOUNTS a banner without going through a render. That is the harder case to spot, because nothing is destroyed: the ring survives on the node it was put on, and simply stops being on the newest scene for that room. Scrolled up the page it reads as absent; with "Pin Room" on the older in-story banner is display:none, so it is absent. Same for the trim just above it — trimNarrativeWindow removes nodes off the top of the window, and the ring's host can be one of them. Restoring a session is exactly this shape: writeRoomSceneToStory prints a fresh scene for the room the player is standing in, and the clock interval that starts the weathering has been running since the script parsed, so the two race across the restore's awaits with no ordering between them. It is not restore-only, though. During play, describeRoom re-renders ONLY when "Pin Room" is on, so with the setting off an arrival or a "look" during a weathering strands the ring the same way, with nothing following to heal it. So addMsg re-asserts it, after the mount and after the trim — before either and it would re-assert against the DOM that was already wrong. The call is idempotent and clears every ring before placing at most two, which is what makes "call it again" the whole fix rather than bookkeeping about which node moved where. The test grows the append and the trim as BEHAVIOUR rather than another source match: a ring is placed, a newer scene for the same room arrives, and the ring has to end up on it with the older banner left clean; then the window is trimmed under it and there must still be exactly one. Ordering inside addMsg is pinned too, since a call in the right function at the wrong moment looks identical in a diff. Eight sabotages, each caught by a distinct named assertion.
Reported: the weathering text in the story ignores the Drop Cap setting. It did, and the reason is structural rather than an oversight, which is why nothing had caught it. A GM turn's narration is its own message, so addMsg can flag it and messageNodeAttrs can put msg-dropcap on the message box. A BRIEF is not. The hour turning and the sky changing both emit a 'room' message that opens with the room title, often carries a banner, and holds the GM's prose in a nested <div class="tod-brief"> below them — filled in later, when the model answers. ::first-letter on that message would cap the room title, so the class has to go on the brief's own box, and the decision has to be made when the text lands rather than when the placeholder goes out empty. So fillTimeOfDayBrief decides it, judged by narrationTakesDropCap — the narration's own test, not a second copy of the length-and-first-letter rule, because two copies is two rules that drift. The class is written to the live node AND to the persisted entry, or a reload would quietly take the initial off lines that had one on screen. That meant rewriting the entry's opening tag rather than preserving it, and widening the pattern that finds it to class="tod-brief[^"]*" so a re-fill over an already-capped entry still matches; the old pattern anchored on the exact plain tag and would have missed it. The CSS selector list grows by one rather than gaining a second block, so the two surfaces cannot drift on what an initial looks like, and the brief inherits the body.drop-caps scope — which is the reported bug in reverse, and the thing to keep true. Verified in a browser at both settings. Three neighbouring assertions were rewritten rather than updated, each because it pinned a shape instead of a property. test_drop_caps matched the literal one-selector rule text and broke the moment a second selector joined it, so it now parses the stylesheet and asks which selectors share a declaration block. test_brief_typing_indicator claimed to check "the ACTUAL fill regex, not a copy of it" while holding a copy, and expected the opening tag to appear exactly twice — the second being that read-back pattern, which is now a superset and says nothing about whether the two agree; it lifts the real fillTimeOfDayBrief in and runs it instead, which is what proves the placeholder and the fill still match. test_weather_imagery bounded the function body by 1000 characters, so a comment counted as a regression; it reads to the closing brace now.
The screen let you name a character, pick a world and press Begin into a game whose very first turn fails -- the only signal a red border on a key field that Vault mode hides entirely. Now the button reads "Configure API Keys", and goes where the key is actually set. Those are different places, and pointing at the wrong one is a dead end rather than a detour: in Vault mode the browser holds no key and never can, so it opens the vault's admin portal; in Direct mode the key is the login field, so it opens the in-app API Keys dialog. Electron needs no separate handling -- it loads the vault-served page, so it is always the first case. Vault mode could not answer this at all until now. /vault/config reported the provider catalog, the model ceilings and whether the client would pass the admin gate, but never whether the vault actually HOLDS a GM key, so the browser had no way to know it was about to start an unplayable game. It reports `gmKeySet` now, asked of the same resolveKey the GM proxy calls, so "the login says the vault is ready" and "the proxy has a key to send" cannot disagree -- the shape the admin hint already uses. It is one boolean and never a value: WHETHER, not the key, and a player learns the same fact one turn later by playing. The two booleans on that config fail in OPPOSITE directions, deliberately. `admin` is strict, because guessing it wrong draws a button that 403s. `gmKeySet` is lenient -- absent means "has a key" -- because guessing it wrong buries a working server's Begin button behind a Configure step with nothing to fix. That default is doing real work today: the vault running on this machine is the pre-restart build and does not report the field, and the login is unchanged. Two smaller things the change turns on. The Configure button is never name-blocked: syncStartButtonEnabled disables Begin until a character is named, and leaving that in place would disable the one control leading to the thing that is missing, while the player named a character to reach a game that still cannot start. And `title` now has a single owner. refreshNewGameHint sets the button's text and runs first, so a title written there is overwritten a moment later; both conditions are stated in syncStartButtonEnabled instead, in the order they resolve -- no key beats no name. Its data-tip clear became unconditional for the same reason: with two conditions the explanation can CHANGE rather than merely go away, and a stale data-tip shadows a new title exactly as it used to outlive a resolved one. Three existing tests failed on this, and all three for the same honest reason: their fixtures set no key, so the button correctly entered Configure mode. Each now sets one, with a note saying why -- the name gate and the new-game hint are only what the button says once a key exists -- and the precedence between the two gates is asserted where it belongs. The fourth failure was a DOM mock missing classList.toggle. Verified in the browser in both modes, and by re-introducing four distinct faults: a vault sent to the in-app dialog, the Configure button name-blocked again, the lenient default flipped strict, and the server asking anything other than resolveKey. Each is caught by its own assertion. NOTE: the gmKeySet half needs a vault restart before it does anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Ratifies the open decision rather than leaving it leaning. Nothing federated is built yet, so this is a decision recorded with the shape it will take, not code — there is no cross-vault surface to gate. The question it settles was what an empty VAULT_PLAYER_EMAILS should mean to a federated caller, and the answer is that on a vault which never asked to be reachable, it should not have to mean anything: the surface does not exist. That is the difference between this and the two alternatives. Keeping one meaning would leave an unconfigured vault open to everyone in our tenant. Splitting the meaning would quietly change what an existing setting does under a feature the owner never asked for, which is the kind of thing found by the person it happened to. A vault must not become reachable by other installs because it was upgraded. The shape follows VAULT_ANYONE_CAN_JOIN, which is the same kind of switch and already has the plumbing: isTruthyFlag so only affirmative spellings count and =0 means off, seeded from the environment, editable on Settings › Access, persisted to VAULT_ACCESS_FILE which wins on later starts, and reported on Settings › Server with its env-or-file source. One more flag beside it rather than a new mechanism. Off, the federated routes are not mounted at all and isPlayerUser keeps its present meaning, which stays correct because a local sign-in is still the only way in. On, an empty player list stops admitting federated callers and one must be named — the split answer, applied only where it was asked for. Two riders are written down because leaving them unstated would repeat the mistake the decision exists to stop. anyoneCanJoin does not extend to federated callers: it was written to mean an open weekend on one's own box, and ticking it answers a different question. And nothing here touches admin, since §12's first rule already keeps a bearer token away from /admin/* — the flag governs what a vault will play host to, never who may administer it. The index's claim that this doc carries four open decisions is updated, and a h4 rule added to its style block for a sub-heading inside a single decision.
Section 11 established how identity travels from one vault to another — an audience-scoped access token the local vault presents, verified against the tenant's JWKS — and stopped there, which left the more important half unwritten: what the vault at the far end is entitled to conclude from it. That is the part most likely to be got wrong later, because the failure is silent. A default reads as sensible right up until the day two vaults talk, and then means something else with nothing about it looking different. The reassuring half first, because it is true and worth stating: the admin gate already holds. isAdminUser matches against the RECEIVING vault's own adminEmails and owner, so a stranger with a perfectly valid token gets forbid, and the loopback escape hatch cannot widen it — adminGateDecision consults loopback only in the auth0On === false branch. That is §10's rule holding in code rather than in prose. The player gate does not hold, and it is a default rather than a bug. isPlayerUser ends with `if (list.length === 0) return true`, which is exactly right while "authenticated" means a browser session this vault established: an empty list reads as "anyone I let sign in here". Accept a shared-audience bearer token and the same line means any user of any install anywhere, because we own the whole tenant. anyoneCanJoin has the same shape. Nothing about the token design caused that; a default written for local authentication did not survive federated authentication. So the section carries five rules — a bearer token never reaches /admin/*, aud is not authorization, the empty-list default needs a separate answer for federated callers, a federation token should be distinguishable from a login token, and no proxy may make remote traffic look loopback — and the two things that move once the email claim crosses a network boundary. Our tenant becomes the authority for who owns an address, which is a trust dependency rather than a flaw and belongs beside the other consequences of centralising authentication. And email_verified being absent stops being evidence of a dev setup once the claim is relayed by another vault, so the federated path wants !== true. One practical note recorded before it is discovered: an access token for a custom API carries neither email nor email_verified by default, so they need an Auth0 Action or a /userinfo call. Until then isAdminUser receives an empty email and returns false — the safe direction, but still a failure. Open decision 4 added for the empty player list, leaning toward federation being opt-in per vault: a vault that never wanted to be reachable by other installs should not become reachable by upgrading. The index's claim that this work brings a new external dependency is corrected — openid-client is already installed as express-openid-connect's own, so the change is a promotion and a deletion.
This document proposed the Device Authorization Flow, and it was right to under the constraint it was written against. That constraint has gone. The distribution is the Electron app with its bundled vault, each user signs in only to their own, and the vault handles anything that crosses a machine boundary — so no browser ever returns to an origin we have not seen. The unknown callback that ruled out the ordinary answer was never solved; it was removed, and §05 is now the ordinary answer: Authorization Code + PKCE against a Native application, one Allowed Callback URL of http://localhost:8787/admin/callback, identical on every desktop there will ever be. The old §04 listed exactly this and set it aside — "worth keeping in mind as an optional convenience later, never as the only path" — so this is that parked option picked up as a decision, which is what the design-doc convention is for. Its §03 warning is not deleted but inverted into the thing to watch: the hazard has moved from the design into a product constraint, stated plainly in §01 so a later reader knows what it is that must stay true. The Device Code flow is kept in full rather than dropped, because what rules it out is a product decision and product decisions move. Two things the rewrite adds rather than restates. PKCE is load-bearing here rather than ceremony: the code arrives over plain HTTP on loopback, where another local process may be able to read it, and the code_verifier never leaving the vault's memory is the whole reason that is survivable. And §11 answers the SSO question, which is the natural next thought and has a counterintuitive answer — an Auth0 session removes the password prompt, never the redirect-URI registration, and that check runs first because it is the open-redirect defence. What actually carries identity from one vault to another is an audience-scoped access token the local vault presents to the remote one, which is the same audience parameter the refresh-token spike has to settle anyway, so §06 and §11 now name one experiment rather than two. §09 gains what was missing: openid-client and jose are already in server/node_modules as express-openid-connect's own dependencies, so the replacement is a promotion in package.json rather than a new dependency. Corrected while here — a Native application's dashboard does display a client secret field, which the old text denied; what matters is Token Endpoint Authentication Method: None, and that nothing in the flow ever sends one. Call-site counts remeasured against the current source.
The app's API Keys dialog and the vault's admin page each tell a player where to get a provider key, and they had drifted apart. Four of the app's links went to a company's front page rather than its key page — higgsfield.ai, elevenlabs.io, runware.ai, ai.google.dev — which leaves the finding to the player, and finding it is the part they cannot do. A fifth read auth.pollinations.ai and went to enter.pollinations.ai, so nothing broke and the wrong hostname was the one on screen. All five now match the admin page. The house rule is to interpolate a roster rather than copy it, and that is not available here: the app runs in a plain browser with no vault behind it, so it cannot read MANAGED_KEYS at load time. The copy has to exist. What must not exist is a copy nobody compares, so the comparison is now a test. It is keyed on the field ids the dialog already uses — setup-<provider>-key, the same ids the vault's roster is keyed on — which means a key field added for a provider the vault knows starts being checked without anyone remembering to add it. It runs one way only: the vault holds World Labs and the app has no field for it, and a missing field is silence rather than a contradiction. Two things beyond the URLs are pinned because they are how this went wrong rather than merely how it could: a link whose text names a different host than its href, and a link that is a bare origin dressed as a key page. Tripo is the written-down exception to the second, for the reason recorded beside it.
Regenerated from the full (non-shallow) git log after the checkout had drifted 109 commits behind origin/main — the report was still showing the Launcher-tray era. tools/gen-progress-report.js needs the complete history to group commits by day and compute the lines-of-code stats, which a shallow clone can't provide.
A rename, in the menu and in the two places that describe it. "Show Launcher" said what pressing it does rather than where it goes, which is the odd one out beside Play, Admin and Quit — every other item on that menu names a destination and lets the click be the verb.
Three things about getting a fresh install working, which is the case all of this was worst at. The tray menu gains Show Launcher. Play hides the landing page, and a hidden window has no taskbar button — so the surface carrying Play and Admin was unreachable while a game was running, which is fine until you want it back without closing the game. It sits below the two doors rather than above them: it is the way back to a choice, not a third thing to choose. The icon's own left click now runs the same function rather than a second copy of the same four lines, because the copy is the one that stops restoring a minimized window the day somebody edits the other. Settings opens on Keys instead of Server. Server led on the argument that the widest question about a machine you have just been handed is what it is — true of an inherited vault, and wrong about a freshly installed one, which does nothing at all until the Anthropic key is in. The panel moved to the front of the source with it, and the test that pinned "Server's panel is first" now states the property instead, so moving the leading tab again moves that assertion with it rather than failing it. And the provider names on that tab are links to where their keys are made. That was the step the page could not take for anyone: know which of ten companies you need, work out whether they keep keys under a console, a platform, a dashboard or a cloud, and find the page. The URL lives beside the provider in MANAGED_KEYS rather than in admin.html, so one added later arrives with its link attached — a list of ten links in the page would be right on the day it was written. Each is the page the provider's own documentation names; Tripo is deliberately the console root, because every source describes reaching its API Keys page from inside the console without naming the path, and a link landing one click short is honest where a guessed deep link would 404 on the person who most needs it to work. Only http(s) is ever rendered into an href, which is not a defence against anything reachable today so much as a refusal to build the shape. The tests run the real nameHtml rather than describing it, and check the roster for a provider with no key page, one on plain http, and one pointing at a host that does not bear its own name (Nano Banana is the written-down exception — it is Google's model and its key is a Google AI Studio key). Two existing assertions were rewritten rather than updated: one pinned "Access comes before the key cards in the document", which was the reason the scoping bug bit rather than the reason the scoping is needed and inverted the moment the Keys panel moved; the other read the absence of one exploit string and so passed against an unescaped href. This file's check() had also been accepting a cause argument and dropping it, so a failure said what was expected and never what was found.
Two things about the desktop wrapper, both about what is reachable and what is visible. Every window this app opens is frameless, and the launcher hides itself the moment the game appears. That leaves nothing to reach the app by once a session is under way: no menu bar is drawn, and the only surface carrying the Play and Admin buttons is off screen. Opening the admin page to top up a provider key meant closing the game first. So there is a tray icon now, with the same two doors plus the way out. Play and Admin call openGameWindow and openAdminWindow rather than reimplementing them, which is what makes a tray Play during a game raise that game instead of starting a second one beside a second vault; Quit goes through app.quit(), because that is what fires will-quit, and will-quit is what stops a vault we started. Closing the windows instead would leave the server holding its port with nothing on screen naming it. The Tray is held in a module-level variable on purpose — one referred to only from inside the function that made it is collected, and the icon vanishes seconds after it appears, which looks like a platform bug rather than a mistake. The 512px icon.png is resized on the way in, since a tray cell is 16 to 32 pixels and macOS clips anything over 22 points. A desktop with no notification area is survivable: the constructor is wrapped, the launcher window still opens, and the console says why. The landing page's backdrop arrived under a scrim that was darkest at the middle of the window, which is also the part of the picture with the square, the well and the guard in it. Read as a page with a hint of a picture behind it. The mask is gone and legibility moved into the type instead: every piece of text carries a two-part dark halo, which costs a few pixels around each letter rather than the centre of the image. Three things that were chosen to recede against a near-black page had to stop receding — the tagline and the window controls now read at the body colour, and the Admin button has a ground of its own rather than the village showing through its outline. The body gradients are kept although nothing normally sees them: they are what the launcher falls back to with reduced motion set, or in a build where Images/ did not ship. The tray menu is lifted out of main.js and run against stubs, so the test says what each entry does rather than that the words appear; the backdrop section now pins the absence of a mask, including the shape of one added back as an ordinary element rather than as ::after. Both were checked against twenty-one sabotages, each caught by a distinct named assertion, and the tray was confirmed to build under a real Electron launch.
The landing page was a title over a gradient. It is the first thing anyone sees of this game, and what it showed them was a colour. Now it shows the place — VillageSquareDay behind the title, covering the window at any size, named by a relative path that resolves both in a checkout (Electron/ beside Images/) and in a packaged build (app.asar beside resources/Images), so the launcher does not look one way in development and another way once installed. The scrim over it is the half that took the work. The square at midday is a bright picture and this page is gold text and a thin gold-outlined button; laid straight over it, the title loses its glow and the tagline stops being readable at all. So the image sits at just under half opacity, warmed and pulled toward candlelight so it reads as the same world rather than a window cut into a different one, under a scrim darkest at the centre — which is exactly where every word on the page sits. The two gradients that were already there are kept and layered on top rather than replaced: they are what carries the glow behind the title and the darkness at the foot of the page. It is an animated GIF, and CSS cannot pause one, so prefers-reduced-motion drops the moving layer and leaves the gradients — the page still looks like itself without it. The Content-Security-Policy had to widen for this, and nothing was pinning it, which is exactly when a policy needs a test rather than after. It now permits img-src from file:, which is where this page already lives, and the test asserts what matters is not that it is strict but WHICH way it was widened: still default-src 'none', still no http(s) source of any kind. The launcher is the thing that works when the vault is down, and the moment it can fetch from the network it stops being that.
The windows carried no icon at all, so every surface that shows one — the taskbar, alt-tab, the installer — drew Electron's. They wear the crossed swords now: the same mark the page links as its favicon and the web manifest uses, rendered from the same icon.svg rather than redrawn, so the desktop app and the browser tab cannot drift apart. The committed favicon.ico is 32x32, which is fine for a window and far too small for an installer, so the icon is rendered at 512 from the SVG by `npm run icon` — a small Electron script, because Electron is already a dependency of this project and the alternative is asking every contributor to install an SVG rasteriser to regenerate one file. It is the same Chromium that draws the SVG in the app, so what the installer shows is what the page shows. Transparent ground, or the rounded corners the SVG draws would be squared off by the capture. electron-builder derives the Windows .ico and the macOS .icns from that one PNG. Two places needed it separately. WINDOW_SHAPE covers the launcher, the game and the admin window; the POPUPS the game opens — the detached Editor, the tab viewers, the Field Guide, the Handbook — build their options in setWindowOpenHandler instead, so without naming it there too the app would have worn its mark and its own popups would have worn Electron's. And on Windows an icon is not enough. The taskbar draws the icon of whatever executable the windows are grouped under, which without an AppUserModelId is electron.exe — the usual reason an app with a perfectly good BrowserWindow.icon still looks like Electron in the tray. It is set, and pinned by test to the same id the installer registers, since a build that declares one identity and runs under another groups as two applications.
Padding the page down by the bar's height moved the CONTENT clear of it and did nothing for the scrollbar, because a document scrollbar is drawn against the viewport rather than against the page: the track ran the full window height with its top 38px sitting behind the bar, which reads as a scrollbar sliding under the window frame. So the shell insets the page instead of padding it — body becomes the scroll container, fixed to the viewport 38px down. The track then belongs to a box that begins where the bar ends. The controls are unaffected despite now sitting inside a position:fixed body, because a fixed ancestor does not become the containing block for fixed descendants (only transform, filter or contain would); that is written down beside the rule, since it looks exactly like the kind of thing that would break. Only the shell does this, and only because only the shell has a bar. admin.html goes on scrolling its document normally in an ordinary browser tab, where there is nothing above it to get behind — which is why the page now declares its scrollbar colours on html AND body: which element actually scrolls depends on where the page was opened. Measured in the running window: the scroll container is body, its box starts at y=38 of a 772px viewport, its gutter is 10px, and the bar and both controls stay at the viewport's top through a 300px scroll.
Minimize sits to the left of close, in the order every window on every platform keeps them, sharing its styling so the pair reads as one control — only the hover colour separates them, since one of the two is the one you do not want to hit by accident. It needed a bridge, which close did not: window.close() is native and minimize has no web equivalent. Rather than widen the shared preload — the empty one the GAME page loads — the admin window gets its own, exposing exactly one function. That preload IS reachable from vault-served content, which is the whole reason its surface is the question rather than an afterthought: a window minimizing itself destroys nothing, reads nothing, and is undone from the taskbar. The handler resolves the window from the SENDER rather than by name, so a page can only ever act on the window it is drawn in. The scrollbar was the last part of that window that had not been told what this app looks like: a bright Chromium-default strip down the side of a dark page. It is themed in admin.html rather than injected by the shell, because a scrollbar colour is a property of the PAGE — so a browser tab gets it too — where the drag strip is the shell's business precisely because it exists only to replace a frame the window does not have. Both syntaxes are declared: scrollbar-color is the standard property and what Firefox reads, the ::-webkit- rules are what Chromium actually honours, and the desktop shell is Chromium. Every colour comes from the page's own palette variables, so it keeps matching if the palette moves.
The drag strip was already there and already a drag region — and invisible, which is the same as not being there. A blank 38px margin says nothing about being grabbable, so the only way to discover it was to try dragging a part of the window that looked like nothing in particular. It is drawn now: a bar carrying the app's name with a rule under it, the same shape the launcher's own frameless titlebar uses, so it reads as the thing it is. The page's own title block drags too. That is what the game window has always done with its #header, and it is where a hand actually goes for a window — a strip above the visible header is not where anyone reaches first. The links inside it keep no-drag, or the ▸ Play link sitting up there would stop being a link and become a piece of the window frame. The close control was in the corner already but drawn at the launcher's own dim grey, which is legible there against a page background and all but invisible sitting on this bar. A close button you have to hunt for is one the window does not appear to have, so it is brighter, on the launcher's own 38x28 hit area, still reddening under the cursor like every other close control in this app. The test now compares the strip's height against the body padding rather than naming either, since they are one number written in two places and a mismatch either overlaps the page's first line or leaves a gap above it. It also reads the close control's colour and checks it is actually bright, which is the property that was wrong rather than the property that was missing.
Start is now Play, and an Admin button sits beside it opening the vault's own admin page. That page is where the provider keys go, which on a fresh install is the FIRST thing anyone needs and until now meant knowing the vault's URL and typing it into a browser — a strange thing to ask of someone who has just installed a desktop app whose entire purpose is not having to do that. Both buttons start the vault if it is not already running, since both are served by it. Admin deliberately does not hide the launcher, which is the one way it differs from Play. It is somewhere you go to set a single thing and come back from, and what you come back to is the Play button that was already on screen; hiding it would mean closing Admin to reach a button that had been visible the whole time. It is a window of its own rather than a mode of the game window, so a DM can leave it open beside a running game, and pressing Admin again focuses the one that is open rather than stacking a second. Borderless costs something on this page that it did not cost on the game's. The game supplies its own #header, which the shell has always made the drag handle; admin.html is a plain document written for a browser tab that provides the frame, so frameless it would be a window that cannot be moved and cannot be closed except through a menu accelerator with no menu bar drawn. The shell lends it both — a drag strip across the top and a close control — injected the same way and for the same reason as the game's drag handle, with nothing baked into admin.html, which still has to work as an ordinary page in an ordinary browser. The injection runs on every load rather than the first, because the admin page navigates within itself (signing in, and its own Play link back to the game), and chrome applied once would leave the window stuck the moment anyone logged in. The page-wide Enter/Space shortcut now steps aside when a button has focus. It was written when there was one button and meant "start"; with two, Space on a focused Admin button launching the game instead is the worst available answer to a keypress.
Pressing Start loaded a URL and hoped somebody had already started the server behind it. On a fresh install nobody had, so the first thing a new player saw was a window explaining that they needed to open a terminal — which is the one thing a desktop app exists to spare them. The shell now starts the vault itself and waits for it to answer before opening the game window. Nothing about where the game comes from changes: it is still fetched from the vault over HTTP exactly as a browser fetches it. What changed is who starts the server. It runs server.js with the app's OWN binary as a Node interpreter (ELECTRON_RUN_AS_NODE), which is what makes this worth doing rather than writing better instructions — a packaged build then needs no Node on the player's machine at all. The vault refuses to run without an access token, since without one its provider proxy would be an open relay, and nobody is going to export a token to press a button; so the shell mints one on first run and keeps it. Every path the vault writes to — the encrypted key store, settings, worlds, generated media — is redirected under userData, because a packaged app's own directory is read-only on macOS and Windows and the vault otherwise writes beside server.js and fails on the first save. An operator who has configured any of those themselves keeps theirs; each is only supplied when absent. Three cases decide against starting one, and each would be actively wrong rather than merely unnecessary. A vault URL naming anything but loopback belongs to whoever runs it, and spawning a local server would bind a port nobody asked for to serve a realm nobody visits. A build that shipped no server/ has nothing to start, so it falls back to exactly the old behaviour — which is also how you still build a thin client for a hosted realm, by dropping the extraResources block. And a vault already listening is attached to rather than duplicated: a developer running one in a terminal beside this gets THAT vault, and still has it running after quitting the app, because stopVault can only reach the child it recorded and never anything it merely found. The window opens only after the port answers, which is the connection-refused page this exists to prevent; a vault that will not start gets an explanation naming whether it timed out or exited, with the server's own last words, since a missing key or a port in use is in that stderr and nowhere else the player can see. The launcher narrates the wait and holds its button while it lasts — a Start button that goes quiet for three seconds reads as one that did not work.
The header now reads glyph, condition, band -- SNOWBOUND · FRIGID -- and then the clock and the time of day, so the band sits beside the sky it belongs to. Its own span rather than more text in hw-label, so the sky stays the headline and the band reads as the qualifier; quieter in text-dim against the condition's gold-dim, with the separator hanging off :not(:empty) so a state carrying no band leaves no orphan dot. The clock tick clears it as well as writes it, because a stale band beside a new sky is worse than none. The second half is the fix, and it is NOT the one this commit's author first proposed. A condition carries baseTempShift -- snow -2, heat +2, clear +1 -- authored on all eight built-ins, normalized on load, editable in the Conditions editor as "Temperature shift", and described to the model during world generation as how the sky moves the temperature. resolveWeather never read it. The condition object was in scope and used for its glyph, name, description and wetness, while the temperature came only from the day-type's declared band bent by the climate. A DM setting -3 on their ashfall saw nothing change. The obvious fix is to add it to the bend, and it is wrong. A day-type that names a band has already answered the question -- whoever wrote "Snowbound, frigid" knew snow was cold -- so the shift prices the sky in twice. Measured on the built-in patterns before writing any of it: applying it unconditionally moves 9 of the 12 authored day-types, and because each then trips bandMoved, all 9 also LOSE their authored labels. "Spring rain" becomes "Rain" and resolves to cold, contradicting its own description, "a steady, mild rain". So the band is three-state, exactly like `effects` on the same record and for the same reason: name it and it is used, omit it and the sky decides. That is the case baseTempShift had been waiting for, and there had never been one, because normalization turned every absent band into 'mild' -- making "the author said mild" and "the author said nothing" the same record. That is the whole reason a documented, editable, model-facing field could sit unread for as long as it did: not that someone forgot to call it, but that the data could not express the question it answers. Nothing an existing world authored moves. Every day-type already on disk carries a band (normalization put one there), so they all resolve exactly as before; the shift decides only for day-types authored from here on that leave the band out. The day-type editor grows a "— from the sky —" option so the third state can be chosen rather than only imported, a new day-type starts on it rather than silently declaring the middle band, and the card summary prints "temp from the sky" so an omitted band reads as a choice and not a gap. The three places that describe the field to a DM or to the model now say when it applies; the old wording was what a reader would act on and what it never did. The test drives resolveWeather rather than recomputing the climate bend, and that distinction is the test's own bug report: written the short way first, the guard against the naive fix passed against the very implementation it exists to reject, because a check that recomputes the bend itself never reaches the line that does it. Verified in both directions -- the field going dead again, and the naive fix -- and the second now names all nine days and the band each would move to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The section read Prompts › Prompt, which names the container and then the container again while never saying what is being described. Every other card with the identical section — Spells, Races, Factions — calls it Portrait, and that is the word that carries information: the heading says these are prompts, the subsection says this one is for the picture.
A skill record has carried `image` and `prompt` since the day it was written, and nothing anywhere drew either. So a skill card was a wall of text on a tab where every neighbouring card — items, spells, races, factions, encounters — carries a picture, and the two tabs whose entire job is "what does this world look like" and "what is still missing" could not see skills at all. The card joins that family rather than inventing anything: a portrait column with Generate, Upload, ♻ and enlarge, and a Prompt field with the ✨ that asks the GM to write one. Pressing Generate with the prompt empty writes the prompt first and then paints, which is what the Item and Spell cards already do and is the one thing a DM pressing Generate actually wants — a button that answers "add a prompt first" has understood the request and declined it. ♻ deliberately does NOT do that: you pressed it to get a different take on the prompt that is there, and there isn't one, so it says which field to fill. The GM's directive says what a skill IS, because the generic one has no way to and the answer is not obvious. A skill is a PRACTICE — not an object and not a person. Asked without that, "Lockpicking" comes back as a picture of a lockpick and "Swordsmanship" as a portrait of a swordsman: a thing and a face, in a gallery whose other cells are already full of things and faces. So the directive asks for the craft being exercised and rules out the two answers it would otherwise give. Every write goes to world.skills rather than through skillCatalog(), which falls back to the built-in set when a world defines none. That distinction is the whole reason worldSkillList exists beside allSkills: a prompt written through the fallback lands on a module-level record shared by every world the session opens afterwards and saved by none of them. Both Art tabs read that same function, so the Missing list and the Review gallery cannot start disagreeing about what this world contains. Five neighbouring art tests needed updating, and one of them needed fixing rather than updating: it asserted that spells were listed LAST in the batch work list, which stopped being true the moment a section was added after them. What it meant is that a section's cards form one unbroken run at the place the section sits — position among the sections, not position from the end — since that run is what the mid-generation pulse indexes into.
The popup a Requires chip opens was landing in the top-LEFT corner of the Skills panel, on top of the card whose chip had just been clicked. The shared popup rule sets position and a top offset and nothing else — the horizontal inset is per-id, every other detail popup in the app declares one, and this one did not. So it does now, and the Skills panel is already the positioned ancestor it is measured against. The popup states what the skill requires whether or not it requires anything. It used to render that row only when the list was non-empty, which made "this skill needs nothing" and "this popup does not discuss requirements" look identical — and left a DM who had just authored a prerequisite on the card beside it unable to tell from the popup whether the edit had landed. It reads the list through skillPrereqIds, the same function the editor's Requires section reads and writes, so what the card shows and what the popup shows are provably one list rather than two readings of one field. The Character › Profile sheet's Skills glance now opens that same popup instead of jumping to the Skills tab. Sending someone to a list so they can find again the row they just clicked is the long way round to what the click asked for, and the popup answers what the glance has no room for — the DC, the point cost, the mastery, what it requires. That needed a popup on the Profile subview, a sibling of the character sheet (which is rewritten whole on every render) with the subview made its containing block, which is exactly the pair the Equipment subview already carries for its own. One neighbouring test needed fixing rather than updating. It pinned four popup ids as a CONTIGUOUS run inside the shared selector list, so inserting an unrelated fifth between two of them failed an assertion about something else entirely — a true statement about the CSS breaking a test that meant to say only that those two ids were in the rule. It reads the selector list and checks membership now, which is what it was always trying to say.
The strip read Taxonomy then Catalog while the panels above it were laid out Catalog then Taxonomy and the tab you land on was Catalog — so the one place the eye goes first named the thing you were not looking at, and the strip ran backwards against its own panels. Swapping the two buttons costs nothing and puts all three in the same order. The order was not pinned by anything, which is why it could drift in the first place: the test checked that each button existed and carried the right classes, and would have passed just as happily in either sequence. It now reads all three lists — the declared ITEMS_INNER_TABS, the buttons in the strip, and the panels above — and compares them against each other rather than against a sequence written down in the test. An expectation spelled out there would have to be edited to add a third tab, and that is exactly the moment somebody edits it to whatever the code happens to say.
Reported as: the Edit button hovers pale grey where every other bordered button in the app hovers gold. The rule read `color: var(--accent); border-color: var(--accent)`, and --accent is defined nowhere in the file and carried no fallback. An unresolvable var() does not leave the property at its previous value -- it makes the whole declaration invalid at computed-value time, which is `unset`: colour falls back to what it inherits and border-color to currentColor. So the hover was not the wrong colour, it was ABSENT, and absent is indistinguishable from a deliberately understated one. Now --gold on both, mirroring the Remove button beside it, which takes --danger on both; the pair is deliberately the same shape and differs only in what the hover promises. Auditing the file for the same fault found one more: --font-body, asked for by seven rules and defined nowhere, so seven font-family declarations were doing nothing. All seven happened to inherit the mono stack and therefore looked right -- the declaration doing the work was the one that was not there. Defined now as a second name for --font-mono, which is what the app's running text actually is. Measured in the browser before and after: all seven already computed to 'Fira Code', so this changes no pixel and makes seven dead declarations mean what they say. The test is the point of the commit. A typo'd or renamed token produces no console warning, no parse error and no visual that reads as broken -- only a rule that quietly is not there, which is why this one survived review and shipped. So: every var() in the stylesheets must name a property this file defines, or carry a fallback. A fallback is accepted because the author has then said what to do when the token is absent, and a dozen tokens here are named that way on purpose (--danger among them); what is also checked is that the fallback RESOLVES, since `var(--a, var(--b))` with --b undefined is the same silent failure one level down and reads more carefully than the bare form it hides. Verified by re-introducing all three: the reported bug, the missing --font-body with its seven users left behind, and a dead fallback chain. Each is caught by a distinct assertion naming the token and where it is used. The test also had to blank CSS comments before scanning -- its own explanation quotes `var(--accent)` in prose, and without that the account of a bug is indistinguishable from the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Filter on the left, New / Export / Import and Collapse all / Expand all on the right, and "+ New type" becomes "New" sitting where the Catalog's New sits -- it does the same job there, the DM writing the thing themselves. Its bar is now laid out by the shared corner-toolbar rule and its panel scrolls by the shared view rule, whose 46px of top padding was cut for exactly this bar; the two private rules it had while it held a single corner button are gone. The result is pixel-identical to the Catalog's: same rect, same filter box, same handler names. There is no "+ Add" beside New, which is the one place this row deliberately differs. A type is a decision about what KINDS of thing a world contains, and asking the Game Master to invent one asks the wrong author. The FILTER reaches inside a type rather than across the thirteen headings -- it searches the name, the id, what the type means and every facet label it offers, so "rapier" finds Weapon. That is the question the screen exists to answer and a search over headings could not have answered it. Two consequences follow from searching bodies: a card that matched is shown OPEN, because returning it collapsed would hide the word that put it on screen, and the universal-labels block drops out during a search, because it belongs to no type and a block that never moves reads as a result that did. EXPORT carries this world's own types and never the built-ins. Every world already has those, an import of one is refused, and a file carrying all thirteen would read as though the whole vocabulary were portable when only the additions are. Import skips a built-in by name and says which -- a file that redefined "weapon" would change what damage dice mean and call it an import -- and an imported type gets its icon, for the same reason the New dialog defaults one: the type dropdowns are built from the icon map, so a type without a glyph is authorable by the GM and unpickable by the DM. Two things worth knowing about how open/shut is now decided. It is a collapse set like every other tab's, registered in EDITOR_CARD_VIEWS so an individual card the DM opens is recorded -- previously a ternary on "is this the world's own type", which no redraw could preserve. The set is SEEDED with the built-ins rather than starting empty, so a world's own types still start open and the thirteen built-ins do not bury the one just made; seeded only from the absence of a saved entry, so a built-in the DM opens stays open across a reload instead of being re-seeded shut under their hands. And the cards' container is static markup rather than something the renderer builds. The collapse listener is attached to it once at startup, so a container rebuilt on every draw would have taken the listener with it on the first one, and every card opened by hand would be forgotten by the next redraw. That is the same failure the EDITOR_CARD_VIEWS registry was written to prevent on five other tabs; it was available here in a new shape because this tab renders a wrapper the others do not. Verified in the browser end to end on a scratch world: filter narrows and opens what it found, expand and collapse all move all thirteen, a hand-opened card survives a redraw, a new type arrives open with its "this world" badge, and the exported file re-imports as an update rather than a duplicate while a built-in and a row with no id are both refused by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Skills have carried a `prerequisites` list since the acquisition layer shipped, and the only way to write one was to hand-edit the world JSON. That is why the four built-in tier-2 skills were the only gated skills any world ever had: the skill tree draws its edges from exactly this field, so a DM who authored an advanced skill of their own got it rendered as one more tier-1 node in a flat row, with no way to say what led to what. The Skills card now carries a Requires section built like the Classes row beside it — chips for what is set, a select of what could be added, one + Add — rendered even when empty, because an empty list is exactly where someone goes to fill one. The chips here ARE clickable, which is the one deliberate difference from Classes. That row carries neither the link decoration nor a click, because a class has no popup to open and the decoration would promise something that does not happen; a skill does have one, so a Requires chip opens the skill it names. That needed a popup on this subtab, a sibling of the card view rather than a child, since every add and remove re-renders the view and a popup inside it would vanish the moment it was used. The part worth the care is the loop guard. Two skills that require each other are not merely odd: skillPrereqsMet asks whether the character HOLDS each prerequisite, so neither can ever be the first one acquired and both are locked out of the game permanently — and the tree renders that pair quite happily, as two nodes that never light with nothing anywhere saying why. It is the one edit this section makes possible whose damage cannot be seen in its result. So the picker offers neither the skill itself nor anything that already depends on it through any chain, and the writer refuses the same pairing again rather than trusting the picker, because an import, a GM edit or a hand-written world all reach that function directly. The walk carries a seen-set rather than a depth limit, so a world that already contains a loop terminates instead of hanging the editor on the way in. Every write preserves the `minLevel` sitting beside the skills list. Nothing on this card asks for it, so a write that replaced the whole prerequisites object would discard it silently — a character-level floor authored in one place, dropped by a click on an unrelated chip in another.
Reported as "the Items tabs do not match the Magic tabs, and Catalog shows no selection". Both are the same omission. Eight screens share one appearance -- World, Art, Environment, Map, Magic, Player, Entity, Weather -- spelled as six selector LISTS, so joining a ninth means editing six places. Items was joined to the tab rule, the strip and the panel, and left out of :hover and .active. Catalog WAS the selected tab and had been since the markup was written; there was simply no rule that draws a selected one, so both sat dim and the screen read as having no selection at all. What hid it is worth recording, because it is the general hazard of a shared-look rule spelled as a list. Three private .itm- rules had been written alongside -- container, body, active panel -- and those made the panels switch correctly. A screen whose tabs visibly work is a screen nobody thinks to check the CSS of. The private copies are gone; all five properties come from the family now, and the computed style of an Items tab is byte-identical to a Magic one, gold rgb(201,168,76) with a 2px gold top border. The test asserts the FAMILY rather than Items. It derives the members from the tab rule itself and requires each of them in each of the six, so the next screen to grow inner tabs is covered before it is written, and a failure names the rule the member is missing from instead of reporting that a screen looks wrong. It also refuses a private copy of any rule the family shares, which is the tell that let this one through. Verified by re-introducing exactly what shipped: four assertions fail, naming the two missing tab rules, the missing panel rule, and "private: itm". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Preparing a loadout is the most expensive thing a caster does with the clock. An in-world hour per point of MP means a full book of mid-level verses is most of two days, which is the tension the loadout is built on — but it left casters with nothing to spend a skill point on that touched their worst cost. So Speed Reading: a tier-2 skill gated behind Spellcasting that halves what a memorization takes. It names no class list of its own, and that is deliberate rather than an omission. Spellcasting is this app's one HARD gate, so a class outside it can never hold the prerequisite and therefore can never reach this at all — the gate is already absolute one rung down. Restating that class list here would add nothing except an off-class PENALTY, and since the skill's whole effect is a flat halving rather than a proficiency roll, that penalty would read as weaker in the UI while doing precisely nothing. The change that mattered was not the skill record, which is one line. It was that the rate had been written out four times: memorizeSpell charged it, applyMemorizeSpells charged it again for a whole preparation, the Memorize button's tooltip quoted it, and the dossier that tells the Game Master what re-preparing a lapsed loadout would cost quoted it too. Only the first two move the clock, so the two that merely display it are exactly the ones a halving would have been forgotten in — and the symptom would not have looked like a missing skill. It would have looked like a broken clock: a button promising twelve hours, a banner sweeping six, and a GM steering the player away from a stop that had already got cheaper. So the rate now lives in spellMemorizeHours and every one of those four asks it. Halving an odd MP cost yields a half hour, which the clock has always been able to carry but the prose had not: durations were printed as bare numbers, and "2.5 hours pass" is a decimal where the rest of this app writes words. fmtHoursSpan spells it out. A whole number reads exactly as it did.
TAXONOMY shows the vocabulary the engine actually reads: thirteen types, each with its facets, and each
facet marked LEAD (the thing it fundamentally IS) or DRIVES (it changes what the engine does -- a finesse
weapon draws on DEX) with the rule printed underneath. A tree rather than a graph because the data is one:
a type owns facets, a facet owns values, nothing crosses between branches. Drawing edges for a structure
with no cross-links would add lines and no meaning.
CATALOG is everything that was on the tab before, and it is where you land. Opening Items to find a
taxonomy diagram where the cards used to be is a surprise nobody asked for; the vocabulary is a reference
you go to, not the thing you arrive at.
"+ New type" writes to world.itemTaxonomy, never to the built-in list -- a world cannot redefine "weapon"
out from under an engine that branches on it by name, only add beside it. Two properties decide whether
that is a real feature or a decorated dead end, and both are the same shape: the type has to arrive
somewhere a second system is already reading.
- It must reach the PROMPTS. Four of them hand a model the type roster, all four reading a constant
computed at load. A type added afterwards would show in the tree and in the DM's dropdown while the GM
went on being told it did not exist -- the DM could pick it and the world's own narrator could never
mint one. The live roster is a function now; the built-in roster stays a constant, because "what may
this world use" and "what does the engine branch on" are different questions.
- It must reach the ICON MAP. Every type dropdown in the app is built from ITEM_TYPE_DEFAULT_ICON, not
from the taxonomy, so a type with no glyph is authorable by the GM and unpickable by the DM. That is
the exact hole `contraption` sat in until last week.
Verified end to end in the browser on a scratch world: added "Tide Bell", and it reached the tree with its
"this world" badge, the type dropdown, itemTaxonomyPrompt with its forms intact, the DM meta prompt, and
serializeWorld. All four refusals -- built-in, duplicate, nameless, idless -- fire with their own sentence
and add nothing.
THREE TESTS failed on this and none of them was about it. Each pinned a proxy for what it meant to assert:
two took src.indexOf('item-ed-head') as "the item editor's header" when a second dialog now reuses those
classes, and one banned the word "interchangeable" across the whole file to stop a prompt claiming
weapon/sidearm are the same slot -- caught by a source comment about facets. All three now say what they
meant. The taxonomy tests that pinned the constant names were updated the other way: the property is
unchanged, and the source it derives from is what widened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvNaming every destination gave away the map. The world map reveals rooms by `visited` and by nothing else, so a player who had walked three rooms could stand at a crossroads, hover four chips, and read off four room names the map would not draw for them — the layout arriving through a tooltip instead of being earned by walking it. So the name is withheld until the destination has been stood in. It is the same gate the map already applies, read at the other end of the exit. The gate is on the DESTINATION, which is the part worth stating, because reading it off the room being looked out of would have looked entirely correct in play: you are always standing somewhere you have visited, so every exit would name itself from the first turn and nothing would ever appear to be withheld. Walking in is also the whole of the reveal — describeRoom already sets room.visited, and neither surface needed to be told anything. A descent is deliberately not gated. It names a dungeon rather than a room, dungeons carry no visited flag of their own, and roomExitEntries already writes the dungeon's name into the description the tooltip shows underneath, so there was never anything there to withhold. An unvisited exit now reads exactly like a broken one and like a descent into a deleted dungeon: the direction, and what it does, with no header at all. That sameness is the point rather than an accident of the implementation, and it is asserted — a distinguishable "not yet" would mark which unexplored ways out lead somewhere the world has a name for, which is a smaller version of the leak this closes.
The exits told the player which way they went and nothing about where they arrived. "NORTH" is a direction, not a destination, and the map only reveals rooms already visited — so a player standing at a crossroads with four chips had no way to tell the way back to the inn from the way into the woods except by walking one and reading what happened. So both surfaces that draw the exits now name the room on the other side of them: the chips under the room description, and the badges in the sidebar's Exits block. The destination is resolved by one function that both call. They are drawn by unrelated code — the story chips are built as markup, the sidebar badges as DOM nodes — and they had nothing in common but a shape, so a second copy of "where does north go?" was the obvious thing to write and the obvious thing to have drift. It also has to answer for a derived Descend, which carries a dungeon id and no `to` at all: looked up against world.rooms it would name nothing, on every entrance room in the world. Where a destination cannot be named — an exit pointing at a deleted room, a descent into a dungeon that has gone — it returns nothing and the tooltip simply keeps the line it always had. A broken exit is world data to fix in the Editor, not a word to show the player mid-sentence. The tooltip is the app's own themed one rather than a native title, which is what puts it above the chip: showAppTooltip measures and places above, falling back below only when the viewport leaves no room, and it is also the single funnel that Settings › Interface › Toggle off Tooltips gates. The destination goes in the bold header with what the exit does underneath, because as body text it reads as more of the same sentence rather than as the answer to "where?". It goes into the accessible name too, after the visible label and never instead of it, so a screen reader reaches what a hover reveals while voice control can still address the chip by the word printed on it.
The persistence shipped doing nothing. The restore path clears the weathered-banner cache -- rightly, so a save written before references existed cannot drag megabytes of old images back into memory -- and the restore call had been placed above it. The references were put back and wiped by the very next statement, so the banner regenerated on every reload exactly as though nothing had ever been saved. Moved below the clear, where the two now read as one thought: start empty, then take back the references. restoreWeatheredBanners accepts only references, never bytes, so the danger the clear guards against cannot return through it. The suite stayed green through all of this, which is the more useful finding. Every test asserted the PIECES -- the snapshot carries refs, refs come back, bytes never persist -- and none asserted their ORDER, so two correct halves in the wrong sequence passed everything. The new assertion pins the order and was verified by re-introducing the bug: it fails, and passes again once fixed. It also refuses any later clear, which would be the same bug one statement further down. Second time today an ordering bug has produced a "works sometimes" symptom with a fully green suite -- the other was the opening banner not weathering on login. Worth assuming, for anything that both clears and repopulates shared state, that the pieces will be tested and the sequence will not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Clamping them to the viewport stopped them covering their menus but left them ON the buttons rather than above them. The measurements say why: the game's top row sits 57px down, a one-line tooltip is 37px and needs 46, and a two-line one is 55 and needs 64. A wrapped tooltip cannot fit above at all, so clamping could only ever park it on the button. The two are two lines only because of their width. .app-tooltip.prefer-above now gets 480px, measured: "Tools -- maintenance actions for this save" needs 319px on one line and the longer Library string needs 470. Width rather than shorter text, because the text was one character from fitting -- adding a full stop to "Maintenance actions for this save" put it back onto two lines. A rule that depends on nobody ever lengthening a sentence breaks quietly, and the wording is worth keeping. Anything longer than 480px still wraps, and the clamp remains the backstop; it is just no longer what these rely on. The class is applied BEFORE the box is measured. Set after, the height would be read at the old width and the tooltip positioned as two lines tall, then render as one -- which my own comment warned about while the first attempt did exactly that. Verified across the whole row: all eight tooltips are now 37px and fullyAbove true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The cache was keyed by roomId alone, three rooms deep, and never saved. Every repeat was a fresh image-to-image call: step out of the rain and back in and the sunny version had overwritten the rainy one; walk through four rooms and the first fell out; reload and all of it went. Seconds and money each time, for a picture that had already been painted. Now keyed by the WHOLE weather identity -- room, condition, time of day, style bit, base image -- through one weatherKeyFor() that the cache, the in-flight guard and the change-detector all share. Spelled out separately, those three could disagree about what counted as the same picture. TWO CAPS, because the entries are not the same kind of thing. A REFERENCE (a vault media URL) is a short string: held 60 deep and written to the save, so a sky painted in an earlier session is served from the vault rather than repainted. A DATA URI is the image itself: as scarce as it always was at 3, and never persisted. That was the whole reason this cache was not saved before, and it stays true exactly where it was earned -- in local-only mode every entry is of that kind, which is the transient case. The two kinds evict separately, so a run of references cannot flush the inline images or the reverse. And restoreWeatheredBanners refuses a data URI on the way back in: nothing should have written one, and an older or hand-edited save must not smuggle megabytes into memory. Four tests needed updating, and three of them had encoded the old decision. The cache-bounds test asserted "the snapshot carries no weathered cache", which is the thing this reverses; it now pins the distinction instead, and keeps the line that still holds -- no data URI ever reaches the save. Two fixtures in the imagery test seeded a deliberately STALE entry and my mechanical conversion turned them into matching ones; they seed a genuinely different key now. And the art-style test pinned a record-field comparison that no longer exists, since the style bit is a segment of the key rather than a field compared beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Item Editor could write an item and never touch it again. Everything the dialog asks for — the type, the slots, the damage dice, the value, the two descriptions — was editable exactly once, at creation, and afterwards only through whatever the card happened to expose. A DM who mistyped a weapon's dice, or wanted an item's value revised after playing with it, had to delete the entry and write it again from nothing, losing its portrait and its lore along the way. So the card grows an Edit button, left of Remove where a mis-aimed click lands on the harmless one, and it opens the same dialog pointed at the entry instead of at nothing. Three things about editing are genuinely unlike creating, and each of them fails quietly. The first is that the dialog does not show the whole item: its own closing note says the portrait prompt, the effects, what it teaches, its abilities, its discovery gates and its lore are all edited on the card. A save that rebuilt the entry from the form would delete every one of them, and the only evidence would be a dialog that closed without complaint. So the save overwrites an allow-list of the fields the dialog actually draws and leaves everything else standing — the right way round, because a field added to items next year is then carried through untouched rather than wiped by an editor that has never heard of it. The second is that a blank box means the opposite thing here: on the create path an empty Condition means "take the default", and on this one it means "I deleted what was there". The third is renaming. Renaming is the one that would have hurt. An Item instance carries no catalog id — an instance and its entry are joined by NAME, which is why creating already refuses a duplicate. Rename the entry alone and every sword already standing in the world stops resolving to it, losing its icon, its description and its effects, with nothing on screen to say anything happened. So a rename sweeps the world and renames the instances with it, by the same walk Remove uses to find them, recursing into containers exactly as that one does; the report says how many placements moved. The catalog KEY is deliberately left alone, because quest rewards and entity inventories point at items by id and the id was only ever a slug of the name at the moment of creation. A rename is refused outright when another entry already shares the OLD name, since the sweep could not then tell the two sets of instances apart. Smaller things that follow: the report goes to the subtab the card was opened from rather than always to the Items line, which is why ITEM_EDITOR_KINDS now carries each tab's output element; the picture is filled from the entry on open and shared out on save only when it actually changed, unlike the create path where there is nothing yet to share it with; and an edited item is not moved to the front of the list, because a DM editing the fortieth item wants to find it in the fortieth place afterwards.
Reported as Tools and Library showing their tooltip below the button, over the dropdown, while the rest of that row shows it above. Nothing about those two buttons was different -- only the sentence was. showAppTooltip places above unless there is no room: top = r.top - tr.height - 9, flipping below when that goes under the padding. Measured on the row: the short tooltips are 37px and fit; Tools and Library wrap to 55px, do not, and flip down onto their own menus. Same row, same kind of control, opposite behaviour, decided by how long the title happened to be. Two changes, because placement alone cannot finish it. A menu button now prefers above and CLAMPS to the viewport rather than flipping -- safe because .app-tooltip is pointer-events:none, so a tooltip nudged onto its own button cannot steal the hover showing it. And while the menu is actually OPEN it shows no tooltip at all, since a button flush against the top of the window has no room above and even a clamped tooltip reaches over the first item. Once the menu is showing, the tooltip has nothing left to explain. Both key off aria-haspopup="true" rather than a new attribute. Every menu button in the app already carries it, and every one of those menus is positioned top: calc(100% + 6px) -- downward, without exception. A bespoke flag would have to be remembered on the thirteenth button; this cannot be forgotten. data-tip-place="above" remains as an escape hatch for anything that needs it without a popup, and .active alone suppresses nothing, since plenty of things carry it. Everything else still flips below when there is no room above. Only menu buttons opt out. Browser-verified in the detached editor, where the row is flush to the top: closed shows above, open shows nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
startGame calls updateRealmCalendar() while setting the world up, which calls maybeRefreshWeatherBanner(). That is enough on the RESTORE path, which re-renders the stored narrative -- banner nodes and all -- before the clock runs. On login the opening scene has not been printed yet. currentShownBanner is data-driven, reading room.getBannerImageFor rather than the DOM, so the key computed happily and the weathering ran. But it applies through patchLiveRoomBanner, which patches the LIVE DOM: the paint landed in the cache and the patch found no node. describeRoom then wrote the plain base image over the top. Worse, that first attempt CONSUMED the key. _lastRoomWeatherKey was left holding the current state, so every later clock tick saw "nothing changed" and did nothing -- the scene stayed plain until the time-of-day bucket rolled over. That is the whole shape of "works on reload, not when logging in". Fixed by clearing the change-detector and asking again after describeRoom. Both statements are needed: the ask alone would be the same no-op, because the key it compares against is still the one the earlier attempt consumed. It is also the file's own idiom -- setRoomInterior already does exactly this pair when reclassifying a room changes weather-art eligibility. The earlier updateRealmCalendar() call is untouched. It settles the clock, NPC routines and much else; moving it would be a far larger change than this bug needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A gear. It had been a taxonomy type with no entry in ITEM_TYPE_DEFAULT_ICON since it was introduced -- and because that map is what fills the Item Editor's type dropdown, the type was offered to the Game Master and unreachable by the DM. The test now pins the invariant instead of recording the exception: every taxonomy type has an icon, so none is invisible in the editor. The reverse is deliberately not required -- the icon map is a superset, because potion, ring, amulet and friends are real authored types that carry no taxonomy entry of their own. Browser-verified: 25 options, contraption between container and drink, and no taxonomy type missing from the dropdown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The dialog was laid out as a 200px thumbnail with the name and type wrapping beside it, and then everything else — Details, Subtypes, Classes, both descriptions — running the full width underneath. That put the two things a DM is actually reconciling, the picture and the numbers, at opposite ends of a scroll. Judging whether four and a half pounds and a value of 450 suit a barbed boat-hook means looking at the hook, and the hook was off the top of the pane by the time the grid was on screen. So the picture is 400px now, and everything that describes the item AS AN OBJECT — the header and the Details grid — stands in one column to its right. The column's width is the Name field's width, which falls out of the header wrapping: Name takes a row of its own and Type and the Magic flag wrap beneath it, so the grid below inherits the same edge rather than the width of all three fields together. The grid drops from three columns to two, because three numeric fields in the ~340px a column leaves would have made "AC Bonus" narrower than its own label. Subtypes, Classes and the descriptions still run the dialog's width, where a comma-separated list belongs. The dialog widens to 900px to hold it, and the narrow-screen breakpoint moves from 560px to 780px with a viewport ceiling on the frame — a 400px picture inside a 560px rule would have pushed the fields off the right edge of a phone before the rule ever fired. The Value field's "— in copper" moves from a span beside the label to a tooltip on the field and its input. In a column half the dialog's width the hint wrapped the label onto a second line and shunted the field out of alignment with Weight next to it. The unit still has to be stated: every price in the world is quoted in copper, and a DM who assumes gold prices an heirloom at a hundredth of its worth.
A borne focus -- channelled through, leaned on, carried as office -- with form, role and material facets, and "focus"/"channel" marked as what makes one a caster's implement rather than a stick with a story. The boundary is stated in its own `means` rather than left to be inferred, because "quarterstaff" was ALREADY a form of weapon and both readings are reasonable: an author who guesses wrong gets an item that behaves like the other one. A plain fighting stick stays a weapon whose form is quarterstaff; this is the implement. Resolved by meaning rather than by deleting one of the two readings, since a mundane fighting staff really is a weapon. It carries damage dice -- a staff you cannot hit anything with would be a curious staff -- so WEAPON_ITEM_TYPES gains it and the editor shows the damage fields for it. The part that made this more than one line: the Item Editor's type dropdown is built from ITEM_TYPE_DEFAULT_ICON, not from ITEM_TAXONOMY, and those two have already drifted -- the icon map offers potion/ring/amulet, which are not taxonomy types, and omits contraption, which is. A type added only to the taxonomy would be handed to the GM and stay invisible to the DM. The test pins that, and records contraption's absence as pre-existing rather than quietly fixing it under cover of this change. Designs/item-taxonomy.html regenerated -- it is generated from the code and has a --check test, so it cannot drift silently. Browser-verified: "staff" appears in the dropdown between spellbook and tool, and selecting it reveals the damage fields and hides Base AC, exactly as a weapon does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Item Editor grew an Update button that hands a half-written item to the GM and takes back whatever the DM had not filled in. Beings had no equivalent, and they need one more: an NPC arrives from the World Builder with a name, a line of description and a stat block at 8 across the board, and everything that makes it interesting — its routine, its trade, how it talks, what it is hiding — is a dozen fields somebody has to type. So the being card now carries the same Update, wired by uid so that two beings sharing a name do not fill in each other. The part worth reading is fillEntityGaps, and specifically which fields it refuses to touch. An Entity carries two unrelated kinds of thing on one object: what it IS — description, profile, routine, lore, stat block — and what has HAPPENED to it — where it is standing, whether it is alive, what the player thinks of it, what it is carrying, what it is doing right now. A completion pass that wrote the second kind would stand a corpse back up, teleport a shopkeeper out of the middle of its routine, or hand a stranger the reputation the player spent an evening earning, and it would do all of that from a button labelled Update. So the writable fields are named in three explicit allow-lists and everything else is unreachable. A denylist was the obvious shape and is the wrong one: it would let the next field added to Entity default to writable, and the failure would not surface until someone noticed a save had gone strange. Filling is additive throughout — a field that already reads as something keeps it, and the GM's suggestion for it is dropped rather than applied. That extends to the awkward cases. A number counts as unset only at its constructor default, since an Entity has no way to say "not chosen"; overwriting a deliberate 8 is the accepted cost of offering the stats at all, and it is why they are offered, because a hand-made being arrives at 8 everywhere. A routine is all-or-nothing, because half a day authored by the DM and half by the GM reads as neither. Health travels as a pair, so a being at full health is still at full after its maximum is raised, and a wounded one keeps the wound — healing a creature from an editor button is not an edit. The test walks every field a fresh being carries and asserts that nothing outside the allow-lists moves under a GM answer that names all forty-seven of them, which is the assertion that will catch the field somebody adds to Entity next year. It writes the allow-lists out by hand rather than reading them off the constants, because a test that sources its expectation from the implementation would have excused the leak it exists to find.
Four additions, and a refactor underneath them that is the actual work.
ENLARGE. A magnifier beside the picture's upload/generate/clear, opening the same lightbox every other
item image in the app opens (openItemImageModal). It shares the ✕ button's visibility rule inside
itemEditorSetArt rather than carrying its own, because both are actions on a picture that may not exist
and two rules for one condition is how they part company — a zoom over "No picture yet" opens an empty
lightbox.
UPDATE, bottom-left in the ghost treatment. It sends the form as it stands and asks the Game Master to
complete the item, then writes back ONLY the fields still empty. Deliberately additive: a DM who typed
"salt-pitted" and meant it never finds it replaced by "pristine". Verified against a GM answering
RENAMED and pristine — both refused, the gaps filled. It reports what it filled, because a button that
changes eight fields silently leaves the DM hunting for what moved.
EXPORT / IMPORT, as icon buttons beside it. The file is the same { "items": { id: spec } } envelope
every other editor tab writes, so an item exported here imports through the Items tab and back again; a
bespoke single-item shape would have been smaller and interchanged with nothing. Import also accepts a
bare id→spec map or one lone item, because a DM handed a file by somebody else should not have to know
which of the three they were given, and it REPLACES rather than merges — choosing a file is asking for
that item, not for a blend with a half-typed one.
SIZE, beside Weight in the Details grid, matching the card and the popup. Left blank rather than
defaulted to 1: blank means unstated, which itemSize() already reads as 1, and writing 1 into the field
would claim the DM chose it.
The refactor: four things now move between the form and an item spec — Create Item, Export, Import and
Update — and four hand-rolled mappings of a dozen fields is four chances to forget the same one,
silently. An Export that drops `classes` produces a file that imports as an item anyone may use. So the
mapping is stated twice, itemEditorReadForm and itemEditorFillForm, and every caller goes through it.
The load-bearing test is the round trip: read the form, write it back, read again, and the two specs
must be identical — the only assertion that fails when one mapping learns a field the other has not.
Which is how a real bug surfaced. openItemEditor clears a hand-kept list of ids and `size` was never
added to it, so a reopened dialog carried the previous item's size — and the round trip could not see
it, because the stale value stood in for the one the filler never wrote. The list is fixed and the
fresh-sheet assertion now checks every field by name rather than trusting the list to stay complete.
Sixteen sabotages, each caught by a distinct assertion — one only after that bug was fixed, since it
was what made the mutation invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe same ✨ the two descriptive fields carry, in the same shared row, refusing in the same way — but a different contract, and that difference is the point. The item card's Icon section generates a 32x32 IMAGE; this field holds the emoji GLYPH the sidebar, popups and lists stand the item up with. Wiring the dialog to the card's generator would have painted a picture into a field the whole app renders as text, so requestItemIconGlyph is its own ask, written beside requestItemDescriptionText and to the same rule: a plain item-ish object in, a value out, no UI and no opinion about where the answer is stored. The brief includes the descriptions, not just the name and type. What a thing looks like is exactly what those two fields are for, and it is the best evidence available for what an icon has to stand for. The answer goes through sanitizeItemIcon — the gate every other icon path already uses, which rejects anything carrying ASCII word characters or longer than eight code points and falls back to a type-fitting default. So a Game Master that helpfully replies "sword" yields ⚔️ rather than the word "sword" sitting in a glyph field. Verified: it does exactly that. Layout reuses .item-desc-edit, which is also how the Rooms card pairs a single-line input with its ✨. One rule was needed for it — the dialog styles its inputs at width:100%, which pushed the button out of the field until the input was made a flex child. Driven in Chromium: the button sits beside the input and inside its field, an unnamed item never reaches the GM, the glyph lands in the field, a word is sanitised, a 500 reports and releases the button, and nothing is committed to the catalog. The describe test grows an Icon section; ten sabotages, each caught. One of its earlier assertions had to be loosened first — it counted .item-desc-edit rows across the whole dialog and read as "the descriptions use the shared row", which the Icon field then falsified without anything being wrong. It now asks that question of each description row directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Both descriptive fields in the Item Editor gain the tiny ✨ the item card has carried all along: write this field from what has been typed so far. Same .item-desc-edit row, same button classes, so the dialog and the card read as the same app. The dialog's item does not exist yet, which is the whole of the work. generateItemDescription reads ITEM_CATALOG[id] and writes back through applyItemTypeField — an id and a destination a half-written form has neither of. So the contract came out into requestItemDescriptionText, which takes a plain item-ish object and returns text, and the two callers keep only their own UI and their own idea of where an answer belongs: the card writes it to the type and saves, the dialog writes it to the textarea and commits nothing. Create Item is still the only thing that creates an item, so a ✨ pressed in a dialog the DM then cancels leaves no litter behind. Sharing the contract is not tidiness. Two prompts asking for "a short evocative description" in slightly different words produce items that read differently from every other item in the world, and nothing about the dialog would ever tell a DM why. The form IS the brief, so the draft handed to the GM carries the name, type, kinds, condition and value typed so far, plus whichever description is already written — the same fields the contract reads off a catalog entry, so a half-written item is briefed exactly as a finished one is. An unnamed item never reaches the GM at all: asked to describe a nameless thing it obliges, with something else entirely. Refusals report into the dialog's own error line rather than onto the button's tooltip, which is where the card puts them and which nobody hovers after clicking. Driven in Chromium against a stubbed GM: the button fills its own field and not the other, the ask carries the form's contents, the short and detailed variants ask for different things, the catalog is untouched, and a 429 reports and releases the button. test_item_editor_describe.js: twelve sabotages, each caught. Writing it found a real trap of its own — requestItemDescriptionText builds the system prompt as an argument to gmFetch, so with no live player the call throws before any stub is reached and the dialog honestly reports that the GM could not write it. The harness now makes a player, and the comment records why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
BOOKS (Designs/class-gating.html 6A). Hardness belongs to the artefact: gating a book IS the statement that its knowledge is closed to outsiders, and an ungated primer on the same subject is a book anyone may study. readBook now checks the BOOK's class gate first, as a hard refusal with its own reason, then the skill's. And the diagnosis in that doc was half wrong. The ENGINE was already soft -- readBook refuses only on !skillEligible && skillHardGated, and says so in its own comment: "an off-class reader can still learn it, but wields it weaker". It was the item POPUP that refused on skillEligible alone and hid the Read button, withholding something the engine would have allowed. So BUG-033's note that "the engine is right and the UI is honest" had it backwards. Both now apply the same two refusals in the same order, and because the soft path is reachable from the popup at last, the Read button states the cost rather than leaving it to be discovered in a proficiency afterwards. lethal-forecloses-lore now requires there to BE lore. A loreKey with nothing behind it forecloses nothing, and reporting it was a true-sounding sentence about a hook that does not exist -- worse than silence, because it sends the author hunting for the lore it names. BUG-035's text said "starting gold per class (the field does not exist yet)", which stopped being true when class.startingPurse shipped. Refreshed with what has moved since -- the purse field, the psalter at 20 gold, the ledger quest's 2000 copper -- and left OPEN deliberately: the numbers changed on paper while the shortfall was measured in play, so it closes on a fresh level-1 run reaching book three, not on the edits. Not done, because it was not real: the vault-mode API-key guard. VAULT_SENTINEL already exists for exactly that, set in detectVaultMode and again in adoptVaultKeyIfNeeded, so every !apiKey gate passes in Vault mode. I had read the guard without checking what satisfied it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Size — how much room a thing takes inside a container — was authored, stored and charged against capacity, but never shown anywhere. It now sits beside Weight on both surfaces: one row in buildItemDetailHTML, which every item popup routes through (story, map, editor, character quick-view and the Compendium), and one row in the card's Details grid. Three things made it more than a row. IT IS NEVER ABSENT. itemSize() defaults to 1, so an item that declares no size still occupies a unit of a chest. Showing the field only when authored would have read as "no size" for almost every item in a world, which is the opposite of true — the built-in world authors no sizes at all, so that version would have shown nothing anywhere. ZERO IS NOT NOTHING. A declared 0 is charged at SIZE_0_NOMINAL, so a purse of capacity 2 holds twenty rings rather than infinitely many. A bare "0" sitting directly above a Container line reading "0.1 / 12 full" is a readout contradicting the one beneath it, so the zero case carries a short note — interpolated from the constant, so the sentence cannot drift from the arithmetic it describes. AND THE NUMBER IS NOT ROUNDED INTO A DIFFERENT NUMBER. fmtWeight rounds to one decimal, which would render a ring's authored 0.05 as 0.1 — which is SIZE_0_NOMINAL, a value that means something else here. fmtItemSize keeps two places and drops trailing zeros. The card and the popup both read itemSizeParts, which returns the figure and the note separately so each surface can render the note its own way — the card as plain text in a kv row, the popup in a dimmed span — without either stripping the other's markup. Two formatters is how one item starts reporting two sizes. One real gap surfaced on the way. The Compendium synthesises an item literal when nothing live matches the entry, and a field that literal omits is not blank but DEFAULTED — so a catalog item sized 6 would have shown as 1 through that path. Both literals now carry size through. They still omit weight, value and condition, which is the same defect on three more fields and is left alone here rather than widened into silently: a catalog Anvil of weight 80 reads as weight 1 in that popup today. Verified in Chromium across the default, declared, fractional, zero, unidentified, container and Compendium-fallback cases, and that the card and the popup agree on all of them. test_item_size_display.js: eleven sabotages, each caught by a distinct assertion — one only after tightening the zero-note check, which agreed on the value and so passed a hard-coded 0.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A 200x200 frame leading the dialog with Upload, Generate and Remove beneath it, and the name/type header beside it rather than above. The picture is held on the DIALOG, not on an item, because the item does not exist yet. The card's handlers are id-keyed and look the item up in ITEM_CATALOG, and generateImageForItem propagates to live copies and calls saveGameState -- all meaningless or actively wrong for something that has not been created. So the URL lives in _itemEdArt and createItemFromEditor attaches it to the entry it builds. Generation is deliberately not the card's flow either. That asks the GM to author item.prompt first, which needs an item to write it to. Here the prompt is assembled from the fields already on screen -- name, type, condition, description -- so it needs no API key and no saved item. The card's own portrait prompt writes a better one afterwards, and the dialog's note now says so. On CONFIRM the picture becomes both the item's portrait and its Compendium image. Those are separate: the Compendium keeps its own imageUrl per discovered entry, matched by name, so setting entry.image alone would show the picture on the card and nothing in the Compendium. Cancel is inert by construction -- the attach happens only inside the create path -- and the next open clears the held picture along with every other field. The dialog's note previously said artwork was edited only on the card, which was a deliberate decision this reverses; leaving it would have had the dialog contradicting itself. Browser-verified: frame 200x200 at the body's left edge, header beside it at x=216, and the Remove button correctly absent while there is no picture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
WORLD_GEN_MAX_TOKENS = 128000, up from 64000. Measured, not assumed: probed against the vault, every model this app offers -- opus-5, opus-4.8, sonnet-5, fable-5, haiku-4.5 -- refuses anything above it with "max_tokens: N > 128000, which is the maximum allowed number of output tokens". 300000 is not available on any of them. It costs nothing until used, since billing is on tokens generated rather than on the ceiling. What it does buy is a longer window in which a browser can lose the reply, which is why the vault now keeps a copy and refuses a truncated one outright. And test_vault_core's eight path failures were the platform, not a defect. resolveStaticPath returns path.join(staticDir, ...) and guards it against path.resolve(staticDir); a bare '/srv/app' fixture makes those disagree on Windows, where join leaves it driveless and resolve prepends the current drive, so the escape guard rejected every path. Production never sees it -- loadConfig resolves staticDir before it is ever passed in. The fixtures now resolve their directories and build expectations with path.join, which tests the same intent on either platform. Same for the three worldsDir assertions comparing against POSIX literals. One more brittle assertion retired: the generation-store test pinned `max_tokens: 64000` beside the keep marker and failed the moment the ceiling moved, for a change with nothing to do with which call is kept. It matches the marker now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Rev. 1 led with memory and art. That was the wrong axis: in a world large enough to want region files
the art is already remoted to the vault by reference, and the reason to isolate a region is that the
Game Master is handed the whole world on every call — every room id and its exits, the beings, the
lore — which is what stops a world growing.
So the prompt was taken apart and measured rather than described. It is 151,163 characters (~37,800
tokens) for 14 rooms and 12 beings, of which about 92% is the fixed GM contract. The part that grows is
the World Map: every room in the world, one ~72-character line each, on every call, plus ~298 characters
a being. Carried forward — 2,000 rooms and the map alone outweighs the rulebook; 10,000 and it is an
obstruction. Stated honestly: with caching the money survives a long way up that table, and attention
breaks first, which is an argument for scoping that bites well before the context window does.
That measurement produced the finding that reorders the phases. Scoping the Game Master's view is a
change to how the prompt is ASSEMBLED, not to how a world is stored — so it ships in P1 with the world
still whole in memory and in the save, no file format, no loader, no migration. Region files then buy
what they are actually good for, authoring at scale and streaming, and the save-by-reference work comes
off the critical path. Scoping first also makes the later phases safer: it forces the codebase to
tolerate content that exists but is not in the GM's view, which is the same tolerance unloading needs,
learned where nothing can be lost.
A new §07 reads the engine against the code and answers what it can support today. Most of the live
world turns out to be keyed to rooms already — a routine is {timeOfDay:{location:roomId}}, an
encounter's placement is where:[{location:roomId}], weather already keys on the region, factions
already carry regions:[] — so much of the scoping is a filter rather than a new model. Which surfaces
the seam that is genuinely authored into the data: a routine may name rooms in two regions. The
built-in world's Old Gatekeeper walks five rooms in a day, so a border drawn through them makes him a
being with a foot on each side. That gives P0 something concrete to count — how many beings and
encounters cross a proposed border — where "most of them" means the border is in the wrong place, which
is more use to a DM than any rule this document could impose.
Scope is now explicit on two axes: it is an opt-in mode for large worlds, with a single-file world left
byte-identical in behaviour; and it maps the seams rather than finishing the mechanism, so the moment
of crossing — how a room in memory hands over to one that is not — is named and left for a later
revision.
Rendered and checked: 12 sections, 5 tables, 8 decisions, no page errors, every internal cross-
reference and every link verified after the renumber (two were stale and one section id was
duplicated).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA proposal for splitting a world into region files that load and unload on demand, so its regions need not all be in memory at once. It opens with the measurement, because the measurement changes what the feature is for. The built-in world serialises to 228,997 bytes across 14 rooms — about 16 KB a room, so an Extra Large world of five regions is roughly 2 MB of text, and rooms plus the catalogs their contents resolve against are 82% of it. But a measured playthrough reached 441 MB once art was inline, of which 377 MB was ~110 weathered banners. The mass is art, not world data, so partitioning text alone moves well under one percent of the weight. That does not make the proposal wrong; it makes the memory argument the weakest of the reasons to build it, and three stronger ones are named — authoring at scale, art partitioning with its region, and bounding the GM system prompt, whose cached half measures 139,500 characters and grows with the world on every call. The spine is a three-way split — base world / region / contested — settled by one computed rule: a region may own only what no other region needs. Computed rather than authored, because a DM dragging a shared "villager" template into one region's file empties the rooms of every other region that used it, and reported in mergeWorldChunk's existing findings voice so the DM sees the shape of their world before committing to it. Two things decide whether it can be built. THE SEAM: an exit target needs a third state. The existing auto-repair drops a dangling exit, which is right for a chunk and destructive for a region file, where absent usually means not loaded — so "frontier" has to be distinguishable from "dangling", which is why seams are declared rather than inferred. THE SAVE: buildGameSnapshot writes the whole world every time, so today unload + save deletes the region, and play mutates rooms in place (visited, looted, opened), so unloading must persist a changed region rather than the one it loaded. Three ways out with a leaning, and it is scheduled as its own phase rather than smuggled in beside the loader. Four things that break quietly are named against the code: room.region is a NAME not an id (rename a region and every room's membership dissolves), item instances link to their template by name, dungeon maps live outside serializeWorld entirely, and the compendium is name-keyed and never unloads. Five phases, cheapest first, P0 being a zero-risk report of how a real world would actually partition — worth running early, since the built-in world defines no regions at all. Seven open decisions, the first recording that the request's two halves pull against each other: a file cannot both be playable on its own and carry the base world's settings, because those settings are the base world. Figures were measured against the built-in world and the prompt-economics branch rather than estimated. Rendered and checked; the three links to prompt-economics.html are prose references instead, since that document is still on its branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A world generation runs to minutes and hundreds of KB and exists only in flight. A crashed tab, a mis-clicked close, a dropped socket, and it is gone with nothing to resume from. The vault now writes a copy before handing the reply back. ONLY WORLD GENERATION. /vault/gm proxies every GM call, so the first cut persisted every game turn -- and in a bounded ring one play session would then evict the very generations the store exists to protect. The caller marks its own call with ?keep=world; the vault does not infer it from token counts, because that would be a heuristic standing in for a fact the client already holds. It rides as a query parameter since the body is forwarded to the provider verbatim and an unknown top-level field would be rejected. ONLY A WORLD THAT PARSES. One that does not cannot be recovered FROM, so keeping it spends a slot on rubble. And a TRUNCATED reply is now refused outright with a 422 naming the reason rather than passed on: handing the browser a body that cannot parse turns a known, nameable failure into a parse error further from its cause. The browser surfaces that reason instead of flattening it to a status code. The parse check is deliberately weaker than the browser's extractJsonObject and does not reimplement it -- two answers to "what counts as a world" would drift. The asymmetry makes that safe: if the vault rejects something the browser's repair would have rescued, the loss is a scratch copy of a reply that reached the browser intact, which is exactly the case where nothing needed recovering. Bounded ring, last N (VAULT_GENERATIONS_KEEP, default 50, 0 disables). Atomic tmp+rename and 0600 borrowed from world-store.js. Names come from the clock so no request field ever reaches a filename. put() never throws: a full disk costs the safety net, never the generation. Only files matching the store's own name grammar are pruned. Two stray NUL bytes found and removed while here: one I had just written into the new test (intended as a space in an unwritable path, which worked by accident and read as a typo -- now a real file where a directory should be, portable and legible), and eight pre-existing in tests/test_dungeons.js where 'Aldric Played' had lost its space. Inert, since a JS key with a NUL still matches itself, but wrong. Swept tests/, server/, tools/ and Designs/: clean. test_key_encryption pinned gmFetch's exact parameter list and failed on an options argument being added, for a change that never touched the key handling it asserts. Matched on the function name now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Regenerated from an unshallowed checkout so the count reflects every commit on main rather than a shallow clone's truncated window: 2468 commits across 47 days, through August 15th. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LjDxmQqU5Cyo6tZKiZ8ip
Editor › Items had two filters at opposite ends of its toolbar: "Filter by name…" on the left, and the funnel that filters by type, slot and artwork over on the right, wedged between + Add and Collapse all. Filtering by name and filtering by field are the same job asked two ways; Add, New, Export, Import and Collapse are things you do to the list rather than ways of looking at it. The funnel joins the name box. Two things had to move with it. The pair is wrapped in one .items-filter-row rather than becoming a second child of the toolbar. That bar is display:flex with justify-content:space-between, so as its own child the funnel would have been spaced away from the box it belongs to and parked in the middle of an empty bar. The drop-down was anchored right:0 — correct while the button sat at the toolbar's right edge, and wrong the moment it moved, because from the left edge a right-aligned menu opens leftwards and runs off the side of the panel. It anchors left:0 now. Measured in Chromium at 1500, 1100 and 860px: the button sits 9px from the name box on the same row, ahead of the action groups, the bar does not overflow, and the open menu stays inside the view at every width. The wrap keeps its own border rather than joining a .npc-tool-group, which is not cosmetic: that class sets overflow:hidden to clip its segmented buttons to the rounded frame and would clip the absolutely -positioned menu away with them — the menu still measures fine and still has its checkboxes in the DOM, so every DOM-level check passes while the panel paints blank. The existing test already guarded that, and it is what kept passing through this move. test_item_facet_filter.js gains the placement itself: the two filters share one row, in that order, the funnel is absent from the action groups, the row is a single flex child, and the menu opens rightwards. Five sabotages, each caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Editor › Items gains a "New" button next to "+ Add". They are two different authors: Add asks the Game Master to invent an item from the world's theme, tone and prologue; New needs no GM, no API key and no world at all beyond the one being edited — the DM types the item. The dialog is laid out as an editable item CARD, because that is what it produces: the same header (name left, type and the magic flag right), a Details grid in the card's own field order, then Subtypes, Classes and the two descriptions. Reading the dialog and reading the card it makes are meant to be the same act. The stat fields follow the type as the card does — Base AC for armor, a signed AC Bonus for anything else, the damage trio only for a weapon — and a field that cannot apply is hidden rather than greyed, since a greyed box still reads as something left unfilled. What is deliberately absent is everything a card can only offer once the item exists: artwork, the inventory icon, effects, what a book teaches, discovery, lore. Those need an id to write against, and the card carries all of them the moment the item is created. It creates through catalogItemShape and moveCatalogItemsToFront — the two calls the GM's Add ends in — so a hand-written item is the same kind of object as an invented one, defaulted the same way and put in the same place in the list. Every field the dialog does not ask for is left to the shape function rather than defaulted again here, because a second set of defaults is how two creation paths quietly start producing different items. Three things the browser found that reading would not have. catalogItemShape does not name the weapon damage trio. It carries ac, acBonus, slots and magic, but damage/damageBonus/damageType simply are not in it — every other creation path spreads its spec whole and keeps them, so this is the one caller that has to set them. Typed 1d8 and got a weapon that dealt nothing, silently, until the created object was actually inspected. Dice are normalized on the way in and an unreadable spec is refused rather than stored, since stored unparsed it reads fine on the card and rolls nothing in a fight. A magic item leaves the tab you created it on. catalogItemsForEditor routes magic to Magic, plants to Flora, spellbooks to Spellbooks — so ticking Magic on the Items tab created the item and left the list looking untouched. The report line now says which tab it landed on, and finds that out by asking catalogItemsForEditor which list contains the id rather than restating its routing. A multi-select draws its rows in the browser's palette, not the page's: the selected slot came out white-on-pale in the middle of a dark dialog. Chromium ignores background-color on option:checked, so the flat gradient is how the colour lands at all. A duplicate name is refused outright. An item INSTANCE carries no catalog id — the two are linked by name (resolveItemIdByName) — so two entries under one name cannot be told apart by anything in the world, and the numeric suffix is left to cover a slug collision between genuinely different names. test_item_editor.js drives the dialog against a stub DOM for creation, validation, the type-driven fields and the reset-on-reopen; fifteen sabotages, each caught by a distinct assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The time-of-day and weather-condition briefs open with a placeholder under the scene's banner while the GM composes them. It was a literal "…", which reads equally as "something is coming" and as "nothing came". It is now the same three pulsing dots the story already shows while the GM is writing a turn — the shared .typing-indicator, at its own size, reused rather than reimplemented. Two things about the placeholder are load-bearing, and a literal ellipsis was quietly tolerant of both. IT IS BUILT FROM SPANS, NOT DIVS. fillTimeOfDayBrief slots the answer into the PERSISTED message with /(<div class="tod-brief" id="ID">)[\s\S]*?(<\/div>)/ — non-greedy, so a div inside the placeholder closes the match early and leaves a stray tag in the saved story for ever. showTyping's indicator is a div; this one is a span, and the indicator's CSS is inline-flex, so nothing is lost. The test proves it by running the real fill regex over the real placeholder rather than a copy of either. IT MUST NOT SURVIVE A RELOAD. Every failure path in requestTimeOfDayBrief clears the placeholder — but only while the page is up. Close the tab mid-generation and it is already in the save with no request left to fill it. A static "…" was merely odd; three dots pulsing for ever are a promise the next session cannot keep. So a restore sweeps them, beside the other repairs a resumed story gets, leaving exactly the empty brief a failed generation would have left. The three sites that open a brief now call one builder, because the fill regex has to match all three byte for byte; the test asserts the opening tag is named exactly twice in code — once by the builder that writes it, once by the regex that reads it back — so a fourth site cannot quietly emit a brief that can never be filled. .tod-brief-pending keeps no styling of its own. A first version trimmed the padding and the dots for the brief's smaller scale; measured in Chromium, the line was 21px either way, because the height is the brief's own line box and not the inline-flex child in it. A rule that says nothing is worse than no rule, so it is gone and the class stays purely as the marker the sweep looks for. test_brief_typing_indicator.js: eleven sabotages, ten caught by a distinct assertion. The eleventh (dropping the sweep's `next !== e.html` check) changes no behaviour — the outer marker guard already makes it unreachable — so it is left as defensive code rather than given an assertion that would assert nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Re-weathering a room banner is an image generation: several seconds long, started by a clock tick nobody asked for, and until it lands the scene looks exactly as it did. A small ring in the banner's own bottom-right corner says the work is running. Nothing goes over the story text; .room-banner is already position:relative, so the ring needs no wrapper of its own. WHICH ROOM IS DERIVED, NOT STORED. There is at most one weathering at a time — _weatherBannerPending is the guard that makes that true — so weatheringRoomId() reads the room off the head of that key rather than keeping a second flag beside it. A flag that cannot disagree with the thing it describes cannot be left set by a path that forgot to clear it. It goes up where the guard is claimed and comes down in the finally, with the guard: put beside the success branch it would keep turning after a 429. IT IS NEVER PERSISTED. A story message is HTML and the log is saved — a spinner baked into a saved message is a scene that spins for ever afterwards, and it would look perfectly right in testing because the bug only appears in the NEXT session. So the node is injected into the live DOM only, by one function, and syncWeatherBannerSpinner is re-called from the three paths that rebuild banner nodes (renderPinnedRoom, refreshCurrentRoomBannerElement, renderNarrativeWindow). It clears every ring before deciding where one belongs, which makes it idempotent and stops one surviving a room change. It lands on the room's MOST RECENT story banner, mirroring patchLiveRoomBanner — an older banner of the same room would spin and then never change — plus the pinned head's copy of that same banner. pointer-events:none, because the banner is click-to-toggle-full-width and a dead zone in its corner is a bug nobody would connect to the weather. A dark disc behind the ring so it reads on a noon sky as well as a night cellar, and reduced motion gets a pulse rather than a stopped ring, which reads as broken. Driven in Chromium against a stalled generation: absent when idle, one ring while generating, still there after a full narrative rebuild, two with Pin Room on (story plus pinned head), and gone after a FAILED generation. Measured 7px in from the banner's right and bottom edges, 18x18, and a click at its centre still reaches the banner. test_weather_banner_spinner.js runs both functions against a stub document for the placement rules and checks the rest at the source; twelve sabotages, each caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Evaluate was a top-level Editor tab, but it reads the WHOLE world rather than any one roster —
reachability, pricing, buildability, XP — which is what the World tab already collects. It moves into
World's bottom inner strip beside Profile, Chunks, Regions, Calendar, Weather, Factions and Login. The
markup moves verbatim; renderEvaluate, renderEvaluateBar and the evaluation itself are untouched.
Two things had to move with it rather than be left behind:
THE REDIRECT. switchEditorTab('evaluate') still has a live caller — the enact-plan flow re-opens the
tab by name after a run. It is redirected to the World tab the same way a legacy 'factions' call is,
rather than rewritten at the call site, so any other path in by that name keeps working.
THE POSITIONING ANCHOR. #eval-toolbar is absolutely positioned at top:10px, and its containing block
was #editor-sub-evaluate. .world-inner-panel is NOT positioned as a class (only the art/env/map/mag/
pl/ent/wx panels are), so without moving the anchor the toolbar would have escaped to #app and landed
over the game's title bar. #world-inner-evaluate takes the old rule's place in that list. Verified in
Chromium: the bar sits 10px below the panel's own top and inside it horizontally.
Currency is a placeholder — tab, panel and an empty-state line saying so. switchWorldInnerTab
deliberately dispatches no renderer for it, so an unbuilt tab is an empty panel rather than a call to a
function that does not exist.
test_world_currency_tab.js checks the placeholder and, more usefully, an invariant over the whole
strip: every button in it appears in BOTH id lists inside switchWorldInnerTab, and neither list names
anything the strip lacks. Both directions fail quietly — a button outside the whitelist silently opens
Chunks, and an id in the render loop with no element throws on a null and takes down every tab in the
strip — so they are checked as sets, which covers the next tab added as well as this one. Six
sabotages, each caught. test_evaluate_tab.js follows the tab to its new home.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe companion to loreNeedsAlive, and the same argument. The pass decided "does this beat require something unkilled?" by pattern-matching the trigger prose, then decided "which creature?" by looking for a placed monster's name inside it. Both halves are the engine second-guessing narration written for the Game Master to read, and both fail silently on any wording the pattern did not foresee. beat.needsAlive names the beings outright, the way `npcs` already does -- settling the fact and the subject together. "Follow the Gill-Wretch back to its nest" is now catchable, and it contains nothing a regex could ever have matched. An explicitly empty list silences the mirror-image failure -- "spare a thought for the Gill-Wretch as you pass its bones" -- which the heuristic flagged and had no way of being told it had misread. Authored through a chip picker on the beat card, the same shape the skill Classes control uses, because the value is a list of EXACT names and typing those by hand is how a gate ends up matching nobody. Removing the last chip deletes the field rather than leaving an empty array, so "requires nothing" and "was never asked" look identical on disk and a beat that sets none serialises exactly as before. A branchGroup still exonerates a declared requirement: the fork is the point, however it is stated. And a named being the world does not place is still reported rather than dropped -- that beat requires something the world does not contain, which is worse than the case being checked for. The regex stays as the legacy path for quests authored before the field, with a comment saying it must not grow: the fix for a phrasing it misses is to name the being. Browser-verified against the real draft: the control renders on all 12 beats with an 11-being picker, an add round-trips to disk, and removing it leaves the beat byte-identical to how it started. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A drop cap on the GM's prose, drawn by ::first-letter so no wrapper element is inserted into the narration — nothing has to be parsed back out for the story-book export or the transcript, restored history styles itself from the same rule as live text, and a line that opens with a block element simply gets no cap rather than a broken one. "Drop Caps" in Settings › Story switches it, default on; the rule hangs off a body class, so toggling it repaints without touching a message. The obvious implementation is wrong. .msg-narrator looks like the GM's class, but 'narrator' is also addMsg's DEFAULT type, and about forty engine notices call addMsg with no second argument — "You cannot cast while you are down.", "◈ Benefit: well-rested." Styling that class would drop-cap the engine's own bookkeeping. So the eligible line says so explicitly: addMsg takes opts.dropCap, stores it on the entry, and messageNodeAttrs turns it into .msg-dropcap on every render, which is what lets a restored save draw its initials identically to a live one. Lines written before the flag existed simply lack it and read as they always did; the alternative was inferring GM prose from the markup it happens to open with, which goes quietly wrong the first time a notice is reworded. Two structural conditions decide, neither of which reads what the narration says. It must be at least 200 characters of text with the markup stripped: a float is taller than the single line it would sit beside, so a capital over "You cannot reach it." hangs out of its own message and into the next. And it must begin with a letter, because ::first-letter takes leading punctuation with it by spec — narration that opens on dialogue sets the quotation mark at 3em with the W tucked in beside it. Both of those were rendered in Chromium and looked at before the rule was written; the screenshots are why the conditions exist. flow-root on the message box makes the overflow case structurally impossible rather than merely unlikely. applyDropCapsSetting runs before the restored story mounts, not after, so a resume paints once with its initials rather than twice. It sits ahead of the writeRoomSceneToStory / renderNarrativeWindow / applyPinRoomSetting trio, which test_pin_room asserts as an adjacency — it caught the first placement. test_drop_caps.js covers the eligibility rules, the class derivation, the single call site and the setting; fifteen sabotages, each caught by a distinct assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The evaluation pass decided this by running a regex over the loreKey -- "spare", "without
killing", "let it speak". That is the engine second-guessing narration the Game Master is
meant to write freely, and it loses on every phrasing the pattern did not foresee: "leave
it breathing", "take it captive", "let it finish the verse". Worse than incomplete, it
puts two systems in an argument over one fact, when the whole point of the GM authoring
the prose is that the prose need not be machine-readable.
entity.loreNeedsAlive is now a field. The DM ticks "Needs it alive" beside the unlock
condition; the GM sets it while authoring, and the contract tells it the engine reads the
field rather than the wording, so it can write the condition however the fiction wants.
Offered only for people, monsters and animals. A room, a faction and a sword are not asked
whether their lore needs them alive -- a control that can never mean anything is worse than
an absent one, because it invites a DM to tick it and expect something.
The regex survives ONLY as a fallback where nothing was declared, and says so in a comment,
because the failure mode is someone adding another phrase to the pattern instead of ticking
a box. A world nobody has re-opened keeps its old behaviour rather than silently losing its
hooks; anything authored since means exactly what it says -- including an explicit FALSE,
which the heuristic could never express and which fixes its false positives ("spare a
moment to read the marks").
Two test-side notes. test_room_lore_editor pinned the XP stepper as being "within 2400
characters of the function name" and broke on a checkbox being added to the same block --
measuring distance instead of membership; it now slices the function. And a loreKey with no
lore behind it still reports as foreclosable, which is wrong but pre-existing and
orthogonal; recorded in the test rather than quietly changed under cover of this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe bag floats over the story's bottom-right on its own surface, so text cannot wrap around it the way it would around a float. Until now the workaround measured: applyDiceMessageNarrowing walked every message, compared its box to the bag's, set an inline max-width on the ones that overlapped, and re-ran on every new message, every windowed rebuild, every window resize and every scroll. It was never reliably right, and the last round of fixes did not make it so. The bag is pinned to the panel's bottom while the text scrolls past it, so the set of covered messages changes with every wheel notch; each recompute was a chance to be a frame behind, and the reported symptom — text still landing under the bag — survived correcting the arithmetic. Chasing it further meant more recomputes on more events, which is more surface for the same class of bug. So it stops measuring. While the bag is open #narrative carries `dice-open` and one CSS rule gives every message max-width: calc(100% - 210px) — the bag's width plus clearance, written down once. Nothing recomputes, on scroll or otherwise. The reservation is wrong only in the harmless direction: a message nowhere near the bag is narrower than it needs to be. It is never wrong in the direction that mattered. Measured in Chromium at four viewport widths, with the story scrolled to the foot and again to the top: exactly 14px of clearance every time, zero messages intersecting the bag, and full width restored on close. reserveDiceSpace keeps the one judgement left. The crawler reparents the bag into its own right-hand column during a fight (syncCrawlCombatUI) and then opens it, where it covers nothing — so the marker is withheld while the bag is parked, and it toggles rather than adds, because the fight can start while the bag is already open. That path is covered by unit test rather than by measurement. test_dice_narrow.js is rewritten around the new shape: it asserts the reservation, asserts the measured version stays gone (each removed piece named separately, so a partial revival is caught), and runs reserveDiceSpace/releaseDiceSpace for real against a stub document for the four marker cases. Seven sabotages, each caught by a distinct assertion. test_dice_resize_narrowing.js is deleted with its subject, and test_weather_imagery.js loses the assertion that the async brief re-ran the narrowing — a message growing taller can no longer reach the bag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The evaluation pass warned whenever a monster's lore asked for it alive. That is not a defect: the player who spares it trades the hook for the creature's XP and its loot, knowingly, and both roads lead somewhere. Reporting it as something to fix trains an author to REMOVE the choice, which makes the world poorer. Downgraded to info, with a detail that says why it is a fair trade. What the pass did not check is the case that can actually strand someone. A quest BEAT that needs the creature alive is different in kind: fighting a monster is the ordinary instinct, nothing in the fiction says "this one you must spare", and if the only way forward asks for it alive then that instinct locks the quest with no signal that it has. There is no compensating reward either -- the player who fights simply cannot continue. beat-needs-it-alive warns on exactly that, and a branchGroup exonerates it: siblings in a group are mutually-exclusive roads, so a spare-branch with a kill-branch beside it is the authored choice this must not complain about. Only a beat on the linear path can lock anything. Verified against the real world first: Verengrad has no such beat. Its only beat naming a monster triggers on "encounter the Bell-Warden", which killing satisfies. So the two warnings it was producing were both the benign case, and the hazard was unchecked. NON_LETHAL hoisted above both readers -- it was declared after the beat loop and the new check hit the temporal dead zone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
While the dice bag is open, messages in its vertical band get their max-width trimmed so their text stops short of the bag. Two things were wrong with it. THE ARITHMETIC (measured). It subtracted the BAG'S WIDTH from the MESSAGE'S width — a different quantity from the one that matters, which is where the message's right edge LANDS relative to the bag's left edge. Those coincide only while the bag hugs the panel's right edge (right:16px), so it worked by luck in exactly one layout. Measured in Chromium: 21px of text still ran under the bag at right:120px, 201px at right:300px. The crawler parks the bag in its own column (syncCrawlCombatUI), so this was live underground. Now the code says the intent directly — want = pr.left - r.left - DICE_MSG_GAP — which is exact at any panel width, bag position and message indent, and leaves a message that already stops short of the bag alone instead of narrowing it for nothing. Verified at 3 bag positions x 5 window widths: exactly 75px clearance in every case. The 75px in DICE_MSG_GAP was a fudge covering the shortfall; it is now a real clearance. THE SCROLL (not measured). The trim ran on open, on a new message, on a rebuild and on window resize — never on scroll. The bag is pinned to the panel's bottom while the text scrolls past it, so which messages sit in its band changes with every wheel notch. Added a captured document scroll listener (the windowed log rebuilds #narrative, so an element-bound one dies on the first re-render), rAF-coalesced, passing pinnedHint=false so it does not yank the view back to the foot when the reader scrolls up. This half is reasoned from the code — no scroll listener existed — but could NOT be demonstrated: #narrative's scrollTop stays 0 in the harness, so before and after measure identically. tests/test_dice_message_narrowing.js covers both shapes; nine sabotages, each caught by a distinct assertion. The width assertions move out of test_dice_narrow.js rather than being duplicated there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The ledger said what it was for and never said what any of its statuses meant, or on what evidence something may close. That gap is why "is this fixed?" kept being answered by recollection. Two kinds of defect, two kinds of evidence. An ENGINE defect closes on proof: the code did the wrong thing, now does the right thing, a test pins it. A GM-CONTRACT defect cannot -- the Game Master is a language model and an instruction is a request, not a guarantee, so there is no contract change whose effect can be proven, only observed. Those close on a span of consistent good results, explicitly understanding a model change can bring them back. Not a weaker standard out of laziness; the only standard the subject matter admits. Which makes a re-opening expected rather than a failure of the original fix -- and that only stays judgeable if the closure recorded what was observed. So the rule requires it: how many runs, what was watched for, and the log line that would betray a recurrence. Without a baseline, "it has come back as a pattern" is a feeling. BUG-017 now carries its own version of that. It stays open until a few runs containing combat pass with zero occurrences of the DM line "Orphan D20 roll during combat -- nothing pending". Any occurrence is the contract change failing to bite and is the evidence that the deferred third half is needed. That line did not exist before this fix, so earlier runs cannot be re-read for it and the count starts now. Also recorded as unverified: the player-facing notice has been read by a unit test and never by a human in a live fight. No // command induces the state, so confirming the wording means forcing combat.awaiting = null from the console once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
"BRIGHT & WARM" at 9:39pm, tooltip reading "Bright & warm (cool, breezy wind)". Warm against cool in one sentence, bright against dusk, a sun glyph over both. Neither was a resolver bug. A day-type's `label` is the one field the engine never bends while everything it describes gets bent: the built-in day-type declares tempBand 'warm', the climate and the season bend that down, isNight takes another step off, and the tooltip prints the frozen label beside the computed band. The label was written for a day this one is no longer — and the declared band is right there in the data, so a stale label is detectable without reading its prose. An authored label is now used while the day is still the day it was authored for, and the condition's own name once it is not. Those names carry the design: Clear, Rain, Fog and Snow are as true at 3am in a cold snap as at noon, which is why they can be the fallback without needing night variants of their own. Only the GLYPH needed one, and only for `clear` — ☀️ is the single built-in glyph asserting daylight, and rain at night is still rain. Night unauthors the label as well as the glyph, which was worth checking before building: swapping only the glyph puts 🌙 beside "Bright & warm" and replaces one contradiction with a fresher one. normalizeWeatherConditions dropped nightGlyph on the way into a world — it names its fields, and this codebase has been taught that lesson twice before, by an economy field and a world flag. Seeded into the built-in catalog and evaporating at the world boundary, the sun kept rising at midnight. Measured rather than assumed: across a year of the built-in world at three-hour resolution, the authored labels still reach the player 65.9% of the time. The suppression does not gut the flavour. Two things the test got wrong first, both worth keeping in the file. It flagged "Bitter & bright (frigid, windy wind)" as a contradiction because its word list held synonyms — bitter IS frigid, and mapping evocative words onto bands turns a contradiction detector into a thesaurus complaint; only a label naming an actual BAND is making a checkable claim. And removing the band guard broke nothing measurable, because in the built-in world that day-type's band only ever moves after dark — a property of the fixture, not the code. A DM whose climate runs colder than a day-type was authored for hits it at noon, so that world is now constructed rather than hoped for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The engine already knew. All five roll handlers return a strict boolean saying whether they took the roll, and rollDie discarded every one of those answers -- so a die that resolved nothing was indistinguishable from one that worked. That was BUG-017's real cost: the GM asked for a roll in narration without setting a request, the player rolled because the story told them to, and the fight sat on round 2 with nothing awaiting, nothing resolving and nothing said. The only escape was to type a fresh action, which the player had no reason to guess. rollDie now keeps the answers and reportOrphanRoll speaks: with nothing awaiting it names the state and gives the way out, mid-turn it says the roll was not sent, and when the fight awaits something else the existing remindCombatInput handles it. The DM gets an error line. COMBAT ONLY, deliberately. Out of a fight the dice bag is partly a toy and a notice on every idle d20 would be noise on a feature that works. Inside one there is no idle roll and a lost one strands the player. And one cross-cutting contract rule replaces four field docs that each described their own route and never said the prose route was illegal. It explains the consequence rather than only prohibiting, and names the legitimate alternative -- roll it yourself and narrate the result -- so it cannot be read as "never ask for rolls". The routes it lists are conditional. An existing test caught the first attempt naming skillRollRequest unconditionally: that field does not exist when auto-roll is on, and naming a field the GM has not been given invites exactly the malformed turn the rule prevents. Five other tests pinned the literal statement `if (pendingX) handleX(s, n)` and failed on an assignment being added in front of the call. Loosened to assert the routing rather than its expression form. Not built: relaying the orphan roll to the GM. It is the only one of the three that can misfire, and may prove unnecessary once the contract change bites. Recorded as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
dropItem held one item. A bare name, or {name, quantity} since the last time this field was too
narrow — but never two names. So the second had nowhere to go: the GM said both went down, the engine
moved one, and nothing anywhere said which or why.
Third visit from the same asymmetry, and the comment above this very block already named it. addItems
has been plural AND quantified all along, so a player could be handed three different things at once
and could only ever put down one. First the quantity was widened, after two Salt-Verse Shards were
sold, one was handed over, and the pair was paid for. Now the count. Both were found by playing,
because every test asserted the singular shape the field already had — which is why the new test
treats the plural as the ordinary case rather than an extension, and keeps both older shapes working.
A contract the GM has followed for months does not get to break because it was widened.
A named item that is not in the pack is now logged with what the player was actually carrying. The
failure this field keeps having is the silent one, so the fix ships with the diagnostic it needed the
first two times.
Six sabotages, and the seventh assertion came out of one that was too benign to catch: dropping the
junk filter does not break anything visible, it just files a "not in the pack" complaint about an
item with no name. That is how a diagnostic stops being read, so it is now pinned too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe same setting under two names — one on the world, one in Settings — reads as two settings, and the whole point of the pair is that the player's overrides the DM's. Now they match. The Editor's field is already a stacked .we-field, so nothing needed moving there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The default settings row is space-between, which works while the control is narrow. This one's options read "Follow this world (decides)", and side by side that ran past the panel's right edge — so the row gets a stacked variant: label on its own line, picker full width beneath it. Rendered and measured rather than eyeballed: nothing inside the panel now extends past its right edge at the panel's real width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Moving the boon out of the synchronous turn fixed when it arrives and broke where it shows. The status chip lives in the sidebar's Character block, painted by updateSidebar; the deferred block called only renderCharacter. The pipeline's own updateSidebar had already run at T+0, seconds before the status existed, so "well-rested" was announced correctly and then appeared nowhere. The principle the first version missed: work moved out of the turn owns everything it changed, because nothing downstream is coming back for it. So the same block now repaints both surfaces — and saves. The turn's own save also ran at T+0, which means a player who closed the tab on waking would have lost the boon they had just watched arrive. It is debounced, so the extra call costs nothing on a turn that already scheduled one. Found by playing it, which is the only way this one surfaces: every assertion about the boon passed while the chip was missing, because they were all about whether the status was APPLIED. The test now counts paints after the sweep settles rather than trusting that applying implies showing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Regenerated from a full, unshallowed checkout of origin/main so the day-by-day history is complete rather than truncated to a shallow clone's recent window. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013eueXZxXzSkGSodGpsrsjR
It was set on the World Builder, which opens only from New World — so on any world already being played, the DM's own default was unreachable. The player's override in Settings › Story reached every world; the DM's default reached only new ones, which is backwards for the one of the two that is supposed to be the world's. So it joins Economy on Editor › World, which is the same exception for the same reason and says so in its own comment: the rest of the framing is frozen at forge time because it describes what the world WAS, while these two are claims about what it currently is, and a world is played before anyone knows how it should be. The hint says whether the value is actually in force. A DM can read this field while the player has overridden it in Settings, and a field that looks authoritative while being ignored is worse than no field — so it asks askBeforeDecidingMode(), the same resolver the prompt reads, and says plainly when the player's own choice is winning. Four sabotages. The one about that resolver passed at first: the assertion matched the function's own COMMENT, which names askBeforeDecidingMode() in prose, rather than the call. Comments are stripped before matching now. That is the third time this session an assertion has read text about the code instead of the code, which is worth saying out loud — a source-shape check is only as good as its anchor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The previous wording forbade adding an activity at all, which flattened two shapes that are not the same thing. A step that comes AFTER the request is satisfied — "I sleep", and the GM also re-prepares the loadout the sleep lapsed — is the GM playing the character, and no setting should permit it. A step that comes BEFORE it — "prepare a new loadout", and whatever reaching that actually requires — is service, and making the player type each one is precisely the chore this rule elsewhere says not to make them do. Banning both would have turned every stated goal into a typing exercise. So the rule asks one question: is this step on the way to what they asked for, or past it? AFTER is refused outright. BEFORE is a prerequisite and belongs to the ask setting — decide, and you have permission to take those steps and must name each; ask, and you put the costly ones back first. A prerequisite is bounded by the goal it serves, with the loophole closed explicitly, since "it was on the way" is exactly how something unwanted would arrive. And a tie-breaker for the ambiguous case: if the player would consider the request finished without the step, it is AFTER. Both branches of the dial now say this in their own voice, including that neither can be used to OFFER a step past the request — otherwise "shall I also re-prepare?" becomes a legitimate question and the prohibition is decorative. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
canMemorize has exactly five bars — combat, a spell they do not know, one already prepared, one above the book's level, no free slot. Rest is not among them and never was. But the contract only ever listed those five without saying the list was CLOSED, and the strongest prior a language model brings to "prepare spells" is the tabletop one, where preparation follows a long rest. Left unsaid, that prior invents a sleep nobody asked for — the same failure as the bug that started this, with the arrow reversed. There the GM slept and then helpfully prepared; here it would prepare by first helpfully sleeping, and either way the player loses hours to a step they did not choose. So both places the GM decides now say it outright: the field note it reads before emitting the directive, and the loadout dossier it reads when looking at empty slots. An empty loadout is never a reason to put the character to bed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The DM's world flag stays the default, because it is a house style and should travel with a world they hand to someone else. But it governs how the game talks to the PERSON playing — whether a loose "get ready" is expanded or handed back as a question — and that is a play-style preference like verbosity, not a property of the setting. A player who wants to be asked wants it in every world. So Settings › Story gains three states, defaulting to the world's own: Follow this world / Ask me first / Decide for me. Following is the honest default rather than a fudge — a DM's choice still lands until the player deliberately takes it over — and the override is global for the same reason it exists at all. Precedence lives in askFirstActive() alone, and the prompt reads that rather than the world flag, so nothing can silently ignore the override by reaching past it. The "Follow this world" option names which way this world actually leans — "Follow this world (asks first)" — because the flag is set in the World Builder and shown nowhere in play, so a player has no other way to find out. This was not hypothetical: the setting could not be found at all, which is how the bug that started this was reported without knowing which mode produced it. Still worth knowing, and not fixed here: the World Builder opens only from New World, so the DM's flag cannot be changed on a world that already exists. The player override reaches every world; the DM default reaches only new ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reverts the intent detector from 7b35ecd and fixes the actual defect, which was in the dossier.
The detector was the wrong instrument and its own test said so: the first version rejected "prepare
tide-bond and bulwark" — the contract's own example — because it required a preparing verb next to
the literal word "spell". A false negative there is worse than the bug it was for, since the engine
silently drops a real request while the GM narrates that it happened. Inferring intent from free text
will not be made reliable by more patterns, and one detector per GM slip is a losing shape.
What the GM was actually told about an empty loadout was this, in full:
• (no spells memorized into a field spellbook — the character cannot cast until they prepare a loadout)
That is a fault report, and a helpful GM corrects a fault. It never said the loadout had lapsed
BECAUSE they slept, and never said restoring it costs an in-world hour per point of MP. So the GM was
deciding to spend seventeen hours of someone else's time while being told neither what had happened
nor what it cost — with a rule underneath telling it not to. The rule was not the problem. The state
line above it was arguing the other way, and it was the one carrying facts.
clearAllLoadouts now records what it took and when, which the engine always knew and threw away; a
lapsed loadout and one never prepared had been indistinguishable. The dossier says which, names the
spells so waking can be narrated properly, quotes the cost of putting them back, and says that the
absence of a request is the player's answer rather than an oversight.
Rule 11c gains the boundary the bug turned on. It governed filling in DETAIL within an activity and
never said a second ACTIVITY may not be added: sleeping is one, memorizing is another, and the test
it offers is cost rather than plausibility — "is it sensible" licenses the addition, "does it spend
their time" does not. Generalised past this pair, since waking-and-eating and arriving-and-buying are
the same shape.
The ask-first dial keeps its exemption for specific requests, which was right: "I sleep until dawn"
is an answer, not a question, and querying it teaches players to switch the setting off. What it
gains is the case it exists for — a BROAD intent the GM expands. "Get ready for the battle" names no
activities, and folding a night's sleep into it settles a tactical question by proxy, discovered
afterwards from empty slots and a clock that moved a day.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThis reverts commit 7b35ecd82bf8bda7e7a9646fb3a267da51c61fd9.
The clock jumping from evening to the next afternoon after a sleep was not the sleep. Sleeping lapses
a caster's loadout by design, the GM helpfully put it back, and preparing costs an in-world HOUR per
point of MP — five mid-level verses is seventeen hours, which from evening lands in the next
afternoon exactly. The player never asked for any of it and had no way to see where the day went.
The contract already said ONLY ON THEIR REQUEST in bold. It did it anyway, which is this codebase's
recurring lesson: a refusal that only lives in the prompt is a request. So the directive is now
dropped in the engine when the player's own words did not ask for it, and the drop is logged with
what was refused and what the player actually said, rather than being silent.
detectMemorizeIntent is looser than detectRestCommand on purpose — a rest command IS the whole line,
while a preparation is asked for inside a sentence ("prepare tide-bond and bulwark before we go
down"). A preparing verb alone cannot license it, since "prepare for battle" and "ready my sword" are
ordinary English; the verb has to be aimed at something spell-shaped. That includes a spell BY NAME,
resolved against the live catalog, because the contract's own example says "spell" nowhere and a
detector that missed it would refuse exactly the players using the feature correctly — which the
first version did, and the test caught. A question about the loadout is not a request to change it,
so "what spells do I have prepared?" carries a perfectly good preparing verb and still does not match.
An ask stays live for two turns, the shape crawlJustLeft uses, so "prepare my spells" / "which ones?"
/ "tide-bond and bulwark" survives the clarifying exchange — the answering line names no verb at all.
It lapses after that, or one preparation early in a session would license every unasked one after it,
including the re-preparation this commit exists to stop.
The field note now tells the GM the engine checks, and names the sleep case specifically. Without
that it keeps trying and keeps narrating a preparation that did not happen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe sleep banner sweeps the clock over five to ten real seconds and locks the action box for the duration — the player is sitting there watching their character sleep. The turn pipeline was not waiting for it. applyStateChanges started the sweep and execution ran straight on to the status block, which applied the GM's rest boon and printed "◈ Benefit: well-rested." about 120ms in. So the benefit was announced roughly eight seconds before the character woke to it, and the status was live on someone still in bed. afterRestSweep(fn) runs its argument when the sweep settles, or immediately when no sweep is running, so a caller does not have to know whether this turn is a sleep, a light rest or neither — a light rest behaves exactly as before. The player-status block moves inside it, and the ability grants with it: on a rest turn a granted ability is the rest's doing too, and gaining one mid-sleep reads just as wrongly. The flag clears before the queue drains, so queued work sees a settled world rather than one still mid-sweep — a boon whose duration were measured from a sweeping clock would expire early. One step throwing does not strand the ones behind it. The test asserts the property rather than the plumbing: with a sleep in progress the boon is not applied, "well-rested" is not on the character, and the story has not announced it; after the sweep settles, all three. Six sabotages. Two were first "caught" only by a SyntaxError, which is not an assertion doing its job, so they were rewritten brace-balanced and re-run — and the third then slipped through entirely: the check that abilities are deferred was positional, and "appears after the deferral opens" is equally true of a block sitting after it has closed, which is exactly where they used to be. It now requires no closer between the two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Brought over from claude/prompt-economics with two corrections, because the version written there got this repo's central working fact backwards. Rule 1 said "merging to main publishes the game — treat a merge as a deploy", and the branches note said not to push to main at all. The deploy half is true and stays: a push touching the app, the guide, support.js, Handbook, Images or Web goes straight out with no staging and no approval. The inference drawn from it was wrong. The site it deploys to is a TEST site and main is the working branch, so the deploy is the point rather than a hazard. What actually follows is narrower and more useful: a push is visible immediately, so run the suite before pushing rather than after. The branches note now says to work on main for ordinary fixes and to reach for a claude/<topic> branch when the work wants reviewing as a unit or is large enough that a half-finished main would be awkward. Also reconciled against what main actually contains. The original described the GM prompt's cached/live split, cited buildSystemPromptParts, and pointed at tests/test_prompt_cache.js and Designs/prompt-economics.html — none of which exist here; they are on the unmerged branch. A conventions file that sends a session looking for a function the tree does not have is worse than one that stays quiet, so that section is a single pointer noting where the rule lives and that it should be expanded when the branch lands. The living-world timer note kept its fact (tryEncounter never calls the GM) and lost only its dead doc reference, and the sample test command names a test that is present. Every path the file cites was checked against the tree. The two that resolve to nothing are deliberate: BUGS.html is named inside the Evaluations/ row, and index.html is cited precisely because the deploy workflow watches a file that is not there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The intermittent failure was a real race, and it was in the test rather than the code. _saveDelayMs computes max(DEBOUNCE, COOLDOWN - (Date.now() - _lastSaveAt)). The test did: const now = Date.now(); T.setLastSaveAt(now); check(T._saveDelayMs() === T.SAVE_COOLDOWN_MS, ...) Two reads of the real clock with an assignment between them, asserted to be identical. Any run that crossed a millisecond boundary there got COOLDOWN - 1 and failed. Nothing about the debounce was ever wrong. The test already installed controllable setTimeout/clearTimeout so the debounce could be driven deterministically; the clock belonged in the same bracket and was not there. It is now, captured from the real clock once and frozen. That also lets one assertion get stricter rather than looser: the part-way-through case carried a +/-50ms tolerance purely to survive elapsed time it could not control, and a tolerance that wide would equally have admitted a 49ms arithmetic error. It is now an exact equality against COOLDOWN / 2. 25 consecutive runs green, then the full suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The per-test normalisation stays as cheap insurance against an odd checkout, but the comment no longer needs to narrate a failure that can no longer happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The repository was already LF-only -- core.autocrlf was normalising on the way in. What it also did was hand Windows a CRLF working copy, and that is what kept breaking the suite: a test searching the source for a literal "\n" anchor finds nothing in a CRLF checkout, so it fails on one machine and passes on another for a reason unrelated to the code under test. Three tests hit this. The last, test_rest_boon_timing, was reported as a real regression by one session and as green by another on the same commit. Because the index is already LF, `git add --renormalize .` produced no content change at all -- this alters only what lands on disk. Also removes .tmpstyle/style.html, scratch I extracted while building the class-gating doc and committed by accident: the cleanup ran in a bash command that failed to parse, and it should never have been written inside the repo when a scratchpad directory exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The two anchors it searches for are literal-\n strings matched against the raw file:
raw.indexOf('afterRestSweep(() => {\n const bedBonus')
With core.autocrlf=true the working copy is CRLF, so \r\n never matches \n, openAt comes
back -1, and the test reports "deferral not found" for a file where the deferral is
present and correct. Green on one machine, red on another, for a reason that has nothing
to do with the code under test -- the worst shape a test failure can take.
Normalised for that comparison only, deliberately not at read time: the sandbox built from
`script` is someone else's in-flight work and did not need touching to fix this.
Third time this has bitten the suite. A .gitattributes pinning eol=lf for the files tests
read would fix it at the source rather than test by test, but that rewrites line endings
across the working copy and is not a change to make while another session is pushing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvDesigns/class-gating.html — why a class-restricted ITEM is refused outright while a class-gated SKILL is learnable by anyone at a penalty, and why that asymmetry is right rather than an inconsistency. Three arguments: what the acquisition already cost (a point spend IS the dedication, so a wall would be double-billing, where gear costs nothing but reach); reversibility (class is chosen once and permanent, skills accumulate, and a gate protecting an irreversible choice should be harder); and what a class is in fiction (gear is creation-time build-craft, skills are abstract learned things and abstract things travel). The argument that settles it is the failure mode. Skills gate CONTENT and gear gates POWER, so the two hardnesses fail in opposite directions: a soft gate on gear dissolves build identity, while a hard gate on skills strands authored content -- the failure this project keeps hitting (BUG-033's eight unlearnable skills, BUG-019's unreachable hook). Which is also why skillHardGated is reserved for spellcasting alone: it is the one skill that is a build identity rather than a capability, so it is gated like gear. Also records the legacy-taxonomy migration trap that would otherwise have made every pre-migration item unequippable, and the worn-not-carried line -- an off-class character may still loot and sell, because a class gate is not a looting rule. Section 6A carries the DM's resolution of the book question: a book is whichever the author made it. Gating a book IS the statement that its knowledge is closed, so hardness becomes a property of the artefact rather than of the skill, and the item hard gate and skill soft gate compose instead of contradicting. Decided, not yet built -- the read path still keys off skillEligible rather than the book's own gate. 6B stays open: nothing checks eligibility before the coin moves, which under 6A now bites only for a gated book the buyer can never open. Indexed in Designs/README.md and the DM's Guide appendix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Every piece of this already existed: the field, the legacy-taxonomy migration that keeps
it honest, itemAllowsClass, the editor chip, and the item popup's "your Tide Cantrix
cannot use this". Nothing enforced any of it.
Worse, the GM was handed a note asking IT to enforce -- "a {class} is NOT proficient;
block or heavily penalize off-class use". That is a rule the engine can settle exactly,
delegated to narration, which is how every other member of this family began.
equipClassRefusal(it, p) returns a REASON or null, like containerAccepts and canMemorize,
and is checked on both routes onto the body: equipping from the pack, and drawing gear out
of a storage slot. Without the second, the whole check is bypassed by stowing the item
first and dragging it to the slot after.
WORN SLOTS ONLY. Carrying a Mage's dagger in a satchel is not using it -- the same line
the equipped-effects logic already draws two screens down. Restricting what may be CARRIED
would turn a class gate into a looting rule, and an off-class player is entitled to pick a
thing up and sell it. Stowing is never refused either; refusing it would strand off-class
gear on the doll with no way to take it off.
The GM note now says the engine already refuses equipping, and names what genuinely
remains the GM's -- reading, drinking, invoking, the uses no slot covers.
The test surfaced the one way this could have gone badly wrong: `classes` used to hold the
item TAXONOMY, and resolveItemKinds only treats it as a gate when `subtypes` is also
present. Fixtures omitting subtypes were passing for the wrong reason. Verified against
the real catalogue: both of Verengrad's gated items keep their restriction and produce a
refusal, and of 37 items exactly 2 are restricted -- no ordinary item became gated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvBUG-033 was closed on one skill when it was a class of problem. A sweep found eight more with the identical defect: four gated to "Cantor-Adept" and four to "Salt-Broker", where the class keys are CantorAdept and SaltBroker. The engine compares raw strings, so every one matched nobody and the specialist toolkits of two whole classes were unlearnable. The uncomfortable part is that skill-gate-misspelled already existed, was already correct, and would have caught all eight. Nobody ran the pass -- it runs from the command line against an exported world, or from the Evaluate tab against the Server Vault, and neither had been pointed at Verengrad. So the fix is not another check. test_walkthrough_class_gate.js drives the real compileWalkthrough with Verengrad's exact shape and asserts all eight are reported, with the gate quoted as authored and the real key named, since the fix is a rename. It also pins the distinction that makes the check usable: a gate naming a class this world has in NO spelling -- Rogue, Mage, kept deliberately -- is NOT a misspelling, because there is nothing to rename it to. Misspelled and absent are different findings with opposite fixes. And a mixed gate with one renameable entry is still reported, so a valid-looking sibling cannot mask it. Deliberately NOT added: an item class-gate check. it.classes on items is documented twice as the LEGACY taxonomy shape that resolveItemKinds files into subtypes, and nothing compares it to player.class. A check there would enforce a rule the engine does not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
2422 commits across 44 days (2026-06-30 to 2026-08-12). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8iXyEsAAD73SL5Wn4z1d4
A field only the GM prompt could reach was half-built: the DM could see the number in the Threads view and had no way to change it. Coin sits beside XP on every beat card, because it is the same decision -- what reaching this beat is worth -- and the engine pays them in the same breath at unlock. Blank reads "none" rather than naming a default, since unlike XP a beat with no coin genuinely pays nothing; there is nothing to inherit. The copper is echoed in coin beside the box, so 1200 is legible as 12 gold. The note appears only once there IS coin. A "who pays it and why" box on a beat that pays nothing is a field asking a question with no answer. It is GM-only narration guidance, which matters precisely because the engine moves the money silently and somebody in the world still has to be seen handing it over. Stored in the beat's own rewards list beside any items, not as a parallel beat.coin -- a second place to look is a second thing to keep in step. Clearing the amount removes only the coin entry, so a beat paying a relic and a purse keeps the relic; editing the amount keeps the note. The coin box briefly borrowed .quest-beat-xp-input for styling, which made test_xp_fields' "count the XP boxes" count the coin boxes too -- an assertion about one thing quietly measuring another. Styling is shared through a grouped rule now and the class names exactly one control again; the test counts both, separately. Browser-verified: paid beat shows 1200 / "copper - 12g" with the note box; unpaid beat shows an empty box reading "none" and no note field. One line each, nothing wraps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Beat rewards were item refs only, and that is a real limit rather than a stylistic one: an item's worth reaches the player through a merchant AND through the sell rate, so an author writing "a reliquary worth 40 gold" cannot say what the player actually receives. Coin is the only reward form that can be priced exactly. Measured in Verengrad: "The Last Descent" is six beats and the world's only quest. Its rewards fire on two of them -- a Salt-Verse Shard at 40 copper and the Coral-and-Bone Reliquary, which is type "key" and value 0. Its terminal beat, the climactic choice the whole arc builds to, pays nothing, while the engine's own rule two screens up says a better outcome must pay more. On the diegetic objection: a beat's XP is already granted for reaching it, in this same object, on these same beats, with no in-world cause. Coin joins XP rather than breaking new ground -- which is why it is paid on the same line, under the same unlock guard. The authored figure is a BASELINE. The GM may name its own on the questUpdate to pay more when the player earned it or less when they scraped through, and the engine settles it within 50% either way, saying so when it has to move one. Same shape as a merchant's offer on a sale, and for the same reason: without a band, "how impressed am I" is the authored economy being re-rolled every time a quest resolves. The engine pays coin; the GM still hands over items. An item can be pressed into a hand or left in a chest and only the narration knows which. Coin needs no such judgement, and improvising it is the unanchored payment we just removed from selling. A coin request on a beat that authors none is refused and logged pointing at copperDelta, which is the field for a payment the scene invents. Third instance today of Number(null) === 0 biting: an unnamed figure coerced to 0 and docked every reward to the band floor. Read raw before coercing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Every character started with a hardcoded 15 gold, identical for all five classes, because
no class could say otherwise -- the field did not exist. That is fine while classes
advance by FINDING things and wrong the moment one advances by BUYING them.
Measured in Verengrad: the Tide Cantrix's progression is three purchased psalters
totalling 52 gold, and selling every object in the world -- floors, containers, every
monster stripped, including plate armour she cannot wear -- comes to about 34 at the
world's sell rate. She cannot reach her own second book, and nothing in the editor could
say so.
class.startingPurse, in copper like every item value, so a loadout and a purse can be
compared without converting. Absent means the shared 1500 default, so no existing world
changes. A typed 0 is honoured -- "no opinion" and "begins penniless" are different
answers and the field has to give both. Editable on the class card, authorable through
the class-edit contract, and the roster now shows every class's purse so a new one can be
balanced against them.
Credited through creditCopper, so 4000 copper reads as 40 gold, and taken before the fame
seed so a richer class does not start with fame for coin it was handed.
Two caught by the tests while writing them: Number(null) and Number('') are both 0, and 0
is a legal purse here, so an unset field resolved to a vow of poverty -- now read raw
before coercing. And test_class_card_equip_slots pinned invSection and eqSection as
ADJACENT rather than ordered, so a new section between them failed a true statement about
the card; it checks order now.
Browser-verified: input 84px with its unit beside it on one line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThere was no sellItem. A sale was dropItem plus a freely-chosen copperDelta, and both
halves were wrong.
The price was anchored to nothing. Every item carries a value in copper and a sale
ignored it, so the eight Verengrad plants could be repriced with great care and then
re-decided, differently, at every till. Purchases were already anchored -- costCopper is
charged against the real purse and refused if short -- so only the income side was
improvised, which is what hid it.
And dropItem does what its name says: room.items.push. Selling a vine to Saltmonger Hesk
left the vine on the floor of Hesk's own market, to be picked up and sold again.
Unbounded money. Its own docs named selling as the use case and its code comment records
Claude13 selling Hesk two Salt-Verse Shards through it, so this was the established path.
sellItem { to, name, quantity, offer } now does all three halves. The price is the item's
value times a per-world sellRate (default 50%, editable as "Merchants pay"). Naming a
buyer makes the goods that merchant's stock; with no buyer they leave play. Neither path
touches the floor.
A band, not a fixed price -- 25% either way, because merchants differing is real and
worth keeping. The GM's offer stands whenever it lands inside; a figure outside is moved
and then said, to the log and back to the GM through a note, so the next sentence matches
the purse rather than compounding the gap.
Also: creditCopper breaks a payout into denominations, so a 24-gold sale reads as 24 gold
rather than 2400 loose coppers. And the "Merchants pay" box is sized to the number it
holds -- the we-inline row had stretched it to 777px and wrapped its own unit onto a
second line. Browser-verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvA placeholder can only ever be as wide as its box, and this box is two digits wide, so "12 (Herbalism default)" rendered as "12 (Herb..." -- the half that explained anything was the half that got cut off. The skill and its default now sit in a dim hint beside the field, which leaves the placeholder as just the inherited number. The hint shows even when a DC IS set, because during a repricing pass the question is not "what did I type" but "is that harder or easier than this skill's own bar". That also frees the width the spinners were being denied, so the number input keeps them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Flora tab shows everything about a plant except the one number a repricing pass exists to set. Added the row for every identity-gated type (plant/magic/contraption), as an input rather than a read-out. Blank means inherit, with the inherited figure in the placeholder. An omitted DC is not "no difficulty" -- it is a difficulty the skill chose, and an empty box saying nothing would hide that the way an omitted loreXp hid the flat 12 every Verengrad flora hook was quietly paying. Clearing writes undefined through applyItemTypeField, so the field stays absent rather than becoming a 0 no roll can fail; values round and clamp to 1-99, and the box is corrected to whatever was actually stored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Asked for on the Flora tab ahead of a repricing pass that has to set the DC alongside value and loreXp. You cannot pitch eight difficulties against each other without seeing what they currently are, and the card showed everything about a plant except the number the pass exists to set. Shown for every identity-gated type rather than plants alone — magic items and contraptions carry the same gate, read by Arcana and Machinery, and a row appearing on one of the three would be a puzzle rather than a feature. Each shows its own skill's figure, not a shared one. And shown even when unauthored, muted, as "12 (Herbalism default)". An omitted DC is not "no difficulty"; it is a difficulty the skill chose. A blank row would hide that exactly the way an omitted loreXp hid the flat 12 every flora hook in Verengrad was quietly paying — which is the reason the repricing pass exists at all. Decided and inherited now look different at a glance. A DC of 0, a word, or a skill this world does not define all fall back to the default rather than printing NaN into the card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two gold buttons in one row give it two primary actions. The row has one: the button that sends the request. The expand button opens a box, so it takes .regions-btn-ghost — the dark variant already used elsewhere in the editor — and sets no colours of its own. Layout only, because colours here would be a third button theme maintained by hand, drifting from the two that already exist. The row assertion now matches on the marker class alone rather than the full class list. A class list is a look and will change; an assertion that fails whenever a button is restyled is one people edit rather than read. What must hold is that every row has the control, which is what it now says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Every editor tab asks the GM for changes through one single-line input. That is right for "add a luminous moor-moss" and miserable for the multi-paragraph briefs these tabs increasingly want — a repricing pass over every plant, a lore-link retrofit — where you cannot see what you typed, a stray Enter fires the request half-written, and pasting anything with newlines flattens it into one line. So each of the eighteen editor rows gains an expand button between its field and Apply, opening one shared dialog with a real textarea, a gold Apply and a Cancel. Whatever is already typed carries into the box, and the row's own placeholder becomes the dialog's description — better than a generic line per tab, and it cannot go stale because it is the same string the row is showing. The dialog finds its row from the button that opened it rather than from a table of ids: a table would be a second list of the eighteen, and the day someone adds a nineteenth it is the one they forget. It submits through that row's OWN Apply button, so each tab keeps exactly one submit path — a parallel one is how "the dialog does something slightly different from the box" begins. It closes before the request goes out, since these take many seconds and a modal left over the top hides the output it produces. The Character sheet's "Ask GM" box shares the row class and is deliberately untouched: it is the player's, not an editor tab's. The test asserts all eighteen rows still have their Apply button, because the first attempt at this insertion silently deleted them — a regex replacement whose group references did not expand, which the suite caught and a glance at the page would not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported as "// guide" no longer opening the Dungeon Master's Guide: it opened a new window showing the current game, at the guide's own URL. The command was fine the whole time. The server was down. The service worker's offline fallback answered any failed same-origin NAVIGATION with the cached app shell, and a popup to /Handbook/dungeon-masters-guide.html is a same-origin navigation. So the request came back as text_adventure.html with a 200 on it, at the URL that was asked for — which the address bar cannot expose, because the URL is exactly right. This is the same substitution the handler already guards against for cross-origin requests and for non-navigations; the comment above it describes a valid GLB being reported as "not a glTF/GLB model" for precisely this reason. Navigations were the case still open, and every page in this app that is not the game falls through it: the DM Guide, the Player's Handbook, the Field Guide. The fallback now requires the navigation to be TO THE APP — the root, an index, or text_adventure.html, the three ways the game is ever opened. Anything else gets the browser's own offline page, which is the honest answer for a document this app cannot serve from its shell. CACHE_VERSION bumped so a browser holding the old worker discards it. The version assertion is now "a version, at least this one" rather than a single string: every fix here needs a bump, and an assertion naming one version has to be edited on each of them, which is how it stops being read and starts being updated reflexively. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both were green elsewhere and red here, which is the worst kind of red: it teaches people to ignore the suite. test_install_world_media spelled a POSIX path, "/tmp/x". That is not absolute on Windows — Node resolves it onto the current drive as E:\tmp\x while path.join leaves it \tmp\x — so the two sides could never be equal and the check failed for the operating system rather than for the thing it tests. The claim is that the tool's media directory is the SERVER's, which is true on any OS, so it is now built from os.tmpdir() and asserted on any OS. test_item_taxonomy compared the generated reference byte for byte. With git handing a Windows checkout CRLF and the generator writing LF, the doc read as out of date with an identical repository and zero content difference. --check now normalises line endings, because whether the reference still describes what the code says is the question, and CRLF-vs-LF is not part of the answer. And the generator no longer rewrites the file when only its line endings differ. Without that it marks the doc modified on every run, which is how the noise gets into someone's commit — or gets reverted along with a real change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Players speak at different resolutions and the same sentence means different things from each. "I rest" from someone who knows the rules is a considered choice between a breather and a full night; from someone who does not, it is just going to bed. The GM cannot tell which it has, so it guesses, and it guesses wrong in both directions — interrogating a player who wanted to get on with it, or quietly spending eight in-world hours and clearing a caster's prepared loadout. So it is authored rather than inferred, and it sits on the WORLD rather than the browser, because it is a house style: a survival world where every hour of sleep is a decision wants asking, a breezy one does not, and a DM shipping a world should be able to ship the answer with it. It is in serializeWorld, so it survives export, import and save — the lesson `economy` taught by being the one brief field that silently reset on a round trip. Rest is the pilot case: the request is casual and the consequences are not. Off (the default), the GM picks the reading that fits and NAMES it — "you bed down for the night" rather than "you rest" — and states the cost. On, it puts the choice back in one line, in the fiction, with the trade-off stated, and does NOT set "rest" that turn; without that last clause it would ask and rest, which is the worst of both. Two closures keep the setting from becoming tiresome: a chore is still just done, and an already-specific request is acted on rather than confirmed. The dial moves only WHO resolves an ambiguity. Rule 11c fixes what the GM may KNOW while resolving one, for every world at every setting, and the branch is appended after the invariants rather than among them so the floor cannot read as part of the option. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two changes from running the taxonomy against a real item. A "Bone Needle" came back ["needle","dagger","one-handed","finesse"] where it had been ["needle","dagger","light weapon"] — the world's own word kept, the vocabulary applied, and two things worth acting on. "light weapon" was dropped rather than mapped to "light". The vocabulary had the bare form, but authored worlds already write "light weapon" — it appeared a dozen times in the surveyed catalogs — and this file's OWN armor facet was already "light armor"/"heavy armor", so the weapon handling facet was out of step with both. The humanized spelling leads now, with the short forms accepted rather than corrected: nothing reads either one, and telling an author their existing label is wrong when it is merely shorter is a vocabulary picking a fight it does not need. Alternates render beside the label they mean rather than as entries of their own, since two spellings listed side by side read as two different things. And the cap goes to four. A weapon answers three standard questions at once — form, grip, handling — which left a world with no room for its own label except by dropping one of them; under a cap of three, something true about the Bone Needle had to go. Stated as three-plus-one rather than a flat four, so the world label stays an ADDITION: it goes beside a standard form label, never instead of one, or a reader outside the setting learns nothing about what the object is. Three assertions in test_item_subtypes_directives.js broke on the cap change while measuring something else entirely — they had spelled "1-3" into regexes whose claim is that kinds are authored under "subtypes". Loosened to match the count phrase without pinning it; the cap belongs to the taxonomy's own test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The other half of BUG-021. The contract now forbids the GM inventing a preparation scene; this gives it a way to actually perform one, so "prepare tide-bond and bulwark before we go down" gets done rather than answered with a pointer to a tab. Every rule stays where it already lived. canMemorize owns the refusals — combat, unknown spell, book too low, no free slot, already prepared — so the directive cannot drift from what the Spellbook tab does, and each refusal is reported OUT LOUD to the player and queued to the GM. A refused preparation only the engine knows about is the bug being fixed wearing a different coat, which is the whole lesson of this family: dropItem quantity, container capacity, the roll asked in prose. Removes are applied before adds, so a swap works in one turn instead of failing against a fullness the request itself was clearing. Time is charged once for the whole preparation rather than once per spell — an in-world hour per point of MP, so a five-slot loadout of mid-level verses is most of two days, and five separate banners would tell that story five times without ever totalling it. The field note says it is only ever on the player's request, points at rule 11c for the knowledge limit — a loadout IS the tactical answer to what is coming, so this is the directive most able to become a cheat code — and tells the GM not to promise the outcome, since the engine decides it. The dossier line written when no directive existed is retired rather than left to contradict the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Players speak at very different resolutions — "I prepare tide-bond, bulwark and the salt answers, then bed down for four hours" and "I get ready and rest" are both legitimate, and the GM meets both. What must not happen is the second player quietly getting a better game than the first, because the GM fills gaps using what it can see and they cannot. That is the real cheat-code risk and it produces no symptom: the dossier hands the GM creature HP, hidden lore and its unlock conditions, trap DCs, the contents of unopened containers. A loadout chosen against the monster downstairs reads exactly like a well-played hunch. So the first rule is that a choice made on the player's behalf may draw ONLY on what the player could know, with the private knowledge enumerated rather than gestured at — "do not use hidden knowledge" is a line every model believes it is already obeying. Then: reasonable rather than optimal, because the GM stands in for the character's judgement rather than improving on it. Name what was filled in, in the prose, since a choice the player cannot see is one they cannot correct — that single habit is what lets one behaviour serve both the loose and the precise player. Never ask about a chore, which is friction wearing the clothes of control. And say what a filled-in choice costs as it happens: the GM can already put a caster to sleep for eight hours off a casual "I bed down", and sleeping clears the prepared loadout, so the player learnt it at the moment they tried to cast. This is the floor beneath the per-world assist setting still to come. The dial will decide who resolves an ambiguity; it never changes what the GM may know while resolving it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The rewrite from regex literals to RegExp constructors left the open-vs-close capture reading `(\/?,?)`, so a comma could stand in for the slash. That capture is the only thing deciding which of the two a tag is, and it is truthy either way, so `<,b>` — an OPENING shape — came out as `</b>`: "<,b>hi<,/b>" before: "</b>hi<,/b>" after: "<,b>hi<,/b>" "</,b>" before: "</b>" after: "</,b>" Nothing threw and nothing was let past the allowlist; the popup simply rendered unbalanced markup. Now `(\/?)` again, and neither shape is treated as a tag at all. The function had no tests, which is how a one-character change to it went unnoticed, so it has some now — weighted toward malformed input, since a sanitizer's ordinary cases keep working while its edges are the entire reason it exists. They cover the allowlist, attribute stripping, the javascript: and data: href refusals, script/style/comment removal, and balance as a property rather than as one example. Two known behaviours are pinned rather than left to be rediscovered: a ">" inside an attribute value ends the tag early and the remainder falls through as text (inherent to matching tags with a regex, and safe — what falls through is text, not markup), and a stray closing tag of an allowed kind survives while a disallowed one is dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The world-edit notices used to end with a shared sentence — "New games will seed from it once you publish. Your saved games are untouched — use 'Edit this save' to change one of those." — and a test asserted every notice carried it. That text was removed deliberately as surplus on the login screen, so the claim is no longer one the app makes and the assertion goes with it, rather than being weakened into something that passes without measuring anything. What replaces it is the part that was never redundant: the three notices are DISTINCT, and each says which of the three situations the DM is in — a world not in the library yet, a resumed unpublished draft, or an ordinary library edit — and all three name the world they opened. A notice about "the world" is the one a DM with several drafts cannot act on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The Items tab's GM box is how an existing catalog would get its subtypes
filled in, and the roster it sends listed id, name, type and lore — but not
subtypes. Since `subtypes` is an ARRAY, writing it REPLACES what was there, so
any instruction to fill them in was a blind overwrite.
The things that would have been flattened are the best-labelled things in the
catalog. A world's plants come back 102 of 115 subtyped and carry the world's
own vocabulary — salt-flora, silencing-flora, cantos-touched — none of which a
pass working from names alone could reconstruct.
So the roster carries them, and says NO SUBTYPES where an item has none rather
than showing an empty column: "nothing there" and "I could not see it" look
identical otherwise, and only the first can be targeted ("fill only the ones
marked NO SUBTYPES"). The directive alongside says the field is replaced on
write, so the GM restates what is worth keeping instead of starting over.
Two of the four sabotages against this initially passed, both because the
assertions matched the wrong place: "NO SUBTYPES" also appears in the
instruction paragraph, so deleting the empty-case branch went unnoticed, and
the lore tail is BUILT above the line it is interpolated into, so dropping it
from the template still left the word in the slice. Both now assert against
the roster line itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLBUG-021's harm is not that the GM cannot memorize spells — it is that it says it has. Asked to prepare a loadout it rolled a check, wrote the scene, reported success, and nothing happened: `carried` stayed empty and 55 minutes of world clock went by. There is no memorize directive to reach for, and the failure was silent because nothing in the contract said the lever was missing. The rule now sits where the loadout is shown, so the capability and its limit arrive together: the GM can SEE what is memorized, cannot change it, and is told not to narrate or roll for it — with the reason, which is that a scene saying otherwise leaves the player walking into a fight believing they are armed. It is asked to say so in its own voice and point at the Spellbook tab. This is the cheap half. A memorize directive is the fuller answer and needs engine-side validation with an audible refusal — slots, book level, the time cost — or it recreates this same bug one layer up. Left for the discussion about where the line between player and GM should sit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
HELD FOR REVIEW — committed locally, deliberately not pushed. Every item carries a `type` (its single mechanical kind) and `subtypes` (what the thing actually is). Only the first was reliably being written. Measured across 39 authored world files, 917 items: 87 of 87 consumables carried no subtypes, 79 of 79 keys, 146 of 173 weapons, 41 of 53 armour pieces. The largest type, misc, held 372 items of which 303 said nothing beyond "miscellaneous". One type came back well-subtyped — plants, 102 of 115 — and it is the one type with an authoring guide of its own. Where the vocabulary was stated, it got used, which is the whole argument for stating it. The gap costs something rather than merely reading thin. FINESSE_SUBTYPES and RANGED_SUBTYPES are what weaponAbility tests, so a weapon authored with no subtypes is silently a STRENGTH weapon — 146 of 173 of them. So: ITEM_TAXONOMY, twelve types with a faceted subtype vocabulary, and Designs/item-taxonomy.html GENERATED from it by tools/build-item-taxonomy-doc.js. Generated rather than written beside the code because a reference that disagrees with what the GM is handed is worse than none — an author follows it, writes a label the engine never matches, and the item quietly does nothing. That is not hypothetical: the equipment-slot roster was hand-copied into two prompts and both went stale. A test runs the tool with --check. The engine-read labels are not restated at all. They are read from the very Sets weaponAbility tests, so adding a label to one puts it in the reference and in every prompt with no further edit. Two corrections found on the way. `treasure` was missing from the type roster the GM was given while the engine has always branched on it, so the prompts named a type they never offered; the union is derived from the taxonomy now. And inferNaturalItemType could mint `type: "potion"` — not a canonical type at all — when migrating a legacy magic item; it answers "consumable" now, with the potion-ness kept where it already was, in the subtypes. Three suites asserted prompt wording this rewrote. Each was re-anchored to the claim rather than the phrasing: membership now reads the taxonomy instead of a prompt literal, and the item-schema detector keys on the FIELDS a schema has rather than on how its type union is spelled — the previous version keyed on that literal and stopped seeing one of the two schemas the moment it became an interpolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Re-Generate and Extract Portrait were in two separate action rows, so they stacked. They are the two things you can do with the picture above them — repaint it, or take a face out of it — so they belong beside each other, and one row costs the column half the height two rows did, which the frame gets back. Equal widths rather than natural ones: two differently-sized buttons under a picture read as a primary and an afterthought, and these are the same kind of action. That styling is opt-in via a marker on the row, because Extract Portrait only appears once a render exists and a lone Generate stretched across the whole column would look like a mistake rather than a choice. Measured in a browser rather than assumed: 180px each inside a 372px column, same row, no clipping, and the empty state still a single centred button. min-width: 0 is insurance, not load-bearing — removing it changed nothing at this width — and nothing pins the labels to one line, so a narrower column wraps them and grows both buttons together instead of clipping one in half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
It was the one prompt that mints items and named the field without saying what may go in it: just "equipmentSlots": ["<slot for worn/wielded gear>"]. A model told a field exists but not its vocabulary writes a plausible id, normalizeEquipmentSlots drops it, and the gear arrives silently unequippable — or, with the field then absent, falls through to type inference and lands wherever the type points, which for a pair of boots typed "armor" is the torso. Pinned by shape rather than by naming the prompts: any line carrying an item schema (the "weapon|armor|…" type union) that also mentions equipmentSlots must interpolate EQUIPMENT_SLOT_IDS_LIST. A prompt added later is covered the day it is written instead of the day someone remembers this file. The roster itself is asserted to be exactly the canonical ids, so it cannot drift from the doll — which is the failure that had already happened twice with hand-spelled lists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The weapon side mirrored the right column: a second Shield under the weapon and a second Boots at the foot, both drawn for symmetry and both a duplicate of a slot already on the other side. Two positions for one logical slot is a question the doll cannot answer — which one is the real shield? — and it put "Boots" on the figure twice with nothing to tell the two apart. So the column now reads Weapon, Sidearm, Gloves, Belt, Leggings from the top. Gloves moves up into the old shield position, a Belt slot takes the place gloves left, and the foot position becomes Leggings. Both are real slots in the shared vocabulary rather than doll decorations, with synonyms, glyphs drawn to match the existing set, and a place-in-words for the character render, because a slot no item can declare is a slot that stays empty. The sidearm stops reading "sheathed at the belt" now that a Belt slot exists — a painter told both has been handed an ambiguity for nothing. Saves need a migration, and this is why: equippedItems is keyed by doll POSITION, so an entry whose position is gone is not merely undrawn — it is unreachable and still counted. playerAC sums Object.values, the GM dossier lists every key as worn, and equippedAbilityGrants folds in its abilities, so a shield left in the vanished shieldL would go on defending the character forever with no slot to drag it out of. Gear moves to the surviving position of the same slot when that is free and is otherwise unequipped, which loses nothing because equipping never moved the item out of the inventory. It does NOT fall through to whatever replaced the position: bootsL held BOOTS, and dropping them into the Leggings slot now sitting there would be a wrong answer dressed up as a migration. Applied on both routes a character arrives by — a restored save and one adopted from another world. The Mage's Spellbook takes the remaining Shield. Its data op already matched by slot id so it needed nothing, but the legacy CLASS_EQUIP_SLOTS fallback named shieldL and dropped shieldR, which would have left a legacy Mage with no shield and no spellbook either. Both routes are now tested, and the first assertion is which route is under test, so a change to the built-in class cannot quietly turn one into a duplicate of the other. Two authoring prompts hardcoded the slot roster rather than interpolating it — exactly the drift this exposed, since they would have gone on advertising the old eleven and no generated gear could ever reach the new slots. They read the real list now, and all four prompts name what belongs in each. Driven in a browser as well as in tests: thirteen positions, none duplicated, every one with a glyph, and the column in the specified order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A first tab under Settings on the admin page, ahead of the key cards, because "what is this machine and what is it configured with" is the widest question here and the one asked first about a vault someone has just been handed. Server Name and Server Description say what this server is — how it would introduce itself to a player who found it in a directory rather than being handed the URL. Seeded from VAULT_SERVER_NAME / VAULT_SERVER_DESCRIPTION and overridden by whatever an admin saves, the same env-seeds-then-file-wins rule the access lists use. Both are cleaned rather than validated into an error — trimmed, bounded, stripped of control characters — and the cleaned value is written back to the page, so what is displayed is what a registry would receive. A name loses its newlines because a name is one line; a description keeps its paragraph breaks because that is what it is for. Public Server offers the vault to a central registry that does not exist yet. The seam is registry.js, and the decision worth stating is how a stub behaves: it validates for real, returns the record it would have sent, and marks every result stub/not-ok with a sentence the page prints verbatim. A no-op that read as a success is how a server ends up believed-listed for months. The switch is disabled, with every reason shown rather than one at a time, until the listing would be accepted — chiefly a public URL that is not localhost or a private range, since a listing pointing at loopback resolves for every reader, to their own machine. The environment read-out is an allow-list, fail-closed, not a filter over the environment. A variable nobody named is invisible; one named as a secret reports set or not set and never a value — not even the last-4 the key cards show under their own write-only rules, because a bulk dump is a different context. The failure mode of a filter is that adding a new secret prints it and nothing complains; the failure mode of an allow-list is forgetting a row. Asserted directly: known secret values are fed in and the whole serialized response is searched for them. Rows carry the RESOLVED value plus where it came from, since an admin reading this page is asking where the worlds are, not whether a variable is spelled out. "Serving: http" is labelled that way on purpose — it is what this process speaks, and it is normally http behind a proxy that terminates TLS. Driven in a browser as well as over HTTP: subtab order and default panel, the textarea, both blocked reasons, the save round trip showing the cleaned text back, and the stubbed publish printing its own answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The new variable defaulted to server/data/worlds, which reads as "where the worlds already are" and is — right up until an install has moved VAULT_SETTINGS_FILE. Worlds have always lived at dirname(settingsFile) + '/worlds', because that is how server.js derived the directory before it was configurable, and media still does. So such an install would have come up listing no worlds at all, with every file still on disk and nothing said about it: the quietest failure available. The default now follows the settings file, so leaving the variable unset resolves to exactly where the server used to look and there is no migration step. Setting it remains the only thing that relocates anything. Pinned by comparing the resolved directory against dirname(settingsFile) rather than against a spelled-out path, so the assertion is about the two agreeing — which is the actual requirement — instead of about today's default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The worlds directory is now configurable on its own (VAULT_WORLDS_DIR) rather than as part of data/, and worlds are written pretty-printed. One file per world, named by an identifier that never changes, written by rename — that was already a git-shaped layout; these are the two changes that make it usable. The separation is the point. vault-keys.json is an encrypted key store and vault-access.json is who may play, so moving the whole data directory to make worlds versionable would commit both. Media stays beside the settings file for its own reason: it is content-addressed binary that is never rewritten, so every regenerated image would live in the history forever. The index was the only thing that knew a world existed, which held while a publish through this process was the only way one could arrive. A checkout makes it false — a world appears because someone pulled it and vanishes because someone reverted, and this process sees neither. So each load reconciles against the directory: a world.json the index has never seen is adopted, one whose mtime has moved is re-read, and an entry whose file has gone is dropped. Unchanged worlds cost one stat, because put() records the mtime of its own write. Only uid-shaped directories are adopted, and only when the world inside agrees with the directory name — the same grammar that gates get() and read(), so an adopted world is never one they refuse to open. An adopted world reports no publisher rather than inventing one, and the admin table says "from disk" instead of showing the same dash a publisher-less publish shows. A world published here and later edited on disk keeps its publisher and its first-publication date. Two things fall out. A truncated index no longer reads as "no worlds" — it is rebuilt from the directory, which was always the better answer. And put() had to read the index before writing the file: reading it afterwards let reconciliation adopt the world the call had just written and report a first publish as a replacement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A world exported for portability carries its art and audio inline, as
base64 data: URIs — which is what lets the file open anywhere, and what
makes it hundreds of megabytes. tools/install-world-media.js walks the
JSON, writes every embedded picture and sound into the vault's media
store, and replaces each with the /vault/media/… URL the server serves it
from.
node tools/install-world-media.js <world.json> [--out FILE | --in-place]
[--media-dir DIR] [--dry-run]
It is the offline twin of the app's Tools › Remote Embedded Media, for a
world that is a file on disk rather than a save in a browser — a world
being installed on a server it has never been played against, where there
is no session to run that command from. Default output is
<name>.vault.json beside the original; the input is not touched without
--in-place.
The store's LAYOUT and the vault's DIRECTORY are both taken from the
server's own modules — MediaStore and loadConfig — rather than
reimplemented. Content addressing, first-byte sharding, the index file
and the VAULT_SETTINGS_FILE resolution are all the server's. A hand-
rolled copy works the day it is written and drifts the first time the
server changes, producing files that are on disk and not servable: a
world that looks installed with art that 404s.
Because the store is content-addressed, a banner reached from two rooms
becomes one file and a second install stores nothing new.
The test drives the tool against a real temp vault and checks the round
trip — every URL resolving to bytes on disk that match what went in —
which is the only assertion that catches a wrong directory, shard or
extension. Its fixtures are a few KB each on purpose: a vault URL is ~90
characters, so toy payloads make "smaller afterwards" false for a reason
that says nothing about the tool.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLCo-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qM8q5qNwFFc2eewX3aXfe
Correction from the DM, and it is the right one: XP can be linear in value because XP has no ceiling, but a difficulty check does. A plant worth twice as much is perhaps a point or two harder to name, not twice as hard — a DC that tracked copper would leave the dice behind entirely and the plant would simply be unidentifiable. So the rule is the ORDERING (a more valuable plant is harder than a less valuable one) and how much harder is the GM's judgement, which is the part a rule cannot fix anyway. The guidance also now says that the richer plant already rewards more through the discovery XP WITHOUT the DC having to climb to match. Without that, a GM raises both and the valuable plants become the unreachable ones. And it is pitched against the WORLD's own ladder rather than fixed numbers, because higher-level worlds are coming — level 50, stats in the 30s, DCs in the 20s and 30s. The 10 / 12-14 / 16-18 bands are given as an illustration of a starting world and explicitly called trivial for a high-level one; what has to hold in either is the shape, commonest at the bottom, rarest at the top, and the top reserved for the rarest rather than treated as a step on the way up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Identifying a plant paid nothing. A successful Herbalism check granted 1 point of skill practice (2 on a critical) and that was the whole reward: the SKILL got better and the character learned nothing. The one mechanic built around going out and finding things gave no progression for finding them. Now a FIRST identification pays character XP linear in the plant's value — twice the copper is twice the XP — at a tenth of the price, which puts it on the same scale as the world's other hooks rather than inventing a new one. A 60-copper kelp pays 6, a 400-copper herb 40. Floored at 1 for any plant with some worth, since finding out what a thing is should never pay literally nothing, and capped at 50 so one treasure of a herb cannot outpay a pivotal secret. A plant worth nothing pays nothing, which is the one case where nothing is the honest answer. First discovery only. Skill practice stays per-attempt, which is right for practice; paying the discovery reward every time would make one patch of a valuable herb an XP faucet, and the thing being rewarded is knowing a plant, which happens once. The two are shown apart in the manifest — "+1 skill xp · +6 xp" — because summed into one figure the reward would appear to shrink the second time the player named the same herb. The dossier now also ties identifyDC to value, closing the chain the DM described: value tracks potency, DC tracks value, XP tracks value. A rare, potent, hard-to-name herb is harder to identify and pays more for it, so the reward is the same shape as the risk — and the GM is told the engine pays for discovery, so it prices the DC knowing what it costs the player to get it wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both numbers were authorable and neither was being decided.
"loreXp" is documented well and ends "omit it to leave the value undecided and let the game pay its
default" — the path of least resistance, and the one the GM took: all eight Verengrad plants omitted
it, so every flora hook pays a flat 12. That is not a scale, it is an absence. The guidance now asks
for a decision whenever there is lore, and says why: omitted is not neutral, and a world of unset
plants is one where a common weed's history and a poison at the heart of the story are worth exactly
the same to find out.
"value" had one instruction — "balance value against similar existing items" — and for a plant the
similar existing items ARE the other plants. It is an anchor that holds whatever was authored first,
and it held low: Verengrad's six placed plants come to 2.17 gold between them, against a class whose
advancement costs 52 gold in books. Value is now priced against what the plant DOES: the size of the
stat change, how long it lasts, how hard it is to find — with a ladder from no effect up to something
worth planning a journey around, and an anchor outside the plant list ("several days' wages") for a
world that sells nothing comparable.
And the DM's point, which is the one an author misses: a harmful plant is not a worthless plant. A
poisoner, a witch, an assassin, a physician after the antidote's source — they have money, often more
than the buyers of healthful herbs. An affliction is priced by its potency exactly as a boon is. Only a
plant that does NOTHING is worth nothing.
One constant, injected into all three paths that author flora — the Flora tab's dossier, the DM's "add
a plant to this room" directive, and worldgen — because a pricing rule that lives in one of three is a
rule the other two quietly break.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe second half of the bug, kept out of the size arithmetic on purpose: equality is right for items and wrong for containers. A size-6 bedroll should fit a capacity-6 pack exactly; an ark that exactly fills a coffer does not go inside it. Leaving it to authored sizes held only while nobody added a chest whose size and capacity happened to line up, and would have broken silently the day someone did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The model was already right and entirely inert. `size` existed and defaulted to 1, containerSizeUsed summed size × quantity, containerSizeFree computed the room left — and containerSizeFree was called nowhere in the whole file, while containerSizeUsed fed exactly one readout. So the anvil went in the coin purse and the number underneath it counted higher. That is BUG-026. containerAccepts is the one place that answers "will this go in", the way acquireItem became the one answer to "the player now has this", and it returns a REASON rather than a boolean: a refusal the player cannot read is the memorize bug again, with the fiction saying the ring went in the chest and the engine quietly disagreeing. It names the container, the item, the room left, what was needed, and how many would fit. The player is told, the GM is told (it has already narrated the thing going in, so silence would leave it describing the item as stowed there all session), and the DM gets a log line. Refusal is whole-move, and the item goes back where it came from — a treasure to the trove, everything else to the pack. An item pulled out to be moved and then placed nowhere is an item destroyed by a full chest, so that is the property the test watches hardest. Size 0 occupies a tenth of a unit rather than nothing, so a container takes capacity × 10 of the truly small: a purse of 2 holds twenty rings, a chest of 12 a hundred and twenty. That is the DM's intended scale reached through the arithmetic already present, rather than a second budget with its own rule and its own readout — and it keeps 0 from being a magic value a hundred times more generous than 0.01, which is a cliff an author falls off without ever seeing it. Coins are unaffected either way: they are scalar on the player, never items, so they never touch a container at all. Also adds a `container-over-capacity` pass check. Enforcement turns an over-stuffed authored container from a curiosity into a trap — the player empties it, goes to put something back, and is refused for a fullness they did not create — so a DM should find out before a player does. It sizes bare placements through the catalogue, since a placement is usually a copy carrying nothing but a ref. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported from the Equipment tab: it was painting a new portrait rather than cropping the render. The prompt was asking for one. "Produce a HEAD AND SHOULDERS portrait of the SAME character" is a commission, and it was answered as one — a fresh face that resembled the render instead of the render's own face enlarged. The last line made it worse by demanding "a plain, uncluttered backdrop", which is by definition a change to the picture, so the two instructions fought. Now the first line names the operation and rules out the alternative, the result is described as the same image rather than a new one, and the crop is put geometrically — as though a rectangle were drawn around the head and everything outside it discarded — so there is no reading of it as a style. Fidelity is demanded pixel-for-pixel rather than as "the same character", and the ways a model helps when left to its judgement are refused by name: cleaning up, sharpening, smoothing, re-lighting, restyling, re-aging, idealising. The existing background is now KEPT rather than replaced. That matters more since "Include Room in Portraits" began feeding real scenery into the render: cropping to a plain field would throw away the room the player had just asked to be painted into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The setting already decided whether the head portrait stands in its surroundings or against nothing.
The Equipment tab's render answered to neither — it always said "a plain neutral backdrop" — so a
player who had asked for their character to be shown where they actually are got it in one picture and
not the other.
Both now read the same facts through portraitSceneryFacts(): the room, the exact weather, and whether
the character is indoors. Written twice they would drift, and the drift would be two pictures of one
character disagreeing about the sky they are standing under. The head portrait already stated the
EXACT condition so its backdrop could not wander into a storm the world is not having; the render says
it the same way, and adds the indoors caveat for the same reason.
On, the plain-backdrop instruction is REPLACED rather than joined — two contradictory backdrop
instructions in one prompt are worse than either alone — while the full-length framing and "keep the
face the same as the reference" survive, since those are what this render is for. The scenery is
described as SUBJECT, never as style, palette or lighting words, because the world's art style is
applied at generation time and style words here collide with it.
Not included: the room BANNER that the head portrait sends as a colour reference. This render's
reference slots are numbered and explained to the model ("Image 1: …"), and an unlabelled extra picture
is exactly what makes it paint a person with a boot for a head.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvReported from the Equipment tab: drag an equipped item onto one of the three storage slots and it vanishes from the doll and never appears in the store. Both halves were behaving exactly as written. equipDrop's slot-origin branch returned without doing anything for every slot except the item's own, and equipSlotDragEnd then unequipped it back into the pack because it had not been dropped where it started. The gear went somewhere reasonable and nowhere the player had pointed at — nothing errored, and the thing they asked for silently did not happen. A storage slot is "somewhere to put a thing, not a fitting for gear" — equipSlotAcceptsDrag has said so in as many words all along. What was missing was anything acting on the drop. A slot-origin drag onto a different slot that accepts it is now a MOVE: the item changes slots, an occupied destination swaps rather than being overwritten (silently deleting a piece of gear is not an outcome), and onEquipped effects follow what is WORN rather than merely held, so a shield stops giving its AC the moment it goes in a satchel. Dropping back on its own slot still keeps it, and releasing over empty space still unequips into the pack. equipSlotDragStart now also declares which slots the dragged piece fits, and lights them the way an inventory drag does. Without that the drag carried no slot set, and equipSlotAcceptsDrag's "no known drag state → don't block" would have waved worn gear into any slot at all — harmless only while the drop did nothing, and precisely not harmless now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The full-body render is painted on Equipment, where it belongs — it is a picture of the loadout and changes when the loadout does. But it is also the best likeness the game holds of a character, and Profile is where you go to look at who you are. So it is shown there on request rather than duplicated by default: Profile is a dense tab, and a second large image above the fold pushes the identity block and the summary off it. Offered only once a render exists, since a toggle for a picture that does not exist is a control that does nothing. It reads the same `bodyPortrait` the Equipment tab does rather than keeping a copy, which would drift the moment the loadout changed, and opens the same lightbox rather than a parallel one. The choice is remembered in localStorage, not on the player: it is a fact about this screen and not about the character, and saved on the player it would follow the character onto someone else's display. The picture is capped in height so it cannot push the rest of the profile off the page, and uses `contain` rather than the Equipment column's `cover` — there the frame is a fixed column and the sides of a standing figure are backdrop, whereas here the picture sets its own height and cropping would be a choice nobody asked for. Also widens the Equipment render column 232px → 272px. Still a FIXED column: the doll's slot positions are percentages of its own box, so letting it flex would move every slot on the figure beside it. The test that pinned 232px now pins "fixed at some width" — a width is a look and will be nudged, and a test that fails on every nudge teaches people to edit assertions rather than read them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Named for what the button produces rather than for the crop it performs. The label only — the id (equip-face-btn), the handler (generateHeadFromBody) and the prompt builder keep their names, since those are identifiers rather than things a person reads. The tooltip already described the action and needed no change. The test asserted the old string, so it now asserts the new one and additionally that the old label survives nowhere: a half-renamed control reads as two different controls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Verengrad passed every wealth check it had and was still unspendable: 59g across twelve rooms, of which one suit of plate armour was 48 — 81% — lying where a Gill-Wretch patrols and unusable by the frail singer the world ships as a class. Remove that one item and the world holds 11g against a class whose advancement costs 52g in books. Nothing reported it, because every total was healthy. The shape was wrong, not the size. The doctrine this measures is the DM's, and it is deliberately not enforced: loot should reward something — effort, a skill, a fight — rather than lie about waiting to be swept up, so gating wealth behind a build is right and wanted. Twenty gold behind a rogue's lock is good design so long as twenty is reachable another way. What is wrong is gating the majority one way, because then every other character is playing a poorer world than the author thinks they wrote. So there is no threshold on "is it gated" — only on concentration. wealth-distribution always reports the shape by gate (open, search, lock:<method>, combat, trade) as info, since a distribution cannot be balanced unseen. wealth-in-one-item warns when a single object is ≥50% of everything findable, and wealth-behind-one-gate when one non-open gate holds ≥60%. Both remedies say out loud that gating is fine and wanted: a check that trained authors to leave loot lying around would make worlds worse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Measured after a full circuit of Verengrad. Every character starts with a hardcoded 15 gold — no class defines a purse — and the Tide Cantrix advances almost entirely by purchase: 8 + 12 + 32 = 52 gold of books. Every scavengeable object in all 12 rooms and every monster comes to 59.49 gold, of which Bladeward's Plate is 48: 81% of the world's wealth in one suit of armour a frail singer cannot use. Take it out and the world holds 11.49 gold, less than one of her three books. Stripping every monster yields 4.6. Claude13 finished at level 3 with 178 XP and 11 gold — less than she started with. Records the DM's herbal-merchant idea with the numbers behind it: six authored plants worth 2.17 gold between them and nobody to sell them to, while the class already carries Herbalism. Smaller and better aimed than scattering coin — and opt-in by construction, since flora is seen:false until searched, so it never clutters a room for a player who ignores the route. Loot on floors cannot make that promise. Not a defect: three dials, all the DM's. Recorded because it is invisible from the editor — nothing says a class cannot afford its own progression, and the pass has no check for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
tools/build-min.js writes text_adventure.min.html beside the original: every inline <script> is extracted to a temp file, run through terser and then javascript-obfuscator, and put back. External <script src=…> tags are left alone — they are other files, and half are someone else's CDN. HTML comments outside the script and style blocks go too. 4579 KB → 3326 KB in about sixteen seconds, the script itself 30.8% smaller. The source file is not touched; the tool only reads it. TOP-LEVEL RENAMING IS OFF IN BOTH TOOLS, on purpose. The app wires 784 controls with inline onclick="fn(this)" attributes, and those resolve their target by NAME against global scope at click time. A build that renames top-level declarations loads perfectly, looks perfectly normal, and does nothing at all when clicked — so terser keeps compress.toplevel and mangle.toplevel off and the obfuscator keeps renameGlobals false. They read like settings someone would tidy up later, hence the note at the top of the file and the test. Obfuscation is string-array work only: controlFlowFlattening and deadCodeInjection are punishing on a ~4 MB script and inflate the output. --no-obfuscate skips the pass. The test builds a small synthetic page and RUNS what comes out, rather than grepping the tool's settings — including that an HTML-comment-shaped STRING inside the script survives, which a whole-document comment strip would eat. Verified separately in Chromium: the built page loads with no errors and behaves identically to the original. Dependencies live in tools/, not at the repo root: the app has no build step and text_adventure.html is what runs, so a root package.json would say something untrue about it. The artifact is gitignored since the build is one command; drop that line to ship it from here instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The DM asked how editing a library DRAFT could reach a save, and the answer is that it cannot. The save router sends IS_SAVE_EDITOR to _saveSaveWorldEdits, IS_DRAFT_EDITOR to _saveWorldDraftEdits, and everything else to _saveGameStateAsync — so a draft window never calls the game-save path and can splice nothing anywhere. The save editor does have the power: _saveSaveWorldEdits writes live.world = edited into the rolling slot, replacing the world wholesale while leaving the player untouched, which is the signature exactly. But it stamps imported = true on both the library entry and the live slot, and this save reads imported: false. It never wrote either. Both windows eliminated on evidence, cause unknown again. One incidental find from the wrong turning: the `if (IS_DETACHED_EDITOR)` guard inside _saveGameStateAsync is unreachable, since the router means no detached editor ever arrives there — which is precisely what made the mechanism look live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported from the editor: adding a class to a skill collapsed the card being worked in. The Skills tab had a collapse set and Collapse/Expand all buttons, so it looked tracked, while nothing recorded an individual card's state — so a redraw rebuilt the tab from a set that had never heard of the card the DM had opened. Harmless until a card gained something to edit, which it just did. Registered in EDITOR_CARD_VIEWS like every other tab. It got through the guard because that guard only read collapse sets declared via _collapseSet(): Skills and the grimoire hold theirs in memory, deliberately, so neither was ever asked whether anything wrote to it. The test now finds `new Set()` declarations too, and does NOT require persistence to consider one tracked — an in-memory set never calls saveEditorCollapse, so demanding it would excuse exactly the sets this is meant to catch. Which promptly found the same hole in the player's Spellbook tab: memorizing, forgetting, casting or typing in the filter all shut the card being read. Same fix, its own listener, since that is a player view rather than an editor one. Also records the DM's confirmation on BUG-034: the library editor window was open throughout, and spliceLatestPlayState keeps its own world while splicing in play-state — which is precisely the observed signature of world edits reverting while player state survives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The field was read-only, so the only way to change a skill's class list was to hand-edit the world JSON. Verengrad's Second Reading was authored before the Tide Cantrix class existed, so its list named the one class that did — and the skill silently became unlearnable by the character the world now ships with. The refusal in play was correct, informative and a complete dead end: nothing in the product could act on it. Built like the book card's Teaches row and the NPC Inventory row, because those are what a DM has already learned here — chips for what is set, a select of what could be added, one + Add button — and rendered even when the list is empty, for the same reason those are: empty is exactly when you need it. An id matching no class in this world is marked, which is the state Second Reading was in and which was invisible. The chip deliberately drops the hover underline its siblings carry: that is the affordance for a chip that OPENS something, and a class has no popup, so wearing it would promise a click that does nothing. "Class gate" is now "Classes" on the card and in the skill popup. The label only — the data key stays `classes`, which is what every reader matches against, and the GM contract keeps its own wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-034 will not reproduce. With all three race flags set live and persisted, every suspect was exercised and the flags re-read: saveGameState, describeRoom, a full GM turn, logout, login/restore and confirmStatAllocation all preserve them. The restore version gate is not firing — save and built-in are both 1.0.0, uids match. serializeWorld passes the live races object through and normalizeRace preserves loreUnlocked. Nothing in the source sets a race's loreUnlocked false. So instrument it. It was found by luck: nothing on screen says a hook has reverted, and a player has no reason to re-check something they watched themselves earn — which is exactly how this could have been happening for a long time unnoticed. loreUnlockCensus counts unlocked races, factions, places, beings and items; checkLoreRegression rides the one-second calendar tick and logs an error with a stack the moment any count falls. A census rather than a diff of ids, because counts catch the regression, cost nothing per tick, and cannot go stale — everything that legitimately unlocks makes a count go up. Logging out drops the baseline, so switching saves is never reported as data loss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
All three race hooks were earned in one session and each confirmed true as it fired; a few turns later Verengradi and Driftkin read false and only the newest survived. Verengradi was earned in-session with no reload, so this is not only a restore loss. Ruled out by setting all three and exercising each: saveGameState, describeRoom and a full GM turn all preserve them, the write itself is sound, and nothing in the source sets a race loreUnlocked false. What is left is the level-up flow and a stale debounced snapshot landing on newer state. Flags re-set by hand to match what was actually earned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Dusk arrived two turns into a conversation with Vell Pagewrack and her routine took her down to the flood-line. On screen nothing happened — she went on answering, in character, reputation climbing. Underneath, entitiesInRoom stopped listing her, so the Driftkin race hook left the dossier, the Present line lost her, and the GM was replying out of conversation history with no idea she had gone. Both turns' handoff payloads confirm it: no Present line, no hook. Two turns spent earning an authored hook could not possibly have earned it, and nothing on screen said so. The hold is scoped to CO-PRESENCE rather than to the conversation ending, which is what answers the obvious objection — a being cannot be pinned by a player who simply stays put, because walking away makes the condition false on the next call, and describeRoom re-applies routines on arrival. A released being therefore takes up whatever slot the clock has since reached instead of resuming the one it was holding. Only the move is held. Status and _routineKey advance as normal: holding the whole slot is precisely what left Vell stuck on an afternoon routine the first time this went wrong. The conversation room is recorded from the being's own position, so a voice the GM carries from off-screen pins nobody, and a stale hold cannot drag back a being something else has already moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The full-body render was painted FROM the head portrait, but nothing went the other way — so a render that finally looked right left the face on the character sheet as whatever it had always been. "Face from Render" sits under the picture and sends that picture back to Nano Banana asking for the same character framed head and shoulders. The result joins the Profile tab's portrait gallery AND becomes the character portrait, so the sheet, the sidebar and the render all show one person. The prompt re-frames rather than describes: it names the attachment as the source, asks for the same face, hair, expression and whatever collar, hood or helm meets the shoulders, says exactly what to crop away, and forbids restyling or inventing detail the picture does not show. It deliberately carries NO written description of the character — the image is the description, and words would only argue with it. REFUSED, not degraded, on any other provider. resolveImageProvider returns the provider that will actually be used after key-missing fallback, so a session without a Nano Banana key resolves to a keyless one that cannot take pictures at all — and painting from the prompt alone would put an unrelated face on the character rather than a crop of their own. NOTE: the default Image AI is fal.ai, so this needs Nano Banana selected before it will run. Also fixes two faults the browser run exposed in the status line, one of them pre-existing: the message was set BEFORE the re-render that wipes it, and `say` captured the status element once, so after any re-render it wrote to a detached node. The body-render handler had the same bug and was failing the same way silently. Both re-query per call now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Three faults, all from the storage slots inheriting .equip-slot and then failing to override the parts of it that only make sense on the figure. SIZE. A doll slot is width:11.5% of the FIGURE. In the narrow right-hand column that percentage meant something else entirely: 140px against the doll's 76px. They are now measured off a real doll slot on each render and on window resize, so they track it as the panel changes shape — clamped to what the column can hold, because three doll-sized slots plus their gaps are wider than the column at a wide window, and staying inside the panel matters more than matching to the pixel. Exact at 36px and 30px; clamped at 53 where the doll reaches 59 and 72. BOUNDS. .equip-slot centres itself on its point with transform:translate(-50%,-50%). Still applied here it computed to translate(-70px,-70px) and dragged each slot a hundred pixels left, clean out of its own group. Cancelled, and the slot put back in the flow. Both overrides were written first and did nothing at all, because .equip-slot is declared LATER in the sheet and won at equal specificity. They are two classes deep now. ICONS. The placeholders were coloured emoji among a set of drawn glyphs. A rucksack and a drawstring pouch now join EQUIP_SLOT_ICONS in its own idiom — 24-box, currentColor, 1.4 strokes — and the slots name them the way the doll's slots do. A FILLED slot still shows the item's own glyph, which is what every other slot in the app does. Verified in a browser at 1600/1300/1100/950: sizes tracked or clamped, every slot inside its group and inside the panel on all four edges, placeholders SVG with no emoji. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The paper doll says what is WORN. There was nowhere to put what is merely carried — a pack, a coil of rope, a lantern — so anything not gear stayed in the list on the right and never reached the body. Back, Left Hip and Right Hip now sit as a group in the tab's upper right, off the doll deliberately: a doll position means "worn here", and a sack slung on the back is carried, not equipped. They share player.equippedItems with the doll under their own keys, so persistence, the drag-out-to-remove gesture and the render all work on them with no second store to keep in step. They accept ANYTHING. A worn slot admits only gear that declares it fits, which is right for a helm and wrong for a sack — and since no existing item declares a storage slot, requiring one would leave all three permanently empty. The render prompt lists them apart from the worn gear, as things the character is CARRYING, with an instruction that they are slung or hung rather than clothing. Run together with the armour a pack reads as part of the outfit and gets painted that way. Each says where it rides — slung across the back, hanging at the left or right hip — and a stored item's picture rides along as a reference image like any other. test_equipment_tab counted .equip-slot across the whole view, and the storage slots share that class on purpose (same styling, same handlers), so the count moved from 13 to 16. The doll was unchanged; the count now scopes to the figure and additionally pins that no storage slot is drawn on it — which the old whole-view form could not have told apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The full-body render sat under object-fit: contain, so a portrait-shaped picture in a taller-than-wide column letterboxed: the figure small, between two bands of empty panel. It fills the frame by HEIGHT now and crops the sides, centred. The sides of a standing figure are backdrop, so the crop costs nothing and the column reads as a character standing in it. The full uncropped picture is still one click away in the lightbox. Measured rather than assumed: a deliberately wide 800x400 source scales 1.61x to cover a 643px frame, making it 1286px against a 226px column — so the sides crop and the top and bottom never do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
BUG-033 was a leftover, not a decision — the Tide Cantrix class was added to this world after the skill was authored, so its single-entry class list is just everything that existed at the time. The descriptions argue for the share rather than merely permitting it: the skill is "a longer stretch of Cantos-script across two held breaths", and the Cantrix is "short of breath … and the discipline never to understand more of a verse than she must". Applied to Claude13's save and verified in play — the popup's refusal became "Read — attempt Second Reading (INT DC 15)" and the check passed 19+3=22. BUG-032 now carries the DM's decision: hold a being's routine while it is in conversation, and release the hold when the player leaves the room. Scoping the hold to co-presence rather than to the conversation ending is what answers the "a being could never leave" objection. Queued to implement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The character render described equipment because the transport carried
one reference image, and it went to the face. Gemini takes several
reference pictures as several inlineData parts in the one array the
descriptor already points at — both ends simply put one in.
initImages, an ORDERED array, now runs the length of it: gathered in the
browser, resolved through the vault's media store, and inserted as a
single block at the FRONT of the parts array. Inserted one at a time they
would reverse, and the prompt names attachments by position ("Image 2 is
the axe, held in the main hand"), so a reversal describes every picture
as the wrong thing.
The render sends the face first, then one picture per worn piece — the
item's full close-up rather than the chip glyph, which is drawn to read
at 16px and carries almost none of the detail a painter needs, falling
back to the chip only when that is all there is. The prompt enumerates
them in the same order and says they are references for appearance, not a
collage to reproduce; pieces with no picture are still described so they
still get painted.
An unreadable FACE fails the render; an unreadable item is skipped with a
line in the log, because a render missing one boot beats no render. With
several references the "produce a variation that keeps the same
character" framing is wrong — the extras are not alternative subjects —
so both transports drop it. Nano Banana is the only provider with an
image input, and a render on any other now says the pictures were not
sent rather than leaving it to be discovered.
Three tests broke on this and none were about the change: each sliced a
function with a fixed character window that the new lines pushed past, so
their assertions had drifted outside the code they meant to check. Every
property was verified in the source before a regex moved; the slices are
now bounded to their own closing brace. The framing-tail count is a real
change — each mode gained a multi-reference arm, and it needs the tail
like the others.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLBUG-031 (fixed) — a sale of two shards removed one, because dropItem was a bare string. BUG-032 (open, DM's call) — dusk walked Vell Pagewrack downstairs mid-conversation while the GM kept answering as her from history alone, so two turns spent earning the Driftkin race hook were spent with a woman the engine had already moved; confirmed in both handoff payloads, no Present line and no hook. BUG-033 (note) — the Primer teaches a skill authored CantorAdept-only, correctly refused to a Tide Cantrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Claude13 sold Saltmonger Hesk two Salt-Verse Shards. He weighed both, said "Nine gold the pair", paid
nine gold — and one shard stayed in the pack. The engine did exactly as instructed; the instruction was
`"dropItem": "Salt-Verse Shard"`, a bare string with nowhere to put a two.
The asymmetry is what hid it: `addItems` has carried a quantity all along, so a player could be handed
a stack and could only ever lose it a unit at a time. Every narrated sale, tithe or payment-in-kind of
several silently under-removed, leaving them paid in full and still holding the goods — and since the
narration reads correctly, nothing on screen contradicts it.
dropItem now takes {name, quantity} as well as the bare name, which still means one. Over-asking hands
over everything carried rather than minting a negative; junk in the field falls back to one, because a
sale that is paid and unperformed is the failure being fixed. The contract says plainly that a count
in the narration must be a count in the field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe Equipment tab showed a paper doll of slots and icons — what the character is holding, never what they look like holding it. A full-body render column now sits left of the doll, shown or hidden by a "Character Render" checkbox, with Generate in the empty frame. The render is painted from the character's head portrait as the reference IMAGE, so the face carries over, plus a prompt built from everything equipped: each piece named, typed, and PLACED on the body — held in the main hand, on the forearms, at the throat — because a painter told only "Bracers" has to guess. The list is closed with an instruction to add nothing beyond it, and an unequipped character says so outright rather than being dressed as a generic adventurer. The item ICONS are described rather than sent. The image transport carries exactly one reference picture in both the direct and vault paths, and it is spent on the face, which is the one thing words reliably fail at. Multi-image would need both transports changed together or Direct and Vault modes would paint differently. paintImageFromPrompt forwards opts.initImage now. The init path already existed in both transports; the helper simply never passed it, so the only way to use a subject reference was to bypass the helper and lose the art style, the logging and the error handling with it. A subject reference wins over a style reference, matching what the transports do with the pair. The column is a fixed 232px rather than a flexed share: the doll's slots are positioned as percentages of its own box, so a column taking a share of the width would move every slot on the figure beside it. Measured in a browser — the doll is the same width with the column shown and hidden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Wakefulness is an absolute in-world instant, so resetting the clock strands it. Restoring a save whose clock is missing or null falls back to a fresh anchor — Year 437, 1st of Frostmark, 09:00 — while the restored player keeps an awakeSinceGameMs from the epoch that was just discarded, and hoursAwake() then reads it as centuries. Claude13 came back from one such restore permanently Exhausted: STR/DEX/INT −3 and CON −2 on every check, nothing on screen to say why, and no amount of sleep able to clear it, because a full rest re-anchors to a `now` the stale value still predates by a millennium. A character cannot have been awake since before the calendar existed; whatever they were doing before the reset, they are as rested as the new morning is old. A null anchor stays null — that means "fresh or legacy, not yet tracked", and filling it in here would claim a wakefulness the save never recorded. The first tick seeds it, as it always has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both found in one eight-gold transaction at the Bell-Tower Market, and both fixed. BUG-029 is the fourth defect from the placement-versus-catalogue seam and the one that finally cost the player something they could feel: a spellbook that could not hold a loadout. BUG-030 is the sentence that announced the rebinding by database key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Claude13 paid Ket eight gold for a Psalter Rebound in Whalehide and got an object that was not a spellbook. What arrived was the raw authored placement — name, ref, quantity, and a scatter of empty editor defaults: subtypes [], classes [], lore "", icon 📦, and no `type` or `level` at all. Every one of those outranks the catalogue, so isFieldSpellbook said no, the Spellbook tab said "you carry no loadout", and the two spells the book exists to carry could not be memorized. The rebinding chain worked perfectly; the book simply was not a book. hydrateFromCatalog fills, from the entry an object names, every field the object is silent about — silence being undefined, null, "", [], and the placeholder 📦, which is makeItem's own default and never a considered choice. Anything the copy actually says still wins, so a renamed or inscribed book stays the one the player knew. It runs at the top of acquireItem, before the treasure branch reads `type` — a shell trophy was reaching the ordinary pack for the same reason, skipping Fame and both stats. Filled generically over the base's own keys rather than from a list of fields to carry. A list is precisely what keeps failing here: this is the fourth defect from this seam, each one in a different field, and `size`, `loreLinks` and `teaches` were each missed by someone maintaining a list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Claude13 paid Ket eight gold at the Bell-Tower Market, and the story said "Your drowned_psalter is gone". Everything else about the transaction was right — the coin left, the original left, the rebound book arrived teaching all five spells — and the one sentence the player reads called the book by its database key. spellbookRebindSource read `rebindsFrom` off the instance alone, so on a book bought off a shelf (the raw authored placement, which carries none) it returned null and the message fell back to the id. That is the third defect this one instance-vs-catalog seam has produced inside this feature; all three resolvers now read through by ref. While there, the sentence takes the name from the copy actually taken, so a player carrying an inscribed original is told THAT book is gone in the words they knew it by — the catalogue's title is right only in the branch where there is nothing to point at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The other branch moved Tools into the World Editor header while this one renamed the tool underneath it. Kept both: the modal refusal it added (a detached editor window has no Logs tab to read a gameLog in) now sits inside remoteEmbeddedMedia, and its new header item and test call the tool by the name it now has.
It has moved sound since the day it was written — isEmbeddedDataUri matches audio, `data` is a media field, and uploadEmbeddedMedia reads the MIME off the URI — but every string it showed said "images". On the measured save that was 80 pictures/8.7 MB and 3 sounds/13.0 MB reported as "83 images": the larger half of the payload, unmentioned. Its own author could not tell sound was handled and asked for it to be built. So the menu item, the dialogs, the busy banner and the Field Guide now say media, and the count is stated by kind before you commit to it. Second change, same theme. collectEmbeddedMedia only looks at MEDIA_FIELD_NAMES, which is right for rewriting — a prompt that quotes a data URI must survive — and useless for auditing, since a field nobody put on the list is invisible to it and the save reads clean while carrying megabytes. On the way out the tool now walks everything and asks only "is this string a data: URI", reporting what is left by field path, collapsed by shape so forty rooms are one line rather than forty. Markup that loads a picture counts; markup that merely quotes one does not, the same distinction the rewriting walk draws. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Remoting a save's embedded images is an authoring chore about a world's art, and the only way to reach it was the main window's tab bar — which the editor hides. So the work and the tool for it were in different windows, and the detached editor could not reach it at all. The same dropdown now sits in the editor header between the file menu and Settings, running the same action on the same save. Its own ids, because the main window's copy still exists in the same document, and its own toggle; opening either header dropdown closes the other, and both answer outside-click and Escape alongside their siblings. Also makes the action's "nothing loaded" refusal a modal. It was a gameLog line, which was fine when the tab bar was the only way in — but a detached editor has no Logs tab, and a world DRAFT is edited with no game loaded, so that refusal would have been silent in exactly the place it is now likeliest to fire. Its two sibling refusals already spoke this way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Auto-logout is the only thing that notices an idle session, and it is a blunt answer: a player who steps away comes back to a login screen, and in the meantime the clock has run for however long they were gone and the living world has narrated to an empty chair. "Pause World when Inactive" sits below it in Settings, same shape — checkbox plus a minutes stepper — and freezes instead of ending. Two things stop together: the clock, via reanchorClock(0), the mechanism combat already uses to slow time and Remote Embedded Images uses to hold the world behind its modal; and the encounter/ambient timers, so nothing narrates or mutates while nobody is there. Any activity resumes both from the instant they were held, so no in-world time accrues while away. Resuming restores the scale that was IN FORCE rather than the default: a session can be idle mid-combat, where the clock runs at COMBAT_TIME_SCALE, and resuming to GAME_TIME_SCALE there would speed the fight up 24-fold. The setupEncounters guard lives inside that function, not at its callers. Seven unrelated things call it — an encounter edited, a save restored, a world re-entered — and any one would otherwise restart the living world behind a pause still in force. A stopped clock looks exactly like a broken one, so the header says which it is: dimmed, with a pause glyph and a tooltip. Turning the setting off or logging out releases a held world, since an off switch that left time frozen would be a trap with no way out but a reload. Independent of auto-logout throughout: either, both, or neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
"Morning Mist" reads "Morning Mist" at midnight, which looks like a weather state that drifted out of its slot. It has no slot: a pattern picks one day-type per in-world DAY, and its label, glyph and description hold for all 24 hours. The only time-of-day input in resolveWeather is isNight, which dips the temperature band and sets a flag and touches nothing else; the 3.5-hour cell ripples intensity alone. So this is a mismatch between what authors reach for and what the model offers, not a fault in one record — any day-type named for a part of the day does the same. Written up in weather.html §12 F with the three ways out and what each would cost, since the cheap one is a naming convention and the expensive one changes the unit of weather from the day to the slot. No code change: recording the finding for a later decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Found in play. Claude13 paid Ket eight gold to rebind her drowned psalter.
The narration was exactly right — "She cuts the pages free, works fast, and
slides the rebound psalter back" — and the engine consumed nothing, left the
player holding both books, and granted a psalter that taught zero spells.
One cause under both. transferItem moves the RAW AUTHORED PLACEMENT when a
whole stack goes:
if (qty >= have) { inv.splice(idx, 1); moved = it; }
That object never passes through makeItem, so it arrives with `ref` and none
of the catalog-derived fields — no teaches, no rebindsFrom. The comment there
says the object is moved "so its own fields travel with it", which is true and
was never the whole story: the fields it does NOT own never arrive either.
consumeRebindSource read rebindsFrom straight off the instance and returned
immediately; spellbookTeaches walked a chain that started nowhere.
Both now read through to the catalog entry by ref when the instance is silent,
which is what item lore and pricing have always done. An instance that carries
its own value still wins — a hand-authored override must not be overruled by
the catalogue it was written to differ from.
This is the same instance-vs-catalog divergence as BUG-006 and BUG-012, and
the second time today it has surfaced in the rebinding work specifically:
first as a stale save carrying type "misc", now as a placement carrying
nothing at all. Worth treating the pattern as a class rather than fixing the
next instance of it.
Verified against the exact failing shape — { name, ref, quantity } — which now
resolves 5 taught spells and consumes its source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvCo-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7S9ybpL7UL4ukbYzDTUK5
The detail rows sat flush against the border: #items-skill-popup joined the shared container rule, which supplies position, width and the flex column, but .skill-popup-body had no rule at all — the padding lives on the BODY, and .room-popup-body's 14px 16px 16px was not inherited by a differently-named element. Same values, stated under its own name, with the matching thin-scrollbar treatment so a long skill scrolls like every other popup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The chip did nothing when clicked. Not hidden behind anything — it was pointed at #editor-entity-popup, which belongs to the editor's MAP subtab. The Items subtab had no popup element of its own, and none of the four in view-editor serve it: editor-entity-popup is the map's, rooms-entity-popup the Rooms tab's, classes- and quests- likewise. Setting display on an element inside a hidden panel shows nothing. Exactly the trap skilltree-skill-popup set an hour earlier, and I walked into it from the other side. #items-skill-popup now sits beside the item list — OUTSIDE #items-view, so it survives the card re-render that follows every edit — with the Rooms popup's geometry and the shared positioning rule. Its body is .skill-popup-body rather than a borrowed .room-popup-body, at Darren's asking and for his reason: two selectors with the same content are fine when each name is true to what it holds, and a truthful name stops a false observation later. They can be refactored together whenever that is actually wanted. showEntityPopup accepts either, queried IN TURN rather than as one grouped selector. Seven tests stub querySelector by exact selector string, so '.room-popup-body, .skill-popup-body' handed them a different element than the one they had written into — behaviour intact, seven red for a reason that had nothing to do with the change. Two queries keep them all valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both enhancements were cases of using what already existed instead of what I had invented beside it. The hover underline lives on .npc-inv-name, not on the chip — and the rule beside it explains why, which I should have read before writing a parallel set: the chip cannot carry the underline because the ✕ is a flex item, so the decoration is drawn straight through it and text-decoration:none on the button cannot take it back off. Underlining a child span is the only version that leaves the button alone. The ✕ likewise has a class already, .npc-inv-del, which fades in on chip hover. My .npc-chip-x is gone. And the popup opened in the sidebar, which the editor does not show. showSkillPopup now takes a popup id — the story keeps the sidebar, the editor passes 'editor-entity-popup', the container every other editor detail uses, positioned upper-right of the tab. A chip naming an id no skill answers to keeps its own marker, renamed to .npc-inv-chip-missing to sit in the same family: not clickable, since there is nothing to open, and no hover brightening either, so it reads as broken data rather than a skill with an odd name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two things from the same session-long thread: the editor doing part of a job
silently and calling it success.
Asked for an NPC plus a new skill, the NPCs tab produced the NPC and no
skill — no error, no note, no decline. requestEntityEdit reads chunk.npcs,
chunk.monsters and chunk.items and nothing else, and unlike the Items and
Lore tabs it had no scope rule at all. So the skill request went into the
prompt and out of existence.
It now states what it owns (beings and the items they carry), names skills,
spells, classes, rooms, quests, factions, races, regions and weather as
belonging to other tabs, and gives the reason rather than only the rule:
there is no field for them in the response, so they would be dropped and the
DM told the edit succeeded. A decline is parsed and surfaced as its own
outcome, not as an error and not as "Done".
And the skill named in a Skill Check row now opens the skill popup, the way a
spell name in the story already did — the popup that existed nowhere outside
the skill tree until an hour ago. A player reading "Echo-Calm d20 13 · WIS 21
(+5) · prof +2 = 20 vs DC 13" had no way to see what the skill actually was.
Ability Check rows are deliberately NOT linked: they carry a free-text label
and a raw attribute ("Sitting perfectly still, exhausted, to count a rhythm ·
WIS 19"), with no skill behind them. There is no stat popup to open either,
which may be worth its own look.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvSkills were the one object with no popup. Items, spells and beings each have one callable from anywhere; a skill had only openSkillTreePopup, which writes into #skilltree-skill-popup — a container that lives inside the skill-tree view. Call it from a card or the story and it silently does nothing, because getElementById returns null out there. The body was never the missing part: skillDetailBodyHTML has always built it, and skillTreePopupHTML is a one-line wrapper around it. So showSkillPopup is the same two lines showSpellPopup is, over the same shared popup. And it fixes something I got wrong an hour ago. The Teaches chips I added were entirely a remove button, so clicking one deleted the skill you meant to look at — the opposite of the Inventory chips they were asked to match, where a chip shows you what it names. Darren hit it immediately. The chip now opens the skill and a separate ✕ removes it, with stopPropagation so it never does both. An id matching no skill is not clickable and is marked, rather than opening an empty popup. Worth noting for later: a skill popup reachable from anywhere is also what Ability Check and Skill Check lines in the story would need to become links, the way spell names already are. Not done here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A book taught exactly one skill: readBook did const skillId = String(it.teaches).trim(), so an authored array stringified to "a,b", skillById found nothing, and the player was told the craft was lost to this world. Spellbooks have taken a list since they were written, and the item normalizer stores an array for either — so a two-skill book could already be authored and could never work. bookTeaches() is the skill-side twin of spellbookTeaches: array, single id, or a comma-separated line, always out as a list. A book is now read once PER SKILL — each still costs its own learning check and its own once-per-level attempt, so a thick primer is worked through over levels instead of emptied in a turn — and it is consumed only when nothing is left to teach. A one-skill book behaves exactly as before. The success line no longer says the volume crumbles to dust while lessons remain in it. Item cards of type "book" gain a Teaches section built like the NPC Inventory row, at Darren's asking and for the reason he gave: the two should read the same. Chips for what is taught, click to remove, a select of every skill not already there, one + Add. Rendered even when empty — an empty list is exactly where a DM goes to fill one, and a book that teaches nothing is unreadable in play, so the card should say so rather than hide the section. An id matching no skill renders as a marked chip rather than vanishing. Budget sweep: ten more editor tabs raised to 16000, from as low as 1200. Every one predates extended thinking being spent from the same allowance, which is what truncated the NPC request. max_tokens is a ceiling, not an allocation. And the last four "GM returned invalid JSON." strings now report through gmJsonFailure, so all 23 call sites explain themselves. One of them — requestCharacterAlignment — names its reply `raw`, not `rawText`, and the blanket replacement had introduced a ReferenceError that would only have fired on a malformed alignment reply. Caught by checking that every call site declares the variable it passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Darren authored a skill-teaching primer and it teaches nothing. The item has type "book" — correct, readBook demands exactly that — and no "teaches" at all, so reading it prints "you page through it, but it teaches no skill you can practise". The skill is promised in the description and delivered nowhere: narrative only. Two omissions, both ours. "book" was missing from the item-edit type enumeration entirely — weapon, armor, consumable, key, misc, plant, animal, contraption, spellbook, container. The GM reached the right type by good sense rather than by being told, and nothing would have caught it if it had not. And nothing documented what a skill book needs. Spellbooks get a KNOWN SPELL IDS roster; skills were never enumerated anywhere, so even a GM that knew to set "teaches" had no id to put in it. The directive now carries a SKILL BOOKS section with every skill id in the world, says the engine reads only that field, and notes that reading consumes the book — which matters for pricing. Fourth instance of one shape today, after loreLinks, item `size` and `race`: a field the engine reads faithfully that no authoring path asks for. In every case the first reading was "the GM got it wrong" and the truth was "we never asked". test_container_holds_itself pinned the type list by ADJACENCY — "contraption", "spellbook", "container" — and "book" landing between them broke it. Rewritten to assert membership in the enumeration, so the next type added is not a false failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Darren asked the NPCs tab for a Driftkin elder and said Driftkin in the
instruction. The GM put it in the description ("A pale, wide-eyed Driftkin
scholar"), in the routineDescription ("Tolmen is Driftkin — one of the
wreck-born") and in an ability blurb ("Driftkin balance") — and set no `race`
field, because the directive that lists every other field it may write has
never mentioned one. Zero occurrences.
The engine reads only the field:
ent.race = RACED_ENTITY_TYPES.includes(canonType) ? String(g('race','')||'').trim() : ''
So the NPC would have arrived race-less, and the Driftkin race hook — the one
this NPC exists to make reachable — would have stayed exactly as unreachable
as before, with nothing to say so. Prose is not data.
The directive now documents "race", enumerates this world's own races so the
exact string is in front of the GM rather than recalled, says plainly that
naming it in the description is not enough, and gives the reason: a race's
lore is earnable only while one of its people is present. An author who knows
why will get it right in the cases this wording did not foresee.
Third instance of one shape this session — loreLinks, item `size`, now
`race`: a field the engine faithfully reads that no authoring path ever asks
for, so it is never written, so the feature it drives looks broken. Worth
checking the remaining specs against their apply paths rather than waiting to
trip over the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvAn NPC-authoring request came back stop_reason max_tokens, output_tokens 3000 — of which thinking_tokens 1159. Over a third of the ceiling went to thinking before the first character of JSON, and the reply died mid-object inside the new NPC's lore field. The budgets were the problem, not the request. requestItemEdit allowed 2000, requestEntityEdit and requestRoomEdit 3000, against peers elsewhere in the file running 8000 to 16000 — and every one of those numbers predates extended thinking being spent from the same allowance. All three now 16000, matching requestLoreEdit. The message was the other half. "GM returned invalid JSON." appeared at EIGHTEEN call sites and is the least useful true sentence available: it covers a reply that ran out of room, one that came back malformed, and one that never arrived, and those need three different responses from whoever reads it. That matters more than it looks here, because the raw reply goes to the game log and the World Editor runs in a window with no Logs tab — so this string is the whole of what a DM can learn. gmJsonFailure() now serves all eighteen, naming which fault it is and, for truncation, what to do about it. The wording moved out of requestLoreEdit, where I had written it inline two commits ago before knowing how widely the useless version was duplicated. The test stops grepping for the phrasing and runs the helper instead, over the actual truncated payload from the failing request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
loreLinks was solving two problems at once, and they come apart cleanly on items. VISIBILITY never applied. An item's hook is listed whenever the thing is on the floor or in the pack — room.items.concat(player.inventory), no reachability test, no link reason — which is why loreHooks() does not build items at all. That was the load-bearing half of BUG-025, where a place or being hook was dropped from the prompt entirely, and items are structurally immune to it. DISAMBIGUATION applies exactly as everywhere else, and had no answer on the item path: a condition reading "show it to Mira" names a person this world calls "Mira the Anchorite", and the Game Master was left to bridge that with nothing to go on. Darren named the pattern — find an item, show it to the NPC with the knowledge to identify it, the lore unlocks — and it is an ordinary way to write a hook even though Verengrad happens to contain none. So the item dossier now prints the same two tiers the subject dossier does, declared and inferred, on locked hooks only. Also corrects the item authoring text, which I wrote two commits ago claiming a link "makes the hook earnable while the player is with any of the things named". False for items, and precisely the misunderstanding that nearly cost a whole items retrofit batch: it now says the job is disambiguation and only that, and names the commonest shape — an item identified by the NPC who can identify it. Verengrad's own item keys need none of this. Their ambiguity is all of the "a salt-verse", "a psalter" kind, which the GM resolves unaided because the qualifying object sits in the inventory it can already read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Omission preserves, so the GM could add a reference and replace one but never take one back. That is not theoretical: a rooms pass wrote seven self-links and one wrong link, and re-running it under the corrected rules would have produced a MORE correct response that changed nothing on disk, because the right answer had become "no update for that subject". The bad values had to be stripped by hand. Three answers now, where there were two. null clears the field — the only way to remove a reference. An empty array still means "not changing this", and that asymmetry is deliberate: a pass told to skip subjects it has nothing to add for may answer with [] anyway, and that must never wipe a link the DM wrote themselves. A list of names sets them, as before. And the roster now carries each subject's stored loreLinks, because the sentinel is unusable without it — the GM cannot judge a link wrong when it has never been shown what is there. Same principle this file already applies to lore and loreXp riding along in that roster: a field the GM may WRITE has to be one it can READ. Missing that was what made the first version of this fix only half a tool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reviewed a real GM pass over Verengrad's rooms and beings: 18 links written,
8 correct cross-references, 2 defensible judgement calls, 1 wrong, and 7 that
do nothing at all.
The 7 were subjects linked to THEMSELVES — Scaffold Landing to Scaffold
Landing, and six more. Inert: loreHookLinkReason skips a reachable name
matching the hook's own, because a hook is already [HERE] when the player is
at its own subject. The contract now says so, and says the right answer is no
update for that subject rather than an empty array.
The 1 wrong was Reciting Crypt linked to "Verse of Stillwater", because its
key reads "without using Echo-Calm" — and Echo-Calm is a SKILL, which cannot
be linked at all. Told to prefer omission, the GM instead reached for the
catalogue item whose description sounded closest ("calms a swell"). So the
contract now states that only a room, a being or an item is linkable, that
skills and spells have no reference to record, and that naming the wrong
thing is worse than naming nothing — the failure here is silent, because the
name resolves and the card's validator passes it.
Both failures share a shape worth naming: the GM filling a field because it
was asked to rather than because a reference existed. Neither is catchable by
the "No subject by that name" warning on the card, which only knows whether a
name exists, not whether it is a reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvDarren's rooms pass failed with "the GM did not return valid JSON". The response was flawless: 422 characters, stop_reason end_turn, exactly the minimal shape asked for, five correct loreLinks including both rooms whose keys say "Mira". extractJsonObject returns a STRING — it walks the raw text and rebuilds the balanced object as text. All eight other callers write JSON.parse(extractJsonObject(rawText)). requestLoreEdit wrote extractJsonObject(rawText) alone, so `chunk` was always a string, the `typeof chunk !== 'object'` guard on the very next line always rejected it, and every lore edit failed identically however good the answer was. Present since 0b8df37 added the tab. So this world's 0 loreLinks on 53 hooks had two causes stacked, not one: the field appeared in no authoring prompt, AND the only tab that could set it threw away everything the GM returned. Fixed, with a test that runs the real extractJsonObject over the exact payload from the failing request and shows a string failing the guard that a parsed object passes. One assertion in that test was too crude and is narrowed here: parseFieldGuideResponse also holds the extracted text in a variable before parsing, deliberately, because a Field Guide reply carries unescaped quotes inside its html field and needs a lenient fallback. Not the same mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
"Failed: the GM did not return valid JSON" on a bulk rooms pass. Three separate causes, and the token budget was the least of them. The response template spelled out every settable field — including the two I added two commits ago — so a GM following the example echoed each subject's existing "lore" and "loreKey" back verbatim. That is not harmless padding. It overruns the budget on a roster-sized instruction, and it silently rewrites the prose the instruction explicitly said to leave alone. The template now shows "<only the fields you are CHANGING>", the settable fields are listed once in a bullet, and echoing an unchanged field is called out as wrong rather than merely unnecessary: a references pass returns category, name and loreLinks, nothing else. max_tokens was 6000 — the tightest of any editor call, against peers running 8000 to 16000 — on the one pass most likely to touch an entire roster at once. Now 16000. And the error carries its own diagnosis, because it has to. The raw response goes to the game log, and the World Editor runs in a window with NO Logs tab, so "did not return valid JSON" was the whole of what a DM could learn. A reply that stops mid-structure is a budget problem with a specific remedy and now says so, naming the character count and telling the DM to ask for fewer subjects; one that is short and malformed is a different fault and now says that instead. test_lore_hook_references pinned the old template literal and was rewritten to assert the principle it stood for — that a field the GM may write is named as settable — rather than where the words sit. Fourth time this session a test asserted a location instead of an intent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
populateLoginScreen() runs at PARSE time — a bare call in the script body — and called populateWorldSelect() synchronously. That reaches IndexedDB, and idbOpen()'s own bindings (const IDB_NAME, let _idbUnavailable) are declared about forty thousand lines further down the SAME script. At parse time they are in their temporal dead zone, so idbOpen() throws ReferenceError. Why it looked intermittent, which is the interesting half. idbGet is NOT async, so that ReferenceError propagates SYNCHRONOUSLY into loadSavedWorlds, whose try/catch swallows it and falls back to localStorage. Wherever a localStorage copy of the library happens to exist the fallback rescues it and everything looks fine; wherever the library lives only in IndexedDB the picker silently offers Default alone, every single load. Measured in the failing profile: localStorage absent, IndexedDB present at 28,191,427 chars — a world that could never fit localStorage's ~5MB quota in the first place. So small worlds are quietly rescued and large ones are quietly broken, which is exactly the shape of a flake and is not one. Consequence beyond cosmetics: while the picker holds only Default, ticking "New Game" starts on the built-in starter world rather than the world the player was just in. Continuing a save is unaffected — a save carries its own world, which is why restoring always worked. The symptom was already known and worked around rather than explained. See pickWorldFromMenu, which re-populates first with a comment reading "on a cold load the list holds nothing but Default ... Browser-verified". Two fixes, for the two independent defects. The call is deferred to a microtask, which runs once this script has finished evaluating and the bindings exist. And an empty list stops doubling as the error value: populateWorldSelect now logs a failed library read instead of presenting it as "you have no saved worlds", so the next occurrence leaves a line to find rather than a silence to interpret. The test reproduces the actual failure — a function called during script evaluation reaching a later `let` throws ReferenceError, and the identical call from a microtask succeeds — and asserts the parse-time call still precedes the bindings, so the deferral stays load-bearing. 550/550. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Verengrad finished a complete playthrough AND a deliberate coverage sweep at
0 of 3 race hooks. Not difficulty, and not for want of trying: the GM was
never once shown one.
A race has no room of its own, so loreHooks added every race and faction with
here=false flatly. Their keys name a KIND of person rather than a catalog
entity — "an elder Verengradi confides why their family never left the
scaffold-towns" — so the name-link path never fired either. loreHookDossier
drops a hook that is neither here nor linked, so every race hook was skipped
in every room, always. Unlockable in principle (unlockSubjectLore('race', …)
resolves and writes correctly) and unreachable in practice.
The data to join it up was already there and never used: beings carry a
`race` matching the world's race names — four Verengradi, two Tidekin — and
the player carries one too.
A race is now HERE where one of its people is standing, a faction where one
of its members is, and the player's own race and memberships count, since a
hook about what was taken from your own kin should be earnable by the kin it
is about. Corpses do not count. Faction membership matches the catalog key or
the slugged display name, because refs are authored free-form.
Being HERE only makes a hook visible to judge; the authored condition still
has to be earned.
The pass gains race-with-no-one-in-it and faction-with-no-members, since no
engine change reaches a race nobody belongs to. It names Driftkin on the real
world — three race hooks authored, and the one whose condition asks for "a
trusted mentor or orphan-hall elder" has no member anywhere. Darren is adding
a Driftkin NPC, so the check should fall quiet on its own.
Verified end-to-end against the real loreHookDossier rather than asserted
from source, because the whole defect was code that looked reasonable in
isolation. 549/549.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvSix commits of engine work had landed with nothing written down, which is the half of this exercise that actually outlives the session. BUGS.html gains seven entries — 28 ids, 8 open, 13 fixed. Fixed: BUG-022 the upgrade psalters that taught nothing, consumed nothing and claimed in prose to be the cheap one; BUG-023 six acquisition paths that had drifted in opposite directions, treasure routing missing from one and the rebinding consume missing from four; BUG-024 the placement plan instructing the GM to put a chest inside itself, initially and wrongly filed by me as an authoring defect; BUG-025 fifty-three lore hooks carrying every cross- reference in prose because the field for it was in no authoring prompt. Open: BUG-026 a container capacity computed, displayed and never enforced, with the container-into-container rule that cannot be folded into the size check; BUG-027 a whole category priced at the default while the warning that would say so is gated shut; BUG-028 lore hooks firing on articulation rather than on the key's action, silently, which is a strong candidate for much of the historical uncollected remainder. The Claude12 report's coverage section is rewritten around the sweep: 4/12 room hooks at the ending, 12/12 after, and the three things only a full walk surfaces. Plus the Guide comparison — 71% reconciles exactly to 23/55, and excludes 22 improvised lore entries worth 585 XP against the 677 it counts. That is not a defect; it is a question about which audience the percentage is for, since it under-reports a player's own play by half. Race lore stands at 0/3 after a complete playthrough and a deliberate sweep. Whether it has an unlock trigger at all is the next thing to settle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Darren: containers should be type "container", not "misc", so the engine can
understand they are containers. Half right, and the half that was not is the
reason this needed an engine change rather than a data one.
isContainer() keys on the container OBJECT, never the type:
function isContainer(it) { return !!(it && it.container && ...); }
and nothing anywhere branches on type === 'container'. So a chest typed
"container" with no object was inert — it read as a chest to every human
looking at the data and behaved like a rock to the engine. That is the worst
of the three states an item could be in, and it was reachable by authoring
exactly what the type name invites.
makeItem now supplies the object when the type declares one, at the default
capacity. Declaring the type is now sufficient, which is what makes it worth
having in the vocabulary at all.
And it was missing from that vocabulary: the item-edit dossier listed weapon,
armor, consumable, key, misc, plant, animal, contraption, spellbook — no
container, though the older placeItems spec had it. A GM authoring a chest
through the Items tab had no correct type available and reached for "misc",
which is exactly what every container in Verengrad was.
Data follows: all five catalog entries and all five placements retyped from
misc to container, so catalog and instance cannot disagree — the divergence
that has produced a defect roughly once per session.
Also from Darren's earlier note: wooden_chest's catalog capacity was absent
and defaulting to 8 while its placement declared 12. Set to 12, and its size
raised 10 → 14 to stay above its own capacity, since raising the capacity had
quietly re-opened the chest-inside-a-chest arithmetic the sizes exist to close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv`size` — how much room a thing takes up inside a container — has been read by
the engine all along: itemSize() defaults it to 1, containerSizeUsed() sums
it, makeItem carries it, and a comment there even names the scale ("a
greatsword might be 4, a coin ~0"). No authoring prompt had ever mentioned
it. So no item in any world declared one, everything defaulted to 1, and a
chest cost exactly as much room as a coin.
The same shape as the loreLinks gap two commits ago: a field the engine reads
and the GM was never told about, inert for as long as nobody noticed.
Now documented in both places items are authored — the item-edit dossier and
the worldgen item spec — as bulk rather than weight, which the item already
carries separately: a bedroll is light and enormous, a lead sinker heavy and
tiny. With the rule that answers Darren's question: give a container a size
LARGER than its own capacity, and a chest inside an identical chest becomes
impossible by arithmetic rather than by asking the GM to remember.
All 34 Verengrad items sized. Every authored container's contents still fit
(the Reliquary Ark holds a psalter and a lichen, 3 of 10), and no container
can now be stowed inside itself.
Capacity is still not ENFORCED — containerSizeFree() remains dead code — so
none of this changes play yet. That is deliberate: enforcement without
authored sizes would have been uniformly meaningless, and sizes without
enforcement are merely inert. This is the half that had to come first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvVerengrad's Gasping Spire held a Wooden chest whose contents were a Wooden chest. I called it an authoring defect on the evidence that it was present in the authored data, which established where it lived and nothing about how it got there. Darren asked. It was not authored by hand. The chain: the pass reported the chest as an unplaced item, the Evaluate tab's placement plan filed it into a container of its OWN name, and Place does not move anything itself — it writes an instruction into the Rooms editor for the GM: "a container named Wooden chest in Gasping Spire, Lower Chamber, holding: Wooden chest". The GM did exactly as told. Nothing downstream was wrong, which is why nothing downstream caught it. The outer chest is the one the GM created (type "misc"), the inner is the thing it was told to stow (type "container"), and that asymmetry was the fingerprint I could not explain at the time. Three fixes, because they fail in different places. The planner now has one definition of a faulted row — rowFault() — covering a missing destination, a missing container name, and a container named after the item it is meant to hold. The reason shows in the row rather than only greying out Place, since a disabled button with no explanation is how a DM concludes the feature is broken. The executor repeats the self-reference check: the modal prevents the row, but that filter is the last point before an instruction is handed to a GM that will carry it out faithfully. The pass gains container-holds-itself, matched on name AND ref so a renamed nested copy is still caught, and walking contents recursively so a self- reference nested two containers deep is found. It catches the shape however it arose — worlds already carrying one, or a DM authoring it by hand. And the library world is repaired: the nested copy is gone, the chest itself stays. It is empty now, which is a content decision rather than a defect. test_placement_plan_review pinned the old inline completeness expression verbatim and was rewritten to assert the delegation, with the three faults covered behaviourally in the new test. 548/548. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported the treasure divergence, Darren said fix it, and the fix grew twice
on the way — both times because a test asserted something and found more than
it expected.
The reported bug: grantTreasure ran on the addItem path and not on
transferItem. So a gemstone or an idol lifted from a merchant — acquired
through exactly the field the GM contract MANDATES for a being's carried
goods ("USE THIS — never addItem/addItems — whenever the player takes,
steals, is handed…") — landed in ordinary inventory instead of the trove and
skipped addFame() plus treasuresFound and treasureValue. Following the
contract correctly was what triggered the miss.
Then a check written to prove the two call sites had been unified asserted
the treasure branch appeared nowhere else, and found FOUR more open-coded
copies: pickupItem, finishLootedItem, takeItemFromContainerByName, and the DM
directive apply path. Six routes, the same eight lines in each, every one
correct in isolation.
Which exposed the mirror-image bug in this morning's work: consumeRebindSource
was on the two grant paths only, so a rebound psalter taken from a container
or off the floor kept its source book. The Drowned Psalter this world ships
sits IN a container.
All six now call acquireItem. Where an object comes from stays a real
distinction; what possession MEANS is one function. A side effect added later
lands everywhere by construction, rather than relying on whoever adds it to
find all six.
Three tests pinned the inlined calls and were rewritten to assert the single
owner instead — the same lesson test_transfer_item learned about fixed-width
slices, one level up: assert the contract, not where the code currently sits.
547/547.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvAsked whether the Lore tab's GM box could resolve loreLinks if a DM asked it to: no, and in three compounding ways. LORE_EDITABLE_FIELDS held four names, and applyLoreUpdate iterates only that list — so a loreLinks the GM returned was dropped on the floor while the tab reported the subject updated. Silent data loss with a success message on top. The prompt's hard scope rule said "You may ONLY set these four fields" while the field documentation two commits ago had already added loreLinks and unlocksLore below it. That contradiction is mine. A GM reading a hard ONLY rule omits the field, or declines the whole instruction. And the return-shape example listed neither, against this file's own stated principle that a field the GM may WRITE has to be one it can READ. All three fixed. The two list fields go through loreNameList rather than the String() branch, which would have stored ["a","b"] as the single string "a,b", and an empty array is treated as omission-shaped output rather than an instruction to erase references a DM wrote by hand — the same reasoning that already protects blank `lore`. Two additions the retrofit needs. The scope block now names resolving references as a legitimate standalone job AND fences it: read each key, write what its wording refers to, change not one word of the key, and where the wording is genuinely ambiguous leave the field off rather than guess — an omission is recoverable, a wrong reference points the GM at the wrong subject for the rest of the world's life. And the directive now carries every name in the world, because the existing roster lists only subjects that ALREADY carry lore, which cannot resolve a reference to one that does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Verengrad has 53 lore hooks, 0 with loreLinks and 0 with unlocksLore. Not carelessness: `"loreLinks"` appeared in no authoring prompt anywhere in the file. The GM wrote all 53 keys using the only vocabulary it was given — lore, loreKey, loreXp — so every cross-reference landed in prose, which is the one place the engine cannot read. That cost two different things, and the dossier only ever addressed one. VISIBILITY. loreHookLinkReason matches reachable names against the key whole-name, so "Ask Mira about…" never matches "Mira the Anchorite". Standing with Mira two rooms from the Rope-Bridge Span, its hook was not in the dossier at all — the authored route was unreachable from anywhere, and the hook only closed in play because the GM improvised a substitute NPC. MEANING. Even a listed hook was read cold. "The drowned choir" names one of five Drowned-somethings in this world and the author had already decided which. Worse, this was silent exactly where it matters most: a [HERE] hook computed no link reason at all, so the player standing in the very room the condition belongs to got the least help. loreHookDossier now resolves references for every locked hook, [HERE] included, in two tiers that are deliberately unequal. DECLARED (loreLinks) binds and is labelled so. INFERRED — a short form in the key resolving to exactly ONE subject world-wide — is advisory and never touches visibility, because uniqueness DRIFTS: "Mira" resolves alone until someone authors a second Mira, and a drifting guess that moved the engine would break authored routes silently, where one that withdraws a hint costs nothing. Uniqueness is tested world-wide, not against what is reachable here: "Drowned" is unique in some rooms and one of five in the world, and the narrow test would invent a reference precisely where it is least safe. loreLinks and unlocksLore are now documented in the room, being, item and subject authoring dossiers, with the worked examples that motivated them. test_lore_hook_unlock asserted a distant hook was absent by testing that its NAME did not appear. A resolved reference on another hook now puts that name in the text legitimately, so three checks were tightened to test what they say — that it is not LISTED as a hook line. The count check was already exact and did not move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two gaps in the rebinding contract, both found by asking how a psalter on a bookbinder's shelf is actually bought. The first is a real hole in the previous commit. Both Verengrad upgrade psalters sit in Ket Drybound's inventory, so acquiring one travels through transferItem — and only the addItem grant path ran consumeRebindSource. A rebinding that was BOUGHT rather than granted left the player holding both books, which is precisely the state the field exists to prevent. Hooked after the push, so the source is never matched against a half-done move. The second is narration. The engine now removes a book the same turn the new one lands, and the GM had no way to know: it would describe the player tucking the rebound psalter away beside its twin while the twin was being deleted underneath the prose. The turn contract now says a rebinding is a trade-in — narrate the binder taking the old book across the counter, price it as labour, never describe the player keeping both — and says what to do when the player does not carry the source, because a binder has nothing to rebind. test_transfer_item.js broke on the way through, and not because the handler misbehaved: it sliced a fixed 3000 characters and the added lines pushed the error-logging tail out of the window. Same failure test_room_lore_editor.js had against CRLF. It now bounds the block by the next top-level `if (changes.…)` so the slice follows the code rather than a number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two loose ends in the portable character bundle. A legend's spoil chips carry a ref — an item catalog id, or a room id or name — pointing into the world the quest was resolved in. Imported somewhere else those refs name things the destination has never heard of, so adoption now clears every one it cannot resolve: the chronicle prose and its labels travel, the pointers into the old realm do not. Treasure refs are left alone rather than blanked, since a coin sum's ref is never consulted and clearing it would be acting on a predicate that was never about it. And the reader now checks the format version it was so far only writing. A bundle from a newer build is refused by name — naming the version it saw and the one this build reads — rather than parsed as if it were current, which would half-adopt a character: some of it right, some quietly wrong. A missing or unparseable version still reads, since v1 is the first format and nothing precedes it. The version is now one constant shared by the writer and the reader instead of a literal in each. The compendium's absence from the bundle is deliberate and now says so where someone would look for it: it is the world's record of its own people and places, not the traveller's to carry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Verengrad shipped three psalters whose prose said they were one book in successively better skins, and whose data said they were three unrelated items. The 60-copper original taught all six tide spells; the 1200- and 3200-copper upgrades taught nothing, so reading either printed "you find no spell you can commit to memory". Nothing was consumed either, so a playthrough ended holding two copies of the same book, and the Spellbook tab cited a teaching book the player had supposedly given up. `rebindsFrom` names the book a binding was made from, and one field does both jobs. spellbookTeaches walks the chain, so a tier-3 psalter inscribes everything the tier-1 did without restating a single spell id — resolved over the CATALOG, never the pack, so a book bought outright teaches exactly what the same book earned by rebinding teaches. consumeRebindSource takes the source out of the inventory the moment the rebound book lands. Matched by ref before name. Name-as-identity has cost this engine seven separate defects and here it would destroy player property, so a same-named book with a different ref survives. A missing source does not block the grant — the GM has already narrated the rebinding, and refusing would strand a player who just paid — it logs against 'gm' instead, so the DM reads a broken contract rather than a book that quietly duplicated itself. Enforced where it is written, not only in one tab's prompt: both upgrade psalters were authored through tabs that never asked for `teaches`, so applyItemSpec now rejects a spellbook with neither `teaches` nor `rebindsFrom`, checked against the merged result so editing a book's price does not trip it. Chains are validated after the whole batch, since a rebinding and its source may arrive in one response in either order, and an invalid pointer is dropped rather than left to rot. The evaluator gains spellbook-teaches-nothing and spellbook-rebinds-nothing, so a DM sees an unreadable book without playing to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The first completed run of this world, by the first caster. Six beats, all paid exactly - twelve for twelve across two completed runs. Sixteen lore hooks for 466 XP, more than the whole quest line pays, against a baseline where 85% of this world's lore had never been collected by anyone. The headline finding is that the 85% was never about difficulty. The same world, walked by a build that reads things rather than passing through them, gave up five authored hooks at full price in 39 turns. Nothing was added to make that happen except one spell and one merchant. The report says plainly that this run still only collected about a third of the world it finished. The Bell-Warden's 75-XP hook - the largest in the world, and one that paid 12 until BUG-006 was fixed that morning - was taken at 140/140 without a scratch, by pressing him until he admitted the rhythm was not automatic and then quieting him with his own broken beat. Five defects filed, 017 through 021. Two share a pattern worth naming: where the GM lacks a directive it narrates the action anyway rather than refusing it - a roll asked for in prose that vanishes, and a memorization that never happens. The xpGain instrumentation answered its question. Seven awards, six with reasons, every one for something no rule covers, none double-paying a beat or a hook. It was never a defect; it was an undocumented feature, and the fix was documentation. Section 9 lists seven claims I made during the run and withdrew after testing, including reading item value as gold when it is copper. The report's credibility depends on that ratio being visible, so it is. BUG-021 filed for the memorization gap the report references, and the Evaluations index carries the new row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Clicking a node in the tree bought the skill on the spot — points gone, skill learned, no way back. A build-planning screen should let you plan. Selections now accumulate as a draft. Nothing in player.skills or player.skillPoints moves until Confirm, which opens a modal naming every pick and the total cost, then acquires them in selection order so a drafted prerequisite is really learned before what stands on it. Revert throws the draft away; since nothing was spent there is nothing to refund. The draft is the lens the tree reads itself through: prerequisites count a drafted skill as held, so a whole chain can be planned in one sitting, and affordability is measured against what the draft has NOT already claimed, so two picks cannot both be offered on the same point. Dropping a pick cascades to anything that stood on it, or Confirm would walk into an acquisition it cannot make. A drafted node is dashed green with a Selected badge — deliberately not the gold of a skill truly held — and the points badge shows the intact purse alongside what the selections would leave. The draft belongs to a character, so a new one or a restored save starts empty. Removes learnSkillFromTree: with the node button drafting instead of buying, an immediate-buy path next to a deliberate confirm flow is only a way to get miswired later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The "/" channel answers from two references — the Field Guide for app usage and the Player's Handbook for in-game rules — but only the Field Guide had a command to open it. "/handbook" opens the Handbook in its own window, matching the toolbar button, and the bare "/" menu lists it. Like "/guide", only the bare word routes; "/handbook <question>" is still a lookup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Both meta-command channels opened a reference document on a bare prefix, which left the commands themselves undiscoverable: a player had no way to learn that /hint or /roll existed, and the DM menu was hidden behind a "// help" nobody was told about. Swap the two on each channel. A bare "/" now prints the player command menu and "/guide" opens the Field Guide; a bare "//" prints the DM menu and "// guide" opens the Dungeon Master's Guide. The "// help" / "// ?" / "// commands" aliases still reach the menu, and every other route on both channels is untouched. The player menu advertises "/verbose on|off", so accept that spelling alongside the bare "verbose on"/"verbose off" line. Both now go through setVerboseMode(), so the two spellings cannot report differently. Field Guide, Player's Handbook and Dungeon Master's Guide all documented the old bare-prefix behaviour; updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj1iTHyESTJHvtPeHzw6av
A run falsified the check's central claim. Handed a key naming something absent - "recover the child's toy boat from the submerged root and show it to Mira", with no toy boat anywhere in the world - the GM authored one at the moment of need. The engine gave it a catalog ref, a weight, a value and a lore hook of its own, and the room's hook then paid its full authored 30 XP. Nothing was unreachable and nothing was lost, so the warning was wrong on both counts and the xpBehind tally was wrong to exist. That division of labour is the design, not a workaround: the world carries the hook, the playthrough carries whatever satisfied it, and the invention lands in the save rather than the library - so a master never accumulates single-purpose props. Warning here pushed the DM toward authoring exactly that bloat, and the action card opened by telling them to. What survives is narrower and still worth saying. The scan already resolves a phrase against names AND the world's whole prose, and prose is sufficient: "toy boat" appears twice in the cloister's own description, so what the GM built belonged there. A phrase with no referent anywhere is usually a rename that left a key behind, or a typo - the GM will improvise something consistent with nothing, the hook will close, and the DM will never learn. That is authoring hygiene, at info. The card now says outright that the DM does not have to author an item for it. Two test cases added: the finding is info, no longer claims unreachability and no longer reports lost XP; and a phrase established only in prose raises nothing - the exact case this reframe came from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Observed across three fresh characters: a run's XP could not be reconciled from
its own transcript. Claude10 announced 37 and held 52; Claude12 announced 40 and
held 50. Beats, lore hooks and resolved encounters all announce themselves;
stateChanges.xpGain was the one award that landed in total silence.
The obvious fix - print it in the narration - is the wrong one, and Darren named
why: a GM handing out XP for something deft is a Game Master doing its job, not a
leak. Interrupting the prose to say "+10 XP" cheapens the moment and still would
not say what earned it. What was missing is accountability to the DM, not
visibility to the player.
So the award stays out of the story and goes to the Logs tab with a reason. The
contract now asks for { amount, reason }, tells the GM the reason is required and
that the award is not shown to the player, and says to award nothing rather than
award blind. `xp` is a registered log category so the Logs filter lists it.
Both shapes are accepted deliberately. The GM is a language model: a schema
change is a request, not a guarantee, so a turn answering with a bare number
still awards its XP and is logged as unexplained, with a detail line saying the
contract asks for a reason.
This is instrumentation before judgement. The silent awards may well turn out to
be earned - the point is that we will now be able to read the rationale and
decide, rather than guessing at a number that appeared from nowhere.
test_log_filter.js pinned the category list as a literal array and broke on the
addition; it now asserts the set, since what it protects is that every known type
is filterable, not the order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe GM authors the player's statuses (rule 15), but the system prompt never told it which ones were live. Clearing one needs its exact label, so the only way it could remove a condition was to still remember applying it — and the message history is capped at 40. A "chilled" applied before sleeping outdoors had nothing reminding the GM it was still there once the player came inside. Add activeConditionsDossier(): every live condition with its exact label, its stat effects, and either how much in-world time is left or a plain statement that it ends only when the GM removes it. Engine-owned sleep fatigue is left out — it has its own dossier and the GM is told not to author it. The World State carries it just after Fatigue, with the instruction to pass the label verbatim to playerStatusChanges.remove. Rule 15 gains a change of surroundings as a removal cue, and the judgement to go with it: weigh severity against how much the new surroundings actually relieve the condition and how long the player has been in them. Stepping out of a light chill may end it on the spot; a deep cold or a soaking should ease by a fire rather than vanish at the doorway, and a room that merely blocks the wind is not a room with a hearth. Partial relief is narrated, not applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The entry cases inverted rather than being deleted: a 0%-chance beat must NOT fire on entry (it used to, which is the reported behaviour), a 100% one still must, and the roll is reported by the same "triggers" line a timer tick writes — because it now goes through the same gate. The time-change case moves to 100% for the same reason; at 0% it would only have proved that 0% does not fire. And an assertion that a played soundscape reaches the Logs tab. Removing that log line was caught by nothing until now, which is exactly the gap being reported: on a refresh the clip plays before the story is mounted, so a record that lived only in the story left nothing to check afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
evokeRoomAmbientOnEntry picked a beat at random and fired it with the chance roll bypassed, so every doorway produced one: ambience that announced arrival rather than ambience that happened to be going on. A 20%-chance beat read as a certainty the moment you walked in, and on a refresh it fired again. Now each authored beat for the hour is offered its own chance — through tryRoomAmbient rather than around it, so entry is judged by exactly the gates a timer tick is (chance, cooldown, one-at-a-time) and reports itself the same way. Renamed to rollRoomAmbientOnEntry, since "evoke" is no longer what it does. A room may now greet you with nothing, which is the point. Also logs a room soundscape when it PLAYS. A refresh plays one before anything else is on screen, and it only ever announced itself in the story — which can be scrolled past, leaving nothing to check when the question is "what was that sound?". Confirmed in a browser: the play produced a story line and zero log lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Tests the helper — a normal reply says nothing, a truncated one names the call, the cause, the consequence, the length and the tail — and then the part that actually matters: that every JSON-parsing GM call routes through it, that only the helper still inlines the extraction, and that every label names a real function. Those three would have failed against the original code, where sixty-eight calls each read the reply their own way. Also routes one site the byte-exact sweep missed: enrichLegendWithGM guards its extraction as `(data.content || [])`, so it did not match and would have stayed silent while looking swept. One assertion is there purely because I did it wrong once: the helper must not label itself. The first pass rewrote the helper's own body into a call to itself, which recurses forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Sixty-eight calls parsed JSON from a GM reply with no check on why it stopped, so any of them running out of budget surfaced only as a parse error about an unterminated string — exact about the symptom, silent about the cause. That was diagnosed by hand once. The check goes in gmTextFromResponse rather than at each site, because a check written out sixty-eight times is one that will be missing from the sixty-ninth. Every inlined `data.content.filter(...)` extraction now routes through it, labelled with its enclosing function, so the line names which budget to raise and carries the tail of the reply — where it stopped is usually the fastest confirmation. No budgets are changed. This only makes a future recurrence say what it is. The bespoke check added to requestPersonalization folds into the shared one. That trades "the NPC stays generic" for naming the call — worth it, since the shared version exists at all sixty-eight sites rather than one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
An observed reply ran out at 1497 characters, mid-word, inside the JSON string. Truncation there is TOTAL loss rather than partial: the object never closes, the parse throws, and the NPC stays generic. max_tokens was 500 — the smallest budget of any call in the app that asks for a paragraph, against a prompt asking for a name, gender, history, trade, quirks AND the local lore they know. Both halves, because neither alone is enough. A bigger budget only moves the cliff, so the directive now states a hard 120-word bound; an instruction is not enforcement, so the budget is 1500 — several times that length, leaving a GM that overshoots badly enough room to still finish its sentence. And the log now names the cause. The API says outright that it stopped for length, and nothing was reading it, so a truncation surfaced only as "Unterminated string in JSON at position 1497" — precise about the symptom, silent about the cause. The turn handler has made this check for a while; this call simply never did. A reply that stops on the budget but still parses is kept, with the warning logged: stop_reason is a warning about the budget, not a verdict on the payload, and discarding a usable answer would spend the call for nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The vault relays what the provider said, and providers say it inside JSON, so the
402 reached the card as
World Labs request failed (402): {"detail":"Insufficient API credits … enable
auto-refill at https://platform.worldlabs.ai/billing."}
— a readable instruction wearing a JSON costume, rendered as a parse dump. Since
the reason for keeping the provider's own words is that they say what to DO,
showing them behind a brace and a quoted key gives that back with one hand and
takes it with the other.
The status prefix is kept, so the message stays identifiable in a bug report, and
the untouched body still goes to the log — this only decides what a human is
shown first. A payload with no human string in it keeps the raw body: better a
blob than an empty message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA 402 from World Labs — insufficient credits, with the billing link that says how to fix it — produced nothing anywhere. generateRoomWorld wrote no log line at all, and the only surface it did use was the editor status bar, whose error linger is twenty seconds against a job that runs to MINUTES: by the time the vault answered, the DM had long since looked away. Three surfaces now, because each answers a different question. The status bar is what you see if you happen to be looking. The CARD holds the reason where the click happened, until the next attempt clears it. The LOG is the durable record, with the provider's raw body beside the parsed message as a collapsible row — which is what every other provider failure in this app already writes, and this one wrote none of. The body is read as text and parsed from that, so the raw payload survives for the log instead of being consumed by resp.json() and lost. Keeping the provider's own words is the point: "add credits or enable auto-refill at <url>" is the actionable half, and a bare status code discards it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The assertion sliced a 400-character window starting at the literal text "addMsg(parsed.narration, 'ambient');". Appending the replay control to that call made indexOf return -1, the slice collapse to nothing, and the check fail for a reason unconnected to what it tests. Lifted from the `if (parsed.narration)` block instead, with a guard that the block was really isolated — the same failure mode as the flat-vs-html slice earlier, where a -1 quietly produced a window that proved whatever you liked. The ordering is now its own assertion, so moving the stamp ahead of the printed line fails on the sentence that says why that matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The control was built with the CAPITALISED time label — requestRoomAmbient holds "Morning" because it reads as prose in the GM directive, while the ambient buckets are keyed "morning". So the index lookup missed for every beat and the helper returned '' every time: the control silently never appeared, which is indistinguishable from never having built one. Lower-cased in the helper rather than at the call site, since that is where the key shape is known. Found by adding the assertion that was missing: the earlier cases drove the helper directly and never checked that the emitted STORY LINE carries what it returns. Dropping the call from addMsg — the exact shape of the reported bug — passed all of them. The "no clip, no control" case had the same weakness. Its beat was not one of the hour's, so the empty result came from the lookup failing rather than from the absence of a clip, and removing the clip check did not fail it. It now uses a clipless beat sitting in the array beside one that has a clip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two sound paths reached the player and only one of them answered the setting. The room SOUNDSCAPE (Settings > Sound) respected Play Room Sounds; an ambient BEAT's clip played whenever its own Play With Ambient box was ticked, with no reference to the setting at all. Unticking Play Room Sounds therefore silenced one and left the other sounding, which reads as the setting not working. The two boxes answer different questions — Play With Ambient decides whether the beat HAS a clip, the setting decides whether room sound is HEARD — so the clip still attaches either way and the beat's line now carries the same replay control the soundscape line does. Shown whenever the beat has a clip, including when it stayed silent, since that is exactly when you want it by hand. It resolves by (room, hour, index) rather than carrying the url, because an ambient clip lives on the act as a data: URI and baking it in would write audio bytes into the save on every beat. highlightSpeech had to become tag-aware first. It ran a bare quote regex over the whole message, so the quotes around class="…" in the appended control would have been wrapped in a speech span and the tag corrupted. It now consumes tags before the quote rule can look inside one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The 30s floor left the setting with no way to say "off", since the number that used to carry it is no longer reachable. A checkbox carries it instead, with the period on its own sub-row beneath — the same shape as "Logout after Inactivity" and its Timeout, which is where this pattern already lives. Off, nothing consults the clock at all and the period beside it costs nothing; it is simply remembered for the next time it is switched on. The switch migrates rather than resets. With no flag stored it is derived from the period, because back when 0 meant off, a number above it WAS a recorded "yes" — so a save carrying 120s comes back on, and one carrying 0 comes back off. An explicit choice always beats the derivation, in both directions. Moving the long name onto the checkbox also retires the stacked row: the period row is now "Cooldown" plus a stepper, which fits side by side. The .stacked rule goes with it rather than remaining as dead CSS describing a row that no longer exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The 30s floor has no "off" position, so a beat that lands now blocks the next one for half a minute — and this file prints a beat and then rolls another, which the cooldown correctly refused. The section is about LOGGING, so it clears the cooldown and says why; the cooldown has its own coverage elsewhere. Also pins that no cooldown is running before the three refusal checks above it. Each of those names a specific gate — wrong room, wrong hour, zero chance — and a running cooldown would satisfy all three for that reason instead, leaving them green while proving nothing about the gates they are named for. That is exactly how the failure above reached a sweep rather than a run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The spinner sits beneath its label rather than beside it: "Room Ambience Cooldown" plus a stepper is wider than the panel gives one row. A .stacked modifier does it, overriding align-items as well as the direction — in a column the row rule's `center` would centre both on the panel instead of aligning them left. 30 seconds is now the lowest value, so there is no "off" position and the default is that floor. The clamp is the migration: this setting shipped with 0 meaning no cooldown, and saves carrying that would otherwise show a value in the panel that the engine ignores. A stored 0 reads as 30. Clamping and snapping move into one normalizeAmbienceCooldown used by both the reader and the writer, so the two cannot disagree about what a valid value is. The zero-cooldown short-circuit in the remaining-time check is gone with the case it existed for, rather than left as dead code asserting a state that cannot occur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Entering a room and the hour turning now clear any running cooldown instead of being held by it. Both are moments the scene genuinely changes, and at those the cooldown belongs to the place and time just left: the floor exists to stop a room repeating itself while you stand still, not to open a new one in silence because the last one spoke a moment ago. Cleared BEFORE the early returns, not after the beat is chosen. Two consequences that are the point rather than side effects: the timer beats that follow the arrival are freed as well, and a room with nothing authored for the hour — or one with a beat already in flight — still clears, so walking into a quiet room does not leave the previous room's cooldown running against the new one. Within a scene the floor still holds: whatever fires next stamps a fresh one. This reverses the call I made when the setting landed, where the entry path was gated on the grounds that a doorway could otherwise step over the cooldown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
It asserted refreshNanoBananaModelRow() appeared within 2400 characters of "function syncSettingsControls()", and the two lines the ambience cooldown added above the call pushed it past the budget. The contract is that opening Settings refreshes the row; how far down the call sits is not part of it. Now lifted from the function BODY — and from `html`, not `flat`. The first attempt sliced `flat`, whose newlines have been replaced, so the search for the closing brace returned -1 and slice(from, -1) handed back two million characters of the rest of the file: an assertion that passed whatever the function held. A companion check pins that the body was really isolated, so that failure mode reports itself instead of masquerading as a pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Drives the spinner (steps, clamps, junk), the gate on both paths, and the two decisions that are easy to get wrong: the check sits BEFORE the roll so a cooled-down room does not spend its chances against a gate they cannot pass, and a doorway does not clear it. The reader-side snap needed its own case. Setting 47 through the setter proved nothing, because the setter snaps first — the getter's snap exists for a value that never passed through the spinner (a hand-edited setting, an imported blob), and one left off-step is one the +/- buttons can never land on again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A floor under how often a place's background life may speak. Each ambient act carries its own chance and interval, and a room with several of them rolls all of them independently — so the rate a player actually experiences is the SUM, which no per-act number can bound. This sits outside them. In 30-second steps to 30 minutes, on the same stepper the inactivity timeout uses. 0 means no cooldown, which is how ambience has always behaved and so is the default: this ships as a control, not as a change to anyone's pacing. It binds both paths. The timer-driven one checks BEFORE rolling, so a cooled-down room does not spend its beats' chances against a gate they cannot pass. The entry/hour-change one is gated too even though it deliberately bypasses the act's chance — "guaranteed" was only ever about the dice, and a cooldown any doorway could step over would be no floor at all. The clock starts where a beat actually reaches the player, not where one is attempted: stamping it at the attempt would let a run of failed GM calls silence a room as effectively as a run of successful ones. Held in memory rather than in the save, because a session resumed a week later should not still be serving out last week's cooldown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Drives the cap through the real funnel with the reported prompt at its reported length, so the assertion fails with the same overrun the provider rejected rather than with a synthetic one. The clause-boundary preference gets its own assertion. Removing it left a valid word-boundary cut, so the earlier "word or clause" wording caught nothing — but these prompts are comma-separated lists of things to hear, and cutting at the space lops the tail off the last item and asks the model for a sound nobody described. Pinned on the item surviving whole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
ElevenLabs refuses sound-generation text past 450 characters outright, and the room soundscape directive — "one or two sentences" — produced 472. The whole generation was lost after the GM had already been paid to write the prompt. Two changes, because a directive is a request and this is a limit. The cap is enforced where the text leaves: generateSoundFromPrompt is the funnel every sound in the app goes through (the Rooms card button, Settings > Sound, the on-the-fly ambient clip), so a hand-typed prompt or an authored world is held to it too, not only a GM-written one. Trimming cuts at a clause or word boundary — a sound model reads this as a list of things to hear, so dropping whole items leaves the rest intelligible where a mid-word fragment means nothing — and says in the log when it bit, since a trimmed prompt paints a different soundscape than the one authored. The directive now asks for the sounds and nothing else. What came back began "Interior of a low-beamed medieval tavern at dawn: …", which is the room described rather than what is heard — scene-setting spends the character budget on words a sound model cannot render. It now asks for a comma-separated list of sounds, bans the preamble, and sets a 200-character budget with room to spare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reproduces the reported sequence: arrive, let the slot settle, then move the clock into another bucket while standing still. The new hour must get its own prompt, its own clip, and play — without the player touching a checkbox. The ordering assertion for describeRoom was anchored to the FIRST maybeRoomSounds in the file, which stopped meaning describeRoom the moment a second call site existed. It is anchored to the music call it is supposed to sit beside instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A room's sounds are authored per time of day, so the hour turning is exactly as much of an arrival as walking through a door. This hung off room entry alone, so standing still while midnight became dawn left the new hour's soundscape neither generated nor played — and since the entry guard is a room+time pair, nothing re-evaluated it until something else cleared that guard. Unchecking and re-checking the box is precisely such a thing, which is how this was found. The time-change handler already did the matching work for everything else at that moment — routines, the banner, the description, and a guaranteed ambient beat for the new hour. The soundscape was simply never wired into it. Resume gets the same treatment, for the reason resumeRoomMusic already documents: describeRoom is not called when a session is restored, so a resumed game had no room sound until the player walked somewhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Drives each gate and asserts the reason reaches the log, then the half that makes the log usable: 25 ticks blocked for one reason produce one line, a change of reason is reported, a beat that fires clears its memory so the next block is news again, and two beats in a bucket are tracked apart. Four sabotages: removing the dedup floods it, removing the clear suppresses a beat forever, collapsing the key silences the second beat of a pair, and dropping the wrong-hour branch returns the commonest case to silence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Every gate in tryRoomAmbient returned in silence, so a beat that never fired looked identical to one that was never authored — and the two commonest reasons (the hour does not match the bucket it is grouped under, the player is elsewhere) are invisible from the Rooms card, which shows the beat sitting there looking ready. Deduplicated per act, because these are TIMER gates: a dozen rooms register a timer per bucket per act and every one ticks whether or not the player is near. Logging each tick would bury the Logs tab and answer nothing, so a blocked reason is written once and stays quiet until the reason changes. An act that reaches a real roll clears its memory, so the next block is reported afresh. Also covers the sound half: a beat carrying a clip with Play With Ambient off prints its line and stays silent, which reads exactly like broken audio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The line lives in messageLog, which is serialized and re-rendered from scratch on resume, so the replay control has to come back working — otherwise every reloaded line looks clickable and does nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A sound is the one part of a scene the player cannot go back to: the description and the picture stay on screen, the soundscape is gone when it finishes. Playing one now writes a line into the story with a replay control beside it. The description is the room's own sound prompt, which is already written as "the environmental sounds one would hear at this time of day" — asking the GM for a second, prettier one would be a paid call to restate what the world already says. Those prompts run to two sentences and this is one line, so a long one is cut at its first sentence break and a run-on is trimmed on a word boundary. The control resolves the clip from (room, time) when clicked rather than carrying a url. The message log is serialized into the save, so a data: URI baked into a line would put audio bytes straight back into it — the thing the storage split exists to prevent. It also means the button follows a regenerated clip, and can say so when there is nothing left behind it. Nothing plays, nothing is said: generating with Play Room Sounds off leaves no line, because a line about a sound the player never heard is a lie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Three sabotages, each reproducing the report: restore the local-key-only gate and Vault mode refuses; stop persisting from the editor button and the clip never reaches the save; read only the memory cache in Play and it claims there is no audio for a room that has some. Each fails on its own assertion. Also pins the half that must NOT change: Direct mode with no key still refuses, and still says what is missing and where. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two separate faults, both of which make the button look dead. The gate. Three callers asked "is there an ElevenLabs key in the browser?" before generating a sound. In VAULT mode there deliberately is not — the key is held server-side and generateSoundWithProvider routes through /vault/generate — so the check refused a generation that would have succeeded, changing its own tooltip to "Add an ElevenLabs key" on a vault where that key is configured. Replaced with soundProviderReady(), which treats vault mode as ready and otherwise asks the selected provider for its key. resolveImageProvider already drew this distinction for images; sound never did. Direct mode with no key still refuses. The storage split, which is mine. Adding Settings › Sound gave a clip two homes — room.audioClips for a vault-stored one, the in-memory cache for the rest — and these two buttons still knew only the cache. So Play said "generate the audio first" about a clip sitting in the save, and Generate Audio produced one the game could never find. Both now go through rememberRoomSound / roomSoundUrlFor, the same pair the entry path uses, so there is one answer to "does this room have a sound for this hour" rather than two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The Settings dialog opens from the LOGIN screen, and currentRoom() reads world.rooms[player.currentRoomId] with no guard — so ticking either Sound box before starting a game threw "Cannot read properties of null (reading 'rooms')". The story-panel Music toggle makes the same call and never hits it, because that button only exists once you are playing. Found in a browser, not by the suite: the test that should have covered it had a stubbed currentRoom left over from an earlier block, which resolved a room even with no world and hid the crash. The stub is gone — the player is built standing in that room, so the real function answers — and the no-game case is now its own assertion that fails with exactly the browser's message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two checkboxes in a new Sound section, both off by default. Auto Generate Sounds fills in what is missing for the room you just entered at the CURRENT time of day: a GM-written sound prompt if the room has none, then the clip synthesized from it. Play Room Sounds plays a clip that already exists. They are separate settings because they cost different things. Play spends nothing and is safe to leave on with an authored world; Auto Generate bills a GM call and an Audio AI call for every gap it finds, which is why it is off by default and why a failure is remembered rather than retried on the next doorway. Every combination works, including generate-quietly. A clip is kept where its size allows. A vault-stored one is a short /vault/media/ path, so it is written onto the room and survives the save — which is what lets "the audio already exists" mean anything after a reload. A Direct-mode clip is a base64 data: URI, and audio ones run to megabytes; those stay in memory for the session. The restore path drops an inline URI too, so a hand-edited save cannot reintroduce the 441 MB export this app measured once. The entry guard is a room+time pair rather than a room id: re-describing the same room at the same hour is a look, but walking back into the tavern at midnight is a different soundscape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Defaulting splatStored to false told every world generated before the field existed that its vault-held splat came from the provider — a claim contradicted by the /vault/media/ path sitting right next to it. The url shape already knows, so it decides when the field is absent. The VARIANT is genuinely unrecoverable for those worlds, and the card says nothing about it rather than guessing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The size-order fix was intact and no World Labs parameter had changed, but the failure path disagreed with the ordering. The loop assigned its candidate url on every pass, so a run where nothing stored ended holding the LAST one tried — 100k, the coarsest of the three — and handed that to the viewer. Switching the media store off reaches it in one step, because that refuses all three every time. The world came back visibly fuzzy and the ordering above it still read correctly, which is why this looked like a reverted fix rather than a live bug. Biggest is the right answer on that path: the only reason to prefer a smaller splat was ever STORAGE, and nothing is being stored. The choice moves into splat-pick.js so it can be driven directly. It was covered before by reading server.js and checking the order of a literal list — a test that passed throughout, because the list was never what was wrong. The new ones call the function, and the pre-fix loop restored under them fails on exactly the assertion that names the defect. The client was also dropping splatVariant and splatStored, so nothing in the save recorded which of the three densities a world actually got. That is the other half of why this went unnoticed: the picture was worse and no field said why. The card now names the variant, and stops reporting "kept on the vault" for a splat still being served from the provider's expiring signed url. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
New defaults: Image AI is fal.ai on SANA Sprint with steps pinned to 1 and guidance to 5; Icon, Map, Gallery and Weather are Nano Banana Pro; World AI is named as World Labs. Sound, Video and 3D are unchanged. The defaults were literals inside six different getters, so "what does Icon AI default to?" had six possible answers and changing four of them was four chances to miss one. They are now one table that slotProvider reads, and the third argument that carried the old copy is gone from every call site — divergence is not fixed here so much as made unrepresentable. Two of these are keyed providers standing where a keyless one used to. That is safe only because the SELECTION and the provider actually CALLED are separate questions: resolveImageProvider still drops any image-family slot to Pollination for the call when its provider has no key. Sound, Video and 3D have no such net and no keyless option to fall back to, which is why they were left alone. Steps and guidance are pinned rather than left blank, which reverses the previous rule that a blank field lets each model choose. Sprint wants 1 step where Base wants ~18, so the pin suits the default model and mis-suits the others; clearing a field stores a real blank and hands the choice back. Absent-vs-blank now mean different things, and getFalParam only applies a default to the absent case — or clearing a pinned field would silently restore the pin. World AI has no picker and one provider, so it gets a named constant beside the table instead of a slot, and generateRoomWorld sends that rather than repeating the string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
test_being_inventory_ref.js matched the dossier line verbatim, so appending the
item's worth broke two of its assertions - shipped in the previous commit because
I read the suite result through `| tail`, which returns the pipeline's exit code
from tail rather than from the runner.
The ref assertion now matches "ref: ${i.ref}" without pinning what follows it:
this test exists to prove the id reaches the GM, not to own the rest of the line.
The no-ref fallback is re-pinned against its new shape.
534/534.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvBUG-016, the economy twin of BUG-011 and the same line of code. That bug gave the GM a name with no identity, so asked to hand a sword over it minted a counterfeit; the fix put the catalog ref in the dossier. The ref settled WHICH object changes hands. Nothing settled what it COSTS, so every figure the GM quoted for a being's stock was invented. Confirmed in play: a rebinder asked "eight gold" for a book the world valued at 55 copper, about fourteen times over, having never been shown the 55. The error is not directional - an earlier session saw quotes come in under - so it cannot be calibrated around. It is noise in the one place the world-economy design and the pass's cost-lock both assume the authored number is what the player meets. The worth now travels with the ref, rendered through copperToCoinText rather than as a bare integer. The unit is the sharp edge: value is COPPER, so handing over "55" invites "fifty-five gold" - a hundredfold error in the direction the GM already drifts, and worse than saying nothing. "5s 5c" cannot be misread. Value 0 says nothing rather than "worth 0c", which would read as free goods. The test renders the real dossier line across six magnitudes, pins that the BUG-011 ref does not regress, and covers the silent cases. Found because my own prices were wrong: the rebinder's stock was authored at 55c and 130c on a misreading of value as gold, trivial against a 1500c purse. Repriced in the world data to 1200c and 3200c against Verengrad's real scale - loose coin totals 175c across four containers, so anything costly is paid for by selling found loot, which is where the effort belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The tab strip had drifted into two kinds of tab with no order to say so. Settings configures the vault; Media, Usage and Logs are read-outs of what it has already done. Users — who may sign in at all — is the first kind, and it was sitting past the last of the second kind, as far from Settings as the bar allows. The switcher resolves a tab by aria-controls, not by position, so nothing in the page depended on the old order and nothing depends on the new one. That is exactly why it needs a test: the grouping is an intention the code cannot state. Two assertions, one for the strip and one for the panels' source order, which is free to drift precisely because ids key it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Three managed keys named providers the Providers tab had never heard of. Runware (video), Tripo (3D) and World Labs (world) are called by hand-written code rather than by a descriptor, because a descriptor models ONE request with an optional poll -- descriptor-schema.js says as much, reserving `poll` and `json-url` as built-in-only -- and all three are job APIs: submit, poll an operation, then fetch the finished artefact from somewhere else. That is a real reason and the page never gave it, so an admin comparing Keys against Providers found three keys with no provider and no explanation. They now appear as read-only rows under their own heading: the same card so they read as providers this vault can call, with a dashed "code" tag, a line saying what the call actually does, the key id that links the row to its card on the Keys tab, and no controls -- no slot checkboxes (they are not slot-fillable; the game picks them in its own 3D / World AI settings) and no Remove (there is no stored descriptor to remove). The lede carries the explanation, including the part that makes the split honest: a provider can be BOTH. Higgsfield's images come from its descriptor while its video goes through the same code path Runware's does, so "descriptors here, code there" is not a clean category and the page should not imply one. Membership is COMPUTED -- any managed key that no descriptor claims, minus anthropic, which is the GM and not a media provider -- so a fourth code-backed provider cannot be added and silently left off. Only the detail (kind, host, what the call does) is declared, in providers.js beside the runners; an id with no detail still gets a row rather than vanishing. Computing it turned up a mismatch worth knowing about: the Pollination descriptor's id is singular while its key is `pollinations`, so a first version matching on id alone listed Pollinations as code-backed. server.js already resolves keys by auth.ref for exactly this reason (its comment says so); the membership test now agrees with it, and a test pins that rule.
Found in play: first turn in Bell-Tower Market with a newly authored NPC threw
TypeError: Cannot read properties of undefined (reading 'bell_tower_market')
at Entity.conversationImage -> portraitImage -> renderCompendium -> describeRoom
and describeRoom died with it, so the player got no scene, no exits and no
occupant list. The cause was one NPC with no conversationImages map. The field is
optional art; the room is the game.
conversationImage now defaults the map before indexing it. Missing art degrades
to no portrait, which is what the function's own `|| null` tail already promises
every caller.
Worth saying why this is a guard and not just a data fix: the invariant looked
airtight and still did not hold. The constructor assigns `conversationImages ||
{}`, the deserializer passes g('conversationImages', {}), and g is
(k,d) => spec[k] ?? base[k] ?? d - three defaults - yet findEntityByName returned
a real Entity whose map was undefined. I could not reproduce the route from the
source. An accessor on the room-render path should not stake the room on an
invariant that has already been observed to fail.
The test calls the real method on the exact shape that crashed, plus a null map,
a locationless entity and a bare object, and pins that the guard does not
short-circuit past compendiumImage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv"Your Name" named the person at the keyboard; the field names the person you play, and every other label on that screen names a thing in the world. Begin is now disabled until it holds something. It was the one answer on the screen that mattered with no requirement behind it -- the world has a default, the class has a first option, the key may live on the vault -- and startGame quietly substituted "Traveler", so a player who skipped the field met their own character under a name nobody chose. Whitespace does not count. CONTINUING is exempt, and the exemption is the part worth reading twice. syncLoginNameLock fills the field from the save and disables it, so a save whose name were somehow blank would leave Begin locked against a field that cannot be typed into. The check runs after that lock for the same reason -- before it, it would read the field while it was still empty. The button says why rather than only greying: a title, so the app's own themed tooltip carries it like every other one. Both channels are cleared when the condition lifts -- adoptNativeTitle MOVES a title into data-tip on first hover and deletes the attribute, so a button the player has already hovered explains itself from data-tip, and clearing only `title` would leave the complaint outliving the condition. And the hover glow is now scoped :not(:disabled), because a dead control that lights up under the pointer reads as one that ignored the click. One thing found by the tests rather than by reasoning: the first version reached for removeAttribute, which six existing login harnesses do not stub. Rather than grow six files to fit the implementation, it uses the property and dataset forms -- identical in a real DOM (an empty title is stripped by adoptNativeTitle before it can become a tooltip) and available everywhere. No other test needed changing. Twelve sabotages, each caught by a distinct assertion.
The screen is a brief -- fourteen boxes that each feed the Game Master a different way -- and which one a given box feeds is not deducible from its name. Several already carry a hint underneath, but a hint says what to TYPE; these say what the field is FOR and what happens when it is left blank. Scope says it multiplies by the region count, which neither field says alone. Regions says the number is a ceiling rather than a quota. Narrative says it is never shown to the player, which its neighbours on both sides are. Economy says the balance pass grades the finished world against it, which nothing on screen shows. World JSON says everything above it is the brief that produces it -- the one relationship a newcomer misreads. Generate World gets its own: what it does, that it takes minutes rather than seconds and longer with more regions or auto-lore, and that nothing is saved by pressing it. Every other button on that screen returns in seconds and commits nothing, and the one that does neither looked identical. They use the app's own themed tooltip (data-tip / data-tiphead, read by showAppTooltip) rather than a native title. Two consequences that are the point: they match every other tooltip in the app, and they obey Settings > Interface > Toggle off Tooltips, which gates inside showAppTooltip -- a native bubble would ignore that switch. Browser-verified on and off and on again. Each icon is focusable, because that tooltip system fires on focusin as well as mouseover, and carries an aria-label, because it renders a bare "i". The icons sit INSIDE their labels rather than beside them: the Rules, Prologue and Narrative rows are justify-content:space-between, so a sibling icon would be pushed into the middle of the row instead of staying with the words it explains. Browser-measured -- the icon's box sits within the label's, and the sparkle buttons still end flush with their rows. Five existing tests pinned the exact label markup and were updated. Four wanted `Label</label>` and now ask that the label is there rather than that nothing follows it. The fifth measured the distance from a label to its sparkle button -- a window of 120, which I widened to 520 and which the longest tooltip then blew past at 742. Widening it again would have been the third guess at a proxy, so it now lifts the label row and asks whether the button is in it, which is the property it was always reaching for and cannot rot. Eleven sabotages, each caught by a distinct assertion.
"Spells & Scrolls" in the title, h1, meta description, footer and every place that referred to the document by name - the stub, the Designs index, and rest-and-fatigue's link to it. The lede and description now mention scrolls outright rather than leaving them to be discovered in section 10. Bumped to rev. 7. Since rev. 6 the document gained section 04 on how the world grimoire, a class and a spellbook combine to decide what is castable, section 13 recording what became of the seven proposals it absorbed from character-spells, and Decision O on world-authored starting spells. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Scrolls are a substantial part of that document - the tactical-not-teaching trade, the level-bypass, the consumed-on-failure channel check, two of its fifteen locked decisions - and the filename did not say so. All 31 references across nine files updated. The replace uses a negative lookbehind on "character-", because character-spells.html contains spells.html as a substring and a naive rename would have produced character-spells-and-scrolls.html. Verified rather than assumed: all 11 anchor links into the document still resolve to real ids, the document has no reference to its own old name, and its fifteen sections and internal cross-references are intact. Git recorded it as a rename. The generated progress report is left alone; it records what the file was called at the time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
spells.html and character-spells.html had become two live specifications of one system, and they had started to disagree. character-spells still labelled its proposals IV and V "proposed" when both had shipped as spellcastingAttackProfile; its proposal III, per-spell mastery ranks, had been decided the other way in spells.html Decision F, which scales power off the Spellcasting skill instead. Yesterday's seeding change had to be written into both files by hand - which is the drift that eventually produces a confidently wrong answer. spells.html is now the single reference. New section 13, "Still open", records what became of all seven proposals - answered, shipped, or decided the other way - and carries the two that are genuinely live in full: schools and targets with real behaviour, and an MP economy. The MP one is the largest untouched question here; nothing else in the document discusses mana at all, and it wants pairing with the memorisation economy, since slots decide what you can cast and MP decides how often. character-spells.html becomes a signpost rather than a specification: why it was merged, and a table mapping each thing it used to answer to where that now lives. It keeps its own head and stylesheet so it still renders in house style, and it stays a file rather than a deletion because five documents link to it. Its prose lives on in git history. Inbound references updated: the Designs index, current-status, the DM's Guide reading list, and the in-app guide's pointer. The generated progress report is left alone - it is a record of what was said at the time. Sections 13-14 renumbered to 14-15. test_spell_phase1b.js cited §13 meaning the build order, which is now §15; its comment says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The docs described a system in which the engine owned the spell list, because until this week it did. New section 04, "Where a spell comes from - world, catalog, class, book", is the reference the TideCantrix work showed was missing: four things decide whether a caster can throw a spell on a given turn, and most of them are called some variant of "spells". It covers what answers each question and what each falls back to; the one rule the first three share (world data authoritative, built-in table as fallback, mirroring classInherentSkills); why every id resolves through spellCatalog() rather than the constant, with BUG-015 as the worked failure; the two unrelated jobs a spellbook does (teaches writes the repertoire, carried is the loadout); the four gates between knowing and casting; when each is recomputed; and how to author a caster the innate way or the tome-only way. Two stale claims fixed while there. Section 05 defined a field spellbook as type "spellbook" whose CLASSES include "field" - true only of legacy data, where a bare classes list IS the taxonomy. On a modern item classes means class RESTRICTIONS, so authoring classes:["field"] restricts the book to a character class that does not exist and leaves it not a field spellbook at all. That is exactly the trap the Drowned Psalter fell into. And the class-access paragraph still credited CLASS_STARTING_SPELLS with seeding every caster's repertoire. Decision O locked: the world decides which spells a class begins with, the engine only supplies a default - with the accepted corollary that a world whose grimoire omits a built-in spell no longer grants it. character-spells.html carried the same two assumptions and now points here. Sections 04-13 renumbered to 05-14; all 49 cross-references shifted with them and verified to resolve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-015. Two halves, and the second is why this matters. A world could not author a caster at all. Starting spells came from CLASS_STARTING_SPELLS, a table keyed by the seven engine class names, and SPELLCASTER_CLASSES is literally its keys. Verengrad's TideCantrix carries the Spellcasting skill, a psalter and a class Spellbook slot labelled "Psalter", and still seeded an empty grimoire, because "TideCantrix" is not one of those seven. Chasing that turned up five sites resolving spell ids against the built-in SPELL_CATALOG constant rather than the world's grimoire. Four fail at character creation, loudly. The fifth ran on every load: player.spells = player.spells.filter(id => SPELL_CATALOG[id]); A world-authored id is by definition absent from the constant, so this kept exactly the spells the world did not write and discarded the ones it did. Learn a spell, play on, reload, and it is gone - silently, with nothing said, every time. All five now go through spellCatalog(), the accessor that returns the world's grimoire and falls back to the constant. Seeding moves into classStartingSpells and isSpellcasterClass, mirroring classInherentSkills deliberately and exactly: world data authoritative, built-in default as fallback. That helper had solved the same problem for skills twenty lines away - which is why the TideCantrix already had its Spellcasting skill while its grimoire came up empty. Existing casters are unaffected. A world authoring no startingSpells takes the branch it always took, so a Mage begins with what a Mage began with. One deliberate change: a world whose grimoire omits a built-in spell no longer grants it. Previously a Mage started knowing a spell their own world did not define, which vanished the moment anything read the real grimoire. test_field_spellbook.js pinned the old restore line verbatim; it now asserts the same intent through the helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The section answered three identity questions the STORE has to settle. This adds the fourth, which the LOGIN SCREEN will have to settle the moment hosted worlds are offered for new games -- written down now because it shapes that UI rather than following from it. Put a vault's worlds beside the browser's own and "the same world in both lists" is three situations, not one. Same uid and same content is noise. Different uid sharing a title needs both rows shown. The dangerous one is same uid, DIFFERENT content -- a local edit never published, or a server copy newer than the local one -- and it is exactly the case a separate tab or a separate "server mode" does not address: those keep the lists apart, which is what stops the divergence from ever being noticed. Two rows in two places are never compared, so an author starts a game on the stale copy of their own world and nothing says a word. Separating the lists is a presentation choice, not an answer. The comparison itself needs no new plumbing: the local envelope holds worldVersion and exportedAt, and a stored record holds uid, worldVersion, publishedAt and updatedAt. Two asymmetries recorded with it, both read out of the code rather than assumed. The local library is keyed by NAME (saveSavedWorld overwrites by name) while the vault store is keyed by UID, so renaming locally makes a second entry while re-publishing updates in place -- the two lists can disagree about how many worlds exist before any UI decision enters into it. And importWorldToEditor calls claimWorldUid with no name argument, so a uid already in use anywhere is re-minted on import: downloading a hosted world and importing it while the original is still held produces a world the vault can no longer match to its own copy. Correct for that function's original purpose, and a trap for anything comparing by uid. Also noted against §04-A, since even the shelf reading does not escape it.
Found in Verengrad's own library master, which had carried it through an export, an import and three reviews - two of which called the world pristine. baseline-not-pristine asks whether an OBJECTIVE is already satisfied. None of these fields satisfy an objective, so nothing looked at them: xpAwarded on Gill-Wretch and The Bell-Warden - awardEntityXp opens with `if (!ent || ent.xpAwarded) return 0`, so two of the world's three monsters awarded nothing when resolved, and no line of story said so. _engagedInConversation on 4 of 5 npcs - ambient speech suppressed, skipped for encounter selection, never counted toward peopleSpokenTo. Only ever assigned true; there is no code path anywhere that clears it. loreUnlocked on a Drowned Longsword inside an NPC's inventory - a carried instance is reachable from neither the room floor nor the catalog, so every count written by hand missed it, including mine. Severity follows consequence, not count. _statusOverride and _diedAtGameMs clear themselves as play resumes, so a world carrying only those is reported at info; a warning a DM should ignore trains them to ignore the ones that matter. Fields present but false or zero are not residue and are never reported. The card says outright that none of these have an editor control, because none are meant to be authored. baseline-not-pristine once sent a DM hunting for a checkbox that did not exist, and that lesson applies double where the right answer is to edit the export or start from a copy that was never played. Verified against the real thing before and after: the check found exactly the ten fields a separate hand sweep of the database had found, and the world now passes with the two lethal-forecloses-lore warnings that are authored design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
It matched /confirm\('Unpublish/, which `if (false && confirm(...))` also
satisfies — the string is still there and the DELETE still runs unasked. It now
requires the negation and the early return, and catches that sabotage.The Admin > Worlds panel had been a named placeholder since the page was built. It is now the table: one row per hosted world with its name and uid, rooms and regions, beings and items, size and media count, who published it and when, plus per-row Download and Unpublish and a footer saying what the vault is holding. Six columns, not eight. The admin panel is 600px wide and eight overflowed it at every viewport, spilling into a horizontal scrollbar on the whole document at 760px; the facts pair up naturally anyway. The headers were also wrong on the first pass -- Size sat over the room count -- so each now names what is under it. The uid is shown under every name because it is the identity and two rows may legitimately share a title. Unpublish confirms first, through the page's own confirm() idiom rather than a second one, and says the images are kept -- they are content-addressed and may be shared with another world. Download navigates rather than fetching, since the route already sets Content-Disposition and fetching would buffer a world in the page only to hand it straight back. UPLOAD takes an exported world file straight off a disk, no World Editor involved. It reads and shape-checks the file in the browser first, so "that is not JSON" and "that has no rooms" are answered instantly rather than after uploading tens of megabytes, then posts to the SAME route Publish uses -- two paths storing worlds two ways is how they drift. The interesting half is the art. A world exported for portability inlines every picture as base64, which is what makes it portable and what would make it unstorable: it blows the ceiling, keeps a second copy of pictures the vault may already hold, and contradicts what makes a hosted world small. So inline data: URIs are moved into the content-addressed media store on the way in and replaced with the URLs it returns. Measured through the real routes: 24 KB in, 703 bytes stored, three pictures moved, the one appearing twice stored once. Extraction is skipped when the media store is off -- there is nowhere for the bytes to go -- and the size refusal then names that switch rather than only quoting a number. The store is an optimisation and never a gate: anything it will not take is left exactly as it was and counted, so one odd attachment cannot fail an upload. The request limit is now separate from the stored limit, because what may arrive is legitimately larger than what is finally kept. One bug found by its own test: the walk skipped any string under 32 characters as a "cheap reject", which bought nothing over the startsWith it already did and silently passed over small media. Removed.
BUG-014. entityCompendiumCategory returns 'animals' for any being with type: 'animal', but compendiumTypeContext only ever knew the ITEM shape of that category and resolved the name against ITEM_CATALOG. A fauna being has no entry there, so the context came back with obj: null, and everything downstream read that as "no such subject": unlockSubjectLore hit `if (!obj ...) return false` and the hook never fired, while subjectLoreXp reached loreHookXp(null) and returned 0 - not the default, nothing. So a beast's lore was unreachable AND worth nothing. Measured on a harness with one being, one set of lore, only `type` varying: npc paid 35, monster paid 35, animal paid 0 and never unlocked. 'animals' now resolves the entity FIRST, because that is what fauna are now, and falls through to the item lookup only when no being answers to the name - so a legacy animal-type item in a pre-entity world keeps its resolution. The returned shape follows the object, so a card never renders the wrong controls, and the fall-through is reachable only by animals: an unknown people/monsters name still returns the entity shape with a null obj exactly as before. applyCompendiumLoreField had already learned this on the write side and runs both paths for animals; its comment records that a Fauna card's lore edit "landed nowhere and the card redrew from the unchanged object, so it read as if it had saved". This was the same split on the read side, left unfixed. With a beast resolvable at last, 'animals' also joins BEING_LORE_CATEGORIES, so fauna get BUG-006's instance fallback like every other being. It was deliberately left out until now: with no type to fall back FROM, including it earlier would have half-answered this instead of exposing it. Found by inspection while scoping the BUG-006 fix, not in play - Verengrad has 5 npc and 3 monster beings and no fauna, so no run could have revealed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Unsigned — the signing host is still unresolvable from this container. test_publish_world.js covers the admin hint read strictly (an absent field is no, and four junk truthy values are not true enough), the button drawn only for an admin on a vault and nowhere in Direct mode, the four blocking faults, and the measured correction the first browser run forced: the built-in world draws a 'no region' note from validateGeneratedWorld and must still publish, because that function mixes unplayable faults with merely-thin ones. Then the flow: a blocked world uploads nothing, a publishable one asks first and shows the non-fatal notes as things worth knowing, the body is the envelope Import World already reads and carries no brief, cancelling sends nothing, a refusal quotes the server's own reason and distinguishes 'not signed in' from 'not an admin', and publish never rehydrates media. Also fixes test_admin.js, red on main since the admin page was retitled: it asserted a bare h1 'Admin' against 'The Lost Realms - Admin'.
Unsigned for the same reason as the checkpoint before it — the signing host is still unresolvable from this container. test_world_store.js covers the uid grammar (refused, never sanitized), writes landing where they should and nowhere else, re-publish as an update in place with publishedAt preserved, two worlds sharing a name, the stats — including regions read off world.regions.LIST and reported as 0 when no region map was generated, and media references counted distinctly — the size ceiling, a corrupt index reading as empty rather than throwing at boot, and atomic writes. The traversal check is hermetic: the store sits one level inside the test's own temp root, so 'did anything escape?' is asked about this run alone. The first version probed a shared absolute path, which meant one escaping run poisoned every later one; it also now proves it can fail, against a throwaway store. test_world_routes.js drives the real app over HTTP: the admin hint (asked both where the answer is yes and where it is no, or it would be a constant), the gate's new JSON branch beside the HTML one a navigation still gets, publish/list/ download/unpublish, publishedBy taken from the session even when the body claims otherwise, and the two source-level facts that keep the gate ahead of the upload.
Checkpoint commit, UNSIGNED: the container lost DNS for api.anthropic.com, which the commit-signing hook calls directly, and an earlier build of this work was destroyed by a container wipe before it could be committed. Committing unsigned on the user's explicit instruction so it cannot be lost again; tests and the final message follow in the next commit. Phase 0: /vault/config reports whether this client would pass the admin gate, computed by calling adminGateDecision itself. The allow-list is never sent. Client reads it strictly and exposes isVaultAdmin() — a rendering hint, never an authorization. Phase 1: server/world-store.js (one file per world plus an index, atomic temp-file-and-rename writes, keyed by the world's own uid so re-publishing updates in place); POST/GET/DELETE/download under /admin/api/worlds behind requireAdmin; a JSON branch in requireAdmin so a fetch gets a readable 401/403 instead of a redirect or an HTML body; the app-wide JSON parser skipping the publish route so the gate refuses before the upload is buffered; and a Publish button in the World Editor header only.
A Publish button beside the Builder's Generate World would sit one click from a world nobody has read, and publishing differs from Save World and Export World in exactly the way that matters: someone else may play the result. So the route to the vault runs through the Editor, which is where a world is read room by room, and it uses a path that already exists -- the Builder's World Editor button saves the draft and opens the editor on it. This removes the invitation rather than enforcing a review; nothing stops an author opening the Editor and publishing without reading a word, and a button placement cannot do more than that. Two consequences, both traced through the code rather than assumed. The brief mostly stops travelling, and the doc now says so instead of leaving it to be discovered. collectWorldEditorFields reads the we-* inputs on the Builder screen, and populateWorldEditorFields is called from exactly one place -- importWorldToEditor -- so nothing fills those fields when an editor window boots on a draft. A publish from there would carry a block of empty strings, which is worse than carrying none: an empty brief is indistinguishable from a cleared one, and Import Brief would faithfully restore the blanks over an author's own text. So the block is omitted when blank, and the stats read tone, art style and economy off the world itself, which carries all three. Scope and the requested region count are genuinely brief-only, and their absence is correct -- they are generation inputs, not properties of a finished world. The gate gets stronger. buildEditorWorldEnvelope refuses two conditions (no startingRoomId, no classes); validateGeneratedWorld checks those plus a starting room that is not among the rooms, missing regions, and more. Publishing from the Editor should gate on that, so an unplayable world cannot be hosted rather than merely being awkward to export. §12's "the publish button in two places" concern is replaced by the friction the single button costs, and by the note that the Editor's three documents -- live session, ?save=, ?draft= -- all publish the same serializeWorld output, so naming which one is going to the vault is a labelling job. Phases drop from five to four.
§05 led with "the client cannot tell whether the user is an admin", which framed a
button-drawing question as though it were the authorization design. Reordered so
the actual model comes first -- the client attempts, requireAdmin decides, nothing
the browser believes changes that -- and the three button postures are visibly
only about what to render before the attempt. Always-show with a clear refusal is
now named as a perfectly good answer; the config boolean keeps the
recommendation on a narrower argument.
Two things that reframing exposed, both grounded in the code:
The gate answers NAVIGATIONS, not fetches. requireAdmin was written for a browser
going to /admin: "not signed in" is a 302 to the Auth0 authorization endpoint,
which a cross-origin fetch cannot follow, so the client sees a network error
indistinguishable from a dead vault; and "not an admin" is a 403 with an HTML
body, while every other error in admin.js is res.status(4xx).json({error}) and the
admin page's own fetches read j.error || 'HTTP ' + r.status -- so the reason is
discarded. It has never mattered because the admin page is itself behind the gate,
so the gate never fires on an admin fetch. Publishing is the first client that is
not the admin page to call an admin route. The gate needs a JSON branch, whichever
button posture wins.
The body is parsed before the gate runs. express.json({limit:'12mb'}) is mounted
at server.js:133 and the admin router at 627, so a refused publish has already
uploaded, buffered and parsed the entire world. Refusing before the read means the
global parser has to skip the path -- and that, not permission, is the one thing
the client-side hint actually buys.§05 recommended adding `admin: true` to /vault/config but never addressed the obvious alternative: the client already knows its own email, so why not compare it against the admin list? Because the two facts sit on opposite sides of the wire. The client is never sent the list and should not be -- there is no adminEmails in buildClientConfig's payload, and shipping one would hand every player on the vault the address of every admin. isAdminUser is also not list membership: it refuses an unverified email and treats the owner as an admin whether the list names them or not, and its own comment promises every caller depends on that one function so it can become a role claim in one edit. And the loopback dev case inverts the test -- adminGateDecision allows a loopback client with Auth0 off, but no email is sent at all, so an email comparison would hide the button exactly where the server permits the action. The server holds both facts; the client holds one. Recorded so the next reader does not have to re-derive it.
A design for publishing a world from the World Builder or World Editor to the vault, where it lives on the server, and listing it with its stats on the Admin > Worlds tab -- which has been a named placeholder since the admin page was built. Proposed only; nothing is built. Most of the machinery already exists, so the document spends its length on the joins and on what is genuinely undecided rather than on restating the obvious. The three hard parts are not the upload: SIZE. Measured rather than assumed: the built-in world serializes to 223 KB of pure text across 14 rooms -- about 16 KB per room, so even the new Extra Large scope at five regions is ~2 MB. But a measured playthrough hit 441 MB once art was inline. Publishing is therefore only tractable with the media store on, keeping /vault/media references instead of rehydrating them -- which turns out to be the inlineMedia:false mode the Editor's export already has. Recorded while there: the World Builder's own Export World takes neither path, so the two Export World buttons do different things with vault art. Pre-existing, noted rather than fixed under cover of this. IDENTITY. The world uid is the store key, not the name -- claimWorldUid already maintains that meaning across import and re-save, and dungeon maps are already keyed by it, so minting a server id would create two answers to "is this the same world" that could disagree. A rename updates in place; two worlds may share a title. PURPOSE. A shelf, a source for new games, a checked-out shared world, or multiplayer. This changes everything else and is left as Q1 with a recommendation: build the shelf, design for the second, refuse the rest until a real published world exists. It also names the failures that would otherwise be found late. Dungeon maps live outside the world envelope, so a hosted world's dungeons would arrive as names with no rooms until the Dungeon Builder's own design moves layouts in. A future content-addressed media sweep has no reference counting and would silently gut every hosted world's art, so whichever ships second must teach the other. And the client cannot currently tell whether the signed-in user is an admin -- /vault/config returns the email and no role -- so the Publish button needs one boolean added, with the note that it decides what to draw and never what is allowed. Every code reference was read against main rather than recalled, and the README index gains its row.
Download Brief has written a complete page file since it was added -- every form field, the auto-lore toggle, the model, and the world JSON if there is one -- and nothing could read it back. Import Brief sits beside it and does. Kept as its own parser rather than folded into parseWorldForEditor, because the two files are different documents: a thelostrealms.world export is a WORLD with an editor block attached and Import World loads it into the JSON box, while a thelostrealms.worldbuilder page is the FORM with a world attached if one existed. Handed the wrong file, each now names the other -- two buttons and one vague error is a guess the author should not have to make -- and a JSON file that is neither is pointed at Download Brief, which is what makes one. The fields go back through populateWorldEditorFields, the same writer Import World uses, so a field lands identically whichever file it arrived in and the size hint, economy hint, Tone preset and art-style presets are all re-synced with it. The model is persisted as well as selected, exactly as picking it from the dropdown would: selected only, the next window opens on the old model and the imported page quietly stops applying. A brief carrying no world does NOT clear the World JSON box. A brief saved before it was ever generated is a normal, useful file, and loading one is not a reason to throw away a world the author has already made -- so the box is left alone and the status line says so, rather than the two quietly disagreeing. A world box that did not parse is restored verbatim, since keeping it verbatim is the whole reason Download Brief records it that way. tests/test_world_builder_import_brief.js covers the round trip, both world-half shapes, the four refusals and the picker wiring; eleven sabotages were each caught by a distinct assertion. test_new_world.js pins that action row's exact contents and was updated for the new button.
Scope now runs Small (4-6), Medium (8-12), Large (14-20) and Extra Large (22-30) rooms per region. The room band was written out in four places, and two of them were ternaries on 'medium' -- so "not medium" quietly meant Small. Adding a third option to the select alone would have generated a Small world under a label promising twenty rooms, and nothing would have said so. The band now lives in one WORLD_SCOPES table that the size hint, the per-region instruction handed to the Game Master, and the list of scopes Generate Brief may choose from all derive from. The <option> list stays written out so the select is never empty before scripts run; test_world_scopes.js holds the two in step, id for id and number for number. An unknown scope falls back to the SMALLEST rather than to whatever the code happened to reach for. A saved file or a GM answer naming a scope this build does not have should cost the author rooms, not spend an hour of generation they never asked for. Two things came out of the sizes being reachable at all. World generation is one call with a fixed output-token ceiling, and a world that runs past it comes back cut off mid-JSON -- every symptom of which is a parse error, so it was reported as "the GM returned invalid JSON". That reads as the model misbehaving and names nothing the author can change. The API says which it was, and the message now says so and what to do about it. And the size hint says, above roughly forty projected rooms, that the ask is large enough to come back cut off. Medium at five regions already sat there, so the ceiling is not new with Large -- the caution is keyed to the projected size, not to the new options, because a warning that appeared only on the new ones would misattribute a limit that was always there. tests/test_world_scopes.js covers the markup/table agreement, the fallback, the hint across the matrix, both GM directives, the brief round trip and the truncation report; thirteen sabotages were each caught by a distinct assertion. test_world_regions_entities.js pinned the hint's source text and was updated, keeping its guard and gaining one that the band is no longer a two-way test.
test_room_lore_editor.js flattened the app with html.replace(/\n/g, ' '), which leaves the carriage return in place on a CRLF checkout. Its assertions then span fixed-width windows, and the lore-XP stepper check measures ~2450 characters against a 2400 budget - so a stray \r per line was enough to push it over. The test passed on LF and failed on Windows for encoding reasons, with nothing about the code changed. Confirmed by measurement rather than inference: the same assertion fails against HEAD's own text_adventure.html once its line endings are converted, and passes against the working tree once they are normalised. /\r?\n/ rather than /\n/, which three other tests already spell correctly. The other 333 share the fragile idiom and only this one has a window tight enough to notice today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-006. subjectLoreXp resolves people/monsters type-first - ENTITY_CATALOG[id] whenever anything matches by name, reaching findEntityByName only when no type exists at all. A same-named type is the normal case, so the instance was invisible and a figure authored on a placement fell through to the flat default. Verengrad's Bell-Warden authored 75 and paid 12, four beings over. Type-first stays; it is right for the reason itemLoreXp gives, and the objection that ruled out instance-first still holds. What was missing is the fallback. A type that never priced the hook is the absence of an answer, not an instruction to discard the one the placement gave, and the instance is the only other place an answer can live. The test is null, not falsy, and that is the whole sharp edge: 0 is a decided price meaning "worth nothing" and must beat a priced instance exactly as 75 would. No regex over the source would catch an inversion there, so the test executes the resolver against a staged catalog instead of reading it. This does not let two copies be worth different amounts. The unlock fires once per type - applyEntityTypeField writes the catalog and every live instance - so only one payment ever happens. Where two placements disagree and the type is silent, the first live match decides: a tie-break, not a double payment, and the evaluator already tells the DM to move the figure to the type. Ledger: BUG-006 moves to fixed. BUG-012 moves with it - its entry has carried a "fixed and confirmed in play" callout since 2026-08-06 while the tag still read open, so the entry contradicted itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Economy was the one brief field collectWorldEditorFields did not read. Export
World recorded every other answer into the file's `editor` block and Import World
restored them; this one came back reading "Undeclared", silently, and the same
gap dropped it from the Download Brief page. It is the author's declared INTENT
-- it briefs the GM before generation, and it is what the balance pass afterwards
grades the finished world against -- so it belongs with the rest of the brief
rather than being read only at the moment Generate World fires.
collectWorldEditorFields now reads the picker's archetype id, which carries it
into the world envelope, the Download Brief page (which is built from that same
function, so it needed no change of its own), and back out again on a re-save.
populateWorldEditorFields restores it, and falls back to the WORLD's own
declaration when a file carries no editor block -- a foreign or older export
still says what it meant, and re-opening it in the builder reading "Undeclared"
would invite the author to generate against an intent the world already holds.
Only a plain archetype, though: a { mix: … } blend has no single option to
select, and picking one of its parts would put a narrower claim in the form than
the world actually makes. It also calls syncEconomyHint, because the line under
the picker is written by an onchange handler that assigning a value does not fire.
With economy in the block, applyGeneratedBrief no longer needs its own writer for
it and routes through the same path as every other field. The archetype check
stays there rather than being left to setSelect: it is also what keeps the
returned count honest, since that count is what the Generate Brief dialog reports
and what it reads as "the GM answered something".
test_world_import_editor.js covers the round trip and the three precedence cases;
seven sabotages were each caught by a distinct assertion.Every other generate button on that screen fills ONE field from the fields already filled, which leaves the first one with nothing to work from and the author typing it by hand. This runs the other direction: an author who already knows their world writes it out in prose once, and the Game Master distributes it across the form -- name, tone, theme and premise, scope, regions, economy, art style, world rules, prologue, narrative and beings, eleven fields from one description. A "Generate Brief" button beside Download Brief opens a dialog with a single large text area. While the call is in flight the actions row is replaced by a spinner, an indeterminate sweep and a live elapsed clock -- the clock because this call reads a long synopsis and writes ten fields, so it runs to tens of seconds and a spinner alone implies a far shorter wait. The text area is disabled rather than hidden, so the author can still read what they submitted, and there is no second Generate to press into the same request. It stops at the World JSON, as asked. Generate World already turns a filled brief into a world with a review step in between, and folding the two together would replace a step the author checks with one they cannot. Auto-generate Lore is left alone too: that is a decision about how much generation to pay for, not a fact about the world any synopsis states. Two things the implementation is careful about: A field the GM omits LEAVES WHAT IS THERE. The dialog is reachable from a half-filled form, and populateWorldEditorFields reads a missing name, rules, prologue or narrative as "take it from the world instead" -- handed an empty object it clears all four, silently, over text the author wrote. The current values are passed as its fallback, and an empty answer is treated as an absent one rather than as an instruction to erase. Writing goes through that same populateWorldEditorFields the Import path uses, rather than assigning to the inputs directly, because it re-syncs the three things a raw assignment leaves stale: the world-size hint, the Tone preset select, and the art-style presets keyed off the tone. A brief that set a typed tone by hand would leave the preset beside it reading the previous one. On success the fields land first and the dialog is then dismissed. On failure it stays open with the description intact and the reason shown beside it -- closing would throw away the paragraphs the author just wrote. The economy vocabulary offered to the GM is built from ECONOMY_ARCHETYPES, so a new archetype is offered the day it is defined rather than the day someone remembers to update a prompt. tests/test_world_builder_generate_brief.js covers the button, the request, the field writer and the dialog's three states; sixteen sabotages were each caught by a distinct assertion. test_new_world.js pinned the exact contents of that action row and was updated for the new button, keeping its real guard -- that nothing in the row re-triggers a single field's generator.
The header Save button now raises an acknowledgement dialog naming the world that was written -- "Save written" in a save editor, "World saved" elsewhere. Gating it honestly took more than the dialog. saveEditorDocument awaited saveGameStateNow inside a try/catch that can never fire: _flushGameSave deliberately swallows writer errors so one failed save cannot break the chain for every save after it. The status line therefore read "World saved." with exactly the same confidence when browser storage was full and nothing had been written. A modal saying so would have been a louder version of the same lie. So the verdict now travels back out of the save chain. Each of the three writers -- session snapshot, world draft, and save-editor splice -- resolves true only when a write actually landed, _flushGameSave carries that out instead of discarding it, and saveGameStateNow's short-circuits resolve false rather than undefined, because they wrote nothing either. Success is tested as === true, not "didn't say no", so a writer that forgets to report is read as a failure rather than a save. A write that fails now says so in the same dialog, rather than doing nothing visible: it names the Logs tab and points out the edits are still in the window and can be exported. That is the counterpart to the success case, not a separate feature -- a modal flow that is silent on failure is worse than no modal. In a draft editor the dialog also names the gap between saving and publishing, but only while the draft actually differs from the library. That gap is this editor's most expensive misunderstanding -- save the draft, start a new game, get the old world -- and a note shown on every save is one a DM stops reading. tests/test_editor_save_confirm.js stands the app up under each of the three editor URLs and breaks storage under each; sixteen sabotages were each caught by a distinct assertion. test_detach_tabs.js and test_save_debounce_and_cap.js pinned the old text of two lines this touched and were updated to the new text; both were re-checked to still fail when the property they guard is removed.
Both group buttons on the Lore tab were one click from a sweep over an entire category, with nothing between the click and the write. They now go through appConfirm first. What makes them worth confirming is the thing the tab hides: the action covers the whole group, not the filtered view. A DM narrowed to three cards was a click away from unlocking forty, and the only warning was in a tooltip. When a filter is actually narrowing the list the dialog says so and names both numbers; when it is not, that line is omitted rather than warning about nothing. The count is what would actually CHANGE, not the size of the group. "Unlock 12 entries" over a group where 11 are already unlocked is a number that misinforms the decision it is there to support. Nothing to change means nothing to decide, so that case skips the dialog entirely and just reports it on the status line. The two are not equally destructive and the buttons no longer imply they are. Unlocking gives lore away and Lock All takes it back, so it is the plain button; its dialog names what a DM unlock skips -- the story beat and the XP. Locking takes back entries the player may have earned in play, and nothing in the save records which those were, so Unlock All would return the entries but not the distinction. That one keeps the red button, and says why. tests/test_lore_bulk_unlock_confirm.js covers both dialogs; ten sabotages of the implementation were each caught by a distinct assertion. test_editor_lore_tab.js gains an await and a stand-in dialog, since the buttons it drives are now async.
Lore is type-wide in this engine. subjectLoreXp prices a hook through compendiumTypeContext, which resolves ENTITY_CATALOG first, and unlocking one Giant Spider's secret unlocks it on every Giant Spider. The being standing in a room is not the authority on what its own lore pays -- its type is. A GM being-edit did not agree. applyNpcSpecToEntity wrote lore, loreKey and loreXp to the one entity, and the propagate-to-type list beside it carried only aggression, armor and xp. Measured on the built world: a GM pricing the Innkeeper's secret at 45 left the catalogue undecided, the Beings card showing 45, the Lore tab showing blank, and the unlock paying the flat default of 12 -- a figure the DM could see and the engine would never honour. The item side of exactly this was BUG-012. The three lore fields now travel to the type like the three combat fields already did; alive, status, location and reputation still do not, because those happened to one being. That fixes it going forward. For saves already skewed, the lore section gains a Set button beside the XP spinner, tooltip "Set from Base Type", which copies the catalog value down through the same writer every other control here uses -- so the type and every live being of that name end up agreeing rather than moving the disagreement to another card. An undecided type copies as undecided, blanking the box so the hook pays the default, rather than storing a zero that would silence it. It is offered only where the card's subject is not already the type. That is why it appears on the Beings cards and not on the Lore tab, whose cards are built from the catalog type, nor on rooms, factions, races or dungeons, which have no separate type at all. Once the two agree it stays visible but disabled, so the control does not appear and vanish as the value changes, and its tooltip says what the type holds. tests/test_lore_xp_base_type.js covers both halves; nine sabotages of the implementation were each caught by a distinct assertion. Also refreshes test_save_button.js, which was left red by the tooltip change in 4cb83b3: the post-save flash is now the gold border alone and no longer rewrites the button's own label.
Editor > Beings > Monsters now carries a Deceased checkbox directly below the Attributes block: tick it and the creature is slain, untick it and it is alive and whole again. It is a state control, not a kill. Marking a creature deceased pays no XP, tallies no kill in the statistics, and scatters no loot -- the same restraint the Lore tab's bulk unlock uses, because a DM staging a world is not the player earning a victory. Neither direction relocates the creature, unlike a timed respawn, which teleports it home because a clock decided it was time. The two directions are not symmetric. Death zeroes HP, since a corpse at full health reads as a card that has not caught up with itself. Revival restores full HP only when there is none, since an alive creature at 0 HP is a state nothing else in the engine produces. Either way the creature leaves combat and, on revival, its pending respawn stamp is cleared. Both directions queue an out-of-band note to the Game Master, for the same reason respawns do: a creature it narrated as slain turning up alive is the one change it cannot see for itself. Scoped to Monsters -- the NPC roster and the Fauna list are unchanged. tests/test_monster_deceased.js covers placement, tab scoping, both transitions, the no-op guard, and the deliberate restraint. Thirteen sabotages of the implementation were each caught by a distinct assertion.
It was the last thing left at the foot of Settings > Providers, below the
provider descriptors and the Add-a-provider form -- a debugging read-out
tacked onto the end of a page you go to in order to configure things. Same
reasoning that already moved Media and Usage out: what the vault has DONE
is something you go and look at, not something you set.
The renderer finds its container by id and nothing else, so the section
moved as markup with no JS change. The test pins that the id survived the
move, against the render path specifically -- loadCalls's error branch
also writes into $('#calls'), so a looser match stayed green with the
renderer itself repointed elsewhere.
Also fixes a genuinely flaky test that has been firing intermittently
through several unrelated changes. test_rest_condition_label asserted the
RE bar's fill percentage with ===, but setAwakeH anchors awakeSinceGameMs
to currentGameMs() and fatigueFillPct reads currentGameMs() again -- so a
real millisecond elapsing between the two calls lands in the answer as a
sliver of game time. Measured at roughly one failure in twenty runs, and
all four of those assertions shared the race. Rounded now; 40 consecutive
runs clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLReported from a live Evaluate tab: Execute for a specific placement did nothing.
Two defects.
A branch button emits onclick="executeEvalPrompt('id','0')" - the index is a
string, because HTML attributes carry strings. The handler tested typeof which
=== 'number', which "0" never satisfies, so it resolved no branch, fell through
to a top-level prompt that decision cards do not have, and hit a bare return.
No message, no state change, no clue. The index is now coerced.
Separately, a card's top-level prompt called execBtn(a, 'main') with no third
argument, and execBtn returns empty when target is falsy - so a card with a main
prompt rendered no Execute button at all. It now passes a.target.
The bare `if (!text || !targetName) return;` is gone. Every other failure path in
that function explains itself; this one hid a real defect behind an empty click
for as long as it existed, and a broken button was indistinguishable from a
working one with nothing to do.
Execute now confirms first. Unlike Plan placement, which opens a reviewable plan,
Execute applies whatever the GM returns with no review step - so the dialog names
the roster, shows the prompt verbatim, lists what it concerns, says there is no
review step, and says it edits the world being evaluated rather than the save.
Declining reports that nothing was sent, so a cancel cannot be mistaken for the
old no-op. The confirm is awaited before the prompt reaches the roster input and
before the roster runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>The card read x.phrase where the subject stores phrases, an array - so every card rendered wants "undefined", which is how the DM first met this check. The check itself compared a capitalised phrase against entity NAMES only, so established fiction that is not a catalogue entry read as a dangling reference. Verengrad's Bladeward's Plate asks the player to "trace the defaced relief with a fingertip while a Cantor reads nearby Cantos-script aloud". Cantos-script appears twice in that world's prose, Cantos six times including in the world's own name, Cantor fifteen times, and there is a Cantor-Adept class. It was the check's only real-world hit and it was false - a 100 percent noise rate on the one thing it found, which is exactly the cry-wolf failure that makes a pass worth less than none. A phrase now resolves if it is a name OR appears anywhere the world writes: descriptions, detail, lore, time-of-day variants, quest and beat text. A genuine rename still dangles, because a deleted name appears nowhere. Verified against the live Verengrad draft: the false positive is gone and nothing replaces it. The test fixture that missed this was unrepresentative - it exercised "Cantos-script" without the Cantor that sits beside it in the real key. It now mirrors the actual world, including the Cantor-Adept class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An encounter carries an image of its own, painted from its prompt and shown on the Encounters editor card and when one fires in play -- so an encounter without one is a hole in the art like any other, and both Art tabs were blind to it. Missing gains an Encounters section beside Races, and Review a matching gallery group, both read from world.encounters so the two cannot disagree about what the world holds. Two pieces of plumbing this needed: - compendiumTypeContext had no encounters branch, so the generic generation path would have failed every encounter card with "not found". It now resolves one to its prompt and image fields, portrait- shaped to match generateImageForEncounter's own call, and carries a suggestPrompt hook pointing at requestEncounterPromptFromGM -- that writer knows which beings the encounter involves, which is most of what makes its prompt worth anything. - compendiumDetailBodyFor had no branch either, and encounters are not a Compendium category at all, so there is no discovered entry to fall back to. A gallery cell would have opened nothing. It now renders the encounter through the shared entry-detail body with the beings standing in for a description. The Art card is a deliberate trimmed twin rather than the Encounters editor's own, the way buildArtRoomCard is for rooms: that card is mostly where-tables and ambient behaviours, and it is drawn as .enc-card. The class is load-bearing here -- the batch marks its current card by index into "#art-view details.npc-card" and the pulse styling hangs off .npc-card, so an .enc-card would both miss the spinner and shift every later card's index by one. Three existing Art tests asserted states encounters now participate in and were updated to account for them rather than narrowed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A race carries a portrait of its own (world.races[id].portrait, painted from its portraitPrompt) and shows it on the Races editor card and in the Compendium -- so a race without one is a hole in the art exactly like an item without a picture, and both Art tabs were blind to it. Missing gains a Races section beside Characters & Monsters, listing every race with no portrait and rendering the Races editor's own card, so the Generate/Upload controls and the portrait-prompt box with its writer are the same ones as on that tab. The name filter reaches them, and the batch Generate picks them up: no race-specific code was needed there, because compendiumTypeContext already resolves a race to the prompt and image fields that generic path reads. Review gains a matching Races group in the gallery, read from world.races rather than the discovered snapshot so the two tabs cannot disagree about what the world holds. Clicking a race cell needed a fix to work at all. Races have always been a Compendium category, but compendiumDetailBodyFor had no branch for them -- it resolved a race to null, so an undiscovered race opened no popup whatsoever, which is every race on an author's gallery. It now resolves from world.races first and falls back to the discovered entry, the same shape places and factions use. Three existing Art tests asserted states that races now participate in -- the all-done wording, the "nothing missing" fixture, and a world with no objects at all -- and were updated to account for them rather than narrowed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A "Missing" checkbox in the Lore toolbar narrows the list to half-authored entries -- lore with no unlock condition, or a condition with no lore behind it -- and each card's head now names which half is absent, so the view says why a card is in it rather than leaving two fields to compare. Supporting that meant widening what the tab lists: a subject qualifies once an entry has been STARTED, text or condition or both. Listing only subjects with lore text hid exactly the entries this view exists to find -- a condition with nothing behind it is a hook that can never pay out. The bottom bar mirrors Editor > Art > Missing: the same right-aligned gold button in the same fixed row, reading Stop while it runs, and the same pulsing header and spinner on the card being worked on. It is enabled only while Missing is ticked and something is shown, because over a complete entry it would overwrite authored prose, and a bulk button that quietly does that is not one anyone can use safely. compendiumGenerateLore gains a single-field mode: it quotes the authored half to the GM, instructs it not to change it, and writes just the other field. Without that the fill would replace a DM's own words with the model's on every entry it touched. It also now returns an ok/error result so the batch can count, and no longer refuses to run in Vault mode, where the Claude key lives on the server and the client apiKey is legitimately empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Report now covers all 2,223 commits across 39 days (2026-06-30 to 2026-08-07). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012o64EUyu7rUd9SfnNw6waB
Lore is authored one subject at a time across a dozen tabs, which is fine for writing a single hook and hopeless for the two questions a DM asks of it: what secrets does this world hold, and which has the player earned. The new tab gathers every lore-carrying subject, grouped by kind — Rooms, NPCs, Monsters, Fauna, Flora, Items, Magic, Dungeons, Factions, Races — with a name filter, collapse/expand all, and the usual GM request box. Each group header carries its unlocked/total count and right-aligned Unlock All / Lock All buttons. Those set exactly what the per-card checkbox sets, through the same writer, so a group action and ten individual clicks leave the world identical. Deliberately NOT the player's unlock path: that announces the discovery in the story and pays its XP, which is right when a player earns a secret and wrong when a DM is staging a world. They act on the whole group, never on what the filter happens to be showing. Cards are headed by name, type and portrait, then carry the shared buildDmLoreSectionHTML block verbatim — the same lore text, unlock condition, link rows, unlock toggle, XP stepper and Generate button the object's own card has. One authoring surface, not a second that could drift. It renders open here, since the tab is about nothing else. The GM box writes the four lore fields on subjects that already exist and cannot create or delete anything; other tabs own that. It corrects the category when the GM guesses the wrong one, since it is choosing among ten tab names for a thing it knows by name, and ignores a blank lore string rather than letting an XP-only edit wipe the prose. Fixes a bug the tab depended on: entityCompendiumCategory() answers 'animals' for a beast, but applyCompendiumLoreField routed 'animals' down the item path only. Every lore edit made on a Fauna card landed nowhere, and the card redrew from the unchanged object so it read as saved. Both shapes now get the write. Two layout rules the floating toolbar needs were missed and only showed up in the browser: the subview must be position:relative or the toolbar escapes to an ancestor and lands over the title bar, and the view needs the top padding or the first group header renders under the filter box. The test asserts both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Each group's title in the funnel drop-down is now itself a checkbox: ticking it selects every option in that group, clearing it selects none. It shows a partial (indeterminate) mark whenever some but not all of its options are on, so a half-selected group never reads as "all" or "none". The case it exists for is isolating one value out of many. Unticking eleven types to see only weapons is eleven clicks; none-then-one is two. That works because a group with nothing checked matches nothing, which was already the semantics -- this just makes it reachable in one click. Groups stay independent: an all/none on one leaves the others untouched, and the test asserts that on the state rather than on a sibling box. Also documents the filter in the Field Guide's Editor table, which the filter itself shipped without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two halves of one mechanic - an act the world is supposed to notice, and a roll deciding whether it did. Today the world notices and then does almost nothing. Written from a run that did all of it. Three thefts and a stealth approach on a level-8 guardian, every one mechanically successful and almost none of them costly: Hesk watched his own strongbox picked and kept trading at -17 rep; the Tide-Cult priced a stolen offering at -6 and stayed warm; a blown Shadow-Step in front of a hostile guardian cost a stair. The diagnosis is not that the GM narrates badly. Reputation is the entire vocabulary it has for "you were caught", so picking a merchant's lock and robbing a shrine both come out as a number and business as usual. The Bell-Warden turning and blocking the stair without charging is an accusing state the GM invented correctly and had nowhere to record - so the next turn began with him unaware. Proposes a per-being awareness ladder, sketches two optional fields, and marks five questions open rather than answering them: whether caught differs from failed, whether alarm is per-being or per-room, whether ownership is worth modelling, what a caught player can do, and what decays. One decision is already settled and recorded as such: the engine moves objects and records state, the GM judges - new fields give it a vocabulary, not a verdict. Also notes what it is not: no crime system, no stealth mode, no rewrite of reputation, and nothing that fires without the GM saying so.
BUG-011's remaining half. The GM had no way to remove an item from a being's inventory, so a successful theft added a copy to the player and left the original in place. Deliberately not a removal field beside addItems: two independent edits are a pairing the GM must remember, and the half it would drop is the removal, because gaining is the narratively interesting part - which lands straight back on the duplication. A single verb cannot be half-performed. The ref decides which object moves. A name resolves only when it matches exactly one carried item, since two objects sharing a display name is the reason this field exists at all. The whole stack moves the object itself so its own fields travel with it; a partial take splits a copy through makeItem so the ref and stats come along. The player gains it through the same grant path addItems uses, so stacking and tracking cannot drift. A transfer that cannot be performed writes a gameLog error naming what the being actually carries and saying plainly that nothing moved, whatever the narration claimed. It is not surfaced to the player: the story has already said the theft happened, and a contradiction on screen is worse than a quiet discrepancy a DM can grep for. Silence is what let BUG-013 void an encounter for a whole run. The engine moves the object and nothing else. No reputation, no hostility - Hesk charged -17 across two thefts and Ys -6 for a lifted offering, judged in context, and a flat rule would be blunter than that. The field note says so explicitly and hands consequences back to the GM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BUG-011, and the cause was not where this entry first put it. The dossier described a being's inventory by NAME and nothing else - carries:[Drowned Longsword] - and Verengrad had two catalog entries under that name, differing by 2d8 vs no damage dice, 420 vs 140 value, and an 18 XP lore hook vs none. Asked to hand the sword over, the GM had a string, no id, and an addItem field whose own note scopes it to "loot that didn't exist in the room beforehand". A sword on a belt plainly did exist beforehand, so it described a plausible one and the engine built exactly that: a counterfeit, identical in the UI, carrying none of the authored weight. The inline item's name then slugged to drowned_longsword - an id the DM had deleted an hour earlier - and registerInlineItem recreated it. The dossier now reads carries:[Drowned Longsword (ref: drowned_blade)], falling back to the bare name for a genuinely inline item, which has no catalog entry to cite. The addItem note forbids describing a new object when the being already carries one, and gives both the dossier shape and the addItems ref shape to answer with, plus why: two objects can share a display name, so the ref is the only thing identifying which one the player gets. addItems always accepted a ref. Nothing had ever told the GM one existed. Ledger corrected. It had claimed there was no entity-inventory field of any kind and that duplication was the only outcome the contract permits. The engine has the operation - it is the DM card's own remove control - and only the schema field is missing. The DM caught it. The remaining half is a transfer directive rather than a bare removal, so the GM cannot perform half of it. Also repaired BUGS.html, which an earlier PowerShell rewrite had left as UTF-8 decoded through CP1252: 225 damaged runs restored, 196 mojibake sequences to zero, tag count unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lore is meant to be hidden, and a key needing blind experimentation is the genre working rather than a defect. So this does not grade difficulty. It asks one exact question: when a key names something with a capital letter - a person, a place, a title - does the world contain anything by that name? A key reading "show it to Mira" in a world with no Mira is not hard, it is broken, and nothing else in the toolchain notices. Narrow on purpose. Three broader checks were built and thrown away first, each failing on real data. Matching a key's words against the subject's own prose passed "Return the BLADE to the flooded transept", because the sword's own description says "blade" - the key anchored on itself. Matching against the whole world flagged a perfectly fair key, because "climb", "packed" and "throat" appear nowhere even though the bell is overhead in the room's first sentence. Word matching cannot separate a referent noun from a verb without part-of-speech tagging, and a pass that cries wolf is worth less than no pass. Capitalisation is the one signal an author supplies deliberately. Two limits, stated in the tests so nobody assumes otherwise: a lowercase referent is not caught - "the flooded transept" named a room Verengrad did not have - and a key that is merely unguessable is nobody's business but the reader's. Verified against Verengrad before shipping: 92 known names, one survivor, "Cantos-script", which genuinely names nothing. The three other hits were this check's own bugs - a skill it was not consulting, and possessives breaking the match on "Mira's" and "Anchor Saint's". Both fixed; names now resolve across rooms, items, beings, factions, races, skills, classes and quest titles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The room is the first and most prominent thing a player is shown - its own art, its own paragraph - and in this world it very often hides the most. But nothing in play says so, and visiting a room and interrogating it are unrelated acts. A full playthrough of Verengrad finished every quest beat, entered all twelve rooms, and unlocked zero of the twelve room lore hooks: 420 XP, close to half that world's lore economy, untouched by a run that had also read the world data directly. Placed straight after the callout that already establishes the world as authored data narrated live, because this is the consequence of that fact from the player's side. Says what a curious person would actually do - dive beneath a platform, sit still, look up, come back a third time - notes that fruitless guessing is the game working rather than failing, and warns in advance that completing a story and exhausting a world are different achievements.
Both reconstructed from the save's own messageLog rather than from recollection: 63 player turns in order, with what each produced. The replay script carries what a repeat actually needs, which is more than the commands. Settings that must match (DM off, auto-roll skills off, idle logout off - it returns to ON across a reload). The engine commits the later turns were played against, since replaying an earlier build reproduces the bugs rather than the outcomes. The world edits the harness made mid-run to clear a lore-lock, without which turns 51-53 have nowhere to go and turn 43 has nothing to take. And the two turns that cannot reproduce: the three lost to BUG-007, and the Bell-Warden resolution the engine dropped and that was applied by hand. Turns are tagged where the outcome depended on a die, on GM improvisation, or on a harness edit - so a replayer can tell which divergences are expected. The transcript is the reading copy, in the house style: the commands as typed and the GM's prose as written, with the run's turning points annotated. It keeps the failures - the trap that re-seated itself while the manifest said Success, the invented gate on an ordinary exit, the three missing turns - because those are the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A funnel drop-down beside the name box, with checkbox groups for the fields an item card already shows: Type, Equipment slot, Portrait, and Icon. An item must match at least one checked option in every group -- OR within a group, AND across them -- so the axes narrow together. Options are built from the items actually on the tab rather than a fixed list, and the state records the values switched OFF rather than the ones left on: item types are open-ended, so a type the GM coins tomorrow has to default to visible. Slots read in paper-doll order to match the Equipment tab, with "No slot" last. Two ways this could hide cards without saying so, both closed: - A group that cannot tell two things apart is dropped from the menu, and its exclusions are released with it. Left in place, a group excluding the one value every item shares would hide the entire catalog with no control left to undo it. - An empty list names the filter as the reason, and the button carries a count badge whenever anything is switched off. "No items match" over a full catalog is how a filter gets mistaken for data loss. An exclusion left over from a deleted or retyped item hides nothing, so it is not counted on the badge either. Export follows what the tab shows, as its tooltip already promised. The filter is the Items tab's own -- Flora, Magic and Spellbooks are already a filtered slice. No enchantment group: an enchanted item catalogues under Magic, so the Items slice can never hold one. The drop-down sits in its own frame rather than inside a .npc-tool-group, which sets overflow:hidden and clipped the menu away -- it measured fine and rendered blank, so only looking at it caught that. The test now asserts the structure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
BUG-013. The GM solved Verengrad's hardest encounter exactly as authored - the
one-use Verse of the Closing Throat spoken into the Bell-Warden's cracked
sternum-bell - and reported entityResolved { name: "Bell-Warden", bonusXp: 40 }.
The entity is "The Bell-Warden". One article. The exact lowercase comparison
found nothing and there was no else, so the world kept him alive at 140/140 and
paid no XP while the narration said he had folded. The item was spent either way.
The same comparison sat at three call sites - roomUpdates.removeEntity,
entityKilled and entityResolved. Two of the three END an encounter, so the
failure always falls in the player's disfavour: over in the fiction, unfinished
in the data.
Silence was the real defect. An exact-match miss and a genuine no-op were
indistinguishable, so a resolution could be dropped every turn with nothing
recorded. It took spending a unique item on a known solution and then checking
the entity's HP by hand to notice.
All three now go through matchEntityInRoom: article-insensitive,
punctuation-insensitive, then a UNIQUE containment match. Deliberately
conservative - no fuzzy nearest-name guessing, because resolving the wrong being
is worse than resolving none. An unmatched name writes a gameLog error naming
what the room actually holds and saying the field was dropped.
Third instance of name-as-identity, after the item-ref work and BUG-011:
placements bind by name, GM item adds bind by name, GM entity references bind by
name. Each assumes a display name is stable and unique, and it is neither.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>BUG-012. itemLoreXp resolved through findItemByName - the player's pack first, then room items, and ITEM_CATALOG only when no instance existed anywhere - while makeItem never copied loreXp onto an instance. So a placed item read null and paid the flat default, and an item paid its authored figure ONLY while nobody could hold it. The two Verengrad items that would still have paid correctly were exactly the two placed nowhere. Verengrad authored 304 XP of item lore and paid 201. The Coral-and-Bone Reliquary lost 38 alone; the Frayed Rope Coil paid MORE than authored, the flat rate cutting both ways - the authored economy replaced rather than shaved. The type is the hook's home and the engine already said so: unlockItemLore calls applyItemTypeField, and Item's own comment reads "Type-wide: unlocking one instance unlocks the item everywhere". Because the unlock happens once, per-instance pricing was unreachable by construction. Resolving the type also needs no migration - every save in flight starts paying correctly on load - and leaves the evaluator correct as it stands, since the pass already prices from the catalog. The instance stays as a fallback: registerInlineItem skips minor items, so an uncatalogued one has no type and would otherwise price at nothing. A figure typed onto an item placement is therefore dead data, as it already is for beings, so the pass now reports item-lore-xp-on-placement - walking room floors, container contents and NPC inventories - with a DM action card. Verified live: all thirteen priced Verengrad items now pay their authored figure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sibling of BUG-006 by the opposite mechanism. Items resolve instance-FIRST via findItemByName (player inventory, then room items, then the catalog last), and makeItem never copies loreXp onto the instance - so a placed item reads null and pays the 12 default while the catalog figure is consulted only when no instance exists anywhere. Confirmed by a deliberate discriminating test: drowned_blade priced 18 on the type, null on the instance, paid 12 in play. Verengrad authors 304 XP of item lore and pays 201. The Coral-and-Bone Reliquary loses 38 on its own. The Frayed Rope Coil pays MORE than authored, so this is the authored economy being replaced by a flat rate in both directions, not a simple shortfall. The only two items that would still pay correctly are the two placed nowhere. The comment directly above itemLoreXp says its lore lives on the TYPE; the code searches instances first. The evaluator reads the type too, so the pass counts 304 where the engine pays 201 - the same class of error as BUG-002, in the same optimistic direction.
Three optional fields under the fal.ai model picker in Settings > Image AI. All blank by default, and blank is the load-bearing state: a blank field is omitted from the request entirely, so the chosen model's own tuned default applies. That is why they were not pinned when the provider landed -- Sprint resolves in a few steps where Base wants ~18, so one number across all four variants suits one and spoils the rest. Values are clamped to what the API accepts, on write (so the stored setting is the value that will actually be used) and again on read (so a setting that arrived some other way is still tamed). Clearing a field restores the model default rather than leaving the old number behind, and an unreadable value degrades to blank rather than going out as NaN -- the model's default always works, a failed generation does not. A seed of 0 is a real seed and survives both the client body and the vault's prune-empty pass. The vault descriptor templates the same three fields; an unset one fills empty and is pruned from the body, so Direct and Vault modes send the identical request. The clamp assertions initially passed with the write-side clamp deleted, because reading clamps it back. They now check the stored value and the read path separately, and each fails on its own when the other is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Selectable in Settings > Image AI, Icon AI and Map AI, with a model picker offering SANA Base, Sprint, and 1.5 at 1.6B and 4.8B. The key is entered in the login API Keys dialog in Direct mode and on the admin page in Vault mode, like every other provider key. Three things this provider does differently from its neighbours: - It authenticates with "Key <token>", not a bearer token; fal rejects a Bearer prefix outright. - Its endpoint IS the model (fal.run/fal-ai/sana/v1.5/1.6b), so the model lands in the URL path, where its slashes are real separators and cannot be encoded the way the prompt is. The descriptor therefore carries an explicit list of the four model paths it may be asked for, enforced before any request goes out — otherwise a client could name any fal model at all and spend the operator's key on it. - It answers with a link rather than bytes. sync_mode asks for a data URI so generated art travels with the save; a real link would point at fal's media CDN, a different host from the API's and so unpinnable, and is fetched server-side and embedded rather than handed to the browser. image_size is an enum of named presets, so the three shapes map onto the nearest one. num_inference_steps and guidance_scale are deliberately not sent: Sprint runs in 1-4 steps where Base wants around 18, so each variant keeps its own tuned defaults. The Field Guide's API Keys section still described four provider fields and claimed only the Pollinations token was wired to anything; the DM's Guide provider table was missing OpenAI as well. Both corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Progression shipped through phase 4 with a player-visible Character > Progression tab, and neither book mentioned it — 'progression' and 'honorific' were zero hits in both. Player's Handbook: a new Chapter Thirteen 'Progression' closing Part Two — the timeline and its Achieved/Next/Later rail, the sparse front-loaded cadence, the reward table, titles as the worn honorific (and what the GM is told), the reveal rule, and granted-exactly-once with the silent catch-up on load. Chapters 13-20 renumber to 14-21; the two body cross-references move with them. A milestone grant is a fourth way to gain a skill, so Chapter Nine's list says so. Quick Reference gains a progression entry. Field Guide: a player 'Progression' section beside Skills, and a DM 'Charting a class progression' section covering the class card's Progression button, Generate replacing a timeline wholesale, the ask bar, and the cadence rules the GM is held to. Both books listed the Character tab's subtabs as four; there are six. Statistics was undocumented in either book and now gets a line. The / channel fetches both books live, so the new sections are reachable without code changes; only its offline fallback needed correcting, where 'three ways' to gain a skill was stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Phase 5 shipped across all three books — the handbook's Chapter Ten carries the inherent-vs-learned spine, the three-lane table, and the saves note. The status badge already said so; the build-order row, the risk list and the footer still said the handbook was outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Three leftovers, and the shape of them is the joke. The page that records which documents have drifted was still stamped "read against main at a24d426 on 5 August", two days and a full sweep behind itself — and its own row in README.md was the single row the phase-D pass did not touch, still announcing rev. 3 and eleven stale markers under a document that has none. Both now carry rev. 4, the commit the sweep actually read, and a marker count of zero with a pointer to where the record lives. The footer no longer claims eleven stale markers a section above it has just finished resolving. One "rev. 3" stays on purpose: §01 describes the audit that re-derived every "not built" claim from the source, and that audit WAS rev. 3. It is history, not a status. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Docs only — no code changed, and the test suite is untouched at 506/506.
All eleven inconsistencies §06 recorded, plus two more the sweep turned up and
three the original audit could never have found.
Nine of the eleven were a header badge the code had left behind: a system that
shipped while the chip at the top of its own page still called it proposed.
Player Progression was the worst — built through phase 4 and described as a
proposal from top to bottom, which is the one shape a read-only review cannot
catch, because nothing on the page contradicts anything else on it. Abilities'
phase 5, Quests & Journal's §07 accessibility directive, and Calendar's phase 1
were the same story. The rest were counts that had stopped being true (eight
growth ideas beside a list of six; four open questions beside five) and two
README rows whose status cells had been swapped between neighbours.
Two more surfaced only when the README's status column was read row by row
against each doc rather than against the list that named the eleven:
weapon-damage's phase 2 was shipped and the README still called it proposed, and
hidden-and-unidentified-things had its phase 2 built with decisions resolved
while the README called it specified with open decisions.
Three more surfaced from a check §06 never made. It only ever read header
badges, so a doc could carry a correct chip and a stale FOOTER — and three did:
Server Vault ("Proposed; not yet built" under four shipped phases), Vault Media
Store, and Player Progression again. A sweep comparing every doc's chips to its
own closing paragraph now finds none.
Combat is the one I made stale myself, closing phase 2 earlier today: its header
said one item left, and the saving-throw section still offered save-for-half as
"an easy later addition once the binary version proves out" — the thing that had
just shipped as a three-rung ladder.
Player Progression got the light touch agreed: status markers and build-order
ticks, prose intact. But a bare tick on phases 2 and 3 would have overstated,
because parts of each never shipped — no earnedTitles list or title picker, and
authoring landed as its own dialog rather than through requestClassEdit /
requestWorldGeneration, which is also why no built-in class ships a timeline. A
note records where the build diverged from the plan rather than smoothing it.
§06 stays, as a record rather than an empty section: which docs drifted, what
each said, what it says now, and the pattern they share.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLReported against OpenAI GPT Image 2: the generate call completes, an image comes
back, the room card does not change. The provider was a coincidence — driving the
real openAiImageGenerate against a stubbed HTTP response paints, stores and
renders correctly. What mattered was the rest of the report: it only happened on
a room with no existing banner.
setRoomBannerSlot opened with
if (!room || !room.bannerImages || !(timeKey in room.bannerImages)) return;
so a room whose slots were missing swallowed the write and returned as though
nothing had been asked of it. The paint succeeded, res.url came back,
refreshRoomBannerBlock re-rendered unchanged state, and nothing anywhere raised
an error — the worst shape a failure can take, and the reason this reads as a
provider bug from the outside.
The slots go missing because restore does not run constructors. Room's
constructor always builds all six; reRoomObj rehydrates through reInstance —
Object.assign(Object.create(Room.prototype), o) — so a room arrives with exactly
the bannerImages its JSON carried, and one that never had a picture can arrive
with none. reRoomObj already backfills lore, detailedDescription, banner PROMPTS
and audio prompts for precisely this reason. The images had no counterpart.
Both halves fixed. ensureRoomBannerImages normalizes the six slots and is called
from reRoomObj beside its siblings, so restored and imported rooms carry them
again. And the setter creates a missing slot instead of bailing: a key outside
the six real times of day is still refused, because that is a caller bug rather
than a room that has not been given a picture yet. setRoomBannerAllSlots carried
the same bail and takes the same fix — it is the Compendium places path, where a
slotless room would have swallowed the write just as quietly.
The test needed three passes to be worth anything. Its first revert check showed
one failure where there should have been six: the assertion after the first
failing one dereferenced the undefined map and killed the run, so everything
downstream never reported. The assertions are null-safe now, and the revert check
fails six of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe last open XP question needed drowned_blade, priced 18 against a default of 12. It was blocked twice: the item had been removed from every placement by the duplicate-name cleanup (placements store a name, not a ref, so deleting either entry took out both copies), and its lore key names a transept and a kneeling owner that were never authored. Clearing it meant editing the world, so the report now carries a provenance table saying which content is the DM's and which is harness scaffolding - and why that distinguishes an engine question, which a controlled fixture answers well, from a world-quality question, which it cannot answer at all. Also records the harness trap that cost an hour: world and player outlive the session, so a logged-out page answers every query with plausible data from the dead session while every write silently becomes a no-op. The inactivity setting returns to ON across a reload, which is how it happened.
The gallery has its OWN popup, #art-review-popup, because the Compendium's sits in a hidden view. That id appeared in neither of the two lists the refreshers walk, and openArtReviewPopup shows it through showEntityPopup, which stashes no __popupItem — so both routes missed it. refreshOpenItemPopupFor iterates ITEM_POPUP_IDS and matches on __popupItem; refreshOpenEntityPortraitFor iterates ENTITY_POPUP_IDS and matches on the title. Meanwhile refreshArtReviewIfActive re-renders the gallery VIEW, and the popup is a sibling deliberately left standing. Hence the report exactly: the thumbnail behind the popup updates and the picture in front of you does not. Two fixes, because the two paths fail differently. Entities only needed the id: name art-review-popup in ENTITY_POPUP_IDS and the existing title-matching patch reaches it. The list already held art-room-popup, a different popup one character away, which is a fair part of why this survived. Items could not take that route. refreshOpenItemPopupFor REBUILDS a body from buildItemDetailHTML, and the same popup shows NPCs too, so a title match could replace a being with an item. Instead regenerateItemPortrait swaps the <img> inside the wrap its button sits in — exactly what the icon button beside it has always done, with the reason written next to it. A second symptom fell out of the same cause: on success the button was never re-enabled. Only fail() did that. Where the popup gets rebuilt the button is replaced by a fresh one and nobody noticed; on the surfaces that are not rebuilt it stayed disabled and spinning over a stale picture. Three test-quality repairs alongside. Two assertions in the existing portrait tests were byte windows measured from a function's name, and both broke on a change they have no opinion about; they are scoped to the function body now. Worse, the first version of the new test passed with the fix removed: its id extractor pulled quoted names out of the comment INSIDE the array, so it was asserting against its own prose. It strips comments before extracting, and the revert check now bites. Verified in a real detached-editor window: opened the Review popup on an item, regenerated, watched the img src change in place and the button come back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The previous commit read "a portrait set in either surface updates all
corresponding portraits" as licence to overwrite instances from the Compendium.
It is not: an instance carrying a portrait of its own is a deliberate override —
one of three wolves painted scarred — and a type-level change must not take it.
The three assertions in test_comp_reveal that said so are restored verbatim.
What was genuinely broken, and stays fixed, is two smaller things that were
hiding behind that rule.
The seed ran once and never again. backfillInstancesFromType fills instances that
have no picture of their own, but both callers gated it on the TYPE having been
blank — so it fired on a type's first image and a replacement portrait never
reached a blank instance afterwards. It is called unconditionally now: a blank
copy takes whatever the current default is, not only the first one ever set.
And nothing redrew the editor. Even a change that DID reach the instances left
the card stale, because the Compendium path re-rendered its own view and the
Rooms tab and stopped. shareTypeImageFromCompendium now refreshes the sidebar,
the editor's entity or item tab, and the Art review — gated on the editor view
being on screen, since switchTab('editor') renders from live data on the way back
and redrawing a hidden tab is work nobody sees.
So the two surfaces mean different things, and now say so in one place: the
Compendium edits the TYPE and offers a default, the editor's card edits the BEING
and overwrites everywhere on purpose.
Verified in a real detached-editor window against Old Gatekeeper, who ships with
a portrait already set: a Compendium upload leaves that portrait alone; clearing
it and uploading again seeds the being and the rendered card shows the new
picture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLReported as a two-way failure between Compendium > People and Editor > Beings >
NPCs. Only one direction was broken, and it was broken twice over.
Editor to Compendium already worked: uploadNpcPortrait calls
propagateEntityPortrait, which overwrites the catalog type, every live instance,
the discovered Compendium row, any open popup, and both views. Compendium to
Editor did not, because the two surfaces edit different objects.
compendiumTypeContext resolves People to the ENTITY_CATALOG type; the editor's
NPC tab lists live INSTANCES through allWorldEntities. Between them sat
backfillInstancesFromType, which seeded an instance only when it had no picture
of its own AND only on the type's very first image — so a replacement portrait
stopped at the type and never reached the being standing in the room. The
built-in NPCs all ship with compendiumImage set, so the reported case was
exactly the one that could not work.
compendiumTypeContext now carries a propagate() per category, and both Compendium
write paths — upload and regenerate — call it. People and monsters route through
propagateEntityPortrait, the same function the editor has always used; items
through propagateItemPortrait plus the instance share and the item-tab redraw.
Places deliberately get none: a room banner is per-time-slot rather than a scalar,
has no instances to reach, and its own setImage already writes every slot.
This overturns a deliberate rule, and the cost should be stated. Three
assertions in test_comp_reveal asserted that a Compendium regeneration must NOT
disturb an instance carrying its own portrait — the type image was a default, and
one of three wolves could be painted differently and keep it. That is
incompatible with "a portrait set in either surface updates all corresponding
portraits", which is the rule asked for. The assertions are inverted rather than
deleted, with the reasoning recorded beside them, so the change is legible to
whoever wonders where per-instance portraits went.
One thing deliberately left alone: the editor tab is still redrawn only when its
view is on screen. switchTab('editor') goes through switchEditorTab and
switchEntityInnerTab, which render from live data, so a hidden tab is already
correct when it comes back. The first version of the new test asserted a redraw
regardless and failed on a harness where no view was active — the probe being
wrong, not the guard.
Verified in a real detached-editor window against Old Gatekeeper, who ships with
a portrait already set: a Compendium upload reaches the live being, the catalog
type, the Compendium row, and the rendered editor card; propagateEntityPortrait
sends one the other way and the Compendium panel shows it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe skill-roll relay handed the GM "the player rolled D20 = 9" and then asked it for an outcome. It is never told the character's ability modifier or proficiency, so it was guessing, and roughly one guess in three was wrong. Observed twice on one trap with the same +7 and DC 15: a raw 9 (total 16) it called failure, a raw 12 (total 19) it called success. The engine meanwhile ignores the outcome field entirely and recomputes from roll + mod + prof - so the manifest read "= 16 vs DC 15, Success, +1 xp" and paid skill XP while the narration and the trap's own disarmed flag both applied a failure. The seam only opens when the raw die and the modified total straddle the DC, which is exactly the band a character's investment buys. It got worse the better the character got: at +7, a third of the d20 range resolved inconsistently. The relay now carries the sum and the verdict, read through skillAbilityMod and skillProficiency - the same two helpers the engine resolves with, so there is no second arithmetic path free to disagree with the manifest. The raw die still crosses because skillChecks.roll must echo it; the engine re-derives the total from that number and never trusts a total it was handed. Both prompt branches were updated to match, including the auto-roll branch, which has the same seam by a different doorway. Not done: making the engine defer to the GM's outcome. The GM cannot see the modifiers, which is why it was guessing; deferring would discard the character sheet. This informs the GM, it does not hand it the decision. test_auto_roll_skill.js asserted the old message verbatim and failed correctly. Rewritten to assert structure, plus the relay's internal consistency - that the stated verdict agrees with the stated total against the stated DC, which is the disagreement this bug was made of. Ledger: BUG-009 open to fixed-unverified. A GM-contract change no test can settle; a run must watch it narrate a low-die success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BUGS.md and README.md become BUGS.html and README.html, in the Designs page style - same tokens, same section heads, same tablewrap and callout vocabulary, both themes and the reduced-motion guard. The reports in this directory were already HTML in that style; the two documents that framed them were the odd ones out. The markdown originals are removed rather than kept alongside. The ledger itself argues the point: a second copy of a list would only drift from the first. Bug entries get a small addition to the design vocabulary - a .bug panel built from the same treatment as .decision, colour-coded by status down the left edge, because status is the first thing a reader wants from a ledger. Statuses are rendered as the same mono tags the design docs use. Inbound links updated: Designs/world-evaluation.html, four reports, and the comment in text_adventure.html that cites BUG-007. Checked with a script that verifies tag balance, that every in-page anchor resolves, that every relative link exists on disk, and that no href anywhere still targets the retired .md. Not updated: Web/Reports/progress-report.html mentions the old path in prose, but it is generated from commit history and would be overwritten on the next regen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Remove button in the bottom-right of every item card — after the body, where nothing sits below it and nothing is reachable past it by accident. Quiet until reached for; its weight is in the confirmation, not in shouting from the card. The load-bearing fact is that an Item INSTANCE carries no catalog id. Look at the class: name, type, description, quantity, and no back-reference. The app links the two by NAME in the one direction it needs, resolveItemIdByName, so name is the link and matching by it is not a shortcut here — it is the only thing there is. Which also means a duplicate name is genuinely ambiguous rather than merely awkward: resolveItemIdByName returns whichever entry it meets first, so a sword on a floor cannot say which of two same-named entries it came from. The dialog says so outright instead of quietly taking placements that may belong to the other one. The count comes BEFORE the question. "It is placed 5 times in this world (6 in all, counting stacks)" with each placement named — reporting it afterwards is telling someone what they have already done. Placements and units are counted separately because a dialog that says "1 placement" for a stack of five understates what is about to go, and each placement is described by its OWNER, so a hit inside a chest reads "inside Oak Chest (Market Row)" rather than naming the room and leaving the box out of it. The walk mirrors retrofitWorldWeaponDamage's exactly — rooms, their floor items, their entities' inventories, the player's own, descending through container contents at every step — because that traversal is already the answer to "everywhere an item can be", and a second list would drift from it. Deletion walks the same ground by the same rule, and returns what it actually removed so the reported number is measured rather than assumed. Two things go beyond the obvious. An equipped copy is unequipped: a slot pointing at a deleted item keeps feeding playerAC and equippedWeaponWithDamage with nothing behind it, and equipDrop stores an id OR a bare name, so both shapes are cleared. And the Compendium's record of having discovered the thing goes too, keyed by the same catalog id. The save is awaited before the status line is written. It posts its own "Saving game…" into the same bar, and it lands after an outcome written first — so reporting the removal before it means reporting it to a line about to be overwritten. Driven in a real ?detach=editor window, not just asserted: five seeded placements across a floor, a chest, two carriers and the player, all found, all listed, all gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Previous generation ran against a shallow clone and missed most of the project's history. Report now covers all 2181 commits across 38 days. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XvvT48z6REpwtiNiuhvqVK
Three icon buttons sat in the detached Editor's header and two of them did the same verb. Export and Download both write the world to a file; the only thing separating them is what happens to art that lives on the vault — Export pulls it back inline so the file travels anywhere, Download leaves the references so the file stays small and keeps pointing here. An arrow beside a disk cannot say that. The pair read as one choice made twice, and the reader had to hover both to find out which was which. They are one dropdown now, under the arrow, with the distinction written out: "Export World — Embed Media" and "Download World — Keep References". It mirrors the toolbar's own export menu rather than inventing a second idiom for one window: same toggle/close pair, same .export-menu-item styling, closed by an outside click and by Escape beside its siblings. That frees the disk, which read as "save" to everybody and meant "download" to nobody. It is Save now. It writes the document this window is editing through saveGameStateNow, which already routes a save editor into that one playthrough and a draft editor into its standalone draft — so the button asks for the write rather than choosing a destination, and cannot drift from the automatic saves in the same window. It is also the path that ignores "Disable Auto-Save", which is what a button pressed on purpose should be. It does NOT publish: promoting a draft into the library stays the deliberate Update Library World beside it. The confirmation flash needed care. Writing btn.title is the obvious way and it is wrong here: adoptNativeTitle MOVES a native title into data-tip on first hover and deletes the attribute, so on a button already hovered .title is '' — the flash shows nothing, the restore writes back an empty string, and a hover DURING the flash adopts "Saved" into data-tip where "newest title wins" makes it the button's permanent tooltip. Verified in a real window before and after: the naive version leaves the Save button reading "Saved" forever. flashButtonTip writes whichever channel the button is actually using and restores the previous value exactly, including its absence. The header test kept its ordering checks but had to stop reading the actions group with a lazy match to the first </div></div> — the group holds a nested div now, so that capture stopped inside the menu and silently truncated every comparison after it. Three assertions about the save handler were byte windows measured from its name; one of them started reading the next function along the moment the body got shorter, so all three are scoped to the function body. Note for later: manualSaveGame flashes its title the naive way and carries the same latent tooltip bug. Left alone — it is the main window's button, not this change — but flashButtonTip is there when you want it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Phase 1 shipped binary saves — "success negates the effect, failure applies it
in full" was the whole vocabulary. But submitSavingThrow has always graded four
ways through checkOutcome, the same grader skill checks use, so it was already
relaying "→ PARTIAL" to a GM whose rule text described two outcomes and had
nothing to say about a third. A grade with no rule behind it.
Saves now resolve onto three rungs — none, half, full — chosen by the grade,
with declared stakes moving exactly one cell:
critical success partial failure
negates none none half full
half none half half full
"onSuccess": "half" buys the classic area-effect save and nothing else; a clean
success is the only cell it touches. partial is half in both columns, because
coming within 2 of the DC reads as almost getting clear of it however the effect
was declared. An unstated or unrecognised stake is negates, so a request in the
old shape means exactly what it used to.
The engine applies the damage. Declare "damage": N and it works out the fraction,
takes it off, and relays what it did — the split weapon damage already runs on,
for the same reason: halving is arithmetic and arithmetic is not the GM's job.
Halves round down, in the player's favour and deterministically, so a relayed
number can be checked by hand against the magnitude declared. Omit the magnitude
and the rung comes back on its own, which is what a petrification or a shove
needs — there is nothing to halve, only an effect to reduce.
In-combat saves are graded here for the first time. They used to come back as a
bare total for the GM to compare, which is why the contract still described them
in binary. Give awaitRoll the governing "stat" beside its "dc" and the engine
resolves the save end to end, in or out of a fight, through one ladder. Without a
stat it falls through to the old relay rather than grading against a defaulted
attribute and silently changing what a save means.
Closes Phase 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLPhase 2 listed five items. Three have landed — armor defense and weapon damage came in with the weapon-damage work, and enemy tactics with the ENEMY ABILITIES and TEMPERAMENT rules — plus a sixth that was never listed, the round a mid-fight gear change now costs. What is left splits cleanly in two, and the split is not where the list drew it. Multi-enemy and pursuit are one piece of work rather than two. Both need the fight roster to become something a being can enter and leave while a fight is running: combat.enemyNames is written once in beginCombat and never appended to, and beginCombat returns early while combat is active, so no second foe can enter a fight at all today. Pursuit is the same gap from the other side — a monster can already break off in narration, because ENEMY ABILITIES lists "a retreat" among its smart plays, with nothing mechanical behind it. The temperament rule's "stands with" tier is waiting on the same roster. They move to Phase 3 together; the old Phase 3 becomes Phase 4. That leaves Phase 2 with one open item, "save for half". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two fixes to the temperament rule, both from the author. "Defensive" is fight-or-flight, and the rule never said so. Passive and defensive both start nothing — the whole difference between them is what happens once the player swings. Passive would rather be elsewhere: it flees, hides, cowers or surrenders, and fights only cornered. Defensive stands its ground. It does not flee and does not need to be cornered, and it breaks off once the threat is gone rather than chasing or dying for pride. The first version read "defends its own" as its own KIND and wrote packmate aid into the value itself. That was an over-reading. "Its own" is what BELONGS to the creature — its life, its young, its lair, its territory, its charge — so trespass on those counts as an attack on it. A field every defensive creature shares cannot carry ally-aid: a lone bear and a golem set to watch a door have nobody to rescue. Which leaves the real question, and it belongs to the GM: does a being join a fight it merely witnessed? The axis is allegiance, not species, and the rule now gives three tiers. Its own — a mother whose cub is struck almost always fights, and that needs no discretion at all because her young are already covered by "its own". Stands with — a bandit of the same band, a wolf of the same pack, a guard of the same watch; expect it to join. Merely the same kind — a commoner watching the player trade blows with bandits stays out of it, and shouting or fetching the watch is the truer reaction than drawing a knife. The middle tier states its reason rather than only its examples, because a GM given three examples pattern-matches them and a GM given the reason extends it: a gang lives by a rule about its own, beasts that hunt together answer for each other by nature, and a band that watched one of its number cut down in front of it would not be a band for long. Mercenaries, cultists and a hound's kennel-mates read the same way. The closing default is scoped to UNATTACHED bystanders. Unqualified it would contradict the band tier one sentence above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
ENTITY_AGGRESSION has carried a hint beside every value since it was written: passive "will not fight unless attacked", defensive "fights back, and defends its own", aggressive "picks fights when it likes the odds", hostile-on-sight "attacks the moment it sees you". Those hints reached the editor card, the aggression selector and the NPC popup. They never reached the GM, which got the bare token and inferred the rest from the word. The contract explained three of the four in a parenthetical at STARTING and skipped defensive entirely — the one value whose meaning is not guessable from its name, because "defends its own" is the whole of pack behaviour: a creature that starts nothing but comes in at once for its young, its territory or its packmate. Nothing about the field was legible to the GM at exactly the moment it matters most. Aggression also only ever decided WHETHER a fight starts. entityIsHostile collapses all four values to a boolean, the contract consulted it once at combat.start, and after that a temperament had no bearing on anything. The ENEMY ABILITIES rule asked the GM to weigh "the enemy's goal and temperament" without naming a field it could read. So: one TEMPERAMENT rule in the contract defining all four values as conduct through the fight — how it opens, how hard it presses, when it stops, and whether it is still fighting next round. A bloodied aggressor reconsiders. A defensive creature disengages once the threat to what it guards is gone. A hostile-on-sight one does neither. A being with no aggression listed is stated to be ordinary rather than left to inference, since normalizeAggression returns '' for anything it does not recognise and that case is reachable in a real world. ENEMY ABILITIES now defers to it by name and orders it first, and each foe's temperament rides the ## Combat block beside its HP and AC — the line the GM is actually reading while it decides the round, rather than only the room dossier. The bare value there, the gloss once in the contract: four foes over ten rounds would otherwise pay for that hint forty times. Written now rather than after multi-enemy, because the moment several foes act in one round "what does this one want" is asked N times a round instead of once a fight, and defensive is the value that only starts to matter when there is someone else to defend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Spark already renders splats stereoscopically — its draw path checks renderer.xr.isPresenting and sorts per eye through renderer.xr.getCamera().cameras, which is the part that would otherwise be wrong, since a splat scene is depth-sorted from the viewpoint and one sort for two eyes is not enough. It also ships SparkXr, a whole session helper. The page simply was not switching any of it on. Three changes carry it. The camera moves inside a RIG and the controls move that instead: WebXR writes the headset's pose into the camera every frame, so anything moving the camera itself is overwritten before it is drawn — and flat, moving either looks identical, which is what makes it worth pinning. The render loop was already setAnimationLoop, so it survives entering a session unchanged. And SparkXr owns the session, with its own button declined in favour of one in the page's palette calling the same toggleXr. The button appears only once a session is reported supported, and when it is not, the page says WHICH of the three reasons applies — no headset, no WebXR, or not a secure origin. Those look identical from a missing button and want completely different actions, a point Designs/dungeon-vr.html §11 makes about the crawler's button too. NOT VERIFIED ON HARDWARE, and the page says so in its own comments. There is no headset here and Chromium offers no session without a device. What was verified in a browser: the flat viewer still draws (the rig change is the risk there), the button stays hidden with the reason stated, a stubbed headset makes it appear, and clicking it reaches requestSession. Expect trouble from framerate — stereo doubles the draw and VR wants 72-90 Hz sustained — and from scale, since a WebXR reference space is metric and a generated world arrives at whatever size Marble's coordinates imply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Setting it.ref in makeItem alone was forward-only: every item already written into a save kept no binding at all and would resolve by name for the rest of its life, which is the situation the ref was added to end. Checked against the live Claude6 save - all seven carried items read "no ref". reItemObj is where this file already migrates fields older saves predate, so the backfill goes there, through the same resolver play uses. A name matching two entries migrates the way it would have resolved anyway, deterministically and with the same warning, rather than differing between one load and the next. A name with no catalog entry - a minor item, deliberately never catalogued - resolves to null and is left alone. Verified on reload: every item in inventory and in rooms now carries a ref. One of them makes the case on its own - Saltmonger's Strongbox binds to saltmonger_s_locked_strongbox, the slug of the name it had before it was renamed. The id survived a rename the name-based link could not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A display name is not an identity: two entries may legitimately share one, with
their own descriptions, stats and lore. Verengrad has drowned_blade (2d8,
martial, an 18 XP lore hook) and drowned_longsword (no damage dice) under one
name, with a being carrying two copies of the bare name.
A placement is a full COPY, not a pointer - it carries its own stats and carried
no ref at all. So the only link back to its type was the name, and everything
resolving to the type (lore unlocks and their XP, compendium identity, a DM edit
that fans out across matching names) went through the one field that cannot be
unique. makeItem now records which entry an item came from, for inline items too,
as an own property that survives restore. Nothing reads it yet; it makes the
unambiguous binding that exists at creation time durable instead of re-deriving
it from the name later.
resolveItemIdByName returned the first hit, so the answer depended on
ITEM_CATALOG insertion order. It now resolves only when that is not a guess: one
match wins outright, and several are broken by the slug of the name - the id
registerInlineItem would mint, so a rule rather than a coin-toss - falling back
to the lowest id, sorted, so two machines loading the same world agree. It warns
with every candidate and what to do about it.
The name path itself stays. It is load-bearing: the GM writes { ref: "Wolf Fang" }
where an id belongs, and inline items have no ref by design. Returning null on
ambiguity would be worse than choosing - makeItem would treat the stray ref as a
name and mint a hollow item with no base to inherit from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Three things a playthrough found because the evaluator could not see them. Class-exclusive gear is real and should not read as unplaced. The pass counted only the EVALUATING character class kit, and the editor picks that class as Object.keys(world.classes)[0] - so Verengrad Cantor Hook passed only because CantorAdept sorts first. Every class startingInventory now counts, which also removes a finding that changed with JSON key order. Placements store a name, not a ref, so two catalog entries sharing a name resolve to whichever is reached first. Two entries under one name are not automatically a mistake, so this reports the AMBIGUITY and names the fields they differ on; identical-on-every-field is reported separately as a redundant record. Verengrad has drowned_blade (2d8, martial, an 18 XP lore hook) and drowned_longsword (no damage dice) both named Drowned Longsword, with a being carrying two copies of the bare name. Dungeons had six mentions in this file and every one was prose. Two stray test dungeons shipped and a run walked into one at level 4. The map lives in browser storage rather than the world, so the pass says what it can - these exist, here are their names - and flags unfinished ones without claiming to know whether any is reachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Disarming the strongbox trap showed "= 16 vs DC 15, Success, +1 xp" in the manifest while the narration and the state both applied a failure. The GM receives only the raw die (roll 9) and judged it against DC 15 without knowing about DEX 20 and proficiency; the engine ignores the GM outcome field and recomputes from roll + mod + prof. So the manifest and skill XP come from one resolution and the narration and world state from the other. Only visible when raw and modified straddle the DC, which is why earlier rolls this run agreed. The band widens as the player invests in a skill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
animateRestNotice() drives its clock sweep with requestAnimationFrame and owns the action box for the duration — gmSubmit deliberately skips its own re-enable while _restSweepActive is set. rAF does not fire in a hidden tab, so a sleep begun in the background stalled mid-loop: finish() never ran, the flag stayed true, and the command box stayed disabled with isProcessing false, so every ordinary busy check reported the game idle and ready. The function already snapped to the end when rAF was ABSENT. This covers rAF being present and never called, via a setTimeout watchdog — timers still fire when hidden. finish() is idempotent and now clears the timer, so a watchdog racing a real frame is harmless and an ordinary rest leaves nothing pending. Found in play during the Claude6 run, where the tab is never foregrounded and the stall is permanent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The idle timeout could fire while a GM turn was in flight. logout() then flushed the pre-turn state successfully, the reply landed and mutated the player, and saveGameState() dropped it on its first line because loggedIn was already false. The turn ran, changed the world in memory, and was never written — while the save diagnostic recorded logout-save-ok, because the save had not failed. It was early. inactivityLogout() now defers while isProcessing and re-arms, which is the idiom the ambient interrupts already use. Only isProcessing defers: a fight merely active is waiting for the player to type, which is the idle case the timeout exists for. Reproduced by arming a 6s logout alongside a move command — the move was lost before, persisted after — and confirmed against the fix rather than by test alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine was already strict about equipping: playerAC reads player.equippedItems, equippedWeaponWithDamage walks the same map, equippedAbilityGrants folds in an item's abilities only while worn. The GM was told none of it. Equipping does not move an item — equippedItems is a slot-to-id map beside the inventory — so a breastplate on the character's back and one in their pack were the same string in the prompt. Worse, with nothing equipped the whole weapon-damage contract drops out of the prompt and nothing takes its place, so the GM saw a greatsword in the list, no rule about weapons at all, and authored entityDamage for a blade still in the bedroll. The numbers were right and the fiction was not, which in this game is most of it. The inventory line now tags each equipped item with its slot, and a new Equipment block states the rule and names what is actually on the character — including when that is nothing. The silence was the bug; an empty loadout now says so out loud. Changing gear mid-fight is now the player's action. The engine spends the round itself by submitting the change as the turn, the same way weapon damage relays [COMBAT DAMAGE], so the foes get their round and the player does not also swing. The swap itself always completes: the engine owns equippedItems, and a GM free to narrate a failed draw would put fiction and numbers straight back out of step. Being caught mid-change is paid in the opening it gives the enemy — their own attack, which the GM already owns — at whatever DC or advantage it judges fair, since the right check here is situational and belongs to the GM. Quick Draw is the way out: a DEX skill, open to every class, that makes the change clean and uninterruptible. It does not make it free. Gating it behind a martial class would leave a caster reaching for a warding ring with no answer at all, and letting it refund the action would make swapping strictly better than not swapping. A gear change is refused outright while a turn is in flight — it would be swallowed by the round already resolving and the action lost. Out of combat nothing is blocked, because there is no turn to lose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
logout() already flushed a final save deliberately, but its failure was
swallowed by a bare catch (e) { /* ignore */ }, so a session ended looking
clean whether the write landed or not. From the login screen the two outcomes
are indistinguishable -- which is how three turns vanished on 2026-08-05 with
nothing anywhere to say why.
The record has one hard requirement: it cannot live in the snapshot. gameLog
appends to messageLog, which is part of the very object being written, so a
failed save takes its own explanation with it. It goes to
localStorage['tlr_save_diag'], written synchronously, capped at 40 entries, and
unable to throw.
Four events: inactivity-logout-fired (the idle timer was the cause, which
nothing afterwards could tell you), logout-save-ok (with duration -- absence of
a record would otherwise be ambiguous), logout-save-FAILED, and
inactivity-logout-threw. The failure carries a readable message -- "The game
didn't shut down cleanly and encountered an error during save: <error>" -- plus
`intended`, the state the session believed it was saving. Diffing that against
storage separates a failed write from a stale one.
reportUncleanShutdown() announces it once at the next boot, so it is noticed
rather than found, and keeps the record for diagnosis.
inactivityLogout() no longer fires logout() and forgets it; the rejection is
observed.
Two test adjustments, both because assertions pinned code LAYOUT rather than
behaviour and a refactor moved lines: the busy-modal test now follows
logout() -> flushSaveForLogout() rather than requiring withAppBusy within 400
characters of logout's name. Extracting that flush also keeps logout() short
enough that the three cue/teardown tests measuring distance from its name pass
unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThree turns -- a room move, the Anchor Saint beat, a level-up and a stat point
-- were watched on screen and are absent from both the rolling state and the
named save, which agree at the earlier point. No second window was open and the
idle timeout is 15 minutes.
The design is right: logout() awaits saveGameStateNow() before clearing
loggedIn, explicitly to flush the debounced save. So this is not a missing
save, it is a save that did not land.
Three things to examine, in order of suspicion. The failure is SWALLOWED --
catch (e) { /* ignore */ } -- so a failed 29 MB write during logout is silent
and the session ends looking clean. inactivityLogout() calls the async logout()
without awaiting, so nothing sequences after it and no rejection is observed.
And the idle timer resets only on real input events, so a run driven by
dispatched events that pauses between turns can idle out with no human-visible
inactivity -- which makes this likelier to bite an automated run than a person,
without making it less real.
Repro given: set the timeout to 1 minute, take a state-changing turn, wait for
the logout, inspect both records.
The Anchor Saint finding is unaffected -- it was watched printing. An unsaved
session does not unmake an observation, only the world state it happened in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvReaching the Hush-Choir Gallery did not fire the beat; its trigger requires interaction and the engine held it until the Saint was spoken to. Then it paid exactly 70 against a 25 default. Three discriminating values now paid correctly: 40 and 70 for quest beats, and 70 for entity combat XP before its GM bonus. One path pays the wrong figure -- being lore, 12 against an authored 30, which is BUG-006 and not an engine fault. Item lore remains untested; its check failed by one. Run state: Claude6, level 3 Rope-Runner, 99/115 HP, 4 of 11 rooms, 3 of 6 beats, Pick Lock gained -- which now makes the locked Saltmonger's Strongbox reachable, the first container this evaluation could open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Confirmed in play -- Gill-Wretch's placement carries loreXp 30 and unlocking its lore paid 12. Not an engine defect: lore belongs to the KIND of thing and unlocks once for it, so pricing from ENTITY_CATALOG is consistent, and 12 is correct given where the figure sits. Not something a DM can create either. compendiumSetLoreXp -> applyCompendiumLoreField -> applyEntityTypeField writes the value to the catalog type AND to every live instance, so a DM setting 30 in the Lore section would have set both and it would pay 30. So something upstream writes loreXp onto placements only -- most likely world generation or a GM entity edit. Verengrad carries eight such figures, all paying the default, and nothing told the author. Preferred fix is to hoist on load: when a placement has loreXp and its type does not, copy it up. That repairs every existing world, needs no re-authoring, and cannot make anything worse since the type is the only reader. Making the engine read instance-first is explicitly rejected -- it would let two copies of a creature be worth different amounts for the same fact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Fought at level 1, wounded deliberately rather than killed, then given room to speak -- the exact condition its loreKey names. 1. The non-lethal route is real and playable. The engine honoured the intent mechanically (maimed arm, 28/42) and offered an explicit off-ramp: "It is wounded and reeling -- what do you do?" Stepping back resolved the encounter AND unlocked the lore. lethal-forecloses-lore describes a genuine choice, not a trap. 2. Entity XP honoured the authored figure. Placement says 70, level-derived default would be 30, so it discriminates -- and the engine paid 115: 70 base plus a 45 GM bonus, inside the 2x cap. Combat XP resolves instance-first as designed, and the "only ever adds" property held a second time in a different subsystem. 3. Being lore XP paid 12 against an authored 30. This is the first OBSERVATIONAL confirmation of lore-xp-on-placement -- until now it rested on reading the engine's resolution path. Eight beings in this world carry such figures and every one is paying the default. Also confirmed in play: Scaffold-Sure, the Verengradi racial passive, fired by name in a check -- "DEX 16 (+3) - Scaffold-Sure +1 = 18 vs DC 12". It binds through effect.modifiers rather than appliesTo, which is exactly the case the race-abilities-unstructured correction was about. An ability bound by a roll modifier is fully wired to the engine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Salt-Verse Shard's lore hook was the first exercise of itemLoreXp, the third XP path and the one no run had touched. It did not unlock, and that is correct: the engine rolled d20 13 + WIS 0 = 13 against DC 14 and returned a Partial -- a miss by one, with the roll, modifier, DC and outcome all shown and the narration matching. So item lore XP stays unverified. The path behaved correctly up to the point of payment; nothing yet shows what it pays. Three things confirmed in passing, none of them the target: the honest skill-check pipeline shows its work; a lore hook is EARNED rather than merely triggered, which means a hook the evaluator counts as reachable is an opportunity and not a certainty; and the narthex applied its own environmental effect (numb with cold, 95 -> 92 HP) unprompted. The session dropped to the login screen mid-run. Both the rolling state and the named save were checked before resuming -- Claude6, 92 XP, Drowned Narthex, beat XP intact in both -- per the pre-run check added after BUG-005. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The efficient shape is a long run in a single save: an observation does not force a restart, so many problems surface per setup. The cost is that the save's world drifts from the library with every published fix. What makes that manageable is an asymmetry worth stating plainly. Behaviour observations do NOT decay as a save ages -- the engine is the engine, so a compound-command or XP-award defect found on an old save is still true today. World-data observations DO decay, because the evaluator recomputes them from the current world and a stale save's version is worthless. So: keep running for behaviour, stop for data. That is the same line the bug ledger already draws between what it tracks and what it leaves to the evaluator, applied to when a run should end. Stop criteria named: confirming a DM fix landed, testing something that depends on data known to have changed, or losing track of which world an observation belongs to. Otherwise keep going -- merely interesting is not a reason to stop. Also adds a world fingerprint to take at the start of a run, so an observation can be attributed to a world state later rather than guessed at. That is what would have caught the beat-unanchored claim made from a day-old snapshot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The report said "The Calling" is flagged beat-unanchored, and used it to suggest the finding deserved scepticism because the beat fired anyway. Both halves were wrong. The claim came from draft-now.json, captured 2026-08-04, where the trigger read "After Mira explains the Ninth Canticle... and Mira names them Cantor-Thief and charges them to descend" -- naming no room, so the finding fired correctly. The current library trigger names Scaffold Landing twice and Drowned Narthex once, and the finding is correctly absent. So the evaluator was right, the trigger was rewritten to name its rooms, and the finding cleared. That is the action-card loop working end to end, and the report had it backwards. The error is the same one the report itself documents two sections earlier: a claim about current state made from stale data. Third time this session that trap has bitten and the first time I set it myself. Added to the ledger's harness list -- recompile from the live library world before asserting what the evaluator currently reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The question BUG-002 opened is answered for quest beats. "Into the Drowned Nave" is authored at 40 against a 25 default, so it discriminates -- and the engine printed "Into the Drowned Nave - 40 XP." The Calling paid 25, which proves nothing on its own since 25 is also the default; the 40 is the result. The first attempt was confounded and nearly produced a false bug with real numbers behind it. Opening a save in a SECOND game window repoints the rolling session state, and "Continue Your Journey" resumes that rather than the named tlr_save: record -- so the run was playing a world whose beats were unpriced while the save held 25/40/70/90/120/180 all along. Unlocking a beat there would have paid 25 against an authored 40 and looked exactly like a defect. Same root cause as BUG-003: concurrent windows sharing one browser. BUG-005 is withdrawn, and re-selecting the save from the disk icon is now a standing pre-run check. The investigation is kept because it rules out three causes with evidence -- the rehydrator preserves xp, there is no DM-only redaction, and the library agrees with the save. Also confirmed, and worth more than the bug: the pass's "floor, not forecast" claim. Observed XP was 92 against 65 of authored beat XP, the excess being GM-discretionary changes.xpGain -- the source the pass deliberately excludes on the grounds it can only add. First empirical test of that claim, and it holds. Noted for the evaluator's own findings: The Calling is flagged `beat-unanchored` because its trigger names no room or placed item, and it fired correctly anyway -- worth weighing before treating that finding as a defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The DM was right that Claude6's quest beats carry XP. Read straight out of
IndexedDB, tlr_save:Claude6 has 25/40/70/90/120/180 -- and was written 4.5
hours BEFORE the run loaded it. The live session has all six as null.
restoreGameState() reads SAVED_STATE_KEY ('tlr_game_state'), the rolling
session snapshot, not the per-character tlr_save: record. So "Continue Your
Journey" resumes the rolling state, which can differ from the named save of the
same character -- and a run then exercises a world the save does not contain.
Three candidate causes ruled out and recorded so the next session skips them:
the rehydrator preserves xp (reInstance -> normalizeBeatBranchFields ->
reQuestObj all return 40, and questBeatXp pays 40); there is no DM-only
redaction of quest data; and the library world agrees with the named save.
Left honestly unexplained: tlr_game_state currently holds Alicia6 at rev 1272,
so how the live session became Claude6-with-nulls is not yet accounted for.
Named as the thread to pull rather than guessed at.
Carries a warning not to let a stale session write back, since a null-beat
session overwriting a correct save would destroy authored data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe Player Progression miss was not a one-off risk, so this checks all of them: the three proposed systems, every outstanding phase, and all thirty-eight growth ideas, against text_adventure.html, server/, Modules/, tools/ and Handbook/. Search terms came from each doc's own vocabulary — the identifiers it names in its <code> spans — rather than names guessed at the claim. Guessing the name is how the first miss happened: the progression doc says "milestone timeline", the app says classProgression, and a grep for the former finds the task system. Two more claims fell. Quests & Journal §07 calls multi-path critical-path accessibility "a GM authoring directive (proposed for new-world generation, region expansion, and quest editing), not an engine check". It is in all three: CRITICAL_PATH_ACCESSIBILITY_GUIDE is injected into requestWorldGeneration, requestWorldExpansion and requestQuestEdit, carrying the whole tier structure — specialist paths, a universal fallback that must be costliest, the no-domination and no-skeleton-key guardrails, the side-content exemption. Abilities phase 5 says the Player's Handbook retrofit is outstanding. The handbook has its Abilities chapter and it opens on the spine the retrofit exists to establish — "A skill is something you learned. An ability is something you have" — then the three-lane table. The file was last touched three days after the design doc, which is why the doc never caught up. Abilities is therefore finished outright, not built in part. Everything else held, and several claims are now firmer than the doc making them. Combat's missing multi-enemy support is a line of code: foes.slice(0, 1) with the comment "Phase 1: one enemy at a time". Weapon Damage's retrofit occurs exactly once in the file, at its own definition, which is what "no caller" means precisely. Weather's engine restraint is a comment explaining itself. A book's level is read in six places and assigned in none, so the slots formula is there and the upgrade is not. Three proposed, thirteen built in part, seven shipped with growth ideas, three finished; eleven stale markers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Caught before a run rather than after one, which is the point of the checklist. The DM saw quest-beat XP values in the editor; the Claude6 save reports all six beats as null. Both are true. Verengrad's LIBRARY world prices its beats 25/40/70/90/120/180 -- 525 total, exactly what the evaluator reports -- while the Claude6 save carries a copy frozen before that pricing. Saves not inheriting library edits is deliberate and documented in the app. The trap is what it means for evaluation: the Evaluate tab compiles the editor DRAFT while a playthrough exercises the SAVE, so the two can describe different worlds. Unlocking a beat in Claude6 would pay the 25-point default and look like a paid-vs-authored disagreement when it is only a stale save. Added to the harness section, which now holds four ways a run can lie to you -- DM mode bypassing concealment, the 24x clock, concurrent browser interaction, and now a stale world copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported by the author, who pointed at the Progression button on every class card in Editor > Player > Classes. It is there, and so is everything behind it: applyProgressionForLevel grants a beat's title, class skill, stat bump, stat points or typed effect on level-up, idempotently through a per-class applied-levels log; reconcileProgression catches up saves made before a beat existed; the player reads the timeline on Character > Progression under the reveal rule. That is phases 1 through 4 of the doc's own build order. The first pass filed it under "nothing built" because player-progression.html says "Design only — not built" in its header, in its lede and in its footer, and the README repeats it. Nothing in the document argues with anything else in the document, so the cross-check that caught calendar.html and character-skills §07 — read the badge against the body, and settle the disagreement in the code — never fired. A page has no reason to contradict itself when the code moved and nobody came back to it, which makes a self-consistent proposal the one shape this review cannot detect by reading. §01 now says so, because the lesson generalises past this one doc. Three proposed, fifteen built in part, nine stale markers. Phase 5 is what actually remains for progression: built-in classes ship no timeline, there is no earnedTitles list or title picker behind the single worn player.title, and only the dedicated dialog emits a progression — world generation and requestClassEdit do not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A targeted run with DM mode OFF, one room and six turns, to settle whether a
compound command drops a clause. It does not.
single take of a CONCEALED item refused AND narrated, without leaking
that the moss exists
examine the scavenged stones seen: false -> true, exactly the
authored seenCondition
compound take + move, both legal both addressed; take executed, descent
blocked in fiction by Mira catching the
wrist and saying why
compound of two benign clauses both executed, both narrated
What happened in the Claude5 run: the weepmoss was seen:false and unfound, so
the take was correctly refused. The word "silently" was never established --
that run checked the resulting STATE (room changed, inventory unchanged) and
never read the narration. The GM demonstrably does narrate these refusals, so
there was very likely a refusal on screen that went unread.
The lesson is about the harness, and is now in the report and the ledger: a
state check tells you what changed, not what the GM said. Filing a bug whose
entire claim is "silently", on state evidence alone, cost a run to undo.
Confirmed working along the way, and worth having: the plant identity gate end
to end -- concealed from a non-DM, absent from the room listing, un-nameable in
a command, then revealed by the authored condition. Every previous run had DM
mode on, which bypasses it entirely, so this is the first real exercise of it.
Also confirms BUG-004 was a setup artifact: Claude6 began Rested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvTwenty-six design documents, and no single place that says which of them still have work in them. Reading them one at a time to find out is exactly the pass that keeps getting redone. Designs/current-status.html sorts every doc onto one axis: four propose systems that do not exist, fourteen are built in part and name what is missing, six are shipped and carry growth ideas only, two are finished outright. Two things the reading turned up that a skim would not. Decisions and phases are independent — Combat, Spells and Weapon Damage have every decision locked and whole phases still proposed, so "no open decisions" is not "nothing left to build", and they are tracked in separate columns for that reason. And the folder runs two markup conventions: the systems docs tag an unresolved call inside a decision block, while the infrastructure docs use numbered Q blocks and header badges. Counting one convention reports zero open questions for Server Vault, which has eleven. Eight status markers have gone stale, listed in section 06 so they can be fixed in one pass rather than misleading the next reader. Two of them claim something is unbuilt that is: calendar.html still calls itself a proposal though world.calendar.months is live, and character-skills section 07 says "Not built" while its own header badge and the app's skillPoints say otherwise. Both were settled by checking the code, not by preferring one badge over the other. The README's World Image Baker and Vault Media Store status cells also appear to have been swapped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Investigating before fixing changed what the bug is. The Bell-Choked Weepmoss is a plant with an identity gate -- seen: false, unlocked by "examining the scavenged stones on the landing" -- so it is concealed until studied. The run was made with Dungeon Master mode ON, and itemHiddenFromPlayer returns false for a DM outright, which is the ONLY reason the item appeared in the room listing and could be named at all. An ordinary player could not have issued that command, and refusing to hand over a concealed item may be the gate working exactly as authored. So the original observation cannot establish the bug, and fixing it now would be fixing something nobody has shown to exist. It is `needs-repro` with two experiments that separate the real question (does a compound instruction lose a clause when nothing is gated?) from the possible non-bug (should a refused clause announce itself, when announcing leaks the existence of a hidden thing?). Established while looking: the GM contract has NO guidance on compound instructions at all. So if the first experiment reproduces, the fix is a contract addition. Which is what the new `fixed-unverified` status is for. Where a fix lives decides what can prove it: a change to build-walkthrough.js is settled by a unit test the moment it is written, while a change to a GM prompt is a change to instructions a model may or may not follow -- the suite passes either way. Marking both `fixed` would let a prompt change that never worked sit unnoticed behind a green suite. The ledger's "Before a run" section now says to exercise those entries deliberately. Added a "Before filing" section too, since all three non-bugs so far were harness artifacts: DM mode bypassing concealment, the 24x clock ageing a character during setup, and concurrent browser interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two of the four bugs in the Claude5 report were not defects: the session
switching world mid-run was concurrent interaction with the same browser, and
the character starting Exhausted was an artifact of a long save setup. Both
were mine to have checked before filing. The compound-command bug stands.
They are withdrawn rather than deleted. A report that quietly loses its
retractions is worth less than one that keeps them -- a later run hitting the
same symptom should find that it was investigated and dismissed, and why,
rather than filing it again.
Evaluations/BUGS.md is now the durable record, because a report is the wrong
home for a bug that outlives its run: reports are dated snapshots, so a defect
present across five runs would need a reader to open five files and diff them
by eye. Reports link by id and are never updated; status lives in the ledger.
Scoped deliberately:
behaviour defects in the ledger -- only a playthrough reveals them, and
they exist nowhere else once the session ends
world-data problems NOT in the ledger -- the evaluator recomputes findings
and action cards every run, so that list maintains
itself and a hand-written copy would drift from it
One file rather than one per world, because most such bugs live in the engine
or the GM contract and are not world-specific. Filed under Verengrad, the next
world's first run would rediscover them from scratch.
Every entry carries a repro, since a bug nobody can trigger on demand cannot be
confirmed fixed -- and BUG-001's entry separates what was observed from the
three leads worth chasing, so the next session does not inherit my guesses as
findings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvRepo was previously checked out shallow, missing most commit history. Unshallowed and regenerated to reflect all 2,144 commits across 36 days. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019qF47K4H3DepYQ8ggUegDW
A short UI run from a clean save, made to test what a static pass cannot: whether the numbers the evaluator reports are the ones the engine pays. Five turns were enough to find out they were not. The headline is a defect in the measuring instrument. Mira the Anchorite carries loreXp 40 on her placement and none on her type; unlocking her lore paid 12. The engine prices being lore from the TYPE, so the pass -- which had been changed to read the placement -- was overstating this world by 179 XP in the optimistic direction. Fixed in bc823fd; the report records the eight stranded figures the DM still needs to move. The run stopped there deliberately. A defect in the instrument invalidates whatever it measures afterwards. Four bugs logged in their own section for a later session: the lore-XP one (fixed), a session that switched world and character on a stray click after a window resize (open, no data lost), a compound command that silently executed only its second half (open), and a fresh character that begins Exhausted (open, probably the world clock running through setup). Coverage is stated plainly -- 2 of 11 rooms, no combat, no containers, no beats -- because a short run is easy to over-read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Found by playing, not by reading. Mira the Anchorite carries loreXp 40 on her room placement and none on her type; unlocking her lore in a real session paid 12 -- the default. The engine prices lore through subjectLoreXp → compendiumTypeContext → ENTITY_CATALOG[id]: the TYPE, never the placement. a5774ed had changed this pass to read the instance, on reasoning that looked sound -- combat xp resolves instance-first, the DM had authored on instances, and reading the type alone reported those beings as unpriced. It was wrong, and it made the evaluator disagree with its own engine by 179 XP across Verengrad, in the optimistic direction. That is the worst direction for a pass whose value rests on being trusted when it complains. The engine is right, too: lore belongs to the KIND of thing and is unlocked once for it, which is exactly why hooks are counted per type. Combat xp is per encounter and resolves per instance; lore is per fact and resolves per type. They differ on purpose, and this pass now matches on both counts. The useful half of a5774ed survives, inverted: a loreXp authored on a placement is now REPORTED as dead data rather than silently counted. Verengrad has eight of them -- 75, 50, 40, 30, 30, 20, 20, 10 -- every one paying 12 instead, with the finding naming each and where to move it. Verengrad's authored XP returns to 1775, which is what the engine will actually pay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Last commit's card said "a lore hook's unlocked flag has no editor control yet, so it needs a fresh copy of the world or a DM directive". Wrong: every Lore section carries an "Unlocked for the player" checkbox, on Items, Flora, Fauna, Beings and Rooms alike -- they all render the same buildDmLoreSectionHTML. Unchecking it is what actually cleared the finding. The claim came from a grep for `setLoreUnlocked` that missed `compendiumSetLoreUnlocked`, and it sent the DM after a whole new world instead of a checkbox already on the card in front of them. The card now names the control and where it lives. A test asserts both labels it names are strings that really appear in the editor, and that the card never claims a control is missing -- the same check already guarding the racial ability card, which I had not applied here. Asserting a control EXISTS is cheap. Asserting one does NOT is a claim about the whole app, and deserved the same evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
An objective already satisfied at the start has three possible sources, and
the world says which. Only two are a fault:
the WORLD a room's `visited`, a lore hook's `loreUnlocked`, a beat's
`unlocked` — play state written back onto world data
the PLAYER an item in the pack the class does NOT grant, so it was picked
up in play
the CLASS an item in world.classes[x].startingInventory — authored
DESIGN. Every character of that class begins that way, and the
item is still acquirable by every other class
Lumping the third in with the other two told the author to go and fix correct,
deliberate data. It is worst in the world EDITOR, which invents a "World
Author" from Object.keys(world.classes)[0]: reported as contamination, the
finding changed with the ordering of a JSON object and pointed at a save that
never existed.
A class grant is now its own observation at INFO -- it still costs something
worth saying, since a run as that class cannot exercise those steps, and the
note admits the editor's class choice is incidental -- while
baseline-not-pristine keeps only what is genuinely a used baseline.
Verengrad: was one warning naming four things, of which two were CantorAdept's
loadout. Now a warning naming the two real world flags, and an info naming the
two the class grants.
Caught while writing it: the class-only branch first used a bare `return`,
which sits inside compileWalkthrough and would have abandoned the pass mid-way,
emitting a half-built plan that still looked plausible. A test now asserts the
plan is complete in exactly that case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv"4 of 99 objectives are already satisfied in this save" sent a DM hunting
through the world editor for things that were never in the world. They come
from two different places and the message treated them as one:
the WORLD carries it a room's `visited`, a lore hook's `loreUnlocked`, a
beat's `unlocked` — play state written back onto
world data, editable where that thing lives
the PLAYER carries it an item objective is satisfied by the starting kit,
which is the player sent ALONGSIDE the world. Nothing
in the world editor can touch it
On Verengrad that is 2 and 2: "Visit Scaffold Landing" and "Uncover the lore
of Drowned Saltbloom" are flags on the world; "Acquire Salt-Verse Shard" and
"Acquire Warding Charm" are in the evaluating character's pack.
The finding now names each group and where it comes from, and there is a card
that says how to clear each -- including, honestly, that a room has a
"Visited" checkbox on its Rooms card while an unlocked lore hook has no editor
control at all, so that one needs a fresh world or a DM directive.
Dropped "in this save" from the wording: the tab evaluates the editor DRAFT
plus whatever player is loaded, which is not a save, and the phrase is what
made the world/player split invisible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvA card and its findings are the same issue counted twice -- the card IS the aggregate. Dismissing "21 unplaced catalog items" left 21 warnings below at full weight, asking the reader to answer a question they had just answered. Findings whose kind is dismissed are now dimmed and tagged "accepted", and the group header says how many, so "warn (30)" does not read as 30 pieces of outstanding work when 21 have been settled. Dimmed, never hidden. The finding is still TRUE -- a dismissal records a judgement about it, not a change to the world -- and a report that quietly drops what it found is a report you cannot trust when it is silent. That is the same generous bound every figure in this pass carries. DONE is deliberately not treated the same way. A card marked done whose findings STILL appear is the useful signal that the edit did not land, which is what the card's own stale notice says; those findings get a "still reported" tag rather than being dimmed away. And blocking is about ORDER, not equivalence: dismissing an unlocker does not sweep the cards it gates. Accepting that the economy is undeclared is not accepting the three warnings that being undeclared prevents you from grading -- they stay open, verified against Verengrad. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Placement is four GM calls back to back, each rewriting part of the world, and the editors work BEHIND the dialog -- so the DM saw a frozen modal with no sign anything was happening, and every button still live. The dialog now lists all five phases BEFORE any of them runs, so the shape of the work is visible rather than just "something is busy": ✓ Room floors placed ◐ Containers asking the GM… · NPCs 1 item · Monsters 1 item · Re-evaluate so the report matches the world A phase with nothing to do is marked skipped, and a roster that throws is marked failed rather than passed over in silence. While it runs, everything that could damage the run is refused: Place (a second press would re-run every roster against an already-enacted plan), Cancel and Discard (both abandon a run that is still writing), the backdrop click, and every row control (a retarget mid-run edits a plan being enacted). Caught while testing this: clearing the busy flag was not enough to unlock the dialog. The buttons had been disabled by a render, and the plan is deleted on success, so nothing re-rendered them -- the run finished and Close stayed dead, leaving the DM inside a modal they could not leave. The end of a run now re-enables Close, turns Place into an inert "Placed", and hides Discard; opening a fresh plan resets all three. Also replaced a bounded-gap regex in the container test that broke purely because lines were added between its two anchors. The distance was never the property under test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
It said "killing it destroys that hook, and there is no second one". The pass
checks the first half -- the unlock's wording asks for the being alive -- and
never checks the second. A second route to the same fact would be prose in
some other hook, and prose is precisely what this pass cannot read. Asserting
it turned an observation into a claim about the whole world on no evidence.
It now reports what it saw, and says plainly that whether the fact is
reachable another way is outside what it can see.
The card also failed to answer the obvious question: how do you ACCEPT a
consequence you intended? Rewording the unlock is what clears it in data,
because the trigger is the wording itself ("without killing", "spare", "wound
it"). Accepting is a judgement no pass can detect, so Dismiss is the mechanism
-- and the card now says so rather than listing "accept it" as though the
evaluator would notice.
Dropped "add a second route to the same fact" from the advice: it is good
design guidance but it does NOT clear the finding, and offering it beside two
things that do would send a DM to author a second hook and find the card
still open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvVerengrad's five racial abilities were never unstructured. Every one is a passive carrying a roll modifier -- "+2 to self saving throws [against Cantos-language comprehension effects]", "+2 to self con [underwater]" -- which is not merely allowed but MANDATED by the Races contract: "EVERY racial ability MUST BE A PASSIVE", with appliesTo/bonus forbidden as "the shape for learned skills and granted boons, not a birthright". The check only looked at appliesTo, so it reported work authored exactly to spec as empty flavour. Worse, it told the DM to fix it in an editor that does not offer appliesTo for a passive at all -- and would next have had them ask the GM to rewrite abilities that were already right. An ability binds three ways, and this check has now been wrong about two of them: appliesTo (a Checked ability's skills/stats/tags), waives (a Passive's moot checks), and effect.modifiers (a signed bonus on a named roll). All three count. Verengrad drops from 6 findings in this family to none, and loses the card. The test now covers a race bound each of the three ways plus one genuinely bare, so a future narrowing fails loudly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Found by a DM opening an existing ability and seeing none of the three controls the card named. The ability editor shows ONE set at a time, chosen by kind: "Checked" offers Skills / Governing stats / Tags, "Passive" offers Waives instead. All five of Verengrad's race abilities are passives, so the card was naming controls that were not on screen -- invisible advice. Two defects behind that. The pass counted an ability as bound only through appliesTo. But waives IS how a passive binds: "an always-true capability that may waive a check entirely". A correctly-authored passive was therefore reported as naming nothing, and no edit through the editor could ever clear the finding, because the editor does not offer appliesTo for a passive at all. And the card gave one instruction for every ability regardless of kind. It now carries each ability's kind and says which control to fill: Driftkin › Salt-Hardened Mind (Passive — fill "Waives"); Driftkin › Shard-Sense (Checked — fill "Skills it reinforces", ...) Checked while here: race abilities normalize through the CANONICAL ability normalizer, not the legacy race one, so appliesTo / kind / waives all survive load. Nothing a DM sets in that dialog is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The card said "add an appliesTo to the abilities on …". `appliesTo` is the stored field and the word appears NOWHERE in the editor: the ability editor presents the same thing as three controls labelled "Skills it reinforces", "Governing stats" and "Tags". A DM went looking for a field that is not on screen -- the card's fault, not theirs. A `dm` card is a promise that the work can be done by hand, so it has to name what is on the screen. It also named only the RACES, leaving the reader to open every ability across three of them to find which were bare. It now names the abilities: Driftkin (Salt-Hardened Mind, Shard-Sense); Tidekin (Choir-Attuned Ear, Drowned Lungs); Verengradi (Scaffold-Sure) and tells them where to click and which of the three controls to fill. The test asserts the labels it names are strings that really exist in the ability editor markup -- a card naming a control that is not there would be the same bug in a new costume. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Diagnosed from Verengrad: the catalogue held "Saltmonger's Locked Strongbox"
while the room held an inline container named "Saltmonger's Strongbox" -- the
same object under two names. Placement is matched by NAME (an inline item
carries nothing linking it to a catalogue entry), so the two never meet: the
catalogue record reported as unplaced forever, and adding more copies could
never satisfy it because every copy took the placed name too.
Reporting that as a plain unplaced item sends the DM hunting for something
already in the world, twice over. It is now its own finding, naming what was
found instead and where, with a DM card that says to rename one or delete the
redundant one. A DM edit rather than GM work: the pass has identified both
halves, and handing it to a model risks a third name.
Matching is on the WORDS of each name, order and punctuation ignored, since
the usual divergence is a dropped or added adjective. Bounded so it cannot
explain away real findings: every word of the shorter name must appear in the
longer AND at least two words must be shared, so "Iron Key" and "Brass Key"
stay two different objects. Tests cover both directions.
The container guide now also tells the GM to pick ONE home for a container --
inline OR the top-level catalogue, referenced by { ref } -- and never both,
since doing both is what created the orphan.
Verified against the real world: the strongbox reports as a mismatch naming
its placed twin, while Cantor's Hook is still plainly unplaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe chip carried .comp-name-link, whose :hover sets text-decoration: underline. Text decoration PROPAGATES to in-flow descendants and is drawn by the ancestor, so a descendant cannot take it back off -- `text-decoration: none` on the button would have done nothing. It only started showing when the chip became a flex row. A <button> is normally an atomic inline (inline-block), which decoration does NOT propagate into; as a flex item it is blockified to block-level and in-flow, so the underline began running through it. So the underline moves to the thing that is actually the link: the name now sits in its own span, the chip drops .comp-name-link, and `.npc-inv-chip:hover .npc-inv-name` underlines just that. The chip keeps the brightening on hover, so the whole thing still reads as clickable and still opens the item popup -- only the decoration is scoped. Verified by hovering the chip on Verengrad: "Bladeward's Plate" underlines, the ✕ does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Missing tab already lists spells without art; the Review gallery — the picture of what a world LOOKS like — did not show them at all. A spell carries a portrait shown in the loadout, on the Spellbook tab and in its popup, so it belongs there beside rooms, beings and items. Drawn from the world's OWN grimoire rather than allSpells(), which falls back to the built-in catalogue: the same set the Missing tab reads, so the two tabs can never disagree about what this world contains. Painted or not — an unpainted spell shows the placeholder cell, because the gallery is what the world looks like including its gaps. Clicking a cell needed a route of its own. compendiumDetailBodyFor resolved a spell only under category 'magic' AND only while the Compendium happened to sit on its Spells subtab — state the gallery does not have and should not depend on. It now answers to 'spells' by name. And a newly painted spell reaches the gallery: generateImageForSpell and uploadSpellImage call refreshArtReviewIfActive alongside refreshSpellViews, the way the item and entity image paths already do. Two assertions in test_spell_art_propagates pinned a function's exact punctuation, so reformatting one line read as a regression; rescoped to the function bodies, as the third assertion in that file already was. The gallery's empty-state case now clears the grimoire too — with spells counted, "no objects at all" means no spells either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The gap I thought I had reserved was being taken away. `.npc-inv-chip` set `padding-right: 20px` for the absolutely-positioned ✕, but `.npc-chip` is defined LATER in the file with a `padding: 2px 8px` SHORTHAND -- same specificity, so source order won and the shorthand reset padding-right to 8px. The ✕ then sat on top of the name. Fixed structurally rather than by raising the padding: the chip is now an inline-flex row with `gap: 9px`, so the space between the name and the button is layout rather than reserved padding something else can reclaim, and it holds for a name of any length. The selector is two classes so it cannot lose the tie again. Measured on two chips of different name lengths: 9px between the text and the button on both, no overlap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two faults, one of which explains why this looked like a missing feature. The remove control already existed on the inventory chip, but at opacity 0 until the chip was hovered. A control nobody can see is the same as one that does not exist. It now sits at 0.45 and comes forward on hover. And it removed immediately. Adding an item is cheap to undo; removing one is not -- the thing leaves the world, and unless it is placed elsewhere the only notice the DM ever gets is an unplaced-item finding on the NEXT evaluation, long after the click. The chip now opens a confirmation, and only that dialog performs the removal. The dialog names both the item and its carrier, because two beings can hold the same thing and a chip does not say which card you are on. It also says how consequential this is, which genuinely differs: an item still in the catalogue can be placed again -- but is left in no room and no one's hands, which is exactly the finding the DM was working to clear -- while an item that is NOT catalogued leaves the world entirely. Presenting those two identically would be the misleading part. Cancelling clears the pending target, so a stale one cannot let the next confirm delete the wrong item. Verified live on Verengrad: Cancel left the item in place, Remove took it, and the being was returned to carrying nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported: turn the music off on the login screen, reload, and it is playing again with the icon lit. The choice lived in a DOM class and nowhere else. toggleLoginSound flipped `sound-on` on the button, and startLoginCues and playLoginCues read that class back as the source of truth — but the markup ships the button as `class="setup-sound-btn sound-on"`, so every page load rebuilt "on" from source. Nothing was ever written anywhere. Not a persistence bug so much as an absence of persistence. It is now a setting (loginMusic, defaulting on, so nobody who never touched it hears a change), the class is a reflection of it, and playLoginCues puts a remembered "off" back on the button. That last part matters because a cold boot, a logout and an inactivity timeout all rebuild that markup lit, and all three arrive through playLoginCues — otherwise two of them would show a lit icon over silence. Four existing tests expressed "the player muted it" by building a button without the class, which is the mechanism that was wrong; they now express it as the setting and keep every assertion. Three assertions in them pinned a character distance from a function's name to a call inside it, so adding a line broke tests that are really about what the function CALLS — those are rescoped to the function body, as test_spell_art_propagates was for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Writes up the exploration of signing every third-party Server Vault install into one Auth0 tenant we own, without distributing a client secret. The finding worth recording is that the client secret is the smaller half of the problem. A public client has no secret at all — Auth0 never issues one, and PKCE does its job per request. What cannot be solved that way is the CALLBACK: a player signs in from another machine and returns to their vault's origin, which is a LAN address or a tunnel we cannot register. So the flow has to be one that needs no redirect back to the install, which is the Device Authorization Flow. Records the four answers that do not work and why each fails — including the two I recommended before the constraints were fully known, so the reasoning is inspectable rather than just the conclusion. Also captures the hazards that come with a public client and rotating refresh tokens: the concurrent-refresh race that can revoke a whole grant family, the write ordering, invalid_grant not being retryable, and refresh tokens sitting at rest on a third party's disk on installs with no master key. Call-site counts measured, not estimated: 13 lines carrying 18 references to three operations, all of which collapse behind identity(req) / beginLogin. The gate decisions and their tests do not move. Proposed only. Nothing is built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported: the local splat's resolution is very low. It was, and the cause was this repo, not a missing API parameter. "100k" and "500k" are splat COUNTS. The route took 100k — a hundred thousand Gaussians, the coarsest thing World Labs publishes — because it was the one size certain to fit under the media store's 24 MB per-file ceiling. Every world was therefore viewed at their lowest setting. It now tries full_res, then 500k, then 100k, keeping the first the store actually accepts, and records which one it kept. The ceiling decides, rather than a guess made before any size is known. And the ceiling now knows a splat from a picture. The general limit exists to catch "something has gone wrong" — generated art runs a few hundred KB — while for a whole navigable world tens of megabytes is what going right looks like. Splats get 192 MB; every other type keeps 24 MB, so an 80 MB "image" is still refused. The one thing that IS an API setting: the model. Marble 0.1-mini is their small, fast model and its worlds are visibly coarser, so the default is now Marble 0.1-plus. A caller may still name another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two things a room's 3D world could not do yet: be walked around in, and be looked at properly. VIEW WORLD opens Modules/Viewer/splat-viewer.html in its own window. Its own DOCUMENT, because the renderer is ES-module code and the app is deliberately file://-openable — that is why its other two viewers are hand-written classic scripts. A splat renderer is not a thing to hand-write, so it lives outside the app entirely and the app's whole involvement is a window.open. three.js and Spark (World Labs' own renderer, which reads their SPZ natively) are vendored under Modules/Viewer/vendor rather than fetched from a CDN, for the same reason howler and crawler.js are vendored: a deployment that cannot reach a CDN is a deployment where this still has to work. Both MIT, LICENSE beside each. Proved to render, not assumed: there is no real world to test against here, so the SPZ v2 layout was read out of Spark's own decoder and a small scene written by hand — a ring of coloured blobs over a floor grid — which the vault stored and the viewer drew. The first build of the page drew a perfectly black frame and reported no error, because a SplatMesh is a splat SOURCE and SparkRenderer is what sorts and draws them. That is why the page now names every way it can fail in a sentence: a silent black canvas is the failure mode here. THE PANORAMA is stored when the provider publishes one, and becomes what the thumbnail enlarges to — the thumbnail is a postage stamp of one view, the panorama is the whole place. Their docs host answers 403 from here and their published example reads only splats, thumbnail and caption, so the panorama is found by what a key is CALLED rather than by betting on one spelling, and a world without one falls back to enlarging its thumbnail rather than claiming a panorama it does not have. Each generation also now logs and returns the asset keys the world really carried, so what this provider publishes becomes a matter of record instead of inference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported from Editor › Rooms: pressing the build-world button gave "source image must be a data: URI or an http(s) URL". The same bug test_media_source_resolve.js was written for, wearing a different path. resolveVaultMediaSource knew only about /vault/media, so a room whose banner references an image THIS SERVER hands the browser — "Images/room.png" — could not be sent to a provider at all, though the bytes are on its own disk. Reproduced against the running vault: an app-served path and a vault media path with no file behind it both produce that exact message. It now resolves any same-origin path through core.resolveStaticPath — the same resolver the static route uses, so the traversal guard, the dotfile rule and the top-level denylist all apply unchanged. This widens what may be READ for a provider call, never where it may be read from. Images only. The fix reaches the 3D-model and video routes too, which share the resolver. And the message now names what arrived. Both causes previously produced one sentence that restated the rule and identified neither, which is why diagnosing this meant running the route by hand against four candidate shapes. The resolver test grew the new branch, including its refusals — asserted after proving the branch is live, because every one of them passes trivially against a resolver that has no such branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
There was no preview: planning and placing were one click. Placement rewrites rooms and beings across two editors at once, so a plan the DM never saw is a change they would have to unpick by hand to disagree with -- and where a thing belongs is exactly the judgement an author will want to overrule. Planning now stops at a dialog. One row per item: the item, the kind of home, the target, the chest name where one applies, the GM's reason, and a drop control. Nothing is written until Place. The reason is what makes a row reviewable at all. "Bladeward's Plate → Reciting Crypt" cannot be judged; "armour a verger would lock away" can, so the plan schema now asks for one. Rows are editable, not just droppable. Changing the KIND of home clears the target, because a room name left sitting in a being slot would be placed against the wrong roster; switching away from a container drops the chest name with it. A row missing a target -- or a container row missing its chest -- disables Place and says why rather than going quietly dead, with the offending rows marked. The dialog widens through a TWO-class selector. `.modal-box` sets width: min(460px, 92vw) and is defined later in the file, so a single-class override tied on specificity and lost on source order: the first build rendered at 460px with the reason column scrolled off the right edge. Verified live with the planning call stubbed -- nothing reached the GM and nothing was written: six rows rendered, retargeting a row to a container cleared its target and disabled Place until the chest was named, and dropping a row updated both the count and the summary line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A world routinely has more items than rooms. Everything that cannot sit on a
person lands on the flagstones, and a room reading as six loose objects is a
worse answer than a chest holding a considered cache. Treasure in particular
wants a container, and a reason to be guarded.
The engine has modelled all of this since Phase 1: normalizeContainer coerces
an authored spec, contents resolve through makeItem (so { ref } works, and
containers nest), and catalogItemShape preserves `container` on a catalog
template. The only thing missing was that the Rooms GM contract never
mentioned containers -- so asking for "in a chest" produced nothing, however
the request was phrased.
ROOM_CONTAINER_AUTHORING_GUIDE gives that contract the shape, matched field
for field against normalizeContainer so nothing authored is silently dropped,
with the lock methods spelled exactly as CONTAINER_LOCK_METHODS has them --
anything else coerces to "none", which is an authored lock that vanishes. It
states the concealment rule, since listing contents in BOTH the container and
the room's items puts one item in two places. And it forbids locking a
quest-critical item behind something the world cannot open: that is a
cost-lock in another currency, and the evaluator would rightly call it
unreachable.
The placement planner now has three homes rather than two, is told the ratio
problem it is solving, and is asked for placements that make LOGICAL sense --
a merchant stocks what they would trade, a creature holds what it took.
Several items share one container by name; dispatch groups by (container,
room) so a strongbox is authored once holding all of its contents rather than
once per item.
Also removes a NUL byte my earlier edit put in text_adventure.html, which had
been making grep treat the whole file as binary.
Verified with the planning call and all three rosters stubbed -- nothing
reached the GM and nothing was written: a six-item plan fanned out to 1 floor
placement, 3 items across 2 containers, 1 NPC and 1 monster.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvA ◆ button on the Rooms card's banner actions sends THAT slot's image to the vault, which runs World Labs' three-step Marble job — start, poll the operation, fetch the world — and hands back a result the card then shows. The image goes INLINE as base64. Their API also offers a media-asset upload (prepare_upload → PUT → reference an id), but an image prompt takes `data_base64` directly and their own example uses it, so there is no upload step to get wrong and no asset id to keep. The room's own banner prompt rides along with the picture; their prompt object takes both. VAULT ONLY, deliberately. Every other AI slot also has a Direct path with the key in the browser; this one does not. World Labs' API is cross-origin from the page and whether their CORS policy permits a browser call cannot be established from here — a Direct path written on that guess would be a button that fails for everyone with no way to say why. What comes back is a Gaussian splat, which nothing here can draw: Modules/Viewer/glb-viewer.js is a glTF MESH viewer by construction. So the card shows the two things that ARE usable — the thumbnail and a link into Marble's own viewer — and the splat is KEPT rather than shown, stored same-origin at the smallest resolution offered, because the provider's URL is signed and expiring and the store's per-file ceiling is 24 MB. A viewer added later reads a /vault/media path that is already there. Along the way: SPZ has no registered media type, so the vault names one (model/spz) and media-store.js maps it — the one place a type and an extension are allowed to be decided. storeModelFile becomes storeRemoteFile, since it now stores a thumbnail and a splat as well as a mesh. WLT-Api-Key joins the log redactor's secret-header set. Not exercised against the live service — there is no key here and api.worldlabs.ai is unreachable — but every request and every refusal is driven through an injected fetch stub, and the whole path (route → store → room card → save round trip) was driven in a browser against a local stand-in for their API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
An entity is anything the engine models; this tab holds only NPCs and
monsters. Beings is the word that fits.
Renamed where it is READ: the editor tab, an encounter's roster of living
things, the World Builder field (whose own hint already called them "the
beings this world MUST contain"), and the evaluator's roster label.
Not renamed where it is a CONTRACT. `etab-entities`, `editor-sub-entities`,
`switchEditorTab('entities')`, `we-entities` and `art-entities-view` stay:
an identifier is not a label, and churning every call site for a word nobody
reads buys nothing. The world's own `entities` array stays for a stronger
reason -- changing a data key is a migration of every saved world, not a
relabelling.
The GM's vocabulary is untouched too. Its room dossier says "Entities
present" and its world schema writes an "entities" array; that wording is
part of a contract a model was tuned against, and rewording prompts for
tidiness risks behaviour for no reader's benefit.
A test pins both halves, so the labels cannot drift back and the identifiers
cannot be "tidied" to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvYes to the flow as described -- plan first, then dispatch -- but the dispatch half had a bug worth the check. Beings are TWO editors, and the roster was chosen with evalBeingRoster(card. subjects). For this card the subjects are ITEMS, so the entity lookup never matched anything and the function always returned 'npc'. Every item the GM allocated to a monster was being sent to the NPCs roster, whose contract manages NPCs. And even with the lookup fixed, one call cannot serve a plan that names both a merchant and a warden. The roster is now chosen from the BEING each placement names, and the being-bound half is grouped by that: NPCs and Monsters are run separately, each with its own slice. Rooms unchanged. Verified against Verengrad with the planning call and both roster editors stubbed -- nothing reached the GM and nothing was written: a three-item plan spanning a room, an NPC and a monster fanned out to Rooms, NPCs and Monsters with one placement each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two corrections. CONTAINERS EXIST, and the evaluator could not see into them. Contents live only in `item.container.contents` -- deliberately, since that is what keeps them concealed from the room's item list until the chest is opened -- so anything reading `room.items` misses them, and an item locked in a coffer was reported as existing nowhere in the world. The walk now recurses through container contents, for items on a floor and in a being's inventory alike, and nests as the design allows. The pass already read `container.lock` for its lock-picking analysis, so it knew about containers and still could not count what was inside one. SEQUENCING WAS THE WRONG SHAPE. Asked to place items in rooms first, the GM places ALL of them in rooms and the second pass finds nothing left -- floors strewn with two dozen objects and no NPC carrying anything, the opposite of a thought-out mix. Re-evaluating between phases does not help; by then the damage is done. So the split is decided ONCE, by a GM that sees every item, every room and every being at the same time, and is told plainly not to put everything in one place. Only then is each half handed to the roster that can enact it, naming the exact allocation so neither roster chooses again. "Execute all" becomes "Plan & place". Still unreachable through Execute, and worth knowing: the Rooms GM contract has no container vocabulary at all, so the GM cannot author a chest with contents through that roster even though the engine models one fully. And hidden-by-search is gated to plants, magic items and contraptions, so an ordinary item cannot be concealed in a room. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The unplaced-item card pointed Execute at the Items roster, which cannot do the job: its contract says in as many words that it must not change "where items are placed in the world" and returns a decline instead. That is the refusal the DM hit. My routing was wrong, not the GM. Placement is owned by whatever HOLDS the item, and those are two different contracts. Verified what each actually permits rather than assuming: rooms "addItems" — drop onto the floor yes entities "inventory"/"addInventory", plus "location" yes quests beats with "trigger" yes items explicitly forbids placement, declines NO So promptIf becomes an ARRAY of branches, each carrying its own roster: leave them lying in a room, or give them to someone who carries or sells them. A merchant is an NPC holding the "merchant" class, so selling is the same branch as carrying. Because one instruction spanning both cannot be one GM call, "Execute all" runs each in turn and RE-EVALUATES between them, handing phase two only what is still unplaced. It re-asks the pass rather than reasoning about what phase one did — the pass is the only thing that decides what counts as placed, so asking it again is both correct and self-updating, and nothing gets placed twice. A test now asserts no card sends placement work to the Items roster, so the mis-route cannot come back quietly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Answering the "21 unplaced catalog items" action card needed somewhere to put them other than a room floor. The data field already existed: entities have carried `inventory`, and the engine already drops what a defeated creature held into its room. The card already listed it too -- but only when non-empty, and with no way to add. An inventory section that appears once it has contents cannot be the place you go to give it some, so it now always renders, collapsed when empty, with a picker and a per-chip remove. Only Items and Magic are offered. Plants and animals catalogue under Flora/Fauna and are found growing or living rather than carried, so listing them would suggest a placement the world does not model. Items are authored on the INSTANCE, not the catalog type -- two copies of a creature need not carry the same loot, the same instance-first rule the XP pass settled on. The half that makes it count: the evaluator read `room.items` and the player's starting kit and nothing else, so giving an item to an NPC changed the world and changed no finding -- the card would have stayed open with no way to close it, which is worse than not offering the feature. Carried items now count as placed, and reach findable wealth. Bounded deliberately: only beings that are THEMSELVES placed count. An item held by a creature sitting unplaced in the catalogue is exactly as unreachable as the item was, and calling it placed would answer one finding by hiding another. Also fixes test_art_pulse_persist, which broke on code nobody had touched. core.autocrlf is true here with no .gitattributes, so the working copy is CRLF; building `flat` with replace(/\n/g,' ') leaves every \r behind and adds a character per line, which turned a 394-char bounded gap into 402. It now strips \r?\n. 150 tests use bounded gaps over that same construction, so this is latent across the suite rather than specific to that one file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
They shared a panel with the provider DESCRIPTORS: the keys — the thing an admin actually comes here to set — sat above a wall of descriptor JSON and an Add-a-provider form. Two different jobs in one scroll. Keys leads the inner tab bar and is what Settings opens on, so landing on the page still shows the key cards first, exactly as it did when both lived in one panel. The descriptors, the Add-a-provider form and the AI-call log stay where they were. The panel itself goes after Access in the document, keeping #access ahead of #cards — the key-card binding loop is scoped to #cards precisely because the Access panel renders .card too, and the test pinning that scoping reads the two in document order. Two things in the page test that this exposed. Its per-site panel regexes each pinned class="tab-panel active", so the moment the Providers subpanel stopped being the default and lost that class they matched an empty string — and every "nothing is left behind there" check passed while proving nothing. They now go through one extractor that is class-independent and is itself asserted to have found something. And the tab/panel wiring invariant now covers the inner tabs too, including that exactly one starts selected and its panel starts active. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Same reasoning as Media: what the vault has spent is something you go and look at, not something you configure. It sat between the provider descriptors and the AI-call log, which made Settings a scroll rather than a page. The AI-call log stays where it was. It reads the same subject a different way, but it is a debugging view of what was SENT rather than a meter of what was spent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The store sat at the bottom of Settings › Providers, below the provider descriptors. That is where you go to configure things; the stored files are content, like Worlds, and belong in a section of their own rather than as a footnote inside another one. The toggle and the totals move with the file list — a setting left behind in Settings would govern something no longer there. Each entry now leads with a small preview. Only pictures get a real one: the store also holds sound, video and 3D meshes, and an <img> pointed at a .wav is a broken-image icon, so those show a glyph naming what they are. The box is a fixed size whatever the file is, so the rows keep one rhythm and the column of provenance still scans. The <img> points at the file's own /vault/media URL — same-origin, immutable and cached hard by the server — and loads lazily, because a page of entries is a page of images. Also: a section title carries a rule above it to separate it from the section before, which left the first one in a panel drawing a second line and 64px of dead space under the tab bar. The page test now runs mediaThumb rather than describing it, and checks the invariant the tab switcher rests on: every tab names a panel that exists, and every panel is reachable from a tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A spell carries a portrait of its own — world.spells[id].image, painted from world.spells[id].prompt — and that picture is drawn in the memorized loadout, on the Spellbook tab and in the spell popup. A spell without one is a hole in the world's art exactly as an item without a picture is, and it was the one such hole the Art tab could not see: it said "Every character, monster, room, item, and icon has artwork" while every spell in the grimoire was blank. Spells are tracked under a 'spells' pseudo-category, like icons. The grimoire is not a Compendium category, so there is no discovered snapshot behind a spell and nothing to backfill; what there IS, and what the generic item path has no reason to do, is refreshSpellViews, because the same picture is already on screen in three other places the moment it changes. The batch generator writes a missing prompt through the spell's own prompt-writer rather than the generic compendium one, which knows nothing of a spell's school, level or effect and would ask for "an object study". Scoped to the world's OWN grimoire via a new worldGrimoire(), not allSpells(): that falls back to the built-in SPELL_CATALOG so pre-game code can resolve a spell by id, and painting a portrait down that fallback would edit a shared constant that serializeWorld never saves. Two consequences carried along the way: refreshSpellViews now returns early with no player, because the Art tab paints spell portraits from the World Editor where there is none; and the Spells editor tab joins EDITOR_CARD_VIEWS, having had the same untracked-collapse-state bug as the five fixed before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two independent causes, both measured in a browser. Five tabs were never listened to. Card collapse state lives in per-tab Sets kept current by a capturing 'toggle' listener, and those listeners were written out by hand, one per view. Flora, Magic Items, Spellbooks, Races and Dungeons render collapsible cards and had none — so opening a card there never reached the Set, and the next redraw rebuilt the tab from the last state the Set knew about. For a DM who works by collapsing all and opening one, that is every card slamming shut on every portrait upload or GM edit. The hand-written list is now EDITOR_CARD_VIEWS, one registry entry per view. Nothing inside a card was remembered, on any tab. Description, Prompt and Lore are each their own <details>, tracked nowhere, so a redraw snapped them back to their authored defaults — and the scroll position went with them, because replacing a view's innerHTML momentarily shortens the scroller and the browser clamps scrollTop. Card views now redraw through setCardViewHTML, which notes what is open and where we are, swaps the HTML, and puts it back. Sections are keyed by the card's own id attribute, never by position, so a card that moves in a re-sorted list carries them with it. The card's own open state is deliberately not restored — that belongs to the collapse sets, and Collapse all / Expand all redraw precisely in order to change it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Matches the item cards: type is the one chip every being has, and `deceased` is the only optional one, so leading with type moved it a chip's width along on every dead being's row. Last in the array is rightmost — .npc-head-tags is a flex row rendering in order, pushed right by the header. The chip-order assertions move out of test_armor_ac.js into a test of their own covering both surfaces, since it is one layout rule rather than an AC one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The stored value was already right: skillIsOffClass compares each gate entry against player.class, and player.class is the KEY into world.classes, so a gate must hold ids. Only the reading of it was wrong -- Verengrad keys a class "ScaffoldScout" and names it "Scaffold-Scout", leaving the DM to translate in their head on the one field where a mismatch is invisible. Four DM-facing sites now resolve through classGateText/classGateHtml: the skill popup's Class gate row, both skill-card badges, and the editor's one-line summary. The GM dossiers keep raw ids, because the GM compares the same way the engine does. Resolution must not paper over the bug beside it. A gate naming a class this world lacks matches NOBODY, and prettifying a bogus id is exactly what hides that -- "Scaffold-Scout" reads like a real class whether or not one exists. So an unresolved entry is marked, struck through with the reason on hover, rather than tidied into looking correct. Quietly, not in error colours: the common case is a stock gate (spellcasting -> Mage) that is merely inapplicable here, and the evaluator is where a real misspelling is called out. classDisplayName now prefers a DISTINCT authored name over splitting the id, so the character sheet and the gate agree instead of showing "Scaffold Scout" beside "Scaffold-Scout". The `!== id` guard matters and was found the hard way: an over-broad first version returned the name verbatim always, which undid the camelCase split for every world that names a class exactly as it keys it. The pre-existing test caught it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
I had made it set-once, on the reasoning that the declaration is the world's stated intent rather than a setting. That was wrong in a way the evaluator makes obvious: the declaration is a claim about what the world IS, and the world gets edited. Adding a vendor or a hoard can shift which archetype actually fits, and frozen at forge time the pass would grade against an intent the world had outgrown -- while raising a finding the author had no way to answer. So Economy is a live picker on Editor > World > Profile. The read-only twin is gone; two controls for one field is two things to keep in sync. The panel intro now lists Economy alongside World Rules and Prologue, since claiming "the rest are read-only" is wrong on its face once this one is not. Clearing back to Undeclared is allowed and stores null: an author may decide the world no longer means to be any one thing, and the pass then describes without grading. A blend cannot be expressed by a single-choice picker, so the stored one is offered as its own option and is what the picker shows. Rendering it as "Undeclared" would let the next change silently flatten a declaration nobody meant to touch; re-selecting it is a no-op. The economy-undeclared card no longer tells the DM to set it "while it is still undeclared", and says it can be revised as the world changes. Verified the round trip on Verengrad -- scavenger, then depression, then back to undeclared, then a blend rendering as "80% Depression, 20% Scavenger" -- and left the world declaring nothing, as it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A finding whose kind has an action card now carries a right-aligned "Action" button that scrolls to that card and flashes it. Cards are keyed by the finding kind they aggregate, so the link needs nothing threaded through -- the finding already knows its own remedy. A finding with no card gets no button rather than a dead one. On Verengrad that is 31 of 37 findings linked; the two unlinked warnings are skill-unreachable and baseline-not-pristine, both non-defects with no remedy by design. The flash is not decoration. Findings sit below the plan, so Action scrolls UP into a list of visually similar cards; landing there with no cue leaves the reader hunting for which one they were sent to. It restarts on a repeat jump (re-adding a live class does not replay a CSS animation) and collapses under prefers-reduced-motion. Scrolling is instant because `behavior: 'smooth'` does not scroll this container AT ALL -- measured in isolation, with and without the class change: the call is accepted, the panel never moves, and the jump looks broken. Instant works, and the flash does the orienting job smooth would have done. Resolved cards are jumped to as well. They sit in the closed section rather than disappearing, and "this is already ticked off" is the answer the reader came for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Dismiss moves left and takes the ghost (dark) variant; Done moves right and keeps the solid one. The two are not equals, and putting the discarding one under the cursor's default path invites the wrong click. "Copy prompt" becomes "Execute", also dark, and now does the thing: it puts the prompt into the matching roster's own instruction input, switches to that tab, and runs that roster's GM edit. Deliberately the SAME path as typing the instruction there by hand -- that path already knows how to call the GM, parse the reply, apply it and re-render, and a second way to change a world is a second way for the two to disagree. Switching tabs first means the DM watches the edit happen where it happens, rather than having the world change under them from a screen that shows none of it. The pass now names a `target` roster per card, as a domain rather than a UI tab: it knows what kind of thing needs editing, the client owns where that lives. Cards with no target show no Execute button, since a button that cannot act is worse than none. Beings resolve to the NPC or Monster roster by reading the catalog, because the two are separate editors. An Execute invalidates the whole report, not just its card, so that is now state rather than DOM text -- returning to the Evaluate tab re-renders, and that is exactly when the DM needs telling. A banner above the plan says the world has moved on and every figure below predates it. Also fixes a card rendering "add an appliesTo to the abilities on ." -- race-abilities-unstructured interpolated subjects its finding never attached. Test now checks every card for empty-subject holes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Evaluate tab now shows the action cards above the findings, each with Done / Dismiss / Reopen and a running "N of M resolved". Checklist state is keyed by world and by CARD ID, and lives in localStorage rather than on the world. Keyed positionally a tick would land on the wrong card as soon as one ahead of it was resolved; stored on the world it would travel with an export, carrying one author's half-finished working notes to whoever opened it next. A tick is not a fix. Each mark is stamped, and a card marked done that a LATER evaluation still reports says so on its face -- otherwise the checklist quietly certifies an edit that never landed. The run is stamped client-side because the plan deliberately carries no timestamp, so it stays diffable. Cards show their actor as a badge with the reasoning behind it in the tooltip, and a conditional prompt states its branch above the text rather than reading as an instruction to run. Economy also gets a home on Editor > World > Profile. Read-only once declared, like the rest of the framing -- but SET-ONCE rather than simply read-only, because a world forged before the picker existed (Verengrad, and every world like it) would otherwise be permanently ungradeable, and "declare an archetype" is the highest-leverage card the pass emits. A declared blend renders as a blend instead of falling through to the picker and being overwritten. Verified against Verengrad: 9 cards, Done persists and counts, the stale notice fires on a later run, and the three blocked economic cards carry their blockedBy note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Findings describe; they never say what to do. This adds plan.actions[] —
remedy cards aggregated from the findings, ordered by leverage.
Cards group by kind rather than mirroring findings 1:1. Verengrad's 37
findings become 9 cards, because 21 of them are near-identical unplaced-item
lines and a list nobody reads is worse than no list.
Each card names an ACTOR, which is the decision that matters:
dm a mechanical edit the pass already solved. The two misspelled
class gates carry the correct spelling this file computed, so
routing them through a model can only introduce error. These
carry no prompt at all.
gm genuine invention. The prompt is text for the matching roster's
"Ask the GM" box, and names what the answer must contain, since
a prompt whose output cannot be applied is prose to retype.
decision the author's intent is the missing input. A decision card may
carry a prompt only behind `promptIf`, gated on the branch it
assumes -- offered unconditionally it enacts one answer before
the choice has been made.
A kind absent from the remedy table emits nothing, deliberately:
capability-specialist is texture, and baseline-not-pristine describes the
save the pass was handed rather than the world.
Ordering is by leverage, not severity. A card is at least as urgent as the
worst thing it unlocks, so declaring the economy -- only `info` alone, but
what turns three economic warnings from complaints into measurable gaps --
sorts ahead of them instead of below. Blocked cards sink to the bottom.
Card ids derive from the kind, never from position, so the Done/Dismissed
marks coming next survive a re-evaluation that finds a different number.
Findings also gain an optional structured `subject` (14 call sites so far),
without which a card can be read but never applied: no link to the skill it
wants renamed, and nothing for the editor to open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvA DM writes a creature's lore on the card in front of them, and that card is the placed creature rather than the catalog type. The pass read the type alone, so Verengrad's eight beings reported as unpriced while all nine placements carried a figure: 96 counted against 275 authored, and a fully priced world was told to go and price it. This is the same mistake entity COMBAT xp was already fixed for, on the same world. It survived because the two are computed in different places, so the fix there never reached here. Counting differs from combat xp deliberately, and the new test pins both halves against one fixture so neither can be "made consistent" later. Two Echo-Choristers are two encounters and two payouts, but the same fact learned twice is still one fact — placements resolve the value, types decide how many hooks there are. Items and rooms are authored catalog-side and pass no resolver, so their answer is unchanged; a test covers that too, along with an empty field on a placement falling through to the type rather than reading as a zero. Verengrad, measured: lore 780 -> 959, authored 1775 -> 1954, unpriced hooks 10 -> 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The tab was written with bespoke spacing and ended up hugging the window edge with an unstyled scrollbar. Every other card tab in the editor shares two rules — a floating *-toolbar pinned to a 24px gutter, and a scrolling view padded to clear it — so Evaluate now joins both rather than carrying its own numbers. The floating toolbar needs a positioned ancestor. Without it the absolute `top: 10px` resolved against #app and the button landed over the game's title bar, outside the editor entirely, which is what it was doing. Its toolbar is a button rather than a filter row, so it is 10px taller than the shared 46px was cut for; the padding-top override has to sit after the shared rule, since a single-id selector ties on specificity and source order decides. Verified against the Verengrad draft: toolbar and content both on the 24px gutter, 10px of air below the toolbar, view scrolling with the thin scrollbar the other tabs use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Editor > Items: the type chip was first in the header row, so its position on screen moved with whichever optional chips an item happened to carry -- value, condition and magic are each conditional, type is the one chip every item has. Scanning a column of cards for "which of these are weapons" meant re-finding it on every row. It is now last in the array, which is rightmost: .npc-head-tags is a flex row rendering in order, pushed right by the header. Nothing sits to its right. The optional chips keep their existing order ahead of it, so only the anchor moved. itemCardParts is shared by the standard item card and the spellbook card, so both follow. Verified in the browser -- the header reads "3S GOOD WEAPON" with the type chip against the right edge -- and the test was checked by restoring the old order, which fails three of its assertions. 476/476 client tests pass.
Editor › Evaluate compiles the draft in front of you and reports what the world can and cannot support: findings worst-first, and the headline numbers a DM is actually pricing against during an XP or economy pass. The draft, deliberately, not the published library world. They differ mid-pass, and "why does it still say 29 unpriced" after an edit is exactly the confusion worth not causing — so the source line names which world was read. The world goes to the vault with its pictures removed. That is not a size workaround: a measured Verengrad export is 23 MB of which 99.4% is base64 art, the evaluator never looks at an image, and the same world strips to 148 KB. The 12 MB body limit is the backstop, not the reason. What matters about the strip is not that it is small but that it changes NOTHING about the answer, since a strip that quietly dropped a field the pass reads would produce a confident wrong evaluation with nothing downstream able to tell. The test compiles a world twice — whole and stripped — and requires byte-identical plans, with a world carrying every kind of media the strip knows about on every kind of object. Verified against the real Verengrad too: identical. The player is sent alongside when there is one, because the growth ratio needs a starting purse and a bare world can only report two of the economy's three dials. Two honesty carry-overs from the pass itself. A world with no findings is not called clean — silence under a generous bound is not a clean bill of health. And an economy that resembles no archetype shows the reason rather than a made-up blend. Direct mode says plainly that the evaluation runs on the vault, rather than offering a button that cannot work. Verified in the browser against the live draft: 50 objectives, 31 findings, 708 XP, 1574c, "88% Patrician, 12% Frontier", compiled in 14 ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The reported mismatch -- a 3D model far softer than the icon it came from, with Detail already at 40k -- was never going to respond to that setting. face_limit caps GEOMETRY. The texture axis is a separate parameter the app never sent at all, so every model ever generated here used Tripo's default. Tripo's field is `texture_quality`, an enum of "standard" | "detailed". There is no numeric field and no texture-resolution parameter, so the "2K" offered on their website is this Detailed path rather than a resolution setting -- worth saying plainly, because the two read as different things. It gets its own Settings row beside the triangle cap rather than folding into it. One control for both axes is exactly what made 40k look like it should have covered this. Defaults to Detailed, since Standard is what produced the complaint. The coupling is the part that could break things. "detailed" is documented as valid only from model_version v3.0-20250812 onward, so the default model moves from v2.5 to v3.0 -- otherwise the app ships a Texture setting that is disabled until you go and change a different row, which is a worse answer to "my texture is soft" than the question. Where a version genuinely cannot honour it the field is OMITTED rather than sent hopefully: a texture setting must not become a 400 on every 3D generation. The client resolves that against the chosen version and signals "cannot" as an empty string; the vault drops anything it does not recognise, so a typo cannot reach the wire either. v3.0 is relabelled "fast & balanced", matching Tripo's own wording so a version picked on their site is findable here. pbr and texture were already sent as true, so those needed nothing. Verified by capturing the actual request body for every combination: v3.0 + detailed/standard both carry the field beside face_limit 40000, v2.5 carries neither, and "2k"/"ultra"/"DETAILED "/junk are all dropped. 475 client tests and 18 server test files pass.
POST /vault/evaluate compiles a world into an objective plan plus findings, using
tools/build-walkthrough.js imported verbatim. That is the whole design: one copy
of the pass, so the command line and the World Builder can never describe the
same world differently. The test asserts it byte-for-byte against calling the
pass directly, because that is the property worth defending rather than assuming.
The world travels in the body, which sounds heavy and is not. A measured
Verengrad export is 23 MB of which 99.4% is base64 art and roughly 250 KB is the
world itself, and the evaluator never looks at a picture — so the client strips
media before posting and the payload is small. The 12 MB body limit is the
backstop, not the constraint.
Unlike /vault/generate there is no key, no upstream call and nothing to meter. It
stays behind requireToken anyway: a world in flight is the DM's unpublished work.
Two failure shapes, deliberately distinguished. No world at all is a 400. A world
with nothing to look at is a 422 — and that case needed its own guard, because
the pass is defensive enough to coerce a malformed `rooms` to {} and carry on,
which would have shown the DM a confident and entirely empty evaluation. A world
wrong in a way the pass cannot absorb comes back 422 with the reason, never a
bare 500, since that message is the only clue the editor can offer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe World Editor had no route to AI Providers, or to anything else in Settings, because the gear that opens them lives in the panel tab bar -- one of the things a detached window tears down. So a DM editing a world could not change which provider paints its art from the window they were doing the painting in. The panel needed nothing. Checked in a real ?detach=editor window before writing any of this: #settings-popup renders at its usual top-right anchor, syncSettingsControls runs clean with no game loaded, and nothing throws. It was only ever unreachable, not broken. So this is a button and one CSS selector, and it opens the app's own popup rather than a second editor-local copy that would drift from it. Placed after Download World, at the end of the row. Passes `event`, because toggleSettingsPopup stops propagation with it and without that the click-away handler sees the same click and shuts the panel in the tick it opened. Geometry measured rather than assumed, and the first three attempts measured a layout that does not exist -- forcing the detach classes onto a page whose other panel-views were still stacked above put the header 350px down the window. With the siblings torn down the way the real window tears them down: header 55px tall, button at 12-42, popup at 62. It clears the header by 7px and does not cover the button, so the second click closes it. One assertion in the header test pinned the exact shape of a CSS selector list and broke when a third button joined that rule -- the assertion being brittle, not the CSS being wrong. It now checks each id independently. 475/475 client tests pass.
The World Builder is to show an evaluation without the world leaving the machine. That needs the pass to have a second caller, and the alternative — a copy inside text_adventure.html — is not an option this repo can afford. ECONOMY_ARCHETYPES is seven rows duplicated between the app and this file and it still needed a dedicated test to stop the two describing different worlds. Thirteen hundred lines with no build step would not have survived a week. The extraction was cheaper than it looked. Of 1425 lines only nine touched fs, path or process, all of them clustered at the top and the bottom, and exactly one reached into the CLI from inside the logic — path.basename(file), now the caller's opts.source. So the middle is lifted whole into compileWalkthrough(root, opts) and the CLI becomes a thin wrapper: read, compile, write, print, exit. Guarded on require.main, because the CLI parses argv and exits when it finds no filename — at module scope that would take the vault down on boot. startRoomName comes back BESIDE the plan rather than inside it. The plan is checked in and diffed, so nothing goes into it that only a console heading wants. Proved rather than assumed: the same world compiled before and after the change produces a byte-identical plan.json AND byte-identical console output, verified by sha256 against a baseline captured first. One diff did surface on the way — the by-kind tally briefly gained an "objectives" row from a careless destructure — which is exactly what the baseline existed to catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Editor > Rooms now ends the way every other editor card ends: details, then Meta, then Prompts, then Lore. Faction, dungeon, race and item cards all run "...details → Prompts → Lore" already; Rooms was the odd one out twice over. Prompts was not merely in the wrong place, it was inside the BANNER block near the top -- so the longest and least-often-touched section of the card sat between the artwork and everything a DM actually edits. It is now its own promptsSection, lifted out and placed after Meta. Lore follows it. Verified in the browser rather than by reading the template: the rendered section labels run ...Items, Entities (home), Meta, Prompts, Lore. Two tests moved with it, one of which was wrong in a way worth recording. test_room_lore_editor pinned the old containSection/loreSection/metaSection order -- that is the thing that changed, so it now pins the new one. test_room_atmosphere asserted `!/room-atmo-row[\s\S]*?<textarea/`, meaning "no textarea ANYWHERE after the first atmosphere row", which held only because the Prompts block -- full of textareas -- happened to sit above those rows. Moving Prompts turned it red with nothing about atmosphere having changed. It now slices each row and checks within it, plus asserts each slice actually contains its field, since "no textarea in here" is trivially true of an empty string. Both directions were checked by hand: the scoped guard still goes red when an atmosphere input is turned into a textarea. 475/475 client tests pass.
Reported from the vault's call log: a turn prompt reached the GM while the app sat on the login screen, before anyone had entered a game. player.loggedIn was the guard, and it is not the answer. restoreGameState sets loggedIn true, loads the world, hides the overlay and starts the encounter/ambient timers -- and only THEN does its caller decide whether the session actually continues. Two paths change their mind afterwards: promptForMissingApiKey (Direct mode, saved key unreadable) puts the overlay back up with display '' so the key can be re-entered, and a restore failure surfaces its note the same way. Either leaves a session parked at the login screen with loggedIn true, a world loaded, and four timers ticking -- and all four checked only `!world || !player`. livingWorldActive() asks whether the login screen is SHOWING, which is the same test the boot path already uses to decide whether it resumed into the game. It also refuses in a detached window, matching setupEncounters, since a second view of a session is not a second player. Applied to exactly the four timer handlers; a player ACTION that happened to share the same guard line -- looting an item, opening a container, a sidebar click -- keeps the plain world/player check, because a click must not depend on overlay visibility. promptForMissingApiKey now also calls stopLivingWorld() before re-showing the overlay. Refusing to act is right; leaving four intervals ticking behind a login screen is still wrong. setupEncounters clears through the same helper, so there is one way to disarm them. The test took three attempts to become real, which is the note worth keeping. The first two passed with the fix REVERTED: the handlers were bailing on a percentage roll and on an entity standing in another room, so "0 GM calls" proved nothing. It now stubs the roll, puts a living being in the player's own room, counts gmFetch rather than fetch (the beat is awaited, so a fetch counter reads 0 in the same tick), and asserts FIRST that the harness can reach the GM at all. Reverted, it now reports 2 calls from the login screen. Three ambient tests grew two lines each: they set loggedIn but never modelled the overlay, which is now part of what "in the game" means. 475/475 client tests pass.
A world is AUTHORED with `items` / `entities` (the WORLD_DATA shape) and SERIALIZED as `itemCatalog` / `entityCatalog`. The World constructor read only the authored spelling, so `new World(serializeWorld(w))` installed EMPTY catalogues. Diffed the two key sets to see how wide this went: serializeWorld emits 43 keys, the constructor reads 43, and these two pairs are the ONLY ones where the reader and the writer disagree. Every other field round-trips under one name, which is why it went unnoticed for so long. Measured: 0 of 12 entity types survived a rebuild. The item catalogue came back PARTIAL rather than empty -- 30 of 36 -- because makeItem re-registers inline room items as it builds, so the loss presented as a smaller number instead of an absence, which is the harder failure to notice. Reachable, not theoretical. The World Builder's Import World drops a saved world into the JSON box verbatim (parseWorldForEditor documents "no shape conversion"), and Save World / Export World then run that through `new World(data)` and re-serialize `built` -- so a world could be imported and re-saved with its whole entity catalogue gone. The live game escaped only because its restore path reads snap.world.entityCatalog explicitly rather than going through the constructor. Tolerant on the way in, one spelling on the way out: serializeWorld still emits only the Catalog names, so this adds an accepted input rather than a second output that would have to be kept in step. The authored spelling still wins when a file somehow carries both. The test was checked by reverting the fix -- four assertions fail, including the partial-item-catalogue one, which is the case a coarser test would have missed. 473/473 client tests pass.
Every editor tab's response schema has long invited "lore", "loreKey" and "loreXp", and the apply side has long written all three back. But no roster carried the CURRENT values: the rooms roster listed id, name and region, the items roster id, name and type, and the beings roster reported xp and aggression but no lore at all. So "price every room's lore by importance" reached a GM that could not see the lore it was pricing, nor what that lore was already worth. The likely outcome is not a bad price — it is a rewrite of lore that was never meant to change, because inventing the text is the only way to have something to value. All three rosters now carry the lore, its unlock condition where there is one, and its current XP — reported as "unset (pays the default)" rather than blank, so undecided reads as a decision not yet made rather than as zero. Clipped rather than sent whole: eleven rooms of full hidden history would crowd out the instruction itself, and enough to judge weight is all the job needs. The rule this follows: a field the GM may WRITE has to be a field the GM can READ. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Room lore was in the model all along — world.rooms[id].lore, loreKey and loreXp, normalised on load and authored by the GM — and it was editable, but only from Compendium › Places. That route could not finish an authoring pass for two independent reasons. The Compendium lists DISCOVERED entries, so a world's unvisited rooms had no card to edit at all: 8 of Verengrad's 11, including the two carrying its deepest hooks. And the Compendium is not reachable from the detached World Editor, which has no player logged in — which is precisely where a DM does a pass. Rooms were the only lore-bearing kind whose own editor tab did not offer lore. The room card now carries the same block every other editor card does, through the shared builder rather than a bespoke copy, so it gains the XP stepper and stays in step with items, beings, factions, races and dungeons. It works on an unvisited room because the places setter walks world.rooms by name and needs no discovered entry, and it persists without the Compendium being the active surface. The Compendium copy stays for now as a convenience, and the test covers both so removing it later cannot quietly take room lore with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported: XP set through the GM landed on the beings standing in rooms and not
on the matching entityCatalog type. Reproduced on the built world -- instance
250, catalogue undefined, and nothing in the exported entityCatalog to show for
it -- while the same edit made on an Entities card wrote 999 straight through.
The two authoring routes disagreed. setEntityXp / setEntityAggression go through
applyEntityTypeField, which updates the catalogue type and every same-named
being; applyNpcSpecToEntity touched only the being it was handed. So a GM edit
changed every spider in the world and left `giant_spider` unpriced, and the next
spider to spawn came back worth nothing.
The write-through now lives at the end of applyNpcSpecToEntity, so both call
sites -- the import path and the GM edit path -- get it and a third cannot
forget. Written from the ENTITY rather than the spec, so the type receives the
normalized value: "Hostile On Sight" lands as "hostile-on-sight" whichever route
authored it. Guarded with `in` rather than != null, since null is a real value
for xp ("not decided").
Deliberately three fields and no more. A spec also carries `alive`, `status`,
`location` and `reputation`, and those are things that happened to ONE being --
writing alive:false to a type would make every future spawn arrive dead.
Widening the list means deciding field by field which side of that line each one
sits on, and that is a decision, not a cleanup.
Also hardens an assertion that was passing for the wrong reason: `!type || ...`
is true when there is no type at all, which is how it survived while the
catalogue happened to be empty. It now requires the type to be found.
472/472 client tests pass.The pass counted entity XP from world.entityCatalog alone, and a DM authors on the card in front of them. Verengrad's first real XP pass set xp and aggression on the ROOM-PLACED instances; the compiler read the types, found nothing, and reported a world with no entity XP at all while the file plainly had it. Instances are now resolved the way makeEntity resolves them — the instance's own field first, its catalog type as fallback — and counted per PLACEMENT rather than per type. Both halves were wrong and each matters on its own: reading only types misses every DM edit, and counting types undercounts a creature the world places twice. Verengrad places two Echo-Choristers, which is two encounters and two payouts. Aggression rides along the same resolution, so the would-fight context follows the instance too. Verengrad now: 1023 XP — 403 lore, 470 entities, 150 beats — level 5 and four skill points, against 683 and three before the pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported as "the pollinations API key is not being sent with the image request URL". It is not in the URL, and should not be. Pollinations authenticates by `Authorization: Bearer`, which is their usage-TRACKED method, unlike the `?token=` query param a browser <img> is stuck with -- that is why the descriptor fetches server-side at all. applyAuth puts a header-style credential in a header and returns the URL untouched, so an absent key in the URL is the design working. The real defect is that the log gave no way to tell. The console echo prints label, model, method, URL and prompt -- never headers. The stored header is correctly redacted. And with no readable key the descriptor's `urlIfNoKey` hands the browser a keyless URL instead, silently, because auth.optional is true. Measured: those two paths produced a byte-identical console line. A key that was never stored, a key gone unreadable after a master-key change, and a key working perfectly were indistinguishable -- which makes "I don't see it in the logs" the correct observation and the wrong conclusion. authNote now states the posture on every call: "auth: Authorization", "auth: ?key", or "NO KEY — keyless tier, URL handed to the browser". Names only, never a value, not even a suffix. It reads the already-redacted entry rather than the raw request, so it cannot become a leak: a redacted header keeps its NAME and a redacted query value keeps its key= prefix, which is all it needs. On its own console line, so the URL line stays greppable and copy-pasteable. Previously this was only visible under VAULT_DEBUG=1, and only as a statement about what the vault INTENDED before the call was built. Tests plant a key on both transports and assert the note names where it rode, that the keyed and keyless notes differ, that a keyless call says the browser is fetching it, and that no planted key appears in the note, the URL or the headers. 472 client tests and 18 server test files pass.
Two reports, one bug. The pollinations GET URL was cut off in the console, and
separately the configured model looked like it was not being sent. It was: the
descriptor has always carried `model: '{model}'` and the client has always sent
its pin, but Pollinations puts the URL-encoded prompt in the PATH and
width/height/seed/model AFTER it, so a tail-truncating cap deletes precisely the
part that is not already printed on the prompt line below.
Measured on a real portrait call -- a ~280-character world art style plus a
subject -- the URL is 811 characters with `&model=` at character 799, against a
600-character cap. The model pin was invisible in every log line ever printed,
which reads exactly like it not being sent.
The cap moves to 4000 and, past that, elides the MIDDLE rather than the end, so
the query string survives whatever the prompt does. That is the difference
between this fix and a bigger number that will be too small again: on a GET
provider the tail is the only part of the URL that is not already in the prompt
field beside it.
Kept generous rather than removed because this is an in-memory ring of 60
entries that never touches disk, and a prompt long enough to reach 4000
characters is pathological rather than expected.
Tests drive the real descriptor with a realistic prompt and assert the model
survives into what is logged, that nothing is elided at that length, that a
planted key still does not leak, and that a deliberately huge URL keeps both its
head and its tail.
472 client tests and 18 server test files pass.Reported: asking the GM on Editor > Entities > Monsters to update Aggression and XP reported "Done — updated ..." and changed nothing on the cards. Two independent causes, either enough on its own. applyNpcSpecToEntity -- the UPDATE route -- copied none of the three. makeEntity reads all of them, so CREATING a monster carried them and EDITING one silently dropped them, which is exactly why this looked like a GM failure rather than an engine one: the same instruction phrased as "add a monster" would have worked. armor had the identical gap one field over, so it is fixed here too rather than waiting to be reported next. The edit directive's field list never named them either, so the GM had no reason to emit them in the first place. Both halves had to be wrong for the symptom to be total silence, and both are fixed: the directive now documents aggression, armor and xp in the same words the world-generation schema uses, so the two statements of one vocabulary agree. Third, smaller thing: the roster the GM is handed listed name, type, occupation, home and (for NPCs) reputation -- so it was being asked to CHANGE a field whose current value it could not see, unable to tell an unstated one from a deliberate one or to leave alone what was already right. It now carries level, aggression, armor and xp per being, saying "unstated"/"undecided" explicitly where that is the fact. xp guards on `'xp' in spec` rather than `!= null`, because null is a real value for that field -- "not decided", which derives a magnitude from level -- and is distinct from both an absent key and a typed 0. A != null guard would have made "set this back to undecided" impossible to express. Tests cover the update path for all three, that an omitting spec leaves them alone, and that explicit null and explicit 0 both land as themselves. 472/472 client tests pass.
The model landed upstream while this was being written and is richer than what
it replaced: normalizeEntityXp / entityXpValue / awardEntityXp for beings,
normalizeBeatXp / questBeatXp for beats, and an engine that pays a floor on
resolution. What it had no way to do was SET either figure outside the world
JSON. These are those two controls -- a number box on monster cards
(Editor > Entities > Monsters) and one per beat in Editor > Quests.
The monster box is gated to type === 'monster'. Every being carries the field,
but "what is this worth to defeat" is a question the Monsters tab asks and the
NPCs tab does not. It writes through applyEntityTypeField to the catalogue type
and every same-named being, like Aggression above it: one giant spider worth 30
and the one in the next room worth nothing is a bug, not a design.
The difficulty is that the field has THREE states and a number box has two.
null is "not decided" and falls back to a derived magnitude; a typed 0 is a real
"worth nothing"; a number is a number. Blank and zero render identically and
mean opposite things, so the placeholder names the figure that applies when the
box is empty -- "not decided — 30 by level" -- and refreshes when the box is
cleared, since clearing changes which default now applies. Both setters route
through the model's own normalizers rather than re-parsing, so the editor cannot
drift from what the engine reads.
Two bugs found by testing that, both in the upstream model:
QuestBeat never read src.xp. normalizeBeatBranchFields normalizes `this.xp` in
place, and a field that is normalized but never assigned normalizes `undefined`
every time, which is null -- so an authored beat XP was discarded on every world
load. Settable at runtime, gone on the next reload or import. One line.
Both normalizers treated whitespace as a deliberate zero, because Number(' ')
is 0 rather than NaN. Clearing a box with the spacebar priced the thing at
nothing instead of leaving it undecided, and those two states pay differently.
Trimmed before the emptiness test.
Verified in the browser on both surfaces and in all three states. Tests cover
the monster/NPC split, the placeholder that distinguishes blank from zero, the
write-through, the round trip (which is what caught the first bug), and eight
flavours of what a DM can type into a number box that is not a number.
472/472 client tests pass.Writes up what reading crawler.js turned out to say. Both vertex shaders take a single uVP, and renderFrame already passes that matrix down to drawSwitches, drawItems and drawFlames as an argument rather than reaching for a global -- so drawing the scene from a second viewpoint needs no change inside any pass. A renderer that had hidden its camera behind a library object would be a harder port than this one. The doc carries the code for both halves: the renderFrame split into frameLighting (per frame -- geometry, sconce selection, torch flicker, all of which must not differ between eyes) and renderScene (per viewpoint, which is renderFrame's existing body below the matrix), then the session plumbing -- makeXRCompatible on the existing context, XRWebGLLayer with antialias moved into the layer, local-floor with a local fallback, the loop handover through the `if (!mountEl) return` seam tick already has, and an idempotent exit, since the runtime ends sessions too. What is not rendering is where the cost actually sits, and the largest one is invisible from the flat view: the world is not metric. cell = 1, WALL_H = 1.15, EYE_H = 0.58, so the two constants imply 2.93 and 2.43 metres per unit and cannot both be honoured -- and eye-at-half-ceiling is proportionally shorter than a person standing in a 2.8 m room. That is a seconds-to-notice problem in a headset and a no-op on a screen. Then: the smooth 90-degree turn tween is right flat and a sickness risk in VR, so the two want opposite things; the minimap is the one real screen-space HUD among six 2D canvases, the other five being procedural texture generation that costs VR nothing; and mouse picking has no VR equivalent short of a controller ray. Scoped deliberately small -- Builder page, keyboard input, no minimap, no mechanisms -- because the technical risk is low and that is the trap. It makes the project easy to start and easy to keep going after it should have stopped. The real unknown is whether a 90-degree grid crawler is compelling to be inside, which no amount of reading settles. Hence the fourth open decision: whether to build it at all, with "interesting, not worth it" written down in advance as a successful outcome. Styled from dungeon-builder.html, its companion. Indexed in the README. 470/470 client tests pass; Designs/ stays denied by the vault.
Quest beats were the last authored XP source with no value at all. Lore pays for discovery and beings pay for danger; neither covers a quest advanced by talking, travelling or deciding — and the critical path is the one thing every playthrough is guaranteed to walk, so a world left it unpriced and paid nothing for its own story. A beat now carries an xp, paid once at unlock. No bonus channel, unlike a being: a beat either happened or it did not, there is no spectrum of "how well" for anyone to judge, and whatever ingenuity got the player there was already paid for by what they resolved on the way. The GM is asked for it in all three places a beat can be authored — world generation, quest editing, and region expansion — because a field only the schema knows about is a field no world will ever have. World generation states it as REQUIRED and says why the critical path in particular must be priced. All three give the same absolute scale, shared with beings and lore, so a beat and a being of equal weight score the same rather than each kind being ranked only against its own sort. An authored value also survives a quest edit now. The edit round-trip rebuilt each beat from a fixed field list, so without carrying xp through it every DM edit would have silently stripped the XP off every beat it touched. Verengrad, with nothing authored yet and everything on defaults: 683 XP — 403 from lore, 130 from beings, 150 from six beats. The compiler reports the three shares separately, so an XP pass can see which lever it is pulling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
I wrote that a root-level Classes directory had never existed. It had — empty, in one working copy and not in a fresh clone, which is why the check disagreed with itself across two machines and why the hasContent filter beside it is the real fix. The comment now says that instead.
The field already existed and already mattered -- the GM is told to judge hostility from it, and adoptOrphanedCombat uses it to work out who the player is fighting when a combat roll arrives with no declared enemy -- but it was never shown or editable anywhere. The only way to set it was to hand-edit the entity JSON. Two surfaces, deliberately different. The detail popup (sidebar, story, map, rooms, compendium and editor all share buildNpcDetailHTML) states it read-only and ONLY when authored: an unstated aggression is not "Passive", it is nothing to say, and a "—" row on every unauthored being would be noise on every popup in the game. The Entities card offers a picker, always, on NPCs, monsters and animals alike, with a blank "unstated" option -- blank is a real choice there and the card is where the choice gets made. Setting it writes through to the KIND via applyEntityTypeField: the catalogue entry and every living being of that name. Deciding cave bears attack on sight and having it apply to the one in front of you but not the four in the next room is a worse surprise than the write-through. Race, on the row above, stays per-individual -- that one is a fact about the person. Added a normalizer, because the values are load-bearing in a way that punishes a near miss. The GM is asked for "hostile-on-sight" but will as readily produce "Hostile on sight", and everywhere aggression is tested the check is "set and not passive" -- so a value that misses by a space reads as a creature that picks fights. normalizeAggression folds spaced, underscored, cased and run-together forms onto the canonical value and everything unrecognised onto blank. Field Guide gets the section, next to Reputation and drawing the line between them: reputation is what a being thinks of you and moves with what you do; aggression is what it is like and does not move. A shopkeeper who has come to loathe you is Hostile by reputation and still Passive by aggression. Verified in the browser on the real card and popup. A test pins the vocabulary, the normalizer's tolerance, both render paths, the write-through, and that the GM schema still lists exactly the four values the app understands -- two statements of one vocabulary that would otherwise drift apart silently. 469/469 client tests pass.
Two things got bundled into one change and then removed together. Only one of them was wrong. The engine paying an automatic share whenever a would-be-lethal encounter ended peacefully WAS wrong: proportion depends on what the resolution cost and risked, and only the Game Master saw that, so an engine rule would have paid the same for a desperate gamble and a lucky swing. The 2x ceiling was not wrong, and taking it out with the rest overshot. So the cap is back and the automatic share stays gone. Deciding the number is judgement and belongs to the GM; holding the range is arithmetic and belongs to the engine. The GM picks anything from nothing up to the magnitude and the engine pays exactly that, never adding a bonus of its own and never inferring one from how the encounter ended — while the ceiling stops any single encounter outweighing a world's whole XP economy. The authored hostility attributes are still surfaced, as a "would-fight" flag on the being's dossier. They are context for the GM to weigh a peaceful resolution against what it avoided, and nothing reads them to award anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Entities carried no XP at all: combat XP was entirely GM-discretionary through xpGain, which is why the evaluation pass could not see it and why lore hooks were the only authored XP a world had. A being now carries a magnitude — what resolving it is worth — and the engine pays that whenever the encounter ends. The number is a magnitude and nothing else. It never says what earns it, because the trigger is RESOLUTION rather than death: a creature talked down, subdued or driven off settles the encounter as surely as one killed, and Verengrad asks the player to wound the Gill-Wretch and let it speak. Paying only for kills would tax the path the world itself asked for. A new entityResolved state change carries the non-lethal case, and both routes go through one helper with a once-only guard. The bonus is the Game Master's entirely. An earlier pass had the engine paying an automatic share whenever a would-be-lethal encounter ended peacefully, keyed off the authored aggression and enemy attributes. Those attributes do exist and are now surfaced to the GM as "would-fight" — but deciding what a peaceful resolution was worth means weighing what it cost, what was risked and what it meant, and an engine rule would make that judgement badly, paying the same for a desperate gamble and a lucky swing. For the same reason there is no enforced ceiling. "Roughly the magnitude again" is guidance in the directive, the room the GM normally has; clamping to it would produce exactly the disproportion the bonus exists to prevent, with the GM judging an extraordinary resolution worth more and the engine quietly paying less than it decided. What remains is an absurdity guard, so a malformed figure cannot end progression in one turn, and a floor at zero so a bonus can only ever add. An unpriced being falls back to ten XP per level, the same reasoning as an unpriced lore hook: a world that has never had an XP pass must still pay something proportionate. The compiler counts these as authored XP, which takes Verengrad from 403 to 533 and from two skill points to three without a line of authoring — and reports the lore and entity shares separately so an XP pass can see which lever it is pulling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The check disagreed with itself across two machines. `Classes` sits empty in one working copy and is absent from a fresh clone, because git does not track empty directories — so one session saw an undecided directory being served and the other saw a stale entry describing a directory that does not exist. Both were looking at the truth in front of them. Empty directories are now skipped, which settles it in the honest direction: an empty directory serves nothing, so there is nothing to decide about it, and the check now says the same thing wherever it runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A run that hits a wall cannot tell, from inside, whether the world is shut or the player simply did not work it out — and that is the exact ambiguity the three-way verdict turns on. The in-game /hint aid answers it cheaply: a standalone GM call that nudges toward the nearest quest thread without touching game state. If the GM offers a route, the impasse was a hidden consequence or a fair defeat. If it has nothing to offer, a genuine blockade is far more likely. Also noted where it belongs: the GM is unlikely to author a blockade it has no means to lift, so the realistic narrative path-lock is an unintended one, which only a playthrough finds. The lint stands as it is until gating between rooms lands, at which point a structured gate should outrank it and the remaining question becomes whether the GM ought to declare a blockade it imposes rather than leaving it in prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
SERVED_ON_PURPOSE listed 'Classes', but the only Classes directory in the repo is Worlds/<world>/Classes, served as part of Worlds. There has never been one at the root. The list's own goneFromRepo check is what caught it, which is the check working as intended.
Reported still fat in the Character Creator after the first attempt, with a screenshot of the Gender select wearing Chrome's white double ring. The first attempt gated the reset on a flag tracking the last input device. That is right for a button or a link, which have no indicator of their own and would be left invisible to a keyboard user by a blanket `outline: none`. It is wrong for a FIELD, and wrong in a way the flag cannot fix: focus moved by SCRIPT -- which is how a dialog's first field gets focus -- leaves the flag reading whatever the last input device happened to say, and the ring comes back. A dialog is precisely where that happens, which is why the report came from there. A field needs no flag. It already shows a gold border on hover; showing the same border on focus IS the indicator, so the ring is redundant and goes unconditionally. That is not a new style invented to dodge the ring -- it is the exact pair the sixteen fields that declared a focus style all used, including the World Builder's, which was the one described as looking right. The other seventeen were the odd ones out. Verified in the real #origin-modal with the pseudo-state forced over CDP, with the flag both ways: outline none and the gold border in both, where before the flag-off case gave `outline: auto 1px`. 467/467 client tests pass.
The commonest narrative blockade is also the most accidental. A DM writes "an impenetrable door" as atmosphere; the Game Master reads it as a fact and stifles every attempt to pass; everything beyond becomes unreachable. The map stays perfectly connected, so nothing else in the pass has any reason to complain — and since a DM can edit any world, doing it unintentionally is always possible. Nothing gates an exit structurally today. There is no door mechanic between rooms, so a passage is open or it does not exist, and the prose is the only signal there is. That is why this reads text at all, and when exit gating lands the structured gate should outrank the lint. Narrow on purpose, because crying wolf here would be worse than silence. It reads only an exit's OWN description — text about a way through, never room scenery, which would flag every world that has ever mentioned impenetrable fog. It matches impossibility rather than closure: a locked door is a gate with a key, while "cannot be opened" says no key exists. And it reports at info, because a way sealed until the story opens it is ordinary design and only a run can tell that from a wall nobody meant to build. Two families, because one is unsafe alone. A predicate — "will not budge", "no force can" — is already a claim about operating a thing and stands by itself. An adjective is not, so it must land within reach of a noun you would walk through. The test caught me on exactly that: "impenetrable darkness" flagged on the first run, which is atmosphere and not a door. Verengrad's twenty-two exits all carry descriptions and the lint is silent on every one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The framework had grown a compiler, a grader, five kinds of lock, a severity model and a report format, and none of it was written down as a whole. It lived in two tests/ READMEs, one design doc covering the economy dimension only, and a long tail of code comments. world-economy.html was effectively a chapter of a book whose other chapters were fragments. The organising idea is that "the player cannot get there" is one question asked of several different things a world can withhold. Four are currencies — coin, capability, people, experience. The fifth is the map, and it belongs at the head of the list: an unreachable room makes every other question about its contents moot, and no amount of coin or capability substitutes for a way in. It is also the only lock whose structural half is a flat error, since a quest room nothing connects to is broken under every reading of intent. Path-lock splits in two and the halves need different instruments. The structural half — a room no exit reaches, an exit pointing at nothing, a room reachable only through a secret way — is settled completely by BFS at tier 0 and is the most reliable check the framework has. The narrative half is invisible to it: a guard who refuses, a rite unspoken, a tide that is in, each leaving the graph fully connected and the player fully stuck. Recorded as an open decision, with the candidate fix being to have the GM DECLARE a blockade it imposes so that a beat trigger which permanently shuts a way becomes data rather than prose. Also written down because it is otherwise lost: the generous bound and what it buys, which is that the pass is trusted when it complains and never when it is silent; why anything the GM was briefed to do is reported at info rather than warn; the fourth verdict, withheld, for a measurement taken while a load-bearing system is unimplemented; and what a run established that arithmetic could not — that prices, stock and lore are all improvised, so no static conclusion may be stated as a prediction about play. The two tests/ READMEs stay as operator docs. This holds the why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Every new character was seeded race = "Human" and the Character Creator offered that Human at the head of its list, ahead of whatever peoples the world actually authored. It did harm in both directions. In a world without Humans it produced a character belonging to no people in the setting, holding none of its racial abilities and never told so — measured last night, where a Cantor Adept walked Verengrad as a "Human" among Verengradi, Tidekin and Driftkin with every racial passive inert. And in a world that DOES author Humans it shadowed that definition with a nameless namesake carrying none of its traits. So there is no engine default anywhere now. The Player constructor leaves race empty, the options are exactly the world's races, and an unrecognised value CLEARS rather than substituting — recording a race the world does not define would state something false about the character that nothing downstream could detect. Older saves restore with no race rather than a backfilled one, for the same reason. The Creator gained an empty-valued placeholder, which is what makes "unanswered" expressible at all: without it the browser selects the first real option and a character leaves creation with a people nobody picked. Its Begin refuses while a people is offered and none is chosen. The gate asks whether options are OFFERED rather than whether the world has races, so a world that authors no peoples stands it down instead of trapping the player in a dialog holding an impossible choice. A Race field was briefly added to the login screen too, and removed again: the login collects a name and a class before the world is necessarily built, while the Creator runs against the world the character will actually inhabit. Two fields would have been two places to keep in step, and the wrong one would have been authoritative. Verified in the browser rather than by pattern alone. A fresh Claude3 in Verengrad opens on "— choose a people —", Next refuses with the field marked and a note on its own line, the list offers Verengradi/Tidekin/Driftkin and no Human, and choosing Tidekin grants Choir-Attuned Ear and Drowned Lungs — the abilities Claude2 never had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Moved Reports/ to Evaluations/<World Name>/, away from Web/Reports/ — which is pushed to a separate server and was close enough in name to be mistaken for the same thing. A report is about a particular world far more often than about the engine, so the world is the right folder. Denied in the vault alongside Designs and Notes. These are working notes on worlds still being tuned; they belong with the docs rather than with the running game. The vault binds locally, so this is housekeeping rather than exposure — but the mechanism is worth knowing about, because resolveStaticPath filters with a DENYLIST over the repo root. A denylist defaults open: Evaluations answered 200 the day it was created, without anyone deciding it should, while Designs beside it correctly 404s. So the new test does the noticing that a denylist cannot. It asserts the denied set is denied and app content still served, then requires every top-level directory in the repo to be either listed as content the game fetches or explicitly denied — and fails naming any directory that is neither, since such a directory is being served right now. It also fails on entries that no longer exist, so it cannot pass by describing a repo that has moved on. Traversal through a denied directory and dotfiles are pinned while it is there. The running vault needs a restart before the deny takes effect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The last label in the app naming itself after its container. The verb was already right -- it writes the page as it stands, unlike Export World beside it, which demands a buildable world -- but "JSON" is the file format, not what the button gives you. What it gives you is the brief: every field as typed, plus the generated world if there is one. Renamed through: the label, the tooltip, #we-download-json-btn -> #we-download-brief-btn, and downloadWorldBuilderJson -> downloadWorldBuilderBrief. The status line now leads with "Brief" to match, and names the World JSON box by the label that box actually carries on screen. The written filename keeps its -worldbuilder- marker: a file extension is the one place the format belongs. Two assertions in test_world_builder_download pinned that UI copy word for word and broke on the rename -- the same failure the header tooltips hit twice. The tooltip is now checked as present-and-substantive, and the status as what it must DO: branch three ways on whether a world parsed, failed to parse, or was never generated, with a different line for each. Both print what they found. 467/467 client tests pass.
Follows the UI principle: elements stay light, a tooltip is a short suggestive indicator, context notes appear where an operation is dangerous, and the Field Guide is ground truth. A test that demanded both buttons recite their half of the media distinction was pulling explanatory text into the UI to satisfy itself -- twice it failed on a rewording and reported a defect that was not there. The tooltips are now held only to being present and distinct. The explanation is asserted in guide.html, where it lives: that Export World embeds the media inline, that Download World leaves vault references, that the guide names Remote Embedded Images as what moves art the other way, and that it does not still call the button by either of the two names it has already outgrown. That last check is the one that earns its keep -- the guide is the copy most likely to go stale precisely because it is not on screen next to the code. Also drops three stale comment lines the merge left stacked above the block. 467/467 client tests pass.
First playthrough evaluation, in a new Reports/ directory in the Designs house style. Claude2, level 1, browser-UI, played until she could go no further: 5 of 11 rooms, 2 of 6 beats, 1 of 35 lore hooks, broken off from the first monster on the critical path without landing a blow on it. That last reads as a difficulty problem and is not one. The character was carrying a weapon, an amulet and a suit of armour and wearing none of them, because equipment effects are not implemented — which the Equipment tab says outright. So the combat numbers are recorded and explicitly not graded: a survival ratio measured while a load-bearing system is missing is not a balance result, and a report that presents it as one will be believed later by someone who has forgotten why. Three findings stand without it. The GM's prices bear no relation to authored `value` — 7.5x over on one item and 4.5x under on another in the same conversation — so coverage and cost-lock describe the author's intent rather than the played economy. The GM stocks and narrates its own content in preference to the authored: not one merchant-only item was offered for sale, and the lore discovered was lore the GM invented, leaving all six authored world.lore entries untouched. And the character's race is Human, which this world does not have, so she carries no racial abilities at all and nothing says so. The static pass had flagged Verengrad as too-little-loot on the grounds that its cheapest authored armour costs three times the world's entire wealth. In play armour cost two gold, from stock that does not exist in the catalog. The world is not cost-locked because the GM improvises around it, which is the "everything dynamic only adds" principle working far harder than the model assumes. Also recorded: three ways the harness distorted the run before it was noticed. DM mode was on for the first turns and bypasses item concealment entirely, so the opening command used a name the character could not know — and there is no route back to the login screen from inside a session to turn it off. Wall-clock time is in-world time at 24x, so a tool-driven run ages a character far faster than a human one. And a DOM read reported a combat prompt that a screenshot showed had already cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both sides independently loosened the same three checks, for the same reason: the tooltip wording on the two download buttons is being tuned, and a test that fails on a rephrasing reports a defect that is not there. Kept theirs. Mine asserted that exactly ONE of the pair names the media behaviour, which is symmetric and would therefore have passed with the contrast inverted — Download claiming the inlining and Export silent on it. Theirs pins the direction that is actually true: Export is the button that inlines, so Export is the tooltip that must say so. Its presence check is stricter too, and its failure messages print the tooltip found. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The test demanded both download buttons recite their half of the media
distinction, which is wordier than the UI wants. The pair reads as a contrast:
one side naming the behaviour ("Embed Media Inline") tells a reader what the
other one does not do, and spelling the same distinction out twice buys clutter
rather than clarity.
So the assertion now guards the invariant instead of the wording — the two
buttons must be tellable apart, meaning exactly one of them names the media
behaviour and the two do not say the same thing. That still catches the failure
worth catching, which is a rewording that leaves neither side saying anything, or
both saying it.
The explanation moved to where explanations belong. The Field Guide's Import /
Export section had nothing on any of this: not the two download buttons, not
vault references, not Remote Embedded Images. It now covers what inline versus
referenced storage means, which button to reach for (share it, or keep it), what
the remote tool does and that it dedupes identical files, and the one thing a DM
should know without being told twice — a world whose art points at someone
else's image service costs nothing to store and is not really yours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvWealth buys gear and capability opens doors, but XP is what buys capability — one skill point per level, a skill costing its tier. So "go away, level up, train lockpicking and come back", the universal fallback the whole build-lock model leans on, is only real if the world awards enough XP to reach the level that pays for it. A world where it does not has an XP-lock: the route exists on paper and cannot be walked. Same shape as a cost-lock, different currency. Scope is deliberately narrow and both exclusions are principled. GM-discretionary XP is improvised and unbounded. Dungeons are an obstacle-for-XP exchange that exists precisely so progression can happen without saturating world rooms with obstacles, and not every world has any. Both only ever ADD, which is what makes this a FLOOR rather than a forecast — a world clearing the bar here cannot fall under it in play, and one that fails is relying on improvisation to rescue it. That caveat is appended to the output unconditionally rather than used as filler when there is nothing else to say, since a reader who meets it only sometimes will read the figures as predictions. An unpriced lore hook counts at the engine's 12-XP default rather than at zero, because that is what play pays; counting it as nothing would understate every world that has not had a pricing pass, which is most of them. Lore with no loreKey counts as nothing, because there is no way to earn it. Which produces the XP map. Verengrad awards 403 XP across 34 hooks — level 3, two skill points, the whole authored world. But 29 of those hooks are unpriced and sitting at the default, and the editor allows 100. Pricing them by importance would take the world to 2955 XP, level 7, six skill points. The cheapest XP in a world is the lore already written for it, so the suggestion is a pricing pass in the editor rather than new content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Second time these assertions have broken on a rephrasing. They now check only what a mix-up would actually violate: both buttons have a tooltip, the two are not identical, and the Export one names the media inlining that distinguishes it. Each failure prints the tooltip it found, so a rewording that loses the meaning still shows what it became.
Reported against the character creator: clicking the Race or Class select drew
a heavy white double ring around the box. That is Chrome's `outline: auto` on a
dark theme, showing because .char-gender-select never declared a focus style --
measured side by side against the login screen's select, which sets
`outline: none` and shows nothing. An audit found the same gap on 17 of the 33
select/input/textarea classes in the file. The reset is a convention that has to
be remembered, and half the time it was not.
:focus-visible is the obvious lever and is not the answer: Chrome matches it on
a plain mouse click for every form control. So the input DEVICE is tracked
instead -- pointerdown sets a flag on <html>, Tab clears it, and the ring is
suppressed only while it is set. Verified by forcing the pseudo-state over CDP
rather than trusting synthetic input, which reports :focus-visible for
everything and cannot tell the two cases apart. On a real focused select:
pointer-focus=true outline: none <- mouse: no ring
pointer-focus=false outline: auto 1px <- keyboard: ring intact
That second line is why this is not simply `outline: none` on everything:
nobody navigating by keyboard loses their focus indicator.
The other half is a default the app never had: `input:focus, select:focus,
textarea:focus { border-color: var(--gold-dim) }`, the same gold border the
sixteen widgets that DID declare a focus style already use. Stating it once
means the seventeen that did not now have one, and the next widget added
inherits it instead of having to remember. Every existing rule outranks it.
Both are at low specificity and sit early in the sheet, so no deliberate style
loses -- the checkbox keeps its own ring, and each widget keeps its own border.
466/466 client tests pass.The build-lock pass treated a skill's `classes` list as a wall. It is not. Per canAcquireSkillWithPoints, a gate naming other classes still leaves the skill BUYABLE with skill points at an off-class proficiency penalty; only `hardGate` — or the id "spellcasting" — truly closes it, and only `pointCost: null` makes it found-only. So a lock-picking skill gated to a rogue does not lock every other build out of a lock. They pay for it. Reading it as hard made the pass cry wolf, and loudly: against the real Verengrad world it called six skills unreachable when exactly one is. spellcasting is hard-gated by id and that world has no caster class, so it genuinely can never be learned. lockpicking, sneak and surprise_attack are gated to a Rogue that world lacks — which costs every build the off-class penalty and is worth an info, not a warning. Fourteen skills native to one class are not fourteen locks; they are fourteen specialist paths. That distinction is not a detail, because the GM is already briefed on it. CRITICAL_PATH_ACCESSIBILITY_GUIDE asks for exactly this shape — a fast, clean specialist path plus a universal fallback that always exists and costs more — and the soft gate with its off-class penalty IS that fallback in rules form. A pass that flags every specialist path as a build-lock punishes the GM for complying with its own directive. The evaluation exists to check whether the mark was hit, so it now grades against the contract: specialist paths are credited as the briefed shape, and only a route that exists nowhere is a defect. Which also settles the case of a door that reads as impassable today. Meeting a locked door, going away to level, spending a point on lockpicking and coming back is good design, not a lock — deferred rather than denied. The only reason the warning still fires is when there is no such skill anywhere to train, and it now says that in those words. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
"Save" already means something specific in this app -- storing INSIDE the
browser. The New World editor's "Save World" writes to the world library and
"Save Game" writes a save; neither of those is what this button does. Both
header downloads are now named for what leaves the window, a file, and the
library push next to them keeps its own name.
Identifiers follow the label: saveWorldFromEditorHeader ->
downloadWorldFromEditorHeader, #detach-editor-savefile-btn ->
#detach-editor-download-btn. Behaviour is unchanged -- it is still
exportWorld({ inlineMedia: false }), the compact write that leaves vault media
as references.
The comment explaining the old collision is replaced by one stating the rule
that removes it, so the next button added here is named by the same test.
464/464 client tests pass.Two assertions were matching the exact title= text on the Update Library World button, and broke when that tooltip was reworded to "Update World — Save in Library". They exist to check the button is WIRED, so they now match id + onclick + aria-label and let the visible wording move. The Export / Save World tooltips get the same treatment: the check is that each one tells the DM which side of the media split it is on — portable-and-inlined vs left-as-vault-references — not which sentence it uses to say so. It reports the actual tooltip on failure, so a wording change that loses the meaning still shows what it became. 464/464 client tests pass.
Cost-lock asks whether a world's wealth can ever meet a price. Capability is the other currency a world gates itself with, and nothing was asking the same question of it. A locked door a scout picks, a warrior breaks and a cantor simply cannot pass is not a fair defeat for the cantor — the player made good decisions and met a wall the world never authored a way past. Same three-way verdict as the rest of the framework, same generous bound: only what is provably unreachable gets named. The keys side is fully structured, so this much needs no play. A class carries inherentSkills and a starting kit, a skill carries a `classes` gate, a race carries abilities; from those, what each build could ever hold is derivable. Run against the real Verengrad world it immediately found three defects nobody had seen: breath_hold and current_sense are gated to "Scaffold-Scout" while the class is spelled "ScaffoldScout", so no character can learn them; spellcasting, lockpicking, sneak and surprise_attack are gated to Mage, Rogue and Cleric, none of which that world has; and twelve of its twenty-two skills are reachable by exactly one of four classes. A typo and a missing class are different bugs with different fixes — a rename versus authoring a class — so they are reported separately, which forced class names to compare with separators STRIPPED rather than turned into spaces. The general normaliser gives "scaffold scout" and "scaffoldscout", which do not match, and the typo would have been misreported as a class that never existed. Where obstacles ARE structured the lock question is answerable too: a container locked to be picked in a world with no lock-picker is a warning, and one only some builds can open is info, because a world is allowed to mean the scout to be the one who picks locks. Most real obstacles are prose in a beat trigger for the GM to adjudicate and cannot be solved statically at all — so the plan now records the class and race it was compiled against, which is what lets a failed objective be read as "this build cannot do that" rather than "this world is broken". Two unrelated tests were anchored on a header button's tooltip, which the other session reworded; re-anchored on the aria-label, since a tooltip is prose meant to be edited and the accessible name is the button's identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The editor opens in its own window, and that window hides the main app's #status-bar along with the rest of the game chrome. So every status raised while the DM was in there was being written to a hidden element. Not just the header buttons' outcomes, which had a chip to fall back on -- the GM "…" lines the editor's OWN Ask-the-GM bars produce (generating a portrait or a description from the editor looked like nothing was happening at all), and the "Saving… do not close the browser" indicator with its shimmer, which is the one status a DM must not miss. The seam is a LOOKUP, not a broadcast: statusBarTextEl()/statusBarEl() answer "which bar is live in this window", and every writer goes through them. A window only ever has one, so no caller has to know there are two -- which is what makes the existing GM and storage status land here without touching any of them. The bar is the same object as the main one: same height, same mono type, same typing/thinking/saving classes and the same shimmer, plus the two outcome colours a long-running editor action needs. setDetachHeaderStatus becomes setEditorStatus, the general sink: 'busy' (gold, does not expire -- the caller reports back), 'ok', 'err' (red, lingering far longer, since outside the engine log this line is the only account there is), or plain. The revert is guarded the way endStorageStatus guards its own: it fires only if the bar still says what we wrote, so a slow outcome cannot wipe a GM line that started after it. A save in flight outranks everything -- an outcome takes the words but leaves the warning up. The header's transient status chip is gone; one place for status beats two. What stays up there is only persistent state: the two chips that say what this window IS. Fixes a real bug found by driving the bar in a browser rather than reading it: a red "Could not update the library" was followed by a save, and "Saving world… do not close the browser." came out red as well. beginStorageStatus cleared typing and thinking, which were the only mood classes when it was written, and is-err rode straight through. The colour classes are now named once in STATUS_BAR_MOOD_CLASSES and all three writers clear that list. .saving is deliberately not in it -- it is reference-counted against a live operation. 464/464 client tests pass.
Tools > Remote Embedded Images exists to move a world's art onto the vault and leave "/vault/media/<shard>/<sha>.<ext>" references behind. Export World rehydrates on the way out — correct for a portable file, and exactly backwards for a world that was just deliberately slimmed: it drags every byte straight back in. Save World is that same export with the rehydrate skipped, so the references survive into the file and it stays small. One code path, one flag. exportWorld gained opts.inlineMedia (default true, the long-standing behaviour); the two header handlers are one-liners over a shared _downloadWorldFromHeader. A second serializer would have drifted from the first. The file earns a "-linked" suffix only when references actually survive into it. A world with no vault media serializes identically either way, and a suffix there would claim a distinction the file does not have. When they do survive, the header says how many, because that number is what the file now depends on. Label collision noted in a comment beside the handlers: the New World editor's "Save World" (saveNewWorld) stores to the browser library, this one writes a file. Different screens, and in this header the library push is the separate "Update Library World" button, so nothing is ambiguous at the point of use -- but the two are not the same action. Tests cover both directions on the same world: Save World does not call rehydrate and writes the reference through; Export World does call it and writes bytes with no reference left; the suffix appears in the first case and not the second; and a world with no vault media gets neither the suffix nor the reference count. 461/461 client tests pass.
Editor › Rooms could generate a banner for a slot, upload one, or overwrite
either — but never get back to the empty placeholder. The Video block sitting
beside it has had a 🗑 all along, so the shape was already established; the
banner just never got one.
A banner slot is not a lone image, and clearing the picture while leaving what
holds it is the failure that looks like success — the card goes empty while the
game still shows the art everywhere else. Three things had to let go:
The slot is { static, gif } and the card renders static || gif, so dropping only
the still would leave an animated banner in place and a button that appears to do
nothing. A room PINNED to this slot shows it at every hour, so emptying the pinned
image now unpins the room rather than pinning it to nothing — and the tooltip says
so before the click, since that consequence is otherwise invisible. The Places
compendium thumbnail is re-resolved rather than blanked, because the room may
still have art for this hour by fallback from another slot and the compendium
should show what the game shows. The Video block repaints too; its Generate is
gated on the room having a banner to animate, and that gate just moved.
No confirmation, matching roomVideoClear: one slot, a button that says what it
does, and the art returns with one Generate or Upload.
One ordering trap worth naming: the remove button's tooltip reads pinnedHere,
which was declared below the actions it now appears in. A const read above its
declaration throws rather than reading undefined, so the pin flags moved up — get
that wrong and every Rooms card fails to render, not just the tooltip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvSurfaced from the same world one art pass later: rooms carry a `bannerVideo`
alongside bannerImages[when].{static,gif}, and the field was not listed. It went
unnoticed before because every banner video in that world is a remote URL, which
the tool leaves alone by design — but a video inlined by an author is the single
heaviest thing a room can hold, and it would have been the one item left embedded
after everything around it moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvSits beside "Update Library World" as its mirror image: that one pushes the world OUT to the library, this one pulls it DOWN to a JSON file. It runs the same exportWorld() the World tab and the Import/Export menu use, so the file is the identical world-only envelope (no character, no save) with vault media inlined. What differs is scope, and that is why it belongs in the header: it exports WHATEVER THIS WINDOW IS EDITING. In the draft editor that is the draft as it stands right now, including edits not yet published; in the save editor it is that one save's world. So unlike the publish button beside it, it is not gated to body.draft-editor — a save editor is exactly the window a DM most wants a backup from. exportWorld gained an optional status sink. Its only status line lives in the Editor > World tab, which is usually not the tab you are on when you click a header button, so without one the button would be silent — and since the export inlines vault media into the file first, a slow success and a dead button look the same. The header handler passes setDetachHeaderStatus and disables the button for the duration, re-enabling it on success, on a returned error, and on a thrown one. The icon-button styling moved from the publish button's id rule to a shared .detach-editor-hdr-btn class so the second action did not have to restate the first's border, padding and hover. Tests: test_detach_editor_header pins the pair and, specifically, that export is NOT draft-gated; test_export_world_menu drives the header handler through success, a returned failure and a thrown one, checking the header text, its ok/err styling, and that the button is never left stuck disabled. 457/457 client tests pass.
Tools › Remote Embedded Images walks the save by field name rather than
deep-scanning strings, which is right — it keeps a prompt that merely quotes a
data URI from being rewritten. The cost is that a media field nobody adds to the
list is skipped in silence: nothing errors, the tool reports success, and the art
stays embedded.
Measured against a real 43 MB world export, 41.5 MB would move and 1.7 MB would
not. Four of the five missed names were one-offs — mapBackground, loginBackground,
loginLogo, compendiumImage. The fifth was room banners, and it was the one that
mattered. A time-of-day slot is bannerImages[when] = { static, gif }; neither key
was listed, while bannerImage (singular), which WAS listed, appears nowhere in a
save at all.
That read as harmless only because the banners in that world were remote URLs, so
the field held almost nothing. Bake them inline — which is exactly what an author
does before running this tool, and why the gap surfaced now — and 66 slots of the
heaviest art in the world stay put while everything around them moves. With the
names added, the same export moves 43.19 MB and leaves nothing behind.
Listing a key as generic as `static` is safe because a field name was never the
whole test: isEmbeddedDataUri still requires the value to literally begin
"data:image/…;base64,", so prose can not be caught by it.
The test reads the list out of the app and checks it against every field a save
is known to carry art in, so the next field added to a save fails here rather
than being discovered as missing megabytes later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvFirst run against a real world instead of a fixture, and the profiler was caught inventing a reading. The Verengrad export came back Frontier 52, Inflation 52, Mercantile 51, Provisioned 51, Patrician 51, Scavenger 50 — a two-point spread across six archetypes — which the share arithmetic dressed up as "19% Frontier, 18% Inflation, 16% Mercantile…". That reads like a considered blend and is nothing of the sort. The cause was a world export carrying no player. With no purse there is no growth ratio, so only two of three dials scored, and with coverage flat at 1% every archetype nailed one dial and missed the other outright, landing them all on 50. Normalising a flat profile produces confident noise, and confident noise is worse than silence because it will be believed. So a profile is reported only when the best fit stands clear of the middle of the pack, and otherwise the pass says plainly that the world resembles nothing in particular and which dial it was missing. Each archetype's fit now carries the number of dials it was scored on, so a two-dial score is no longer indistinguishable from a three-dial one. The same world spliced against a real starting purse reads 56% Scavenger, 44% Depression on all three dials — which is the reading the numbers should have given in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
It had been living as Markdown under Notes/ while every other system's write-up is a self-contained styled HTML page in Designs/, read in a browser and indexed from that README. The economy is a shipped system now, not a working note, so it belongs where the rest of them are. Converted rather than pasted: the palette, section furniture, formula blocks and locked/open decision cards are the shared style the other docs use, the numbered flow lists carry the invariants and the GM brief, and the gaps became proper open-decision cards so what is unresolved reads at a glance. Added a closing map of where each piece actually lives in the tree, since the table is duplicated across two files on purpose and that is worth being able to find. The comment in text_adventure.html that pointed at the old path now points at the new one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The brief said "this world IS a Frontier economy" and then listed rules, which invites the one failure that matters: a GM quietly bending a scavenged-wasteland premise into whatever shape satisfies the arithmetic. The archetype is one input among the theme, the premise, the rules, the prologue and the narrative, and it has to be reconciled with them rather than imposed on them. Where they genuinely pull against it, they win. Which means an honest blend is the ORDINARY result and not drift to be corrected. A rich city ringed by a scavenged wasteland is one world, not a world that missed. The pass already reports what the reconciliation produced — 60% Depression, 32% Scavenger — and that is the sentence a DM can hold against their intent; what the degrees should actually be is their call, taken with the story in view. The wording follows the design rather than the other way round: reported, not corrected, and the degrees are yours to set. The one thing the GM may not do is discard the target silently, and the conformance score is what catches that. Regional economics is written down and deliberately left alone. The pieces exist — rooms carry a region, wealth already partitions by reach, the ratios are scale-free — but the global mechanisms have only met fixtures so far, never a real authored world. Splitting an unvalidated model per region multiplies the errors instead of finding them. Prove the single economy first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
"Off-archetype" is not something an author can act on. It does not say by how much, which of the three dials to touch first, or whether last night's edits helped. So each dial now reports its distance from the nearest bound as a fraction of that bound, scores out of 100, and the world scores their mean — a number that moves whenever any one dial moves and can be watched across a night of editing. A distance still leaves the author converting a ratio into a change to the world, so each miss carries the edit that closes it, in copper: place another 464c of findable wealth, move 13c out of the starting purse, add 200c to the shelves. The coupling is stated rather than hidden — coverage and growth both rise with findable wealth, so the fixes are alternatives to weigh and not a list to work through. The larger addition is that every world is now graded against EVERY archetype, declared or not, and the closest reported as shares of resemblance: 60% Depression, 32% Scavenger, 8% Frontier. That answers a different question from conformance — not "did you hit what you declared" but "what did you build" — and it needs no declaration, so an author who has never thought about the economy still gets told what theirs looks like. It also turns the verdict from a judgement into a description. A world declared Depression that comes back leaning Scavenger has not failed; the GM may have gone that way for a narrative reason the brief never contained, and every Depression world playing identically would be the worse outcome. So while the declared archetype is still what the world most resembles, the drift is reported at info and named rather than warned about — a creative choice flagged as a warning teaches an author to ignore warnings. The final say was always the DM's. And since 80/20 is a real intent that neither archetype expresses alone, a world may declare a blend, whose bands are the weighted average of its parts. No picker for it yet: the workflow runs the other way, in that the profile tells you which blend describes the world you already have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The balance pass could measure an economy but had no standing to judge one, because the shape of an economy is a choice. So the author now picks an archetype in World Builder and it does two jobs from one control: it briefs the Game Master before generation, and the pass grades the finished world against it afterwards. The declaration is stamped on by the app rather than read back from the GM's output — the intent has to survive the case where the generated economy misses the brief, which is the case grading exists to catch. Each archetype is half arithmetic and half prose, and the prose is not decoration. Scaling every price and every purse by the same factor changes no ratio at all, so the numbers cannot tell a Depression from an Inflation — one has bare shelves and small sad figures, the other has coin heaped everywhere and a loaf of bread at 10,000. The bands say what must be true of the economy; the texture says what it should feel like, which numbers to reach for, and what the player should be FINDING, since a world that hands you purses is not the world that hands you a blade still in a dead hand. Rich and Poor did not survive as archetypes. They name an absolute scale, and absolute scale has no effect on play — a world where a sword costs 5000 and you find 50000 plays exactly like one where it costs 5 and you find 50 — so what those words reach for is always one of the three ratios. Imbalanced did not survive either: every archetype but Mercantile is imbalanced relative to Mercantile, so the word names a deviation rather than a design. The table is defined twice, since text_adventure.html cannot require anything, and that duplication has teeth: an author picks Frontier, the app briefs one set of ratios and the pass grades against another, marking the world off-archetype for hitting exactly what it was asked for. A test reads both literals and asserts they agree, and that the picker is built from the table rather than hand-listed in markup — a hand-written list would be a third copy, and the one the author actually sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The economy checks had been growing one finding at a time, each carrying an unstated assumption about what a world should want. That assumption is wrong at least half the time: "every vendor item is eventually affordable" is a deliberate setting, and so is "the shop is a menu and you will leave most of it behind." A check that assumes either nags correct worlds and passes broken ones. So the rules are split by who decides. A short list of invariants that hold in any posture — chiefly that nothing the story requires may be unaffordable, and that progression items must be affordable when they are needed rather than merely eventually. A posture the world declares for itself, so the pass grades against stated intent instead of a universal ideal and the DM does not have to re-explain what they meant to every future run. And guidelines under that, which report and let the DM read them. The same split settles what a cost-locked item means: a defect in a world that promised everything is buyable, and the point in one that promised otherwise. Two proposals are written down but not built: the declared posture, and challenge gating — coverage says the wealth exists, not that anything guards it, and a world with its riches in the safest room is not the world it looks like on paper. Gating waits on AC, since armour cannot currently be weighed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A starting purse that can already buy the shop is not too much wealth, it is wealth in the wrong place. Verengrad's character begins with 1500c and the whole world adds 68c across eleven rooms, so the growth curve is flat by construction: every find afterwards is rounding on what was handed over at character creation. Removing coin would fix the curve by making the world poorer, which is not the same fix. So the suggestion conserves the total and only moves it. The opening purse is sized to the cheapest weapon plus the cheapest armour — the two choices that make a build one's own — and the remainder is reported as coin to place in the world or goods to sell. Same wealth, earned rather than granted. For Verengrad: 70c to start, 1430c redistributed, total unchanged at 1568c, and a growth multiple that goes from 1x to 22x. The starved case now reads the price list rather than the shelf. Cost-locked items were being left out of the opening-kit budget, so a world too poor to afford anything reported that it sold nothing at all — the one case that most needed reporting. What a kit costs is a property of the prices; whether it can be paid for is the separate question the pass already answers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A total answers "ever affordable". It does not answer the question a player actually faces, which is whether they can afford the thing while standing in front of the vendor. Meeting a merchant two rooms in with a fifty-gold sword on the counter and fifteen in your pocket is not a bug; whether the coin to come back with is three rooms away or the whole world away is the difference between a goal and a tease, and that is not readable by eye across a map with this many paths. So the pass now emits cumulative wealth by reach from the start, and reports for each merchant-only item the depth at which it first becomes affordable. An item that only comes within reach once every room has been emptied is called out, because it reads as a purchase long before it is one. The same curve answered a question nobody had asked. Verengrad's character starts with 1500c and the entire world adds 68c across eleven rooms — the player begins as rich as they will ever be, and every later find is rounding. That is invisible from any single room, and it decides whether "wealth must be earned" is true of the world or only of its intentions. Flagged in its own right, since it holds whether or not anything is cost-locked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An identity-gated item carries one value, its true one, so a vendor who cannot identify it trades at a price the arithmetic never sees — which overstates both what selling one raises and what buying one costs. And prices are negotiated in character, so value is the anchor the GM reasons from rather than a contract it is held to. Neither breaks the bound, which is already generous in the player's favour, but a number that looks exact and is not will be read as exact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cost locking is the first balance property that can be settled without playing. Combat difficulty needs a character to meet an encounter; affordability is arithmetic over prices and placements, so it can be checked the moment a world is authored. The verdict is three-way and only the first is a world bug: the world holds less wealth than the item costs, so nobody can ever buy it; the wealth exists but not yet, where it is needed; or the player spent it badly, which is a fair defeat and the lesson is the point. The pass bounds the first, generously — every placed item sold at full value, merchants stocking anything asked for. Real play returns less, so a flagged item is unaffordable even being maximally kind to the player, while an unflagged one is merely not provably locked. Two neighbouring checks came with it, now that prices mean something. A weapon that hits harder AND costs less than another is an inversion no pricing scheme should permit — each editor tab prices its own kind against archetypes without seeing what the others cost, so this is a likely outcome rather than a hypothetical. And armour with no "ac" is reported as uncheckable rather than silently passed, with a separate word when it has fallen behind the weapons around it — Verengrad's best armour currently costs less than its cheapest weapon, a repricing pass having moved one and not the other. The inversion rule had a false positive on its first run, against real data: the Drowned Longsword out-damages the enchanted Sable Undertow for less coin, which is exactly how a +1 weapon should be priced. An enchanted weapon's price buys the enchantment, and none of that shows in its dice, so the comparison is now between mundane weapons only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The World row appeared only when New Game was checked, so the saved-worlds library — and with it the only route to the World Editor for a library world — was reachable only by first loading a saved game that used that world. A world nobody has played yet has no such save, so it could not be opened at all. The row is now always visible. Choosing a world still only applies to a fresh game, so the select is disabled while a save is being continued, with a line saying so; the library and upload buttons stay live either way, because opening a world in the editor or adding one from a file has nothing to do with which game is about to be entered. The per-world edit action already existed in the library menu and already bypassed saves — it was simply unreachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was a raw number input, so it wore the browser's own spin arrows — the one numeric control in the app doing that. The app hides the native spinner everywhere via .stepper-input and drives it with themed minus/plus buttons instead, so the field now uses that same composition. Stepping from an empty box starts at the default rather than at zero, since a blank field already reads as 'this will pay 12' and the first nudge should agree with what it says. Browser-verified in DM mode: gold buttons, dim placeholder, no native arrows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Until now, unlocking one paid nothing. That was invisible while every hook also elevated, because the loreDiscover entry written beside it carried the XP. Once elevation became a real judgment and the GM began correctly declining it, the gap showed: a driven run dove under Scaffold Landing's pilings, passed a CON check, took a chilled affliction for 3 HP, earned the place's authored secret, and was paid nothing at all. The value is authored on the hook rather than judged at unlock time. A secret is worth what it is worth whether it is earned on turn three or turn three hundred, the difficulty of earning it is already authored as the loreKey, and a per-turn judgment made once per hook across a world is the shape that produced every spread this project has measured. So: a loreXp field on places, beings and items, editable by the DM in the Compendium's Lore section, applied by all three editor tabs from what the GM returns, and normalized on load. Undecided and worthless are kept distinct. A blank field means "not decided" and pays a default of 12; an explicit 0 means the author judged it worth nothing and silences the reward. That distinction is also where the one real bug was — Number(null) is 0 rather than NaN, so every hook in every world authored before this field would have read as priced-at-zero and paid nothing, which is exactly the case the field exists to serve. Two things came along with it. The Rooms tab could previously author everything about a place except the secret it keeps — a third of an authored world's hooks — so it now takes lore and loreKey as well. And each editor tab is a separate GM pass that cannot see the others, so all three stateted the same absolute band rather than each ranking its own kind against itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: the same .glb shows no blocky patchwork in a three.js viewer. That ruled out my earlier conclusion that the pattern was baked into Tripo's albedo atlas. It was not. It was ours. glTF puts UV (0,0) at the image's TOP-left, and uploading an HTMLImageElement with UNPACK_FLIP_Y_WEBGL FALSE is what achieves that: the image's top row becomes texture row 0, which is where t=0 samples. The code set it TRUE — putting the image's bottom row at t=0 — so every UV read the atlas mirrored vertically. The line carried a comment citing glTF's top-left origin as the REASON for the flip, which is exactly backwards. three.js arrives at the same place from the other direction: its GLTFLoader sets texture.flipY = false. Why it survived so long: it did not look like a flipped texture. A generated atlas is a patchwork of UV islands separated by padding, so sampled upside down each triangle lands on a different island or on the padding between them. The result was hard-edged patches of plausible-but-wrong colour — which reads as a rendering artefact, not as a mirrored image. And it was pinned by a test asserting the same mistaken reasoning, so the belief was written down twice and guarded. Both are corrected. Measured on the reference models against the original viewer: hard-bread contrast 24.9 -> 29.7 saturation 0.467 -> 0.632 rusty-key contrast 19.5 -> 21.6 saturation 0.496 -> 0.585 A note for whoever meets this next: the synthetic test texture used earlier in this investigation was a symmetric grid, which looks identical either way up and could never have caught it. The real model was the only thing that could. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The viewer read only baseColorTexture. A Tripo material also carries a normal map and a metallicRoughness map, and the normal map is where most of a generated model's surface detail lives — the albedo atlas is a patchwork of flat UV islands, while the crust, grain and brushwork are relief in the second texture. Tangent-space normal mapping WITHOUT a TANGENT attribute, which these meshes do not ship: the frame is built per-fragment from the screen-space derivatives of the view position and the UV (Schuler's cotangent frame). That needs dFdx/dFdy, which in WebGL 1 is OES_standard_derivatives, so the block is spliced into the shader only when the extension is there — the extension has to be obtained before a shader declaring it will compile — and the fallback is the plain vertex normals it drew with before. On that path the map is not even decoded. Two things this needed that are easy to miss. The vertex stage passed a NORMALIZED view direction; the frame needs the position itself, so it now passes that and the direction is derived. And the fragment stage asks for highp where available: the derivative of a view position is a small difference between values around 5.0, which mediump can barely represent. The roughness-driven highlight came with it, deliberately. A normal map on a purely diffuse surface only nudges a broad cosine term and is all but invisible — rendering the perturbation alone, it had to be amplified five times before it could be seen. The highlight is where a perturbed normal actually shows. It is a single Blinn-Phong term, white rather than albedo-tinted, tightened by the material's roughness (glTF packs roughness in G of the metallicRoughness map). This is not a PBR model and does not pretend to be. Measured on the two reference models, against the additive-rim original: hard-bread contrast 24.0 -> 28.2 saturation 0.453 -> 0.606 rusty-key contrast 21.0 -> 22.6 saturation 0.494 -> 0.588 The ASCII guard now scans every GLSL template literal rather than two named constants — the fragment shader is assembled from pieces, and a guard that knew only FRAG had quietly stopped covering the code it exists to protect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The reference models checked in are 14.0 MB and 4.4 MB. Measuring where the bytes
go answers which knob matters:
hard-bread.glb 14.02 MB geometry 13.27 MB (94.7%) textures 0.74 MB (5.3%)
250,908 verts, 490,734 tris, three 2048x2048 JPEGs
It is not the textures. It is 490,734 triangles for a loaf of bread — and
rendered at the largest size the app ever shows one (the lightbox, where it
covered 60,786 pixels) that is EIGHT TRIANGLES PER PIXEL. Every one of them
rides in the save, through the media store, and up to the GPU.
Tripo's image_to_model task takes `face_limit`. The direct-mode client had always
accepted opts.faceLimit and nothing ever set it; the vault path did not carry the
field at all, so a vault-mode DM had no way to ask for a smaller model. Both
routes now resolve the cap in one place and send it.
A new Settings > 3D AI > Detail row chooses it, defaulting to 40k triangles:
face_limit 40000 -> ~1.8 MB (7.7x smaller) 0.66 tri/px
face_limit 20000 -> ~1.3 MB (10.9x smaller) 0.33 tri/px
face_limit 10000 -> ~1.0 MB (13.9x smaller) 0.16 tri/px
Even 10k stays finer than the screen can resolve at that size. "No limit" remains
available and is what every existing model was made with.
Deliberately NOT touched: texture/pbr. Turning textures down would cost visible
quality for 5% of the bytes, and the pbr flag is what carries the normal map —
which turns out to be the one thing our viewer still ignores that three.js uses
(a separate finding, not addressed here).
Zero or absent means uncapped and omits the field entirely rather than sending a
zero Tripo would have to interpret; negatives are dropped and fractions rounded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLReported with two screenshots of the same .glb: ours rendered it as a pale milky
wash, three.js rendered the same file with its real colours.
The texture pipeline was not the problem. Sampling, LINEAR filtering, mipmaps,
anisotropy, the UV origin, the sRGB round trip, depth, culling and blending were
all verified correct, and the view-space normal transform was checked numerically
— the camera-facing normal comes out exactly (0,0,1).
It was one character. The rim light read `+ vec3(rim)`: a constant ADDED in
linear space, immediately before the sRGB encode on the next line. Adding a
constant to a linear value before a gamma encode is a black-point lift, and 0.22
linear encodes to roughly 0.50 sRGB — so nothing in the picture could be darker
than mid-grey however dark its texel was.
Measured by rendering a textured sphere and reading back its 36,612 model pixels:
mean luma contrast (std dev) saturation darkest px
additive rim 150.4 19.7 0.165 95
multiplicative rim 86.4 43.4 0.590 22
Saturation collapsed by 3.6x and contrast halved. Multiplying keeps the edge lift
the rim exists for while black stays black; its coefficient is raised (0.22 to
0.30 with a 1.4 gain) because a multiplicative term of the old size is invisible.
Also learned the hard way and now guarded: GLSL ES restricts its source character
set, and a non-ASCII character inside a GLSL COMMENT fails compilation. The first
draft of the note explaining this fix was written inside the shader and silently
broke it — `node --check` passes either way, since the JavaScript is fine. The
new test asserts both shader sources are pure ASCII, and the rule is written down
beside them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe sidebar Inventory panel populates correctly — the column scrolls and the block was below the fold. At this viewport the Portrait and Character blocks fill almost exactly to the bottom edge, so the column does not read as scrollable, and a visible header above invisible contents looks like an empty panel rather than a clipped one. Four other blocks had never been seen either. The return to the login screen is the inactivity timeout, which is a setting. Worth keeping only because a driven run that pauses between batches will be logged out and keep accruing in-world time, so a Rested-to-Exhausted slide across a gap is not a balance signal. What remains is the world picker, which was real and blocking and is fixed, and a tooltip covering a field label. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upload was a full-width text button; it is now a ⬆ icon button paired with a new ⬇ Download, styled as the same icon row the image column above already uses. Download saves the item's model to a file. It has to hide three different storage shapes: a same-origin /vault/media URL (the usual case behind the vault), a data: URI (Direct mode, where there is nowhere to put bytes), and an external https URL. fetch() reads all three, so the button does not need to know which. The EXTENSION does follow what is actually stored — an uploaded .gltf is kept as model/gltf+json, and naming that file .glb would hand back something no viewer would open. Download is gated on the item having a model, and says why when it does not; an enabled button with nothing behind it is a promise the card cannot keep. Upload stays ungated, as before: Generate converts the item's icon, but an upload brings its own model and needs no source image. Rather than write a second anchor-and-blob download, downloadJsonFile's picker + fallback path is generalised to downloadFileBlob(data, filename, pickerType) and downloadJsonFile becomes a thin wrapper on it. One consequence worth noting: the save-dialog path is now handed a Blob rather than a raw string. A FileSystemWritableFileStream takes either, so the file on disk is unchanged — test_new_world.js asserted the carrier's type and now reads its content instead. Verified in a browser: the pair renders as ⬆/⬇ with the old "Upload" text gone, Download is enabled on an item with a model and disabled with the reason on one without, and pressing it saves "iron-sword.glb" from a blob URL while the item with no model reports instead of saving nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Generating an item icon and then pressing Generate for its 3D model failed with
"source image must be a data: URI or an https URL".
When the media store is on, a generated image's URL is one the vault minted —
/vault/media/<shard>/<sha>.png — which is RELATIVE, so neither a data: URI nor an
absolute https URL. Every second-pass route wants one of those two, and each
fails differently:
• runModel3d refuses it outright — the reported error.
• runVideo forwards it verbatim to a provider that cannot fetch a path on our
own host.
• runDescriptor's init-image injection drops it SILENTLY — not data:, not
http(s) → empty → no inline part → the request goes out as text-to-image and
nothing anywhere says so. A gallery variation quietly stops varying.
It looked time-related but is provider-related, which is why a reload did not
help and why it worked the day before. Pollination hands back its own https URL
when no token is set (urlIfNoKey), so icons made that way sailed through; an icon
made by any provider whose bytes the vault fetches lands in the store instead.
Switching Icon AI to Nano Banana is what crossed the line.
The bytes are already on this disk, so they are resolved here rather than making
the browser fetch its own vault and post a megabyte back. One helper, applied at
all three entry points — the gap existed because each route did its own thing
with the incoming field. Path handling is delegated to MediaStore.resolve, whose
shard/sha/extension guards already exist; a media URL with no file behind it
passes through unchanged so the caller reports its own error rather than this
turning "missing file" into "no source image".
Verified against a real store: the raw URL reproduces the reported 400 and the
resolved one completes the whole Tripo flow, and the silent init-image drop goes
from one part to two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLFiled as a UI problem, and it is not one. loreThumbImage resolves an explicit subject before it ever falls back to scanning the entry's text, and the fallback never ran — the GM had set a subject on all four entries. Three of the four came out of one conversation with Mira and all three name her, including a card about a canticle and one about a role. So the engine renders what it was told, and the contract asks for what the lore centers on. The GM is using the field for where it got the lore from instead. Either tighten the wording, or split subject from source and keep the provenance it evidently wants to record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The label changed upstream; the comments and test labels that quote it by name were still saying "Include Scenery". The setting KEY (portraitScenery) is deliberately untouched — renaming that would orphan every saved preference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Nano Banana Pro does not return a transparent PNG. Asked for one it PAINTS a grey-and-white checkerboard — a picture OF transparency — which then sits in the inventory as an opaque checkerboard behind the icon. So the prompt names the app's own background colour instead, as one flat solid field, and calls the checkerboard out as a thing not to draw, because it is what the model reaches for when transparency is mentioned at all. Nothing downstream changes: .item-icon-img already composites over var(--bg), so a flat field of that colour blends seamlessly, and its background-color still covers an older icon that does carry alpha. Background: one flat solid #0d0b08 field — never white, never a checkerboard or transparency pattern. The subject stays first (offset 30) and the prompt stays short (641 chars). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The previous message asserted a specific mechanism — that Flux and the SD family encode ~77 CLIP tokens and drop the rest — as the cause. That is not something this codebase can measure, and it was stated as fact in a code comment and a test where it would be read as established later. What the evidence actually shows: Image AI and Icon AI are independent settings, so the reported session ran the item's IMAGE through Nano Banana Pro (a correct shortbow) and its ICON through Pollination, the Icon AI default (the portrait) — same world, same art style, same moment. And the icon prompt was the only one in the app long enough for position to matter: every other prompt measures under ~310 characters end to end, so its subject sits near the front regardless. Where a given model stops attending is not knowable from here, and the fix does not depend on it. A prompt that names its subject in the first six words is robust to any cutoff; one that buries it 45 words behind a competing instruction to paint a moody oil painting is fragile under all of them. No code changes — comment and test wording only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported with a screenshot: an item's large image was a correct shortbow, but its ICON — the glyph in the inventory row, the equipment paperdoll and the popup's Icon cell — was a moody painted portrait of a woman. The cause was where the subject sat. paintImageFromPrompt prepends the world art style, and the icon prompt then spent six sentences on specification before naming the item. Measured, against a world whose art style is 238 characters: prompt length ............. 1499 characters (Pollination GET URL: 2199) "Shortbow" first appears at char 318, 45 words in inside the first 300 chars? NO Image models do not read an unbounded prompt — Flux and the SD family encode roughly 77 CLIP tokens, call it 300 characters, and drop the rest. So what the model actually received was an instruction to paint a moody oil painting and no subject whatsoever. It painted a moody oil painting of a person. Room banners and character portraits were never affected because their prompts are short: a banner measures 286 characters total, so its subject still lands inside the window. Only the icon prompt was long enough to push its own subject out. So: name the thing in the first few words, spend the rest of the budget on the constraints that matter, and put the world art style LAST — appended here rather than prepended by paintImageFromPrompt, via ignoreWorldArtStyle so it is not added twice. If anything is truncated now it is the styling, which costs an icon that does not match the world's look; losing the subject cost an icon that was not the item. The item description is capped at 100 characters for the same reason, so a wordy item cannot push the constraints out the way the style pushed the subject. subject offset ... 318 → 30 prompt ... 1499 → 586 URL ... 2199 → 906 The old URL also sat on the classic 2048-character proxy limit; the new one has room to spare. Not verified against the live provider — this environment cannot reach pollinations.ai — so the mechanism is inferred from the measured prompt shape and the reported output, and the fix is written to be right regardless of where any given model truncates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
It did not close the window. It stripped the detach params and reloaded, turning the popup into a second fully live app instance sitting at its own login screen — able to start a game and write the same save as the window that opened it. And closing is not merely the alternative exit, it is the one the app is built around. detachEditor and detachTab tear a tab's DOM out of the main window, hide its tab button, and poll win.closed to put it back; watchDraftEditorWindow polls win.closed to clear the login screen's "Editing … in a separate window" notice. A window that navigates instead of closing satisfies none of them. Measured before removal: pressing it left the main window with _editorDetached still true and the Editor tab still hidden, and left the login notice up — with no way back except closing the popup the button existed to avoid. Removed from all three headers, not just the Editor's. The tab viewers have the identical defect (detachTab tears their view down the same way); the Guide's was harmless but equally pointless. Every route to a detached window is window.open — there is no same-tab route to ?detach= — so the window's own close control is always available, and it is the only exit that runs those watchers. Verified in a browser: no power button in the Editor, Guide or viewer headers; the publish button and the viewer's "Live view" badge are untouched; and closing an Editor popup restores the main window's tab, closing a viewer restores its tab, and closing a World Editor clears the login notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A fresh game driven entirely by clicking and typing, to surface the things a scripted run walks straight past. The blocking one is already fixed: the world library could not select a world at all, so the intended flow of starting a new game on an authored world was impossible from the interface. Worth noting how it hid — the library listed the world, the click registered, no error appeared, and only the absence of a selection gave it away. Two more stand. The sidebar Inventory panel never populates: the character carries three items, Character to Equipment lists all three, and the sidebar block stayed empty across seven turns including a level-up, so nothing in a normal session fills it. And lore cards infer a thumbnail when an entry names no subject, which put Mira's face on a card about a liturgy — the inference doing what it was asked, and a neutral placeholder reading better than a confidently wrong face. Also recorded what worked, since a bug list alone is a distorted picture. Ability checks render their arithmetic legibly, level-up and title and skill gain all land in one place, NPC portraits shift with the scene, and the elevation cross-link added yesterday shows up in the interface exactly as intended. The run also produced content evidence worth carrying over: two hook unlocks both elevated with declared links and both adding meaning rather than restating, one hook unlocked with no elevation at all — the discretion tier working for the first time, where the previous run elevated every one — and an authored codex entry discovered rather than a new one minted beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pickWorldFromMenu became async so it can rebuild the dropdown before assigning to it; the test called it synchronously and read the value before it was set. Also asserts the rebuild happens first, as a source check — the mock select's options array is static and cannot represent the list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picking a saved world from the library emptied the World row instead of choosing it, and no amount of clicking the dropdown could reach the world either — so on a cold load there was no way through the interface to start a new game on anything but the built-in starter world. The dropdown is primed once during login render, before the saved-worlds library is readable, and the only thing that rebuilds it afterwards is TOGGLING the New Game checkbox. On the common path that toggle never happens, because the box starts checked when there is no save to continue. So the list held nothing but "Default", and assigning a name with no matching option does not select a world, it silently blanks the field — no world staged, and nothing on screen saying why. Rebuild the list before assigning to it. Browser-verified from a cold load: picking Verengrad now selects it, swaps in its backdrop and classes, and reports its room count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Logging out and back in and asking for an image reported "Start a game first" or a missing key, and a page reload fixed it. Two defects, both on the CONTINUE path, and the reload is the clue to the first. restoreGameState reloads the Claude key from storage. In Vault mode storage is empty by design — the vault holds the key — so that reload faithfully returns nothing and wipes the sentinel every `!apiKey` gate in the app depends on. Boot auto-resume had always put it back; Continue never did. That is why a reload worked while logging out and back in did not: the reload came in through the boot path. The branch's own recovery read the login key field, which Vault mode deliberately empties and disables, so it recovered nothing and left the key blank. Not actually intermittent — it hit every Continue. New Game was fine, which is what made it look occasional. The race was real too, and separate: `await ensureVaultDetected()` sat below the resume branch, so Continue never waited for the vault probe. Whether isVaultMode() was true by then depended on how fast /vault/config answered — and on a slow vault a Vault-mode player could be sent down the Direct-mode "no key entered" path. The probe is now resolved at the top of startGame, before any branch. The adoption rule lived in five scattered copies of the same three lines, which is how one of them came to be missing. It is now one helper that awaits the probe before deciding, returns whether this is a vault so Direct-mode callers can take their own no-key path, and is used by all of them. The sentinel is assigned in exactly two places now: the probe, and that helper. Measured against a simulated vault before the fix: at boot apiKey was "vault-managed"; after Continue it was "" and every gate failed; after New Game it was "vault-managed". After the fix all three hold the sentinel, including with a 2.5s-delayed /vault/config where the probe is still in flight when Continue runs. Direct mode is unchanged — a typed key is still adopted on Continue and on New Game, and a missing one still blocks the login. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Wired the same way every other provider is: a registry entry with the slots it
serves, a per-slot model setting, an API Keys card, and a vault descriptor so
the key can live on the server instead of the browser.
Two things about this provider are easy to get wrong quietly, so both are
pinned by tests:
• The size is an ENUM (1024x1024 / 1024x1536 / 1536x1024), not free
width/height. Asking for our own 512x640 does not fail — it returns a
picture of some other shape, so a portrait comes back square and nothing
says why. The three app shapes map onto the nearest accepted value.
• No response_format. The GPT Image models always return base64 and reject
that parameter, so sending it to be explicit would fail every request.
OpenAI is offered in the three text-to-image slots (Image, Icon, Map) and in
neither image-to-image slot. It does edit images, but through a separate
multipart /images/edits endpoint — a different transport, not a field on this
one — so listing it under Gallery or Weather would put it in the picker and then
paint a fresh face instead of varying the player's portrait.
Also fixed a latent gap this surfaced: the descriptor engine read provider usage
from a hardcoded `usageMetadata`, which is Gemini's name for it. Any second
provider reporting token counts under its own key tallied nothing and its admin
ledger row read as free. The path is now the descriptor's to declare, defaulting
to the Gemini field so nothing changes for it. usage.js already understood both
field names.
Verified in a browser: OpenAI appears in the Image/Icon/Map pickers and in
neither image-to-image picker, its Model row shows GPT Image 2 and hides the
others, the key round-trips encrypted through the API Keys dialog, and a banner
generation issues one POST to the Images API carrying model gpt-image-2, the
world art style, size 1536x1024, and a bearer token — returning a data URI.
Note: no dollar rate is registered for gpt-image-2, so the admin ledger will
show its token counts without a cost figure until one is added to pricing.js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe setting told the GM to describe the surroundings in words. Words carry a
palette badly: the directive can say "a snowbound square at dusk" and the model
still paints whatever dusk it likes. The room's banner IS the picture of that
place, already painted in the world's style, so it now travels with the prompt
as a colour and style example.
An attached image is ambiguous on its own — given a picture and a prompt, a
model has no way to know whether to edit that picture, put its subject in the
new one, or borrow only its look. So the attachment is always named, and the
sentence leads: "Use the attached image as a color and style example. The image
will contain the following: ". It is prefixed inside paintImageFromPrompt rather
than at the call site, because the world art style is prepended there — applied
earlier it would sit behind the style, describing an attachment several clauses
after the model has been told what to paint.
A style reference is not an init image and must not be confused with one. An
init image is the subject ("here is the character, vary them") and carries the
"keeps the same character" framing; a room banner has no character to keep. The
new opt rides the same inline transport (and the same vault initImage field the
nanobanana descriptor reads) but keeps the ordinary text-to-image instruction.
Attached only where it can be. Pollination's image-to-image is kontext, which
edits the source picture — handed a room banner it would return a painted-over
room, not a person standing in one. So the reference is Nano Banana only, and
when it cannot be attached the sentence is not written either: a prompt claiming
an attachment that never arrived is worse than one that never mentioned it. The
banner is read to bytes before the prompt commits to mentioning it, so a
CORS-blocked cross-origin banner degrades to a plain portrait with a Logs line
rather than a request describing nothing.
generateImageWithProvider built its opts from scratch and dropped anything the
caller passed; it now merges them.
Verified in a browser against an intercepted Gemini call: with a reference the
body carries two parts (inlineData then text) and the prompt leads with the
sentence; without one it carries a single text part and no sentence; with
Pollination selected neither appears; and the vault params keep the variation
wording for an init image while a style reference gets the plain instruction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLBoth buttons opened the same thing: a world DRAFT keyed by world name. So a DM
who used "Edit this save" to replace a login logo, then published it, still
found the old logo in their game — correct for a draft, baffling for a button
labelled "save". The shared draft made it worse: what "Edit this save" showed
was often leftover unpublished work rather than the save, and it looked
authoritative.
Now the URL says which document is open, and the two are exclusive:
?save=<key> the SAVE ITSELF. Loads the world stored in that snapshot and
writes edits straight back into it. No draft, no library, no
publishing — there is nothing to publish to. The per-save
workbench: unlock a quest beat, fix one player's world, swap
the login logo in that game.
?draft=<name> the LIBRARY's world, through a working draft, published with
"Update Library World" so new games seed from it. Never
touches a save.
The save writer is a read-modify-write on every flush that replaces `world` and
nothing else, so editing the world of a game in progress cannot roll back the
player's story or position. It updates the library entry, and the raw session
slot as well when that save is the active playthrough — both, or the edit shows
up in the save picker and not in Continue.
The save editor has no publish button and no unpublished hint; it shows a scope
chip naming whose save it is. "Edit World" now looks the world up in the library
and opens that copy, falling back to the save's own copy only when the world was
never published, and saying so. The draft editor's "Reset from Saved Game" reset
a library draft from the active save — a different world entirely under this
split — and is now "Reset from Library".
Also: the power button stripped detach/draft/player but not save, so it reloaded
back into the editor it was leaving. And IS_LIBRARY_WORLD_EDIT was dead code
whose comment promised an automatic draft→library mirror that had been removed.
Verified end to end in a browser: the editor loads the save's world (not the
library's), the flush lands in both stores with the player's story and character
intact, the world library is untouched, no draft is created, and editing a
non-active save leaves the active session alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLOpening "Edit World" on a save made BEFORE a published world change showed a red warning saying the draft differs — which is true, and which is also exactly what a correctly-published world beside an older save looks like. Nothing was wrong, nothing needed doing, and the notice implied both. The two divergences are not the same thing and should not read alike. A draft that matches the library is settled: the editor and the library agree, and the save is simply older, because publishing deliberately never rewrites a save in progress. That case is now styled as information rather than an error, leads with what actually happened instead of with "it differs", and states the rule behind it outright — publishing never touches a save in progress — since that is the question the notice exists to answer and it was being left to be inferred. A draft that is NOT published keeps the warning. There the work exists in one place only, which is worth interrupting for. 452 client tests and 17 server test files pass.
Select a save and both its backdrop and its music take over — correct. Tick "New Game" and the backdrop returns to the built-in world's while the save's music keeps playing. Login cues resolve through three layers: the world STAGED for a New Game, then the SELECTED SAVE's, then the live world's configuration. Ticking New Game cleared only the staged layer, so resolution fell straight through to the save's — and the screen showed the default world under the previous save's music. The same split this was fixed for once already, one layer down. The saved game's cues now stand down while New Game is checked. Suppressed rather than cleared: unticking has to bring that audio straight back, which it does. The flag is real state rather than a read of the checkbox, and that is the part worth keeping. Swapping what is actually sounding needs a signature from before the change and one from after, and by the time an onchange handler runs the checkbox has already moved — both reads would be identical, the swap would see no difference, and nothing would ever be re-cued. For the same reason the suppression happens at the top of the toggle, while the save's cues are still what the screen resolves to; inside resetLoginToDefaultWorld it is already too late. A no-op flip returns early, so nothing restarts needlessly. Verified in a browser against a save configuring its own Login cue: select save → Audio/Creepy.mp3 background …SAVEBG tick New Game → Music/Intro.mp3 background default untick → Audio/Creepy.mp3 background …SAVEBG 452 client tests and 17 server test files pass.
Not a flake — a bug I introduced yesterday, and restarting is exactly what hides it. The watcher that clears the "Editing …" notice when the editor window closes matched on the phrase "separate window" inside the note's own text. The divergence wording added yesterday does not contain those two words, so it could never be cleared: it survived closing the editor and switching saves, and only a page load removed it, because populateLoginScreen clears the note unconditionally. That is why a restart made it vanish. The watcher is now handed the exact text the open set and clears only that, so a change of wording cannot break the clearing of the thing it words. A notice that is NOT the one it set is left alone — clearing whatever happens to be there would wipe an unrelated message. Selecting a different save also clears it now, since a notice about the previous save's draft is worse than none once that save is off screen. The advice was also wrong, which is why the editor header showed no Unpublished-changes hint after the notice claimed there were unpublished edits. Those are different questions: the notice compares the draft to the SAVE, the editor's hint compares it to the LIBRARY. A draft can match the library exactly — nothing to publish, no hint — while still differing from an older save, and sending that DM to "Update Library World" points them at a button with nothing behind it. The library is now consulted too, and the notice says which case it is: publish it, or the draft is already published and this save is simply older, so start a new game on the world to play the newer one. 452 client tests and 17 server test files pass.
Deleting the active save from the "Your Name" menu while "New Game" was checked, then unchecking it, left the screen making claims that could not all be true at once. Reproduced in a browser, the state read: New Game unchecked (so: continue) world picker hidden (so: no world to pick) no resumable save note empty Begin enabled Pressing Begin there would have quietly seeded the built-in world, with no way to have chosen another and nothing on screen saying so. The resumable cache was correct throughout — deleting a save does refresh it. What was missing is that the UI never said what "unchecked" MEANS when there is nothing to continue. With no save it is not a choice: every Begin starts fresh. So New Game is now forced on and locked in that case, which puts the world picker back on screen, makes the button label honest, and replaces the blank note with "No saved game to continue — choose a world below and begin a new one." The lock carries a title explaining itself, and lifts the moment a save exists again — loading one from the menu refreshes this. The correction happens before `continuing` is derived, or every downstream decision would still read the stale checkbox; a test pins that ordering. An existing world-select assertion — "unchecking New Game hides the world row" — was written with no save present, which is exactly the case whose meaning changed. It now drives both: with a save, unchecking hides the row and means continue; without one, the row stays up and the checkbox springs back. 452 client tests and 17 server test files pass.
The button opens the SAVED GAME's world, so it was gated on a save merely existing. But with "New Game" checked the player is starting fresh on the world picked in the selector above, and pressing it would open a different world entirely — the old save's — which is worse than offering nothing. `continuing` already means exactly the right thing (a save exists AND New Game is unchecked) and was already computed two lines above, so the gate becomes that. Both branches of onNewGameToggle call refreshNewGameHint, so the button updates the moment the checkbox moves. Verified in a browser across all four states: no save reads disabled either way; with a save it is enabled while continuing and disabled the instant New Game is checked. The existing test had asserted only "enabled when a resumable save exists", which no longer holds on its own — it now drives the checkbox through both positions in both save states. That assertion had also been passing for the wrong reason: the fake DOM never registered the New Game checkbox, so the test could not have seen this distinction at all. 451 client tests and 17 server test files pass.
Every step of the world path measures clean. Written to the library, read back by the login picker, staged, and rebuilt at Begin, a world keeps its loginBackground, sounds, music and soundConfig throughout — driven end to end in a browser, not reasoned about. So the code was not dropping anything; the inputs differed. "Edit this save" seeds a draft from the save only the FIRST time that world is opened for editing. Every later open keeps the existing draft, so a DM's editor work survives close and re-open — which is right, and losing those edits would be the worse bug. But it means what opens may not be the save, and the note said only "Resuming your edits to X", which reads as "here is your save". That is how audio and login branding could appear in the editor and nowhere else: authored in the draft, never published, so the library entry and every save seeded from it had none of it. The login picker showed the world's NAME (the library entry has that) while its backdrop and login music stayed default, a new game carried nothing, the in-game editor showed nothing — and "Edit this save" showed all of it, because the draft was the one place the work existed and the only surface that reads it. So the draft is now compared against the save it was opened for, and a divergence is stated outright: what is on screen is the unpublished draft, not the save's world, and "Update Library World" is what publishes it. Styled as a warning. An identical or freshly seeded draft keeps the quiet wording, and both branches still promise the saved game is untouched — which it is. 451 client tests and 17 server test files pass.
Reported as a disconnect: after logging in from a save, the DM Editor's Art › Audio tab was empty and World › Login showed the default backdrop, while "Edit this Save" on the login screen showed both correctly. The world plumbing turned out to be fine. Measured end to end in a browser, the constructor, serializeWorld and rebuildWorldFromSnapshot all preserve sounds, music, soundConfig and the login branding, and with a resumable save the Login tab renders the custom background exactly as authored. What was wrong is what happens when a restore FAILS. startGame's resume branch called restoreGameState and, on false, fell straight through — into deleteStateRaw() and a fresh world seeded from the built-in WORLD_DATA. So a player who asked to CONTINUE landed in a different world with none of their audio or login branding, and their saved session was deleted on the way past. Nothing said a word. The reason was already computed into lastRestoreFailReason, and showRestoreFailureNote already existed — boot auto-resume had always shown it; this path never did. That also explains why "Edit this Save" still looked right: it reads the saved-games library entry, a different store from the raw session slot Continue had just wiped. So a failed Continue now reports the reason, logs it, and stays on the login screen — where "New Game" is still one click away if starting over is what was wanted. Verified by reverting: without the guard the probe reports savePreserved false, i.e. the save is gone. The gate that fires this is the built-in world-version check, which exempts worlds that did not come from WORLD_DATA. Both routes onto a custom world — the login Import World button and staging one from the saved-worlds library — do set that flag, and it is persisted through serializeWorld, so a custom world should pass. A test pins all three, since a hole there would send every custom-world save down the path above. 450 client tests and 17 server test files pass.
Every generated image in the app can already decline the world's Art Style through its own object: a room, a region, an item, a faction, a race each carry an ignoreArtStyle flag, surfaced as an "Override World Art Style" box on their prompt card. The PLAYER'S portrait was the one generation with no control at all — it was the only paintImageFromPrompt call in the app that passed no options, so it always wore whatever the world declared. That matters when the world style names a MEDIUM rather than a look. "Waterlogged etching style, bleeding ink washes" and "rich impasto texture" are written for scenery, and they are exactly the words that make a model paint a picture-as-an-object instead of painting a person — the substrate the framing tail has been arguing with for several rounds. So: Settings › Imagery › "Include Art Style in Portraits", on by default, because carrying a world's look across its people is what portraits have always done and no existing world should change. Off, the portrait is generated from its subject alone. Scoped to the character portrait deliberately. Room banners keep their own rule (roomBannerIgnoresArtStyle), and everything else keeps its per-object override; this fills the one hole rather than adding a second lever over things that already have one. Verified in a browser on the composed prompt, which is the only thing that actually decides the picture: on, it leads with the style; off, the style is absent entirely; and a room banner painted with the setting off is still styled, so the scope holds. 449 client tests and 17 server test files pass.
Gemini was the only image provider not told the shape it was painting. Pollinations takes width and height as real query params; Higgsfield takes a size enum; Gemini was told in prose alone. So it composed on whatever canvas it chose and satisfied "a wide landscape scene" by drawing one INSIDE that canvas, filling the remainder with bands that read as a border. That is the structural half of the framing problem the prompt tail could only argue with. Both modes now send generationConfig.imageConfig.aspectRatio: 1:1 square, 4:5 portrait (512x640 exactly), 16:9 wide (896x512 rounds to it). A MAP takes 16:9 even though it is square-shaped internally, because its prompt states 1376x768 — sending 1:1 there would have the parameter fighting the prompt. The field is sent WITHOUT having been confirmed. Google is unreachable from this environment, and an unknown key inside generationConfig is rejected outright, so betting on it would trade a stray border for no images at all. Instead it is withdrawn on evidence: a 400 that NAMES the field strips it, retries once, and latches it off for the rest of the session. A 400 about anything else — a bad key, a blocked prompt — is thrown with the body that explained it, because a blanket retry would hide a real fault behind a second identical failure. Worst case is one wasted call and exactly the behaviour we had yesterday. Server-side the mechanism is declared by the descriptor (dropOn400) rather than special-cased for Gemini, and empty template leaves are now pruned, so an absent ratio means no imageConfig at all rather than imageConfig with a blank string — a malformed request where omitting it is a valid one. Driven both ways rather than reasoned about: in a browser against the real provider function, and through the real descriptor executor. Ratio sent on a normal call; refusal retried without it and the image still returned; the next call omitting it outright; an unrelated 400 not retried; providers with no rule never retrying whatever the 400 says. 449 client tests and 17 server test files pass.
The scenery the GM invented — the character standing in the snowbound square they were actually in, under the sky actually overhead — was worth having. The previous commit removed it wholesale, which threw out the good part. So it is a setting now: Settings › Imagery › "Include Scenery in Portraits", off by default, and the directive swaps between two rules instead of only forbidding one. Off, the backdrop stays plain and the same character comes back the same however often they are redrawn — the consistency the rest of that directive works for. On, the portrait becomes a moment instead of a reference sheet, and changing every redraw is the point rather than a defect. Three things the "on" rule does that the accidental version did not. The face stays the subject and the backdrop is explicitly subordinate to it, since an unbounded background is a landscape with a head in it. The sky is pinned to the REAL current weather and stated the way every other image brief states it — the exact condition, only that condition — because left loose a backdrop invents a storm the world is not having. And indoors is told as indoors, so a character in a common room does not get open sky behind them. The no-style rule is untouched in both branches: a backdrop is SUBJECT, named as what is there, never as palette or lighting. And the room description and weather are only sent when scenery is on, rather than riding along unused in every portrait request. Driven both ways in a browser against the real directive: off names no surroundings at all, on carries the room, its description and the exact weather line, with every earlier rule still in place. 448 client tests and 16 server test files pass.
A captured portrait-redraw prompt read as two prompts spliced together, and it was: four things competing in one request. The world art style leads, as designed. Then the GM's prompt — which ended with its OWN style clause, "Grim dark-fantasy oil painting … muted earth and iron tones", plus a scene of falling snow and village rooftops. Then the player's appearance again in full, because the engine appends it. Both halves were deliberate, which is why neither looked like a bug. The character appeared twice because the GM was told to LEAD with the stated appearance while the engine appends that same text verbatim as a guarantee. Two correct rules, one description written down twice — once paraphrased, once original, competing for the same subject. The GM is now told plainly not to restate it and why, so it writes what the appearance does not cover. The engine additionally skips its append when the text is already present, on normalised whitespace and case; anything less certain than "already there" is still appended, because a fuzzy match would silently drop player-authored text, which is worse than repeating it. The second art style was there because nothing forbade it. World-gen has always told the GM to write image prompts as plain subject descriptions with no art-style, medium or palette words; the portrait-redraw directive never got that rule, so the GM wrote a complete image prompt as anyone would. It now carries the same constraint, plus no background — a snowy village behind a head-and-shoulders portrait is a different picture every redraw, which defeats the consistency the rest of that directive exists to protect — and no "no text/watermark", which the framing tail already appends. Nothing migrates: a redraw asks the GM for a fresh prompt, so the next one comes back clean. 448 client tests and 16 server test files pass.
Pollinations confirmed they switched their server-side default to "zimage", which is what replaced the painterly look with a photographic one: same prompt, same art style, an unrecognisable result. Nothing here changed — we named no model, so we inherited theirs. Every request now names one, defaulting to flux, which is what they recommend pinning. Free text, not a dropdown. Their model list is theirs to change, and hard-coding one means a release every time they add or retire a name — which is precisely the failure this exists to stop. Blank names no model at all and restores their default, so the way back out does not need a release either. The pin travels in both modes. Direct mode appends it to the three URL builders; Vault mode carries it through vaultImageParams into the descriptor, so switching modes cannot silently change which model painted the art. Three edges worth naming. Image-to-image only works on kontext, so that path now STRIPS the pin before setting its own — two model= params in one URL is a coin toss over which the server honours. An empty query value is now dropped by the descriptor executor rather than sent, because "model=" is a different request from no model at all, and a template naming a param the client did not send should not invent a blank one. And the optional Pollinations token still rides alongside, unaffected. Verified in a browser against the real page and through the real descriptor executor: all three shapes carry flux, kontext carries exactly one model param and it is kontext, an emptied pin drops the parameter entirely, and the settings row appears only while Pollination is the Image AI provider. 447 client tests and 16 server test files pass.
The console echo sliced the prompt at 500 characters with no marker, so a perfectly intact prompt arrived looking cut off mid-word — which is exactly the failure the log was built to rule out. Reading it, you cannot tell whether the prompt was truncated or the log was, and the log is the instrument, so its own artefacts are worse than useless. Both echo lines now print what they hold in full. Neither is unbounded: the stored entry has already been through truncate(), which appends "… [N chars]" when it shortens — so a genuinely long prompt still announces itself and states its real length instead of trailing away. The error line had the same 200-character slice and loses it for the same reason: a provider's stated reason for refusing is the last thing worth clipping. Nothing on the request path ever truncated. The log is a copy; the prompt sent to the provider was always whole. A test drives it: a ~900-character prompt must come back through the echo with its tail intact, and one past the entry bound must carry the marker and the true count. 446 client tests and 16 server test files pass.
Two things. The Weather AI provider dropdown was empty in Vault mode while its model row beside it worked fine. The slot did not exist in the vault's vocabulary at all: "weather" was in the client's GENERATION_SLOTS and on the client's Nano Banana entry, but was never added to descriptor-schema.js. In Vault mode the server catalog governs slots and its entries win, so a slot the server has never heard of is stripped from every provider and its dropdown renders with no options — while the model row, which reads the model list rather than the slot, carries on looking healthy. Weathered banners are ordinary image generations, so it is an ordinary image slot; it is now in the shared list and on the Nano Banana descriptor. Nothing about that failure was loud, and the two vocabularies can drift again the same way, so a test asserts they agree in both directions, in the same order, and per shared provider — with video carved out, since the server has no video descriptor by design. Reverting the fix fails it with a message that names the symptom. Second: the media store now records WHAT MADE each file — provider, model, kind and the prompt — beside the index entry. A stored image was a bare URL, which is why working out that a changed room banner came from a different provider took a comparison of pixel dimensions. The call log answers this for the last few minutes; the index answers it for a file found months later, which is the case that actually bites. Three decisions in it. A dedup hit is a RE-USE, not a creation: the first provenance stands and the re-use is counted, so whoever regenerates an identical picture cannot rewrite its history and a file shared by three places does not read as made once. A file with no recorded origin says so rather than guessing. And this does put prompts on disk, which the usage meter and the call log both deliberately avoid — a considered trade, since provenance is worthless if it dies with the process, bounded by the number of stored files rather than rolling forever, truncated, and switchable off with VAULT_MEDIA_PROVENANCE=0 while the store keeps working. 446 client tests and 16 server test files pass.
The captured prompt showed the previous fix already running and still losing: "waterlogged etching style … bleeding ink washes … Fill the entire frame edge to edge — no border, frame, matting, or letterbox bars" and the image came back as an ink-wash etching on deckled paper with a plate margin. The model was not disobeying. An etching is a print ON paper and ink washes are ON paper; asked for those media, it rendered the substrate too. "Border, frame, matting" names none of that, so nothing in the instruction applied to what was actually being drawn. Nano Banana Pro takes a medium more literally than Flash, which is why the same style stopped being safe. So the tail forbids the substrate by name — paper edge, deckled edge, plate mark, margin, mount — and states the underlying rule outright: do not depict the picture as a physical object. The pixel hint is gone with it. "Roughly 512x512" came back 1024x1024, so the model demonstrably ignores it, and meanwhile "a 512x512 picture" is a nudge toward rendering a picture OBJECT of that size — feeding the very artefact the tail exists to stop. A non-square shape keeps its ORIENTATION, which is meaningful and stated nowhere else; only the numbers go. Pollinations is unaffected: it gets width and height as real query parameters and honours them, which is why its output is exactly 512x512 and Gemini's is not. Vault mode had not been carried over in the previous change — its branch still held the old wording. Both modes build this instruction separately, so a test now asserts no pixel hint survives in EITHER, alongside the count of the eight shared uses. 445 client tests and 16 server test files pass.
Generated art stopped matching what was authored, and there was no way to see where it went wrong. Between the browser composing a prompt and a provider rendering it sit the world art style, the shape hint, the descriptor's URL template and the vault's key injection — and the usage meter, being aggregate-only by design, can say how many tokens a call cost but never what it asked for. So the vault now records each provider request as it goes out: provider, model, the exact prompt, the resolved URL, the body, and how it came back. Readable in the admin page (newest first, one expandable row per call) and echoed to the console. Images, GM turns, video and 3D all report. Three decisions worth stating. It records on the way OUT, not on completion, so a call that hangs or never returns still appears — that being exactly the call worth looking at. It logs the keyless hand-off too. A keyless Pollination generation is never fetched server-side; the vault resolves a URL and hands it to the browser. That is the path most images take, and unlogged it would have left no trace at all. It is marked as a hand-off so a 200 beside it is not read as a response somebody saw. And two properties it is not allowed to lose, both driven against the real server rather than asserted from the source. It never writes a key: keys are injected server-side, so by the time a call goes out the URL and headers hold them — the test plants one, proves the vault really sent it upstream, then proves not a fragment of it reaches the log. And it never touches disk: prompts are player content, which is why usage.js is aggregate-only, so this is an in-memory ring that dies with the process. 445 client tests and 16 server test files pass.
Images from Nano Banana Pro started arriving with a white border. Nothing in the prompt path had changed — the cause is what we DON'T send. The request body carries only responseModalities. No aspect ratio, ever. So Gemini composes on its own canvas while the prompt asks, in prose, for a shape: "Render a wide landscape scene, roughly 896x512." A model whose canvas is not that shape satisfies both by drawing the picture INSIDE the canvas and filling what is left — pale bands that read as a frame. Pro follows an instruction more literally than Flash, which is why a framing implication Flash shrugged off started showing up as soon as Pro was selected. Half of that is fixable from the prompt, so: one shared tail, saying outright to fill the frame edge to edge and forbidding a border, frame, matting or letterbox bars alongside the existing no-text-or-watermark. It replaces the old tail in all eight places — four shapes across direct and vault mode, which build their instructions separately and had already been kept in sync by hand. A constant now makes drifting apart impossible, and a test counts the eight uses so a mode cannot be left behind. The GM's prompt-AUTHORING rules are deliberately untouched. Those stay subject-only; framing belongs at generation time, so restyling a world never means regenerating every stored prompt. The real fix is sending a genuine aspect ratio rather than asking in prose. That is not in this change: this environment cannot reach Google (the proxy 403s generativelanguage.googleapis.com and the docs), and an unverified field name in generationConfig would be rejected outright — trading a white border for no images at all. 445 client tests and 15 server test files pass.
The ledger showed two rows both labelled "Nano Banana". One carried its model; the other showed GENERATE — its kind — in the same slot, because the sub-label fell back to the kind when no model was recorded. Sitting where a sibling row showed GEMINI-2.5-FLASH-IMAGE, "GENERATE" reads as a model name, and the row was reasonably taken for the Pro model. It is not Pro. It is the 1,034 requests recorded between the meter's window opening on 22 July and the rows being split per model on 2 August, when the model was not recorded at all. Whatever mix of Flash and Pro that was, it cannot be recovered. So a row with no model now says "model not recorded" rather than borrowing its kind, and a row naming a model that has no rate says that instead. The kind is already implied by the columns; it was only ever filler. The blank cost was honest but unhelpful — it hid the largest generate line on the page. Such a row is now bracketed between the cheapest and dearest model its provider bills under: ~$80–$161 here. Deliberately not a figure — muted and italic rather than the gold a known cost gets, rounded to whole dollars because pennies inside a bracket are noise, and NOT summed into the total, with a note under the table saying so. An estimate absorbed into a total would read as measured. Two rendering fixes while there, both from looking at the page rather than the markup: the sub-label now sits on its own line instead of trailing the name and wrapping mid-token (GEMINI-2.5- / FLASH-IMAGE), and the table scrolls in its own box so a narrow window cannot clip the cost column. Verified by rendering the real admin page in a browser against a ledger seeded to match the reported one. 445 client tests and 15 server test files pass.
The published rates, per 1M tokens: Flash $0.25 in / $60 out, Pro $2 in / $120 out. Two entries, because the output rate — where essentially all of an image generation's cost sits — is 2x between them. That gap is what the per-model row split was for; merged, neither could have been priced honestly. The rates alone would only have priced generations made from now on. The ask was to price the usage already recorded, and the ledger stores aggregates with a dollar figure banked at record time — so tokens counted while no rate existed were stuck at $0.00 forever. So a generate row's token cost is now DERIVED at read time instead of banked. It can be: such a row pins exactly one model and carries no cache tokens, so its cost is linear in the two running totals, and summing per call gives the identical number. Credits stay banked (they are per-job figures the provider reported), the token half is computed from the totals, and the row reports their sum exactly once. Two things fall out of that. A rate added later prices the history rather than only the future — which is what makes this ask answerable at all. And a rate later corrected re-prices it, instead of leaving a window billed at a number now known to be wrong. Rows written before the per-model split hold two models' tokens under one bare provider id. Those stay unpriced: neither rate can honestly be applied to a merged row, and a blank cost is the truthful answer. Verified against a ledger seeded on disk with costUsd:0/priced:false, read back through the real admin API: 800 in / 48,000 out on Flash → $2.8802, 200 / 6,450 on Pro → $0.7744, both matching the rates by hand, with Tripo's credits and Claude's text cost untouched beside them. 445 client tests and 15 server test files pass.
The design named this setting autoRollDamage, defaulting ON, sitting beside "Auto-roll skill checks" and reading the same way round. The checkbox that actually shipped was its negation, which meant the two dice settings in the same panel answered the same kind of question in opposite directions — one saying what the engine does for you, the other what you do yourself. So the setting is now autoRollDamage: ON (the default) rolls the faces for you, OFF opens the dice bag. The math is untouched — this is which of two existing branches a hit takes, not a change to what a hit deals. Sense-flipping a stored boolean is exactly the change that silently reverses someone's choice, so loadSettings translates a saved playerDamageRolls to its inverse at read time: a player who had opted into rolling their own damage keeps rolling it. The translation is idempotent, never overrides an explicit new value, and leaves the old key alone rather than rewriting it. Verified in a browser with the old key seeded before boot: the checkbox comes up unchecked, getSetting agrees, and toggling it persists under the new key. The player's handbook is updated in both places it describes the setting — and while there, Chapter Eight now says what Phase 2 actually made true, that a statted weapon's damage is computed rather than judged. Also renamed the test file to test_damage_roll_bridge.js, since it covers the request/relay bridge rather than one setting's name. 445 client tests pass.
Phase 1 gave a weapon a damage stat and showed it on the card. Nothing read
it. A "3d6+1 slashing" greatsword and a bare `type: "weapon"` stick dealt
exactly the same damage, because in both cases the GM made the number up.
Phase 2 makes the stat decide. One function owns the math:
total = max(1, round((sum(dice) + flat + max(0, abilityMod + magic)) * scale))
A crit doubles the dice and not the mods. The ability/magic bonus floors at
zero, so a weak arm gets no bonus rather than a penalty. The function is
pure — it takes already-rolled faces — so where the faces come from is a
separate question from what they mean.
Who rolls them is the EXISTING "Player Damage Rolls" checkbox, not the new
setting the design called for. That setting shipped after the design was
written and is the same question inverted; a second checkbox beside it would
have given the player two controls for one decision. Off (the default) the
engine rolls silently; on, the player rolls in the dice bag. Same formula,
same total, one tap's difference.
The engine claims a hit only on an explicit "weapon": true or dice that
match the equipped weapon letter for letter — never a guess, so spells,
traps and thrown rocks keep their GM-authored damage. When it claims one it
applies the damage and tells the GM the blow is already landed, with a
bounded one-shot guard that drops a single duplicate if the GM re-applies it
anyway. Crits come off the to-hit d20 the combat bridge already sees.
The whole feature is gated on the weapon carrying a stat. A world whose
weapons are statless plays exactly as before — its GM prompt does not even
mention the field. Verified in a browser, both directions.
Two bugs found and fixed while building it, each caught by measurement
rather than reading:
- The duplicate guard swallowed the engine's OWN next application, so a
second engine-owned hit in a fight silently dealt nothing. Confirmed by
reverting the fix: 9 tests fail.
- Auto mode relayed to the GM from inside the turn still being applied,
re-entering the turn loop. Deferred out of it.
445 client tests and 15 server test files pass.Gemini reports a `usageMetadata` block on every image: `promptTokenCount` for the prompt (plus the source picture on an image-to-image call) and `candidatesTokenCount` for the generated image itself — roughly 1,120-1,290 tokens for a 1K image. The vault already carried that block from the descriptor executor through to the meter, but the meter filed every generation under its provider id alone. That is a problem for this provider specifically: Nano Banana fronts TWO models, gemini-2.5-flash-image and gemini-3-pro-image-preview, which are separate line items on Google's price list. Merged into one row, the tokens are real but no rate could ever be applied to them correctly. So generation rows are now keyed per model where the provider named one — the same shape the Claude text rows have always used — and the row carries the model for display. Providers called without a model keep their bare provider id. The admin table names the model beside each row, so two rows from one provider are told apart. No rate is added. pricing.js documents where the two Gemini entries go and what shape they take, and a test proves that dropping one in is the only change needed to turn the recorded tokens into dollars — and that until then the model prices to null rather than a guess. Unpriced rows keep showing real token counts and a blank cost. Verified by reverting: with the rows merged again, the new test cannot find the second model's row at all. 444 client tests and 15 server test files pass.
Three changes, each measured. ANISOTROPIC FILTERING, where the GPU offers it, at the maximum it reports. This is the biggest of the three. Trilinear picks a mip level from the worst-compressed axis, so a surface seen at a shallow angle — which on a model you can spin is most of it — gets a blur sized for the direction it is squashed in. Measured on a 512px 4-pixel checker at a grazing view, mean absolute difference between adjacent pixels over identical coverage (76,729 opaque pairs both ways): 60.84 with, 51.21 without — +19% local contrast for the same geometry. Measured under SwiftShader, a software rasteriser; real GPUs generally separate further. Applied only on the mipmapped power-of-two path, since it refines mip selection and the NPOT branch has no mipmaps to refine. THE DEVICE-PIXEL CAP was 2 and is now 3. Measured buffers for the lightbox's 898x758 CSS box: DPR 1 → 898x758, DPR 2 → 1796x1516, DPR 3 → 2694x2274. At the old cap a 3x display rendered the model at four ninths of the pixels it was displayed at and let the browser upscale — a softness no texture filtering can recover. A snapshot opts back down to 1x: it renders off-screen at an exact size, so scaling by the display ratio would draw 3x and discard the difference. A TEXTURE LARGER THAN THE GPU TAKES is now fitted rather than refused. texImage2D raises INVALID_VALUE on an oversized texture instead of scaling it, leaving the 1x1 white placeholder — which reads as "this model has no texture". Generated models carry 2K and 4K atlases, so it is reachable. The power-of-two test now runs on what was actually uploaded, not the original dimensions, or a downscaled NPOT texture would be mipmapped and sample black. On the icon sent to image-to-3D: no change was needed. Measured — the provider already receives the full stored image at 512x512 (natural 512, displayed at 32px purely by CSS), and the lightbox shows that same source. 444 client tests and 15 server tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
claude-opus-5 was a sanctioned model but missing from pickGmModel's preference list, which meant it was unreachable unless it was the only thing in the admin's ceiling. The consequence, measured: a vault permitting only Opus 5 and Fable 5 fell back to FABLE for gameplay — the slow, thorough world-generation model, the worst of the four to land on for a per-turn call. Opus 5 leads the walk. It IS the current balanced Opus and bills at the same rate as 4.8 ($5/$25 per 1M, see pricing.js), so preferring the older one bought nothing. This does change the fallback a vault with no ceiling uses from opus-4-8 to opus-5 — it only fires when a client sends no model or an invalid one, which the app itself never does. The test pins the specific case and then generalises it: every sanctioned id in GM_MODEL_IDS must be reachable by the walk, so the next model added cannot repeat the omission. Verified by putting the bug back — the guard fails on exactly claude-opus-5 and leaves the other three passing. Also covers the ceiling itself, which had no direct test: no settings, an empty list, and a list naming nothing sanctioned all mean "no restriction" rather than an empty menu, so a typo cannot lock every model out. 444 client tests and 15 server tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
TRIPO IN THE COST LEDGER. Tripo bills in its own credits and reports what
each finished task spent; the published rate is 100 credits to the dollar.
runModel3d carries that number back, the route hands it to the ledger, and
pricing.js converts it — the same rule as everything else in that file, a
real number the provider returned times a published rate, never an estimate.
The docs use BOTH spellings across endpoints — credits_consumed on some,
consumed_credit on others — so both are read rather than betting on one and
silently metering nothing. A task that reports no credits carries undefined
rather than 0, because a 0 would be recorded as a real "this cost nothing"
instead of "nothing was reported". Writing that test caught a genuine gap:
Number(null) is 0, so a null was pricing as a genuine $0.00; null and ''
are now checked before the conversion.
The admin usage table gained a Credits column — blank, not 0, where a
provider does not bill that way — and states the rate, since a credit means
nothing on its own. An older ledger written before the field existed takes
the new total cleanly instead of going NaN.
THE VIEWER BUG, which was mine. dispose() called
WEBGL_lose_context.loseContext() unconditionally, to keep from exhausting
the browser's live-context cap. The cap is real; the remedy is not. After
loseContext(), getContext() returns the SAME dead context for the rest of
that element's life. The lightbox reuses one canvas, so closing it once
broke every later open. Measured:
open 1 → ok
open 2 → "The viewer could not start.", isContextLost TRUE,
the identical context object handed back
open 3 → the same
Losing the context is now opt-in. snapshot() takes it, because it mints a
canvas per call and drops it — that is the case the cap was ever about,
canvases we keep making rather than the one we keep. The lightbox does not.
A canvas arriving already dead is now named instead of failing later at
shader compilation, which is what that message actually was.
The browser can still lose a context on its own — GPU reset, backgrounded
tab — and no viewer-side change prevents that, so the lightbox now swaps in
a clean canvas element when it finds its own dead. A healthy one is reused.
Verified in a browser: four consecutive opens each drew identically (324
opaque pixels, 174 colours on an 18x18 grid, 12 triangles) with an empty
status every time; killing the context by hand and reopening replaced the
canvas and mounted a live viewer; and 24 snapshots in a row all succeeded
with the lightbox still opening afterwards.
444 client tests and 15 server tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL168px, up from 104. A 3D preview is a small object photographed from a distance — at 104 the model occupied maybe half the box and read as a smudge rather than a model. The buttons beneath it go back to the Icon row's own sizing. The tighter 10px text and 4px padding they carried was a workaround for the old width, where "Re-Generate" wrapped to two lines; at 168 it fits with room to spare (107px of text in a 168px button, measured). Measured across card widths to check the details column still holds up: card body picture details 3D 1100 → 1038 120 722 168 900 → 838 120 522 168 (details was 586) 700 → 638 120 322 168 560 → 498 120 182 168 The details table does not overflow at any of them, and no button overflows its box. At 560 the row grows taller as content wraps, which is the right trade for a column that is now readable. 444 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
An Upload button under Generate/Re-Generate in the item card's 3D column, for a model you already have. Deliberately NOT gated on the item having an icon, the way Generate is: Generate converts the icon, while an upload brings its own model and needs no source image. It is the only route in for a hand-made or externally-made model. The file is parsed BEFORE anything is stored, by the same reader the lightbox uses — so anything accepted here will open there, and a file that is not a model is refused at the door with the reader's own message. Storing it instead would reproduce, from the other direction, exactly the confusion the substituted download caused this week: a bad artefact discovered later, by the viewer, pointing the blame at the viewer. Where the bytes go depends on the mode, and it matters. Behind the vault they go to the media store as RAW bytes with the model mime — where a generated model now also lives, content-addressed and deduped — and the item keeps a same-origin URL. In Direct mode there is nowhere to put them, so they ride on the item as a data: URI; that is honest but heavy, so anything over 2 MB says on the card that it will ride in every save. The still is rendered from the scene already parsed rather than by fetching the file back out of wherever it just went. Browser-verified by driving a real file input with a real .glb: Direct mode inlined 2918 base64 chars for a 2164-byte file; Vault mode posted 2164 raw bytes to /vault/media with Content-Type model/gltf-binary and the bearer token, and the item kept /vault/media/ab/abc123.glb; both produced a 256x256 still with 20,076 opaque pixels and 2,995 colours and opened in the lightbox; and a text file named .glb was refused with nothing stored. 444 client tests and 15 server tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported as a valid Tripo GLB failing with "That file is not a glTF/GLB
model." The download never happened. Reproduced in a browser:
ok:true status:200 content-type:text/html bytes:4,006,048
first bytes: 3c 21 44 4f 43 54 59 50 45 … = "<!DOCTYPE html>"
The service worker's fetch handler took EVERY GET, including cross-origin
ones, and its catch answered with caches.match('text_adventure.html') — a
200 whose body is this app's own page. A caller asking for bytes of one
kind was handed a web page and told it succeeded. That is why the error
pointed at the file: the file was fine, the fetch was substituted.
Nothing cross-origin was ever cached (the caching branch already refused
it), so intercepting it only created the chance to substitute. Cross-origin
GETs now decline the event and go to the network, and the app-HTML fallback
is reserved for navigations — a script, an image or a model that fails now
fails. Same request after the fix: "Failed to fetch", which is the truth.
THE VAULT NOW KEEPS THE MODEL. It was returning Tripo's URL for the browser
to fetch, which is wrong twice over. It is cross-origin, so the browser has
to talk to a provider — the thing the vault exists to prevent. And it
EXPIRES: the URL from the report carried a CloudFront policy of
"DateLessThan: 1785715200", about 24 hours, so a model stored on an item as
a link stops existing tomorrow. /vault/model3d now downloads the GLB and
puts it in the media store, answering with a same-origin, permanent,
content-addressed URL. Verified end to end: the server fetched the CDN once,
stored 2164 bytes, and GETting the returned URL gave content-type
model/gltf-binary, magic "glTF", byte-identical to the original; a repeat
generation deduped; and a 403 from the CDN degraded to the provider URL
with a storeError rather than failing.
The store keys its extension off the mime and object storage commonly
serves a .glb as application/octet-stream, so the GLB magic decides. The
media store's mime allowlist gained model/gltf-binary and model/gltf+json.
The viewer's errors now say what arrived: HTML gets "That download returned
a web page, not a model", with the byte count and opening characters in the
detail, and a fetch that throws names a cross-origin refusal instead of
relaying a bare "Failed to fetch".
444 client tests and 15 server tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe admin can now store a Tripo key on the server and the game invokes image-to-3D through the vault, so the key never reaches the browser. CODE, NOT A DESCRIPTOR, for the same structural reason runVideo is: the flow is three requests with different content types — a multipart upload, a JSON task creation that consumes the upload's token, and a poll — while the descriptor engine models a single request with an optional poll. A descriptor bent into that shape would be a worse contract than a function. - providers.runModel3d: the upload -> task -> poll flow, host-pinned to api.tripo3d.ai, every URL asserted https before the fetch. - POST /vault/model3d: token-gated, resolves the key from the store, maps the provider's status through, and records usage. - vault-core: tripo joins MANAGED_KEYS, which is what drives the admin key card, the boot-log line and the key-store status alike — no admin UI change was needed. The config now advertises model3dUrl. - descriptor-schema: model3d joins the shared slot vocabulary beside video, and like video is deliberately NOT a descriptor kind — no custom descriptor can claim a slot whose flow no descriptor can run. - The client gained vaultModel3dGenerate and a dispatcher; the Items card now generates through the dispatcher rather than the provider, so Vault mode is reached at all. It only demands a client-side generate when there is no vault to do the work. Two details that would each fail silently, carried over from the browser path and re-tested here: the multipart request sets no Content-Type, because setting it by hand omits the boundary and the upload answers 200 with no token; and pbr_model is preferred over model, because output.model can be the untextured mesh. Verified by booting the real vault on an ephemeral port with fetch stubbed: /vault/config advertises the endpoint and the slot and lists tripo among the generate providers; no token is a 401 with zero upstream calls; an unknown provider is a 404; no key is a 503 naming the admin page; and with a key the three requests go out carrying the vault-held key and never the browser's token. Through the admin API a key stores encrypted, reports configured with last4, and is never echoed back. In the browser, Direct mode called the provider once and never touched the vault, while Vault mode posted to /vault/model3d and called the provider zero times. Still not exercised against the live Tripo service — there is no key in this environment. 443 client tests and 14 server tests pass, including a new server/test/test_run_model3d.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A 3D column on each item card, right-aligned on the Details row: a preview box, a Generate button beneath it, and a status line. Generate converts the item's ICON through whichever provider Settings > 3D AI names, renders one still of the result, and stores both the model's url (model3d) and that still (model3dPreview) on the item. The card holds a PICTURE, not a viewer. A live WebGL context per card would be dozens of contexts on one tab and browsers cap them hard — the Items tab of a real world is a long list. So the model is rendered once at generation time through a new GlbViewer.snapshot, and the interactive model is one click away in the lightbox. Two things snapshot has to get right, both of which fail silently: - It waits on the viewer's textures to settle. Captured at mount it catches the white placeholder — and since the result is SAVED, the model would look untextured for the rest of its life. The viewer grew a `ready` promise for this, and a texture that will not decode still settles so one broken image cannot hold it open forever. - It draws in the same task as the read. preserveDrawingBuffer defaults to false, so after compositing toDataURL answers a blank image. The button is disabled until the item has an icon, because the icon is the input and offering the button without one offers a request that can only fail. An empty preview box carries no click handler. A still that fails to render does not lose the model — it falls back to a glyph and the lightbox still works. Both fields are copied in makeItem the way iconImage is, as own fields only when set, which is what makes them serialize and survive a restore. Browser-verified against the real card markup with the provider stubbed and a real GLB as its answer: the provider received exactly the item's icon; the stored still decodes to 256x256 with 20,076 opaque pixels and 2,995 distinct colours, where a blank canvas gives 0 and an untextured cube about 3; the card re-rendered with the thumbnail and a Re-Generate button; clicking it opened the lightbox on that model. Layout measured at a 900px card — the column's right edge is flush with the body's and all three columns share a top edge. A provider failure reported the reason, stored nothing, and restored the button. 443 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Modules/Viewer/glb-viewer.js — a glTF/GLB parser and a WebGL 1 renderer,
plus the lightbox that hosts them. Drag to rotate, wheel or pinch to zoom,
shift-drag to pan, Escape or the backdrop to close.
Hand-rolled rather than vendored, and the reason is concrete: modern
three.js is ESM-only. Measured on 0.185.1 — three.module.min.js opens with
`import{...}`, GLTFLoader ends with `} from 'three'`, and the release ships
no UMD build. ES modules do not load over file://, and this app is
deliberately file://-openable; it is the stated reason howler.min.js is
vendored and the stated reason crawler.js is a classic script. Bundling
would fix it and this repo has no build step. So: a classic script beside
its neighbours, scoped to what the 3D AI slot actually produces — one
static mesh with a baked PBR texture, no Draco (Tripo's compress defaults
off).
What it will not read, it names: Draco, Meshopt, KTX2/Basis, an external
.bin. A viewer that draws a wrong picture is worse than one that says what
it cannot read, so those are detected up front and reported as sentences.
Four things that would each have failed silently:
- uView was declared in BOTH shader stages, and GLSL ES refuses to link on
the precision mismatch that creates. The normal is now taken to view
space in the vertex shader, which removes the shared uniform rather than
papering over it.
- Textures are flipped on upload; glTF UVs originate top-left, and without
it every model is upside down while still looking plausible.
- A non-power-of-two texture is clamped, not mipmapped — WebGL 1 samples
the mipmapped case as pure black.
- The overlay is shown before the viewer mounts, because a canvas inside a
display:none parent measures 0x0 and never recovers.
The parser is pure JS with no DOM, so the test exercises it against real
GLB bytes assembled in the test rather than reading its source: packed and
interleaved (byteStride 32) buffers yielding identical geometry, a nested
node hierarchy whose translate and scale bake into world space, materials
with and without textures, and every refusal path. The renderer needs a
GPU and was browser-verified: mounted at 898x758, screenshotted, centre
pixel [99,84,52,255] with 244 distinct colours across a 24x24 grid where an
untextured cube gives 3, transparent background, rotation changing the
image, zoom clamped to radius*0.25..radius*22, reset restoring the framing,
and close disposing the context.
442 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLAn item ICON goes up, a textured GLB comes back.
A slot of its own rather than a mode of Image AI. The panel is data-driven
— a provider declares which slots it can fill and every dropdown is built
from that catalog — and the slot vocabulary is about what comes OUT: image,
icon, map, gallery, weather, sound, video. A mesh is none of those, and the
providers that make one are not the providers that make the rest. So
model3d joins the vocabulary, MODEL3D_PROVIDERS joins the catalog, and the
dropdown builds itself; a second 3D provider is a registry entry with no
markup change.
The API contract was read off the official published SDK (the tripo3d
0.4.2 wheel from PyPI). Every documentation host was 403 behind the proxy,
so nothing here is from memory: base https://api.tripo3d.ai/v2/openapi,
Bearer auth, POST /upload as multipart under the field name "file"
answering { data: { image_token } }, POST /task with
{ type: 'image_to_model', file: { type, file_token } } answering
{ data: { task_id } }, and GET /task/{id} answering a status plus
output.{pbr_model,model,base_model}.
Two details that would each have failed silently: the multipart request
sets no Content-Type, because setting it by hand omits the boundary and the
upload parses as empty; and pbr_model is preferred over model, because
output.model can be the untextured mesh and picking it first would quietly
drop the textures. The declared file type is read from the image's own mime
— Tripo validates the extension against the bytes.
NOT exercised against the live service; there is no Tripo key in this
environment. What is verified is every request the client makes, captured
from a running page with fetch stubbed: the multipart upload, the task body,
the polling, progress reported through running:40 → success:100, the GLB
URL returned, a terminal failure surfaced rather than swallowed, an http(s)
source skipping the upload entirely, and a missing key failing before any
request goes out. Parsing is deliberately tolerant across token spellings
and model fields so a small drift is a clear error, not a wrong answer.
441 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLReported as "pick a custom world under New Game and the background and
music don't change". Half of that reproduced. Measured against a saved
world carrying both a custom loginBackground and custom Login / Login
Ambient cues, driving the real UI:
background Videos/loginBackground.gif -> the world's data: URI CHANGED
login cues builtin-login|Music/Intro.mp3 ~ builtin-login-ambient
|Audio/torch.mp3 — identical at every step NEVER CHANGED
The backdrop tracked the selection through the whole lifecycle: cold
login, New Game ticked, world picked, refreshNewGameHint,
refreshResumableCache, playLoginCues, unticked and re-ticked. The world's
loginBackground also survives World -> serializeWorld -> the library
envelope intact.
The music never did. stageWorldForLogin sets the title, tagline, header,
version, Class dropdown and branding, and says nothing about sound, so the
screen showed the chosen world under the built-in world's music.
loginCueSound had three sources — the resumable save's cues, the LIVE world
(null on the login screen) and the boot defaults — and the staged world was
not one of them.
The audio now goes where the picture already was: staged first, then the
resumable save, then the default. Built through the same extractor the
saved-game path uses, so the two cannot disagree about what "the Login cue"
means, and gated on the cue having a playable source, so an empty one
cannot shadow the default and leave the screen silent. Cleared by
resetLoginToDefaultWorld beside the branding, and dropped in startGame with
the staged world — but NOT by stopLoginCues, which the mute toggle calls.
Verified in a browser with three saved worlds: the staged cues adopted and
audibly playing; a second world with identical audio keeping the very same
Sound instances rather than restarting; a third with different audio
swapping to it; Default restoring the built-in; and a staged world whose
cue has no source falling through to the working default.
439 tests pass. One anchor in test_login_saved_game_cue required `const
saved` to be the first statement of loginCueSound — it now tests the
precedence it was actually about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLExport World was already there, but it runs the JSON box through a World constructor and refuses anything short of a finished world — measured on an empty box: "Generate a world (or paste world JSON) first." So a brief someone has spent half an hour writing has nowhere to go until it has been generated, and a reload takes it. Download JSON asks nothing of the page. Fields alone is a valid file. It sits beside Generate rather than in the Export/Import row, because that row is about a finished world and this button's whole point is the page before there is one. It carries the form fields through the same collector Save World uses, plus the two page inputs that collector leaves out — the lore toggle and the model, which are decisions about a generation run rather than part of a world. The preset selects are deliberately left out: picking one writes into the box beside it and is never read back, so recording the pick would put a staler second answer in the file next to the real one. The JSON box is parsed into the object when it parses, and kept VERBATIM with the parse error when it does not. Half-edited world JSON is exactly the state worth being able to save. An empty box adds neither key. Verified in a real browser with real downloads, reading the files back off disk: fields-only, fields plus a parsed world, and fields plus an unparseable box kept as text. The status line names which halves the file holds, since "no world" is a normal outcome here. A check in test_new_world counted the buttons in that row as a stand-in for "nothing here is a second prologue trigger". It now tests that directly. 438 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Four changes, one of which was a live bug.
THE YEAR NEVER TURNED. Patterns resolve climate.year -> the calendar's
month-to-pattern binding -> the season-quarter id -> "any pattern beats
none". Generation never authors a calendar, so the default one names
winter/spring/summer/autumn — while the schema invited custom ids in the
same breath ("<pattern_id>": { "name": "<Name, e.g. The Long Dusk>" }). A
GM doing exactly what it was shown matched nothing at every step and landed
on the terminal fallback. Measured in a browser: four beautifully authored
regimes, and the_long_dusk twelve times, for the life of the world. Nothing
errored. The fix is the IDS, not the names — each regime is still called
whatever the world calls it, which was always the part worth having.
ONE CLIMATE PER REGION. The binding rule was singular — "give THE region a
climate" — written when there could only be one. It never said each region
gets its own, nor how many climates to author. That is not cosmetic: the
climate is the only thing that bends the day the engine rolls, so two
regions sharing one have identical weather forever however differently they
read.
CLIMATE.YEAR reaches the schema. The per-climate twelve-month override the
engine already reads is what lets a frozen north run its winter regime
through the months everyone else calls summer. It was never asked for.
THE DM'S REGION HANDOFF never mentioned climate at all. Its effect is in
the saved worlds: Verengrad's regions carry climate:"" and every one
inherits the default, so a charted continent sits under one sky. It now
gets the world's climate roster to reuse, may author new ones where none
fits, and those are merged additively before the regions land.
The validator gained the two failures nothing reported: a year that cannot
turn (or only partly turns), and regions sharing a climate.
Two test fixtures called a world well-formed while keying its patterns
outside the four seasons — both described worlds whose year could never
turn. Re-keyed, names kept.
Verified in a browser end to end: a coast turning through four named
regimes while a frozen north runs The Endless Dark through six months of
everyone else's spring and summer. 437 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA verdict should be able to look at the whole world and say whether the means to survive an encounter exist or need adding. Whether it can depends on something this world does not have. The engine reads weapon.damage, damageBonus and damageType, so a world can carry a real power curve, and given one a compiler pass could compute the best loadout obtainable before any encounter and therefore the survival ratio at the ceiling rather than at whatever the tester happened to hold. Verengrad carries none of it: every weapon and every piece of armour has a type, a price, a weight and prose, and no damage or ac at all, and creatures carry hp and level but no xp. Its fight numbers are improvised at runtime and cannot be predicted from the data. So for an unnumbered world the verdict is structural. Three things still read straight out of it and carry most of a recommendation. The Bell-Warden is level 8 where every other monster is 2 or 3 and the route tops out at 3. The best weapon and the best armour in the catalog — a Drowned Longsword and a Bladeward's Plate — are placed in no room, along with six other items, so the character fought the world's hardest encounter holding its cheapest weapon not through bad play but because nothing better is reachable. And value at least orders the gear even when nothing says what it does. That is enough to recommend placing those two items before the Chancel and re-observing, ahead of touching the Warden's stats, while being honest that whether it closes a 0.15 ratio is unknowable until someone runs it. The leverage is upstream: if generation emitted damage, ac and xp, the ceiling calculation would be static and every world would get its encounters checked without anyone playing them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Achievability and difficulty want different machinery. The first is a property of the world data and the compiler settles it statically for nothing. The second cannot be read out of the data at all: a world's encounters were balanced by reasoning about a level-1 character, never by playing them, so the first time a fight's numbers meet a real character is when someone runs it. Keeping the two apart matters — a walkthrough that stops at an unwinnable fight has still proved the content reachable, and should not be allowed to read as missing content. The ledger is per world, append-only, and takes entries from dedicated balance runs and from playthroughs that happen to produce something worth recording. A remedy never edits the observation that prompted it; it lands on a new observation, so before and after sit together the way the elevation runs did. The figure to sort by is roundsToDie over roundsToKill, which folds regeneration, hit chance and damage into one comparable number. Below 1 the fight cannot be won as fought. Three caveats are written into the schema because leaving them out is how a ledger misleads: a measurement taken with the starting kit is a floor and not evidence about the world, an unwinnable fight may be a world saying not to fight, and a ratio near 1 is an anecdote until several runs agree. Seeded with the one real observation to hand. The Bell-Warden nets 3 HP a round against 140 while dealing 13, a ratio near 0.15 — far outside variance, so it needs no repeating. But it was measured at the gear floor and its authored non-lethal route never cleanly fired, so the entry says what it is: a player following the main quest to its object walks into a fight they cannot win, and that is not yet the same as the encounter being unwinnable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan emitted "Defeat <name>" with a predicate that only went green when the creature was dead. Driving it through Verengrad killed the Gill-Wretch, whose own authored lore reads "wound it without killing it and let it try to speak" — and there is one Gill-Wretch, so the hook was destroyed for the rest of the playthrough, by the plan, in the course of checking the plan. The world was coherent; the objective was too coarsely worded and the predicate encoded the coarse reading. An encounter now counts as resolved if the creature is down, OR its authored lore has been earned, OR a beat that turns on it has fired — with the beat having to name it, so the first beat of a quest does not mark every monster in the world as dealt with. entityDefeated stays as it was, because "defeated" and "got past" are different claims and both are worth being able to make. Where a creature's lore asks for it alive, the compiler now leads with that route instead of an attack, records what killing would foreclose, and raises a warning. On Verengrad that catches two: the Gill-Wretch, and the Bell-Warden, whose key likewise says to defeat him without killing him. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine pairs now instead of five, and the full sample splits the two conclusions apart. Restatement is fixed: eight of nine entries sit at or below 50% overlap with their hook and six at or below 26%, against a before-run whose median was about 50% with two entries above 90%. Linkage is not: the GM declares the link five times in nine, and the early run of four consecutive links was luck. The correlation I reported between the two — that the entry which declared no link was also the one near-verbatim copy — does not survive the larger sample. The unlinked entries are 0%, 19%, 50% and 97%; three of the four are not duplicates at all. One outlier made a pattern out of nothing. So the remaining problem is narrower than it looked: not that the GM copies the hook, but that it often does not say which hook the entry came from, which costs a cross-link rather than costing the player a second reading. Three content findings from the same run. The Bell-Warden guards the reliquary that the central arc exists to steal, regenerates 3 HP a round against about 6 dealt and 13 taken, did not let two flee attempts resolve, and leaves a level-3 character with no healing item and no way through — the run ends parked at 21 of 115 HP mid-fight. Killing the Gill-Wretch permanently forecloses its lore, whose key asks for it to be wounded and left alive, so two authored objectives on one entity are mutually exclusive. And the Drowned Echo-Chorister's unlock condition, typed verbatim by a character carrying the skill it names, was refused as outside what the character can do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The author's number caps how far the world may spread; it is not a target
the GM has to hit. Held to it exactly, a GM invents regions the geography
does not support, and splits one coherent place in two to reach a total —
which leaves two worse regions than the one they came from. So the rule now
reads "author AT MOST N", says plainly that fewer is allowed, and says why.
The room-count line follows it ("IN EACH REGION YOU AUTHOR"), and so does
the schema, which now reads "... up to 3 of them" rather than "3 in all" —
the schema is the half a GM copies, and left alone it would have
contradicted the rule above it. The rooms still have to be filed into
regions with none left empty; that part was right and is unchanged.
Also fixes a check that was wrong before this: validateGeneratedWorld
hard-coded "a new world should start with one region". It would have fired
on every world the Regions field exists to produce. It was ALSO wrong at
its other call site, which validates an IMPORTED world — an established
world the DM has charted legitimately has several, and was told off for it
on every load. It now takes the allowed count where one is known, ignores
the count entirely where it is not, and only complains about overshoot.
Verified on the live function: 3 authored where 3 allowed, and 1 where 3
allowed, are both silent; 5 where 3 allowed reads "it has 5 regions, more
than the 3 allowed"; 3 and 8 with no expectation are silent. Directive read
back from a composed prompt at one region and at three. 437 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLIt was rendering as raw OS chrome in the middle of the row: white ground, black Arial, a 2px inset border, no radius, 19px tall against its neighbours' 35px. The World Builder's field rule lists input[type="text"], so a number input matched nothing in it and fell through to the browser default. Nothing in the CSS said so, and getComputedStyle is where it shows. The types are listed explicitly rather than matching a bare `input`: the checkbox fields live inside .we-field too, and width:100% with 9px of padding would stretch a 16px box across the panel. Short numeric fields are centred, which is how the DM editor's own region-count field is set. The spinner is native chrome of the same family as the dropdown slab this sheet already fights, and it takes its colours from color-scheme the same way, so it gets the same treatment — follow the theme rather than pin it. Stated for number inputs generally: the DM editor has eighteen more of them sitting on the same dark chrome with the same light arrows. test_select_theming counted color-scheme pins and expected exactly one, which was the wrong invariant — a second legitimate shared rule failed a test that is really about pins with no light-theme counterpart. It now measures that instead, and was checked by planting an unpaired rule. Verified in a browser on both themes: identical to #we-scope and #we-tone in background, colour, border, radius, font and height, with the same gold-dim focus border, and the checkboxes unchanged at 16x16. The spinner fix was confirmed by putting the bug back — the arrows return as a white slab in the corner of the dark field. 437 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Two fields on the World Builder that reach the generation dossier. Regions (1-5, under Scope). Generation only ever authored one region and told the GM so in as many words; an author who wanted five could not ask for them at the point the world is made. Scope is now PER REGION, because "4 to 6 rooms" split five ways leaves regions holding one room each, and a region with no rooms is a name on the map the player can never reach. The hint under the field does that multiplication out loud — that is the difference between a small world and a thirty-room one, and it is also a much longer wait. Above one region the GM gets a different rule: make them differ from each other, give every one of them rooms, lay each out as a coherent place joined to its neighbours by few crossings, and start the party in whichever one the prologue wants. The schema line says how many entries to expect, or the GM authors one and stops. At one region the old instruction is unchanged. Entities (below Narrative). Free prose describing NPCs or monsters the world must contain. They are requirements rather than suggestions, the GM fills in everything the brief leaves out from the world's own canon, and it invents the rest of the cast around them rather than instead of them — without that last clause, naming two NPCs is a world with two NPCs in it. An empty box adds nothing to the prompt at all. Verified in a browser by capturing the composed directive at one region and at three: the per-region room count, the multi-region rule and its bullets, the schema's "... 3 in all", and the author's brief fenced in triple quotes. Round-tripped both fields through the editor, including a stored 9 clamping back to 5. 437 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The World Builder's bottom action row carried a "Create Prologue" beside
Generate World, and the Prologue field carries a ✨ Generate beside its
label. Both were onclick="createPrologue()", and that function takes no
argument and has no branch on which one fired — it even disabled and
re-enabled both while it ran, and said so in its own comment.
Prologue was the only field with two triggers. Theme, World Rules and
Narrative each have exactly one ✨ beside the label, which is the pattern
this row predates. The copy agreed: the Prologue placeholder still named
"Create Prologue" while the Narrative placeholder, written later, says
"Generate".
Gone with it: `we-spacer` on Generate World, which existed only to push it
past the button that has now left. Alone with margin-left:auto it would have
right-aligned this row against the left-aligned Save World row below.
Browser-measured after: both start at x=100.
Three pieces of copy that named the button are reworded — the placeholder,
and the two status lines that offered it as a workflow step ("…then Create
Prologue or Generate World").
One stale assertion is worth calling out. test_new_world checked that Create
Prologue "comes before (left of) Generate World", and that check kept
PASSING once the button was gone: indexOf returns -1 for a missing id, and
-1 is before everything. A position check against something that may not
exist is not a position check. It now asserts the commit row holds one
button, which fails when the duplicate is restored.
tests: the three action-row assertions replaced with the new invariant;
each checked by reverting the change it guards. 436/436.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA fight that goes wrong has no exit. The engine holds movement while combat is active (isBlocked), and above ground there is no engine path out of a room at all — only the GM's moveToRoom, which it refuses mid-fight. So a broken encounter is a stuck session, and a DM trying monsters out in a dungeon is exactly who meets one. An "End fight" button on the combat bar, DM-only. It rides into the crawl view with the rest of the bar rather than being placed a second time for the dungeon. endCombat already does the whole teardown — the countdown, the bar, the inCombat flags, the clock scale, the input, and endDungeonFight below ground, which hands the borrowed body back and brings the movement pad out from behind the dice bag. So this repeats none of it; a second copy would drift from the one the GM-driven endings use. What it adds is telling the GM, which is the whole of the work. Without a note this reproduces the page-reload soft-lock exactly: the engine has no fight, the GM's transcript says it is mid-round, and it goes on asking for rolls the engine will not route while refusing to let the party walk away. The prompt already states no combat is active and adoptOrphanedCombat would catch a stray roll request, but neither should be the first line of defence for something the DM did on purpose. The note also names the surviving foes and says to leave them standing — a cancelled fight is not a victory, and the monsters must not quietly vanish — and says not to narrate the interruption, since a DM control is not an event in the world. The names are read BEFORE endCombat, which nulls `combat`; after it they would be nobody. The handler re-checks player.isDM rather than trusting the hidden button. A hidden control is not an access rule. Verified in a browser both ways. Above ground: as a plain player it computes display:none even mid-fight and a direct call leaves the fight running; as a DM, cancelling leaves combat null, the bar hidden, the timer cleared, both inCombat flags false, the input enabled, the foe alive, the GM told exactly once, and a second click inert. Below ground, the full round trip — pad grid → none → grid, dice view-story → crawl-ui → view-story, bar in-crawl false → true → false. Placed 291px clear of the prompt text, 20px in from the bar's right edge. tests: +test_dm_cancel_combat.js; each of the four load-bearing pieces checked by reverting it. 436/436. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The dice bag appeared but the pad did not go — it slid upward instead, which
reads as a layout bug and is a cascade one.
`#pad.crawl-hidden { display: none }` lived in the game's stylesheet, and
`.tlr-crawler #pad { display: grid }` lives in crawler.css. Both are one id
and one class — a specificity TIE — and crawler.css is <link>ed into <head>
at mount time, so it is appended after the page's inline <style> and wins on
order. Reproduced in a browser: with the class applied, the pad still
measured 191px and computed `display: grid`.
Moved beside the layout it has to beat, where there is nothing left to race.
The pad now measures 0 and computes `display: none` with the class on, and
191 again with it off. All nine controls go with it — the six movement keys
and OPEN / CLOSE / SEARCH are all inside #pad already, so nothing extra was
needed to cover them.
Kept honest about what is load-bearing: source order alone carries it inside
this file, measured by dropping the `.tlr-crawler` prefix and watching it
still hide. The prefix is what keeps that true if the two rules are ever
reordered, which is the accident that caused this in the first place, and
the test asserts the order as well as the selector.
tests: test_dungeon_combat now reads crawler.css for this rule, checks no
losing copy is left in the game's sheet, and checks the declaration order.
Each of the three checked by reverting it. 435/435.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLClicking the Game tab and pressing W to walk drew a gold ring around the tab — a control the author had already moved on from — while the keys carried on reaching the dungeon. Measured rather than guessed: right after the click, :focus-visible on the tab is FALSE. It flips true on the first KEY press, because :focus-visible reports the browser's current MODALITY, not how this element came to be focused. That is why the ring appears when you move rather than when you click, and why it reads as the movement keys drawing it. Not a tab problem either. Anywhere a shortcut follows a click has it — Rotate tool prints its own "R" on its face, and clicking it then pressing R rang it identically. So one rule, not one per control. The ring is deliberate: an authored 2px gold outline that replaced the browser's white one for keyboard navigation. Deleting it would leave Tab users with no indicator at all, so it is suppressed for pointer focus only — a `mouse-focus` class set on pointerdown and cleared on blur. Both listeners capture, because blur does not bubble and the mark has to be on the element before focus lands. The first version of this blurred the button on click. That removed the ring too, but it took the keyboard away from the control just chosen and left nothing focused at all. The focus is wanted; only the ring is not. Verified in the browser across all three paths: mouse-clicked tab then W — still focused, no ring, party walked 1,18 to 1,17; the same button reached by Tab — ring present, unmarked; Rotate tool then R — no ring. Each of the four load-bearing pieces checked by reverting it. 435/435. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Six of the eleven groups already kept their instructions in a collapsed "Notes"; five spelled theirs out in full, always — History, Levels, Pieces, Tile graphics and Map. Tile graphics alone was eleven lines on sprite alpha, additive flame and strip layout, which is worth reading once and scrolling past on every pass after that. Measured in the browser rather than estimated: the sidebar's scroll height was 3436px against a 950px viewport, and folding those five took it to 2499 — 937px, 27% of the panel. The line drawn is between INSTRUCTION and STATUS. A note explains how a tool works and is read once; the "no exit yet" notice says something about this dungeon right now and is shown and hidden by syncExitNotice. The first folds, the second stays — folding it would hide the one thing in the panel that changes. The header hint stays too; it is the top bar, not the sidebar, and it is rewritten on every tab switch. All eleven folds now carry the same "Notes" summary and none start open, so the panel reads as one repeated affordance rather than a scattering of differently-labelled disclosures. No control moved. tests: +test_builder_sidebar_notes.js, which walks the markup counting <details> depth rather than pattern-matching — so an unbalanced tag shows up as a wrong answer instead of a silently mis-nested panel. It earned that immediately: reverting one fold to check the assertion bites left a stray </details>, and the balance check named it. 434/434. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The renderer never had a problem with this: the billboard quad is built from spec.w x spec.h and the texture is stretched onto it, so a squat spider has always been drawable. What was missing was any way to SAY so — w/h were literals in a const ITEMS array, and adding a monster meant editing crawler.js. The near-miss was worse than the absence: an author could upload a square spider over the built-in skeleton's slot, whose quad is 1:2, and get it silently squashed narrow and tall. So ITEMS and TEX_SLOTS take appended entries, and Monsters gains a Create button: name, sprite sheet, frame count, height. WIDTH IS DERIVED, NEVER TYPED. w = h x (frameWidth / frameHeight). A width an author types is a width they can get subtly wrong, and a stretched sprite is hard to see against a dim wall. The derivation agrees with the hand-picked built-in — a 1:2 frame at height 1.10 comes out 0.55 wide, which is exactly the skeleton. A frame wider than it is tall can derive a width past one square, and walls stand on the cell edges — a 4-frame count on an 8-frame sheet derived 2.4 squares, a spider through both walls of a corridor. Both dimensions are scaled down together rather than the width being clipped, since clipping stretches the art, which is the one thing this exists to prevent. The dialog says when that happened, or the Height box would read as broken. Only the measurements are new state. The sheet rides in `tex` and its frame count in `frames`, exactly like every other uploaded texture, so export, import and the synchronous claim that stops a save landing mid-decode from erasing art are the proven paths rather than second copies of them. Registration lives in load(), which is the ONE function both hosts call. That placement is the whole of it: the Builder does not call startCrawler — it mounts and then builds its own textures — and a registration in startCrawler would work in the game and be invisible in the Builder. That exact bug has already happened here once, with the built-in skeleton art. Verified on both pages. Two things the browser caught that reading would not have. The fitted height was being re-clamped by clampMonHeight on the way into the registry: that minimum is an INPUT bound, and re-applying it left a 10:1 sheet at 1.0 x 0.15 instead of 1.0 x 0.09 — the sprite stretched, by the very function meant to keep it honest. And the file input rendered as an invisible zero-height gap, because this page hides every file input and drives it from a button; it now follows that idiom. Deleting a created monster asks first, takes it off every square standing one, and is undoable. Built-ins offer no Delete — they are what the module ships. Two monsters of the same name get different ids, since the id is what a cell records. Verified end to end in the browser: created, placed, rendered wide and squat beside nothing that stretched it, saved, cold page reload — chip, texture, frame count, derived width and its square all came back; then export, wipe, import, and delete. tests: +test_custom_monsters.js, the arithmetic lifted and run. Each of the four load-bearing decisions checked by reverting it. 433/433. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Five pairs now rather than three, and the extra two change the reading. Four of the five entries declared their link; the fifth did not — and that same entry is the only near-verbatim restatement in the set, at 97% overlap where the four linked ones sit between 14% and 45%. The two failures arriving together suggests they are one failure: when the model treats the entry as an elevation it writes an elevation, and when it does not, it copies. That also names what a guard would have to catch. The unlinked entry carries neither elevates nor subject, so nothing about it is structurally detectable — only comparing its text against the hook's would find it, which is where the before-run's reasoning about subject-matching also ended up. Every hook that did get a declared link came down, including the 97% case that started this: 97 to 37, 64 to 45, 49 to 14. Recorded two other findings from the same run. The Drowned Echo-Chorister's unlock condition, typed verbatim by a character carrying the skill it names, was refused as outside what the character can do, and the hook stayed locked — an author's own instruction failing is the plainest way for content to be unreachable. And turn latency roughly doubled, with every turn in the first batch hitting a 75-second cap where the previous run averaged 25. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same instrumentation as the before-run, on a new game rather than a restored save. Three hook unlocks, three elevated entries, three declared links — every one of them named its hook, the engine verified the claim against what actually unlocked that turn, and the back-reference landed on all three subjects. Before, the only clue was the subject field and it was set on half of them. The tier now runs both ways: one entry was minted with no hook unlock at all and correctly recorded as unlinked. In the previous run every Compendium entry came attached to a hook, which is what made elevation indistinguishable from a side effect of unlocking. Restatement is down where it mattered. Overlap with the authored text went 97% to 37% on the worst case, 64% to 45% on another, and the spread closed from 21-97 to 26-45. The near-verbatim reword is gone; what is left reads as the facts followed by what they mean, which is the shape the directive asked for. Three pairs is a direction rather than a settled number, and the first pair on its own looked like no improvement at all. Two things to watch. Turn latency roughly doubled — every turn in the first batch hit a 75-second timeout where the previous run averaged 25 — and the expanded rule is the obvious suspect. And the New Game world picker does not populate its own dropdown, so choosing a world from the library menu blanks the field instead of staging the world. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from another session as a stale anchor in test_dungeon_minimap_stairs: the extraction /\/\/ descending alcoves[\s\S]*?\n \}\n/ coming back empty and eight assertions going red, on the theory that a dungeon refactor had moved the block. It had not. The comment is alive at Modules/Dungeons/dungeon-builder.html:1998. That report grepped text_adventure.html, which this test never reads — its `src` is crawler.js + dungeon-builder.html concatenated. The real cause is line endings, which is consistent with the CRLF trouble that session was already having. Reproduced by converting a copy of the tree to CRLF and running against it: 5 pass, 9 fail, matching the report. The mechanism is narrower than "CRLF breaks regexes", and the difference decides which anchors are at risk. /\n\}/ survives — \n} still matches inside \r\n}. What breaks is a trailing newline AFTER the brace: /\n \}\n/ needs \n, two spaces, }, \n, and under CRLF the character after } is \r. Same file, same style of anchor, one lives and one dies. Measured rather than guessed which tests this actually reaches: the whole suite run against a CRLF checkout fails exactly six — the one reported, plus test_dungeon_entrance, test_dungeon_play_state, test_higgsfield_request, test_portrait_gallery_use and test_room_title_region_chip. Six is also the count flagged earlier in this session, which ties that thread off too. All six now strip \r on read, which is what thirteen other source-reading tests here already do. No assertion is loosened — the anchors are unchanged, and the code they describe was never broken. Verified both ways: 432/432 under LF, and all six green against the CRLF tree they failed on. Reverting the strip puts them straight back to red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A hook unlock and a Compendium lore entry are often one discovery, and only the GM knows it — it writes both in a single reply. Measured over a playthrough, the entry left "subject" empty half the time, so matching the pair by name found three of six. So the link is now declared: loreDiscover carries "elevates" with the kind and name of the hook it came from. A declaration is not a fact, though, so it is honoured only when the hook it names actually unlocked on that turn, and the name recorded is the engine's rather than the GM's — the two are written independently and disagree about articles and hyphens constantly. The lore entry moved below both unlock blocks so there is something to check the claim against, and the hook's back-reference is written only once discoverLore has confirmed the entry stored: that call refuses a repeated id, and a reply can be truncated mid-turn, so a link written any earlier could name an entry that never existed. Both records stay visible, cross-linked, because the entry is not reliably a superset of the hook: overlap with the authored text measured anywhere from 97%, where the entry is the hook reworded, down to 21%, where it is genuinely new. Hiding the authored text behind the 21% case would lose most of what the world author wrote. Where to draw that line is now one decision in the renderer rather than a judgment the model makes thirty-three times a world. Rule 13a now says what an elevated entry IS — the discovery written up as what it means for this player, this world, and the beats already unlocked — with a test the model can apply to its own draft, and says when not to elevate at all. The prior rate was six of six, including two purely atmospheric room hooks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Instrumented a run from the clean baseline to record, per turn, which authored hooks unlocked and whether a Compendium entry was minted beside them. Six hook unlocks, six mints, and not one Compendium lore entry from anything other than a hook unlocking. Elevation is not a judgment the GM is making today — it fires one-to-one, on atmospheric hooks as readily as on pivotal ones. The duplication is not uniform, though, and that is the useful part. Overlap between a hook's authored text and the entry minted beside it ranges from 97% down to 21% — at the top the entry is the hook reworded, at the bottom it is genuinely new material about the same subject. Same GM, same turn shape, a four-fold spread. The 21% case shows the model can write an elevated entry that earns its own existence when it happens to; it is simply never asked to, so it usually paraphrases. That argues for telling it what an elevated entry is FOR rather than suppressing the entry. It also kills the cheapest enforcement idea: only three of the six mints carried a subject at all, so a guard keyed on subject-versus-unlock-name would catch half of them and miss the rest. Alongside: combat runs to a win with the timer off, which it could not with the countdown on; a GM reply was lost to the token ceiling mid-run; and a purchase was refused as out of scope from a merchant who had sold the same item before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerated from full commit history (unshallowed, 1914 commits, 33 days, June 30 - August 1). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ExX27Dj9t3GEGECNFzaEjn
Replaces the datalist combo box from the previous commit. Tone is now a plain text input, with the old eleven-item dropdown restored beneath it as "Presets" — choosing one writes into the box, and nothing more. TWO CONTROLS, ONE FIELD, and only one of them is the field. The box holds the truth: it is what all six readers take and what the generator is handed. The select is an input device for it and is never read as the tone. The select FOLLOWS the box rather than leading it — syncTonePreset re-derives it from the text on every route in: typed, restored from a save, or written by a preset. Deriving rather than remembering is what makes the two incapable of contradicting each other. Typing a custom tone drops the select to its placeholder, as asked; typing a preset's name re-selects it, matched trimmed and case-insensitively, because "Dark Fantasy" is the dark fantasy preset by any honest reading. The placeholder is disabled. Driving it in a browser turned up the one way the pair could still disagree: picking "— Choose a preset —" left the select reading "none" over a box that held a preset. It is the label for "not one of these", which syncTonePreset selects; it was never a choice. This drops the drawn-arrow CSS the datalist version needed, which existed only because Chromium renders no affordance for input[list]. A real select brings its own. Carried over from that version, and still load-bearing: Tone restores as text rather than through setSelect (which discards a value that is not an option, and would silently reset a custom tone on import), the art-style lookup trims as well as lowercases the now-typed key, and the collected value is trimmed like every other free-text field. syncTonePreset guards `sel.options` the way setSelect does — it runs from openWorldEditor, ahead of a real select in three existing tests, and threw without it. Verified in the browser through the whole cycle: opens with both agreeing, picking fills the box, typing a custom tone clears the select, typing a preset name re-selects it, padding and casing still match, and a save/ restore round-trips a custom tone with the select on the placeholder. tests: test_world_tone_freetext rewritten for the new shape; each behaviour checked by reverting it. test_artstyle_presets now asserts that BOTH routes into the tone rebuild the art-style presets. 431/431. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
It was a <select> of eleven, which is the wrong shape for what tone is
downstream. Every consumer already treats it as free text: it reaches the
generator as a phrase ("- Tone: …"), it is written into the world and shown
on cards, and it keys the art-style presets through a lowercase lookup that
simply finds nothing when there is no entry. Nothing needed it to be one of
eleven — so the eleven stay as suggestions in a <datalist> and the field is
an <input>. All six readers take `.value` and are untouched.
Two things had to change with it.
populateWorldEditorFields restored Tone through setSelect, which DISCARDS a
value that is not one of the options. Exactly right for a dropdown and
exactly wrong now: a world saved with a typed tone would have come back
reading "dark fantasy", silently, with the author's own word gone. It
restores as text; Scope, which really is a fixed choice, keeps the guard.
And the preset lookup is keyed by whatever was typed, so it trims as well
as lowercases — a trailing space is invisible in the field and would miss
every key in the table. Verified against the one tone that actually has
presets: " Dark Fantasy " finds the same two entries as "dark fantasy".
The handler moved from onchange to oninput. A <select> fires change when a
choice is made; an input fires it only when the field is LEFT, which would
have left the Art Style presets a step behind whatever was being typed.
The affordance needed rebuilding. Chromium draws no persistent arrow for
input[list] — measured in the browser, beside the Scope select it read as a
plain text box, so eleven presets were invisible to anyone who did not think
to click. It now draws the same arrow the selects draw, from the same
gradients at the same offsets, with the browser's own indicator suppressed
so the two cannot stack. The selector is input[type="text"][list] rather
than input[list] because .we-field input[type="text"] sets the `background`
SHORTHAND, which resets background-image to none, and at (0,1,1) a bare
attribute selector loses to it — the arrow simply did not paint until the
specificities matched. A hint line says outright that either is allowed,
which is the part that works whatever the browser decides to draw.
tests: +test_world_tone_freetext.js; each change checked by reverting it.
test_artstyle_presets pinned the old markup in two places and now asserts
the invariant — that the presets are rebuilt after the Tone is restored —
rather than the spelling of the call that does it. 431/431.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLCombat is transient by design — restoreGameState tears it down and says so
— and measurement confirms the save carries no combat state at all. So the
engine comes back clean. conversationHistory does NOT: it is saved, and the
leave handlers write it mid-fight, so this fired on every reload during a
fight rather than only on a hard kill.
The lock, in four steps:
1. combatStatePromptBlock() returned '' with no fight on. Silence never
contradicts the GM's own memory of the fight it was running, while the
Combat Contract still tells it out-of-combat actions are impossible
mid-fight.
2. So it refuses to move the player — and above ground there is NO engine
path out of a room, only stateChanges.moveToRoom, the very thing being
refused.
3. It asks for a roll; setCombatAwaiting's `if (!combat) return` drops it.
No bar, no dice prompt, no timer.
4. The player rolls anyway; submitCombatRoll's guard drops that too.
Nothing could end it: the GM only emits combat.end when IT decides the
fight is over, and from where it sits the player has stopped answering.
Three fixes, deliberately independent.
The prompt now states engine truth instead of going quiet — that no combat
is active, that a reload discards a fight, and not to refuse ordinary
actions on those grounds. Ten tokens, and it converts an ambiguity the GM
resolves from its own memory into a fact it is already told to obey.
A restore whose transcript ends mid-fight queues a one-shot GM note saying
so. Detection reads the engine's own markers in the last few turns only —
"[COMBAT ROLL — …]", "[COMBAT ACTION]", the GM's combat block — never
prose, and a fight that ended before the reload does not count. Scanning
the whole history would tell every long game a fight had just ended.
And a roll request arriving with no fight is ADOPTED rather than dropped:
the foe is recovered from what the turn named (damage, a status change),
falling back to a hostile in the room, never a bystander. When nothing
resolves it says so to both the player and the GM instead of going quiet.
This is the fix that does not depend on a model complying with the other
two, and it also covers a first turn where the GM sends awaitRoll without
combat.start, which failed identically and always has.
The guards in setCombatAwaiting and submitCombatRoll are untouched: with no
fight there is genuinely nothing to await or submit. What was missing was a
path back INTO one.
Also sweeps a dungeon body stranded by the same reload. materialiseDungeonFoe
LENDS a monster a body in the room the party descended from; endDungeonFight
takes it back, but a reload lands between the two and serializeWorld writes
`rooms` wholesale — so the body is in the save while dungeonFoes, the only
record it was borrowed, is not. It would stand in that room for ever. Marked
with dungeonLoan, carried across a restore by an explicit copy in makeEntity
(entities are rebuilt by field copy, so an unlisted own field is dropped),
read from the spec only so a catalog type can never lend the mark, and
filtered out with the rest of the combat teardown.
Verified end to end through the app's own restore path on a real 20MB save
doctored to end mid-fight: the loan is swept, the engine is clean, the note
is queued exactly once, and a save that is not mid-fight gets none.
The restore note was first written beside the combat teardown, where it read
conversationHistory 68 lines before it is assigned and queued into
pendingGmNotes one line before that is cleared — two silent ways of doing
nothing. The test asserts that ordering on the live function, and it fails
when the block is moved back.
tests: +test_combat_reload.js, lifted and run. Each fix checked by reverting
it. test_combat_enemy_abilities pinned the old empty-block behaviour and now
asserts the new contract. 430/430.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThey were settable only from world JSON or generation, so the direction
with no substitute — a book naming the distant room it explains — could not
be authored by hand at all on a world already built. Verengrad has none.
Two rows under the existing Lore section, in the same shape as the lore and
unlock-condition boxes above them:
Unlocked by subjects whose presence lets the GM see this hook. The
forward direction, which the condition's own wording already
does for free when it happens to name what it needs.
Unlocks other subjects' hooks that THIS one opens. The reverse
direction, and the reason the prompt does not need a list of
every hook in the world.
"Unlocks" is offered only for subjects that can BE in a room — items,
beings, places. A faction or a race is never somewhere the player is
standing, so a link authored on one could never fire, and a control that
silently does nothing is worse than no control.
Whatever the DM types is normalized to an array and the field is rewritten
to what was actually stored, so the reachability scan never parses prose and
the box never disagrees with the data. A name matching no subject in the
world is called out under the field: a dead link looks authored, reads as
authored, and never fires, which is the silent failure this whole area keeps
producing. The warning is written in place rather than by re-rendering,
which would collapse the section the DM is typing in and take the caret with
it, and it collapses to nothing when every name resolves.
Also teaches the Generate button to prefer a condition met AT the subject,
since one met elsewhere needs a link recorded by hand to be reachable.
The placeholders are short with the explanation in a tooltip — the first
draft put the full sentence in the placeholder and it wrapped out of a
one-row box, reading as truncated. Seen in the browser, not reasoned about.
Verified in the page: both rows render, a faction gets only the forward one,
typing a comma-separated list stores an array and flags the name that
resolves to nothing, and the stored link really does surface its subject's
hook through the live item. tests extended; the row, its category gate and
the warning each checked by reverting them. 429/429.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLAll four carried lore/loreKey/loreUnlocked, the DM's Compendium editor
wrote all three, and playthroughLoreHooks counted them toward completion —
but nothing in play ever flipped loreUnlocked except that checkbox. No
unlock function, no GM field, no rule. On an authored world every hook was
dead, the lore dimension of computeWorldCompletion could not reach 100%,
and the GM — having no field to set — filed its own invented lore under
loreDiscover instead, beside the authored hook it was plainly answering.
The two kinds failed in opposite directions, so this is not purely
additive. A room's lore never reached the GM at all: buildSystemPrompt had
no room.lore anywhere in it. A being's reached it with no lock and an
instruction to spend it — "draw on it for atmosphere and when the player
investigates", "let it surface naturally as rep allows" — so the authored
secret was simultaneously leakable and unearnable. Both dossier lines now
report only the lock state and defer to one gated copy.
Adds rule 13d and a "loreUnlock": { kind, name } field, the non-item twin
of itemLoreUnlock, plus a clause in 13a telling the GM to prefer an
authored hook over minting a new entry beside it.
REACHABILITY, not a global list. An unlock takes a name, which looks like
it demands every hook in the prompt. Measured rather than assumed: on the
authored Verengrad a full entry averages ~534 chars, so a flat listing is
11.5KB there and ~52KB — about 13k tokens — at 100 hooks, every turn,
growing with the map forever. The same measurement says it is unnecessary:
19 of those 22 conditions are satisfied at the subject itself, and all 3
that are not name what they reach for. So a hook is sent when it is on the
room or a being in it (HERE), when its condition names something in the
room (FORWARD, needs no authoring — it reads prose already written), or
when something in the room declares in unlocksLore that it opens that
subject (REVERSE).
The reverse link is what makes the global list unnecessary rather than
merely expensive. A book in a library says which distant room it explains,
so that room's hook appears exactly when the player holds the thing that
could earn it. The forward direction cannot reach that case: a room's
condition would not name every book that mentions it, and rewording it to
try puts the link at the wrong end. loreLinks is the same idea at the other
end, for a link the prose does not spell out. Both optional, both persisted
as own fields on rooms, beings and items, both taught to world generation.
Measured after: the section averages 287 tokens across Verengrad's rooms
against 2,883 for a flat listing, and is flat in world size rather than
13k at 100 hooks. A hook with nothing in the room to act on is not listed,
which costs nothing real — it is one the player has no means to earn this
turn either — and the dossier is a reminder, not a whitelist, so a hook the
GM remembers is still unlockable by name.
Also fixes loreKindCategory to fall back to ENTITY_CATALOG: findEntityByName
walks live rooms only, so a monster met but not currently standing anywhere
would have linked to the People tab — exactly the beings whose lore is most
often earned after they are dead.
Verified in the page against the real makeItem (the item keeps unlocksLore
through construction, the hook appears only while the book is carried, and
the unlock flips once). tests: +test_lore_hook_unlock.js, lifted and run.
Both link directions and the dossier leak checked by reverting them.
429/429.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLCombat has always put a 30-second real-world clock on a player's turn, after which the engine resolves it for them — they hesitate, or a die is rolled on their behalf. That suits a fight played at pace and does not suit a table that reads carefully or a session played across sittings. Checked keeps that behaviour exactly. Unchecked removes the clock: the combat bar waits as long as it takes and nothing is decided for the player. Two things carry it: THE DEFAULT is ON. A setting only exists in storage once it has been toggled, so defaulting off would have taken the clock away from every save that predates this without anyone asking for it. One helper reads that default, so the checkbox and the engine cannot disagree about it. THE ONE EXIT is what makes the off state honest. autoResolveCombatInput is what decides a turn for the player, and it is reached from the countdown tick and nowhere else — so not starting the interval really is the whole of "waits indefinitely". The test counts the callers rather than trusting today's shape. Toggling applies at once rather than at the start of the next turn, in both directions: switching off with eight seconds showing has to stop the clock, not resolve the turn the player just decided not to be rushed through, and switching on grants a full turn rather than the remainder of one that never ran. The readout is emptied rather than frozen, so #combat-bar-timer:empty takes it out of the bar instead of leaving a stale "12s" beside the prompt. Nothing in COMBAT_CONTRACT or any prompt block mentions the clock, so this is an engine setting and not a rules change — the GM asks for an action or a roll exactly as before and simply waits. Verified in a browser: default on, counting 30s→28s; turned off mid-turn the interval stops and the readout goes and stays empty; a new turn while off starts nothing; back on gives a fresh 30s; the checkbox round-trips through the popup and sits under the Story heading. tests: +test_combat_timer.js, lifted and run rather than matched. Each assertion checked by reverting the code it guards. 428/428. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Re-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 (a4cb23a).
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 24c1f8f124dcccf6a35ebfc25e992a0ad8739afa.
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
When the time of day advances and swaps in a new banner image for the current room, prepend the room title above the banner (matching a fresh room entry) and append the new time-of-day label after a colon, e.g. "The Village Square : Afternoon". Previously the banner appeared with no title on time-of-day transitions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Generate oil-painting banner art for every room at all six in-world times of day (dawn, morning, afternoon, evening, dusk, midnight) from the prompts in room_banner_prompts_by_time.md, and wire the resulting URLs into each room's bannerImages object in WORLD_DATA. This replaces the single legacy bannerImage per room (13 rooms x 6 = 78 images), so each location now shows art matching the current time of day. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Add a Quests tab to the main panel that is only revealed when player.isDM is true. The tab shows every quest thread and all of its story beats (both locked and discovered) in a linear timeline, mirroring the journal timeline style, with per-quest progress and the GM trigger for each beat. Beats carry only prose, so each beat's referenced locations, NPCs, and items are detected by scanning its title/text/trigger against a name index built from the live world, then listed as chips beneath the beat. DM-only elements are gated through a single applyDMVisibility() helper (toggling any .dm-only element) called from startGame, giving future DM UI a consistent hook. 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
Add a "DM" checkbox above the Begin Your Journey button. Its checked state is read in startGame() and passed to the Player constructor as a new isDM flag (defaults to false), so DM-only UI components and tabs can key off player.isDM once they are added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Track, behind the scenes, how the player's actions exercise each of the six attributes. The GM grades relevant actions via a new statEffects field; the engine accumulates those deltas into a hidden per-stat progress total (capped, can go negative). At each level-up the accumulated progress is reviewed and converted into a visible per-stat bonus, shown beside the attribute as e.g. STR 8 (+1) (green) or CHA 8 (-1) (red). Because the bonus is re-derived from the running total every level, it can rise or fall as the player's recent conduct shifts. Bonuses feed the effective attribute scores used by the GM prompt and the CHA persuasion calculation, so training matters mechanically, not just cosmetically. The progress accumulates before the level-up review so a level gained on the same turn includes that turn's training. Stat effects stay invisible in play by design; the only surfaced feedback is the level-up summary line and the bonus shown in the sidebar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Give the Hide Sidebar button an 8px left margin so it no longer sits flush against the Save Story button, leaving room to breathe as more buttons are added to this toolbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Each person in the Compendium's People subtab now shows the player's current reputation with that NPC, displayed as a colored tier badge (label + signed score) that mirrors the sidebar's reputation styling. The value reads from the live entity, looked up by name, and refreshes in place when reputation changes while the People tab is on-screen. Factored the shared tier->CSS-class mapping into repBadgeClass() so the sidebar and Compendium stay in sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF
Align Save Story button flush against Hide Sidebar button
Remove the 16px gap between the export/Save Story button and the sidebar toggle so they sit directly adjacent, overlapping their borders into a single shared line and centering the toggle vertically to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFnyp4HfCEBviAgDGQ7MaF