BraveYouWorlds/TheLostRealms · all monthsReported from play: with the setting on, walking into the Village Square — where the Old Gatekeeper stands with a single ambient behaviour — produced nothing, and no log line said why. His beat was never broken. rollRoomAmbientOnEntry read world.rooms[id].ambient and nothing else, and returned before it even reached the setting whenever a room had no beats of its own. Ambient behaviours authored on a BEING are a separate system, fired only by their own setInterval through tryEntityAmbient, and entry did not touch them. So the Gatekeeper's beat was sitting on a thirty-second timer at 80%, which is why the room eventually spoke if you stood still — and why the setting looked broken if you walked on. In the built-in world nothing at all is authored as room ambience, so switching this on changed nothing anywhere in it. A setting named for entering a room that has no opinion about anyone standing in it is answering a different question: an NPC in front of you is the most visible ambience a room has. The pool is now the room's beats for this hour and the beats of the beings present, and one is chosen from the whole of it. The guarantee is unchanged and is the point of the setting: a beat is picked at random and fired, with no chance roll after the pick. evaluateAmbient gained the same `force` the room path already had, and the test rigs the roll to always fail and demands that twenty-five entries produce twenty-five beats. Which is why an ineligible beat is kept OUT of the pool rather than picked and dropped. Everything the picker can choose must fire, or the guarantee is not one — and every gate downstream of the pick produces exactly the silence this setting exists to remove. So chance 0 (which says never), a statusChange beat (event-driven — walking in is not a change of the being's status), a oncePerPresence beat already fired for its target, a comment whose target is not here, a dead or conversing speaker, and a being in another room are all excluded before the pick rather than after it. And when there is nothing to fire at all it now says so. The old code returned in silence there, which is the single most likely reason for the setting appearing to do nothing and looked identical from the outside to a beat that fired and was missed. Four of the new assertions were too weak to trust and were rewritten before the sabotage pass would stand up. Each asserted that an ineligible beat "does not fire", which passed with the guard deleted: with one beat in the fixture it was picked and then dropped downstream, producing exactly the nothing the assertion asked for. They put a valid beat beside the ineligible one now and require every one of twenty-five entries to speak — with the guard the pool holds one and always fires, without it the pick is a coin toss and half the entries fall silent, which is the failure a player would actually see. The existing on-enter test also counted only the room's requester. It counts both call sites now, and clears the starting room of its beings for the sections that are about the room's own beats — the built-in Gatekeeper standing in that fixture is what made the run intermittent, and is the bug in miniature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A push to main patched .npc-equip-box with !important on both dimensions, which fixed a real bug: the dialog was opening at the ordinary modal's 460px. The cause is that `.modal-box` is declared several hundred lines BELOW that rule with a width of its own, and a lone `.npc-equip-box` ties it on specificity, so the later rule won. `.modal-box.npc-equip-box` beats `.modal-box` outright, wherever either one sits, and does it without leaving an escape hatch in a shared stylesheet for the next person to trip over. The comment now records what happened, so the plain selector is not restored by someone tidying up a compound one that looks redundant. A test reads the rule by selector and fails on either regression — the plain selector, or a return to !important — with a control that asserts .modal-box really does set a conflicting width and really is declared later, since without both of those the assertion is about nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
An NPC card carried Race, Aggression and Size and no way to say whether the person it described was a man or a woman. The GM picked a pronoun from the name, got it wrong in front of the player, and then had to stay wrong for the rest of the run for consistency. Three values and a real blank, in a picker directly under Race and wearing the same dress as the Aggression and Size pickers below it. Blank stays IN the list rather than being implied by an empty box, because "unstated" and "Other" are different statements: Other is a positive answer about this person, unstated means nobody has decided, and the GM's rule for an unstated one is to judge it from the description. A free-form box would have collected "F", "woman" and "Male " as three genders nothing downstream matches, so the same normalizer every other being vocabulary uses accepts any of them and sends a word it does not know to the blank rather than storing it. The load-bearing decision is that this is a fact about the INDIVIDUAL. Aggression and Size are traits of the kind — how big a rat is is a fact about rats — and write through to the catalogue type and to every live being of that name. Gender does not, and must not: two beings called Town Guard are two people, and deciding one of them is a woman must not decide it for the other, for every guard in the world, or for every guard yet to spawn from that template. It sits with Race, which is per-instance for the same reason and is why the row is directly under it. The test drives that rather than reading the roster, with a setEntitySize call beside it as the control — without one, the assertion would pass just as well against a setter that wrote nothing anywhere. Three things read it. The room dossier carries it beside race, so every line the GM writes about a being has a pronoun to use. The card's Update button may fill an unstated one, through the same normalizer, and never overwrites one the author chose. And the being's paper doll and full-body render now have a gender to draw — the adapter's comment used to read "beings carry no gender, so every doll uses the default silhouette", which was true when it was written. Other and unstated both keep the default figure, and Other is left out of the render prompt rather than written into it: "an Other Human guard" is a worse instruction to a painter than "a Human guard". The authoring schema names the three values by interpolating the roster, not by typing them — the pasted equipment-slot roster went stale in two prompts and this is the same shape. The schema test lifts that roster from source and evaluates it, exactly as it already does for the creature sizes, so a fourth value added to the table has to appear in the prompt. One assertion of mine needed scoping before the sabotage pass could trust it: it looked for the blank option across the whole card, and the Aggression and Size pickers each carry a blank of their own, so deleting gender's went unnoticed. It reads the gender select's own markup now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The popup overlapped the divider it was meant to sit beside. Its position was arithmetic over the viewport — half the dialog's margin plus the pack column's declared width — and that has to guess at the column's padding, its border and the dialog body's own padding inside it. Guessed, it came up short, and a detail panel landed across the rule separating it from the list it describes. The column knows where its own left edge is. It is asked now, at open time, and the popup's right edge is set from that with 14px of air so the two do not read as one panel. Exact at any window width, and the same measure-don't-guess the stored row's slot size already makes. The CSS value stays as the starting point and as the fallback for a pane that has not been laid out: a hidden pane measures zero, and a popup pinned to the left edge of the screen is worse than one pinned to the right. The cursors were naming the wrong gestures, and one of them was naming a gesture that had been taken away. A filled slot showed `grab` where the tab shows `pointer` — its click opens the item, which is what most people want from it, and the tab settled which of the two a cursor should advertise long before this dialog existed. A worn row in the pack showed `default`, because it had been made undraggable on the grounds that dragging the list copy onto a second slot means nothing. That is true of a helm and false of anything fitting two slots: dragging the dagger's row onto Sidearm while it sits in Weapon is a move, and equipEntityIntoSlot already performs it, clearing the slot it came from. For a one-slot piece the drop lands where it already was and changes nothing. So every row drags, as every row on the tab does, and the grab cursor is honest again. Two of my own assertions had to be repaired before the sabotage pass could trust them, both the same mistake this repository keeps making. One read a CSS rule out of a fixed 900-character slice from a neighbouring selector, and the rule had since moved beyond it — the assertion went quiet while describing a rule it could no longer see, and re-adding the cursor it forbids was not caught. It reads the rule by selector to its closing brace now. The other described the placement in source regexes only; the arithmetic is the part that was wrong, so it is now driven with a real column geometry and a real window width, and checked against the number it should produce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Three things about the dialog, all of them about reading it rather than about what it can do. It is 75vw by 75vh. The width alone was not the fix: the figure sizes itself from the space it is given, so a box that hugged its content let the doll shrink to whatever the shortest of the three columns happened to need. A fixed height gives the layout a known box to fill, and the body no longer scrolls as a whole — each column manages its own overflow, which is what keeps the figure in view while a long pack scrolls beside it. The figure was bottom-aligned, which was correct where the rule came from and wrong here. On the player's tab an `auto` top margin and the hint's `auto` bottom share the free height and centre the pair. In a fixed-height dialog with a tall render column beside it there IS free height, so the doll sank to the foot of its column while the render stood at the top and the two read as different screens. It starts at the top now and the slack falls below, where the hint already sat. And clicking an item opens it, in the pack and on a filled slot alike, exactly as it does on the tab — a list you can only drag from is half the affordance. It resolves the being's OWN inventory entry rather than the catalogue, so the popup describes the copy this being is carrying and not the pristine type. That needed a popup of its own, and the reason is worth recording: every detail popup in the app sits at z-index 45 and a modal overlay sits at 200, so opening the tab's #equipment-item-popup from here would have drawn the detail UNDERNEATH the dialog that asked for it. That is the same failure the sidebar doll hit when clicked from the Story tab, and it is fixed the same way — by naming a popup that is visible from where the click happened. It is a sibling of the box rather than a child, so it escapes the box's overflow, which also means hiding the overlay does not hide it and closing the dialog has to take it along. It sits beside the pack column rather than over it, because a popup covering the row you just clicked hides the list you are reading. Eight sabotages, each caught. Two of the assertions needed strengthening first: one read the popup as open only when display was a truthy value, when showEntityPopup CLEARS display so the stylesheet decides, and one asserted the item's catalogue name — which a lookup that never touched the being would satisfy just as well. The being's copy is renamed in the fixture now, so only the right lookup passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
It was a doll and a pack in an 860px box. The ask was parity with the player's Equipment tab, and the tab is three columns — the character as they LOOK beside the character as they are OUTFITTED, with what they are carrying on the right. The dialog now has all three, at nearly the width of the window, because a tab's worth of screen is what it takes to be a tab: at 860 the figure was the size of a playing card and the render column had nowhere to go. The way it got there matters more than the layout. The render column is five buttons over a billed image API with three prompts behind it, and a second copy of that would have been correct on the day it was written and quietly wrong by the next change — a column that paints a being slightly differently from the way it paints the player is not a bug anyone reports, it is just a worse picture. So the pipeline is parameterised by an owner adapter rather than duplicated: equipRenderColumnHTML, the gallery strip, Generate, Update, Extract Portrait, the uploader and all three prompts now take a wearer, and the dialog draws the tab's own function. A test asserts that it CALLS that function rather than resembling it. The adapter is the only thing threaded through, and it carries exactly what differs: which portrait the render is painted from (the player's, or the being's compendiumImage), where the render lands, whose gallery files it, what adopting a face means — join the Profile gallery and redraw the sidebar for the player, propagate to every copy for a being — and the element-id prefix, because the Equipment tab can be sitting behind the dialog and two nodes answering to #equip-render-status hand every status message to whichever came first in the document. What is deliberately still NOT shared is equipping. equipItemIntoSlot charges a combat round, refuses off-class gear and runs onEquipped effects, and a DM dressing an NPC is doing none of that; threading an owner through a live combat path to save an editor some code is the wrong trade. So gear goes on by two paths and is painted by one. Beings gained the three stored places with the rest of it, and with them the distinction that had cost nothing to ignore while every filled slot was worn: a pack on the back is CARRIED. Stowed gear counts toward no Armor Class, is listed apart from the worn pieces on the card, and reaches the GM as its own stowed:[…] with a — STOWED flag rather than — WORN, so it can be described without being counted. A stored place also takes anything, which is why an item no slot on the figure would have — a potion, a coil of rope — is now draggable in the dialog where it was deliberately not before. Seven tests were repaired rather than worked around, and each was pinning something that was never the claim. Two anchored on a function's empty parameter list and stopped finding it the day it took an argument — one of them then sliced unrelated source and failed while describing a scenery helper. Three pinned handler strings that now carry the wearer's key. One asserted a cap on reference images by reading the FUNCTION'S ARITY, which says nothing about a cap; it now fills every slot with a pictured piece and counts what actually gets sent. And two of my own from the last change described a design this one supersedes. Two more distance-window regexes were replaced with a scoped function slice, since "renderCharacter() within 2600 characters of this name" is a measurement of unrelated code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The paper doll gave a being gear the engine understood and the model could not see. The room dossier
listed what a being CARRIED and nothing said any of it was on, so the GM described a mailed sergeant or
an unarmoured one at random, and in a fight the sword in the dossier's carries: list read like luggage
while every enemy blow was a number invented from nothing.
Each being present now carries a wearing:[…] fragment naming the slot each piece fills and the one number
that piece decides — an armour's base, a weapon's dice, a signed AC bonus — and those same pieces are
flagged "— WORN" in carries: rather than removed from it, because a worn piece can still be taken, sold or
handed over and dropping it would break the transferItem path the ref exists for. The flag is the
difference between lifting a spare out of a pack and taking the blade off a body, which is a thing the
narration has to acknowledge. A being wearing nothing adds not one character: the fragment is omitted
rather than rendered empty, so every world authored before the doll costs exactly what it did.
In a fight each foe's own line repeats what it is wielding, with the dice, and what it has on. The armour
is already inside the AC beside it, so what the weapon adds is the half AC cannot carry; the armour is
named anyway, without numbers, because "your blade turns on his mail" is a sentence the GM can only write
if it knows there is mail. The combat contract gains the two rules that make it safe to read: the foe's AC
already counts every worn piece, so the armour is never added twice — doing so makes a fight unwinnable
for a reason the player can never see — and a listed weapon's dice are the ones to roll for its blows. A
foe with nothing listed is unarmed or improvising, which is a real answer rather than a gap to fill. That
rule lives in the cached half of the prompt and the values in the per-turn half, which is the split the
prompt-cache design asks for and a test asserts.
Writing that surfaced a bug older than any of it. catalogItemShape preserved a weapon's equipment slots
and both of armour's AC fields and DROPPED damage, damageBonus and damageType — and every route into the
item catalogue goes through it: a GM addItem, a world chunk's items, an inline item registered on the
spot. So a sword was catalogued without its dice and every later { ref } to that entry built a weapon with
none. Silently, everything Phase 2 added then stopped applying to it: the pack stopped showing its dice,
equippedWeaponWithDamage stopped finding it, and the engine handed the number back to the GM. Same shape
as BUG-011 — the authored value existed and the thing that resolves the ref did not carry it. The three
fields now travel with the shape, asserted both on the shape and end to end through a real makeItem.
Two tests were repaired rather than worked around. test_being_inventory_ref matched the fallback branch
with the ".join(', ')" that happened to follow it, which is punctuation and not the claim; it failed the
day the map body grew a statement of its own. test_dossier_item_worth lifts the shipped carries:
expression into a sandbox and so needed the worn set the expression now consults — supplied rather than
stubbed away, so the WORN flag is exercised by the shipped code and not described by the test.
All four guides carry it: the Field Guide and the Player's Handbook for the player, in the terms a player
acts on — a foe in mail is harder to hit, a foe holding a blade swings that blade's dice, and stripping a
body is a disarmament — and both editions of the DM's Guide for the author, including that none of it is
bookkeeping and all of it reaches the model.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLAn entity had an inventory and no notion of wearing any of it. The breastplate in a guard's pack was scenery: its defences were one flat number, ent.armor, and nothing connected the two. The GM described a mailed sergeant and rolled against the AC of an unarmoured one, which is the same failure shape as gear the GM narrated putting on that never reached the player's doll. The NPC card now carries an AC row above Health and an Equipment section beneath Attributes, and the ⚙ in that section opens a paper doll for the being: its pack on the right, drag onto a slot to dress it, drag a piece off the figure to take it off. A being's gear is deliberately the same SHAPE as the player's — a doll slot key → a catalog ref — and follows the same AC rule: worn armour's own ac replaces the base, every other piece's signed acBonus adjusts the total, and 10 + DEX mod is the base when it wears nothing. A chain shirt that meant one number on the player and another on a guard would leave the GM's to-hit maths depending on who was wearing it. ent.armor keeps its meaning as NATURAL armour and stands on top of whichever base applies, which is also what makes this change invisible to every world already authored: a being wearing nothing returns exactly what it returned before, and the test asserts that first and hardest. What is deliberately not shared is the code. equipItemIntoSlot and the doll's drag handlers are welded to the player — class refusals, the combat round a swap costs, onEquipped status effects, the storage slots — and none of it applies to a DM dressing an NPC in an editor. Threading an owner through all of that would have put a live combat path at risk for an editor feature. Every TABLE is shared instead — EQUIP_SLOTS, EQUIP_SLOT_ICONS, itemEquipmentSlots — so a slot added to the doll appears here without anything being kept in step by hand, and the test fails if the layout is ever hand-listed instead. The slot map holds a ref, not an inventory index, because indices move the moment anything is added or taken away and this editor does both for a living. entityEquippedList is the single funnel that decides what counts as worn, and the chips, the doll and entityAC all read it, so they cannot disagree. That funnel resolves from the being's OWN inventory entry and nowhere else. It was written as a fallback to the catalogue at first, which quietly undid the whole rule: an item taken off a being is still a catalogue entry, so a sold breastplate kept its slot, kept its chip and kept paying its 18 AC. The test found that before a person could. A slot pointing at something the being no longer carries reads empty rather than being erased. The item may come back — a restock, an undo — and silently deleting a DM's choice behind their back is worse than a slot that reads empty until it does. The unequip gesture is "dropped on no slot", so it runs from dragend; a slot that took the drop clears the source key, which is what stops every move between slots from also being a removal. That was a separate boolean at first — one more thing to keep in step, and able to be true while the slot key was still set. Two tests were rewritten rather than worked around. test_monster_deceased pinned the Deceased toggle by naming its NEIGHBOUR in the template, so it failed the day anything was added between the two, describing a misplacement that had not happened; its claim was always that the toggle is the LAST thing in the Attributes float, and it now reads the float's tail and says so. And an assertion of my own searched the whole card for an item name, which the card's "+ Add" dropdown supplies from the catalogue regardless — it reads the Equipment section, with the equipped card as its control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Being States phase 4, and it turned out to be a renaming rather than a removal. Darren's objection was that the engine has no business deciding the physics of a world it did not author: a restrained creature might be carried, dragged, or moved by a spell it mutters, and dead does not mean dead where a DM has written resurrection or undeath. The flags read exactly like that kind of claim — acts, bars, moves — so the phase set out to work out how few could survive. Three of the four survived, unchanged in behaviour, because the problem was in what they were CALLED. Renamed haltsTravel, keepsRoutine and readsAsFight, each names one of the three things the engine does without being asked: truncating a compound walk, running the daily schedule, and printing a would-fight label in a dossier. Nothing in the registry now asserts anything about what a being is capable of. acts is deleted. It meant "this being cannot act", it was read by nothing, and it could not have been read by anything, because what a foe does on its turn is entirely the Game Master's — there was no site at which the engine could honour it. It was a claim about the creature wearing a registry's clothes. The intent survives in the contract, which tells the Game Master a subdued being does not act, and that is where a claim the engine cannot enforce belongs. Its four test assertions went with it. restrained keeps its scheduler flag, which reverses what the design doc predicted, and Darren approved the reversal rather than my making it quietly. Read as "a restrained being cannot move" it was indefensible and the doc was right to condemn it. Read as "the scheduler does not walk a pinned creature off to the tavern on its own initiative" it is plainly right and forbids nothing: phase 3's lease outranks the schedule, so a prisoner who is carried, dragged or spirited away simply goes, and a DM's authored transport routine still runs whenever nobody has declared the creature restrained. alive === false survives as predicted, and reads honestly under the same phrasing — not "corpses cannot walk", which is not the engine's claim to make, but "the scheduler does not overwrite the Game Master's own declaration of a death". The lesson worth keeping is that the guards did not need removing, they needed re-describing. Two survived a rename that changed no behaviour at all, and the only one that genuinely had to go was the one that had never done anything. Sabotages confirm each surviving guard still bites, including flipping restrained to subduing — the reversal this phase declined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
D-5 has been an open worry with a number attached to it and nothing else: roughly 660 MB of content LICENSE-CONTENT licenses as proprietary, unaudited. Tools/build-asset-inventory.js turns that into a list. It reads what each file says about its own origin and writes Evaluations/asset-provenance.html and a CSV beside it, so the conversation with a lawyer starts from a document rather than a directory listing. Re-runnable, deterministic byte for byte, and expected to be regenerated after any scrub. Two findings changed the shape of the problem. The payload is not mostly an art library. Nine playthrough SAVE files account for 311.7 MB, 46% of the whole, one of them 92.8 MB on its own. They are development artefacts with generated art baked into them as base64 — twenty JSON containers hold 328.7 MB between them — and they are not authored content anyone would license. Removing them halves the payload and shrinks the audit surface in the same motion, which is a decision about repository hygiene rather than about copyright and can be taken immediately. And the art is machine generated, which inverts the risk this decision was written around. Of 110 raster images, 25 declare Google generative AI or carry a SynthID watermark note, 3 more carry C2PA provenance assertions, and 45 embed the generation prompt that produced them. The earlier framing — that D-5's failure mode is a claim from somebody else — was probably backwards. The likelier failure is that parts of this may be owned by nobody, because purely machine-generated images may attract no copyright at all, and a proprietary licence over them would assert a right that does not exist. That does not void the licence; it makes part of it unenforceable, and the content licence is the half doing the commercial work. 92 files, 208 MB, report UNKNOWN. That is deliberately not a verdict of "hand made" — it means the tool found no marker, and those are the rows a person has to work through. The report says so in the header rather than letting a colour be read as a conclusion. Five providers are implicated across the tree (Google, Pollinations, Higgsfield, Tripo, World Labs), so output ownership and commercial use want checking per provider rather than once. Worth deciding separately: 45 images carry their generation prompt in plaintext and 14 carry a SynthID watermark. Neither is a problem, both become public with the repository, and both should be a choice rather than a side effect. The tool is deliberately not a --check gate. Nothing here should fail a build: it is evidence for a judgement a person has to make, and a generated verdict is not a substitute for making it. Scanning is bounded to the first 300 KB for markers, and the containers are counted with a streaming reader with an overlap, so a 92 MB save is not read into memory to count substrings in it. App suite 733/733, vault suite 40 files, the static denylist test, and two consecutive runs of the generator produce identical bytes.
Server/admin.html is the third standalone HTML page this project ships and it carried no notice at all. It has one now. What makes it worth its own commit rather than a third entry in a list is that its banner must say something DIFFERENT from the other two, and the test had to learn the difference before it could be trusted about either. text_adventure.html and guide.html are software and proprietary game content in the same file, so their banners name both licences; naming one would misstate the other half. The admin page carries no game content whatsoever — it is the vault's key-management interface — so naming LICENSE-CONTENT there would be a false statement in the opposite direction, asserting a proprietary content licence over a file that has none. A notice is only worth putting on a file if it is accurate about that file, and "both licences, everywhere" would have been the easy wrong answer. So the test's FILES list became a table of pages with a `mixed` flag, and the assertion split in two: a mixed page must name the content licence, a software-only page must NOT. Both directions are sabotage-checked, because only one of them was covered before and the new one is the direction a careless copy-paste actually fails in — adding LICENSE-CONTENT to the admin banner because the other two have it. That fails now, by name. Everything the banners already had is unchanged and still asserted for all three: the bang that keeps a build from stripping the notice, position immediately after the doctype since a notice buried mid-file is not conspicuous, no double hyphen to close the comment early, the copyright holder, and where a commercial licence comes from. The seven vault tests that read admin.html were run individually as well as in the suite, since a comment inserted at the top of a file those tests parse is exactly the change that breaks one of them quietly. App suite 733/733, vault suite 40 files, both desktop suites and the static denylist test.
The Equipment tab's render column could only ever be filled by Generate, which needs a configured Image AI and costs a billed call per picture. A player who has their own art, or who is playing a build with no image provider at all, had no way to put anything in that column — the whole left half of the tab was unreachable to them, and the strip of kept renders below it could never hold anything. The ⬆ beside the buttons takes an image file instead. It is offered in BOTH states of the column, and it is the only one of the three controls that is. Generate is always there because it always works; Extract Portrait appears only once there is a render, because it re-frames that picture and has nothing to work from without one. Upload looks like it belongs with the second group and belongs with neither: the reader who most needs it is the one staring at an empty frame, so tucking it inside the same ternary would have hidden it from exactly the player it was added for. The test asserts both halves of that, and asserts Extract Portrait stays conditional, so the two cannot be quietly swapped. The uploaded picture is worn as well as filed. Filing alone would have left the frame still saying "No character render yet" after the dialog closed, which reads as a button that did not work; the render it replaces is not lost, because it is in the strip directly below. It goes through rememberBodyRender rather than onto the array, so re-uploading the same file does not double the strip, and the input is cleared so that picking the same file twice fires at all — onchange does not fire for an unchanged value, and without that line the second attempt silently does nothing. Capped at 1024px on the longest edge. That is not a number picked for feel: Update and Extract Portrait both send this picture back to the model through compressImageForInit at maxDim 1024, so anything larger is bloat in a save that already carries every render in the strip, held to feed a picture that is resized away again before anything reads it. The test measures the canvas the downscaler produces rather than matching the constant in the source, so an option object that is written but never passed still fails. A file that will not decode says so on the status line the column already has. Every other uploader in the app swallows that case, which on a file the player believed was a picture is indistinguishable from a dead button. The message is written after the redraw rather than before, because renderEquipment rebuilds the column and a message set first is thrown away with the node it was written into; the test models that by having its redraw clear the element, so the ordering is pinned rather than assumed. The control is a label wrapping a hidden file input, not a button calling .click() on one, which some browsers block outright — a file dialog opens only from a genuine user gesture on the input itself. That is the same reason the palette's Import and every other uploader here is a label, and it is asserted so a later tidy-up cannot turn it into a button that looks right and does nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
BSL requires the licence to be conspicuously displayed on each original or modified copy of the Licensed Work. For most projects the LICENSE file at the repository root discharges that. It does not here, and the reason is the thing that makes this repository unusual: text_adventure.html IS the app. The README tells people to double-click it and play from file://, so it travels on its own routinely, and until now a copy that had left the repository carried no licence, no copyright and no way to find either. guide.html is the same shape. Both now open with a banner naming both licences, what is free under them, and where a commercial one comes from. Naming both is the point rather than a flourish. The file is software and proprietary content at once — engine and prompts under BSL, the built-in world's prose and data under LICENSE-CONTENT — so a single SPDX identifier at the top would be a false statement about half of it. That was already the conclusion recorded in D-8; this is the follow-through, in prose, which is the only form that can say two things. The build is why this needed a test rather than a glance. Tools/build-min.js strips HTML comments, so adding a banner and stopping there would have produced a minified copy that silently carries no licence at all — the obligation failing on precisely the copy most likely to be handed to somebody, and failing invisibly, since the source file would still look correct. The stripper now spares a comment whose opening bracket is followed by a bang, the same convention terser uses for a legally significant JS comment, and the banners are written that way. Verified end to end rather than by reading the regex: the real minifier was run over the real file and the banner is in the output, with ordinary comments still stripped. Two commented-out script tags survive the strip, which looked like a regression and is not — the guard treats the <script> inside such a comment as a region to skip, so the comment straddles a boundary and never matches inside one gap. Running the OLD regex and the NEW one over the same input leaves both of those in place either way; the only behavioural difference the lookahead makes is that the banner lives. Tests/test_license_banner.js pins all of it, and reads the two strip regexes OUT of build-min.js rather than copying them, because a copy would pass forever while the tool regressed — the same mistake test_media_pack.js was making until this week. It also checks the banner holds no double hyphen, which would close the comment early and dump the rest of the notice into the page as visible text, and that the banner is the first thing after the doctype, since a notice buried mid-file is not conspicuous. Sabotaged four ways, each caught by one named assertion: reverting build-min.js to the old regex fails the two strip-site checks and the real-banner check; removing the bang fails the banner check; a double hyphen in the prose fails its own; dropping the mention of LICENSE-CONTENT fails the both-licences check. support.js was deliberately left alone. Its first line says it is generated from dc-runtime and must not be edited, so a banner there would be wiped by the next rebuild; it needs one at the generator. App suite 733/733, vault suite 40 files, both desktop suites, the generated-reference check and the static denylist test.
Darren asked whether the game log records a Game Master taking control of a being and the lease expiring. It recorded neither: the move was logged with no mention of the hold, and the lapse was told only to the Game Master through queueGmNote. The DM is the third audience here and the one who actually debugs a world — "why is Vell still on the docks" is the question these lines exist to answer, and a move logged without its hold looks exactly like a broken routine. So the move log now names the hold, its remaining time and the reason given, or says plainly that no hold was taken when holdMinutes was 0; the lapse logs under the routine category, beside the scheduled moves it is explaining; and an explicit release already logged and keeps doing so, which matters because nobody moved and the hold would otherwise end with no visible cause. Finding the gap also surfaced a smaller one. The move log's new clause calls formatGameCountdown from inside applyMoveEntity's existing try/catch, so in a harness lacking that helper the whole log was swallowed silently rather than failing loudly — which is what test_move_entity.js reported. The fix is to supply the real helper to both of its sandboxes rather than loosen the assertion, on the same reasoning as the lease functions before it: this file covers what moveEntity does, and stubbing away a part of that lets it stop happening while the test still passes. Decision K puts the same information on the Beings card in Phase 6. This is the cheap half and it costs a clause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-080 stopped the Builder's "Random map" leaving the old level's lockActs, chest and mon behind on regenerated squares, but it could do nothing for maps already saved with that residue in them. Reading deserialiseMap to add the cleanup showed two of the three were never exposed there: a stored ch or mn is kept only where the tile's item is still a chest or a monster, so a regenerated square drops both on the next load and stops writing them back out. That guard was put in so deleting a chest could not leave its contents orphaned in the file, and it happens to answer this too. la had no such guard. It rode through every load, was written straight back out by serialiseMap and travelled in an export, so the file went on claiming a puzzle with no door left to be about. It now survives only on a face that is still a door. Keyed on the face and deliberately not on the lock. Every stranded act is on wall, on open, or inside a filled-in cell: setEdge wipes an edge's authored fields on both sides whenever its kind changes, and the generator's door pass goes through setEdge, so nothing can strand an act on a door. Measured at zero on a door face over 200 regenerations of the pre-fix generator, which is what makes the face test complete. Gating on locks[d] instead would have been complete by a different route and destructive: unticking Locked deliberately keeps the sentence the author typed — setLock does not clear it and the editor only greys the field — so a door that is merely unlocked has to come back off the file with its act intact. Tests/test_dungeon_narration_lock.js runs the real deserialiseMap rather than reading it, over a map carrying the same act on a locked door, an unlocked door, a wall, open floor, a secret face and all four faces of a solid cell. Removing the guard, inverting it, sweeping one face instead of four, and keying it on the wrong face are each caught by named assertions; the lock-keyed variant is caught by the unlocked-door line alone, which is the whole reason that line is there. A case for the legacy stairup face was written first and then dropped: the v2 remap turns it into wall before the guard runs, so it asserted nothing the wall case did not, and its stated reason could not fire. The secret face replaced it and does earn its place — a deny-list naming wall and open walks straight past it. BUG-080. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Being States phase 3, settling decisions I and J. The routine system had no idea when the Game Master had taken an interest in a being: applyMoveEntity wrote ent.location and applyNpcRoutines overwrote it from the slot, from seven call sites including describeRoom, so "Joss storms out to the docks" was undone on the player's next room entry with no note to either side. Control is a lease rather than a transfer, and that choice is the whole design. Take-control/release-control strands a being forever the first time a Game Master forgets to release — silently, permanently, and invisibly. This codebase had already solved that once: the co-presence hold has no release directive at all, because it is derived from a condition rather than set as a flag somebody must remember to clear. So _routineHold carries an absolute expiry in the same expiresAtGameMs idiom statusEffects and granted abilities already use, and forgetting costs a lease, never a being. moveEntity takes the hold implicitly, because moving a being IS the collision case and a second directive alongside it is one the model would forget, landing straight back in the bug. Implicit to write, explicit to read: everything held is listed back every turn with the time remaining and the reason, so renewing is a deliberate act rather than something to remember unprompted. holdMinutes extends it; holdMinutes 0 releases, and releasing needs no destination, because "their part is done" is a statement about the hold and not about where anyone is standing — demanding a room to say it would make the Game Master move a being it had no reason to move, which is the tidy-the-stage habit that directive's own rule warns against. The handback is announced. Returning a being silently is the original bug wearing a new hat, with the Game Master narrating someone at the docks who has quietly walked home; the note fires once, tied to clearing the record, which is what stops being true. Six in-world hours by default — longer than four of the six time-of-day windows, so a scene is not cut off mid-errand — and an absurd holdMinutes is capped at a week of world-time, because a typo must not be a permanent removal from the world. The test asserts in pairs throughout: the hold works, and it cannot outlive its lease. Five sabotages, each caught by its own named assertion. Two sibling tests needed the new helpers and were treated differently on purpose: test_move_entity.js gets the REAL lease functions injected into both its sandboxes, since taking the hold is now part of what that directive does and a no-op stub would let it silently stop happening, while test_routine_conversation_hold.js stubs routineHoldActive false because its subject is the co-presence hold and the lease is driven for real elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Being States phase 2, and decisions I through L settled as Darren approved them. This is the awareness half: no engine behaviour changes, and the blindness that let the routine system and the Game Master collide is ended from both directions. The roster. nearbyRoomIdsForRoster picks the branch Darren specified — the player's region if their room belongs to one, taken whole however large the DM authored it, otherwise the twelve nearest rooms crossing region lines freely. NEARBY_ROOM_LIMIT is a named constant because twelve is a tuning knob, chosen to sit near a modest region's own size so the prompt does not swell the moment the player steps off regioned ground. nearestRoomIds is a spatial BFS and the comment says at length why compoundRoute is not reused for it: that one refuses shut and locked doors and skips unvisited rooms because it answers "can the player walk there", and a being two rooms away behind a locked door is still two rooms away. The test pins that directly, so nobody can helpfully consolidate the two later. Each being gets one thin line — name, room, what its routine has it doing. No stats, no HP, no conditions: those stay on the beings actually present, which keeps this cost flat in the population and keeps "nearby" meaningfully different from "here". The roster necessarily hands over rooms the player has not found and people they have never met, because it is world state rather than player knowledge. Decision L settled that the limit is STATED rather than the data thinned, which is how loreUnlocked and the reputation-gated profile already work: the Game Master is trusted with the fact and told the boundary. So the block says so in the same breath — do not describe a place the player has not discovered, do not have a stranger arrive already known to them. The notes are deliberately bounded to the player's own room, arrivals and departures only. gameLog is the DM console and the model never reads it, so a being walking out mid-scene was invisible; queueGmNote already exists for exactly this class of event, injected into the next turn and not stored in history. But a period turning can move a whole region at once, and twenty notes in one turn is not awareness, it is noise that teaches the model to skim. The roster reports where everyone stands; what it cannot convey is that someone the Game Master was mid-scene with has just left, which is the failure this whole thread began with — so the departure note says "Do not go on answering as them" in those terms. One process note worth keeping. Reflowing the prompt string through a bash heredoc turned its \n escapes into real newlines and broke the template literal; the repair had to build the backslash with chr(92) to get it past the shell. That is the second time this session heredocs have mangled escapes in this file. Use a script file for anything containing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Being States rev. 5. Darren settled the outer bound: if the player's room belongs to a region, scope to that region whole; if it belongs to none, take the twelve nearest rooms regardless of whether they sit in a region, or in several different ones. The second branch is the case regions cannot cover on their own. A world may have no regions at all, and even a regioned one has roads, edges and one-off places between them, so a rule that only knows about regions has nothing to say exactly where the map is loosest. Twelve sits near a modest region's own size, which is the point: both branches then cost about the same, and the prompt does not swell the moment the player steps off regioned ground. It wants to be a named constant, because it is a tuning knob. "Nearest" is breadth-first over exits, and the section says plainly that compoundRoute must NOT be reused for it, because reaching for the BFS already in the file is the obvious move and it answers a different question. That one refuses shut and locked doors and skips rooms the player has not visited — correct for "can the player walk there", wrong here. A being two rooms away behind a locked door is still two rooms away, and the living world goes about its business in rooms nobody has seen yet. Those are player-knowledge constraints and this roster is world state; it also has to match the region branch, which shows a whole region without asking what the player has explored. Which surfaces the one thing both branches share and neither solves: the roster tells the Game Master more than the player knows. That is the right data — it needs to know where its cast is — but it invites revealing a room the player has not found, and having a stranger walk in already introduced. Decision L is narrowed to that plus per-being detail, and leans to stating the limit in the contract rather than thinning the data, which is how loreUnlocked and the reputation-gated profile already work: the Game Master is trusted with the fact and told the boundary. Also adopts the h4 rule from authored-mechanics.html rather than demoting the heading — several docs in the family use h4 and this one's copied style block predated that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
# Conflicts: # Evaluations/BUGS.html
"Random map" throws the level away and draws another, so it wipes every per-cell field that described the old one. That wipe was a hand-kept list, written out twice — once in the initial solid-fill and once in the pass that re-derives walls from adjacency — and it had drifted three fields short of the list loadAscii uses in crawler.js for exactly the same job. lockActs, chest and mon all came through a regeneration intact, still bound to squares that no longer had anything to do with them. It hid because the survivors were latent rather than live. crawlerChests, crawlerChestAt and monsterOn all gate on a.item, which was cleared, so the new dungeon showed nothing and played correctly. The stale data waited instead: paint a chest on that square later and it arrived already carrying the previous map's lock, its trap and its contents; paint a monster and it was the previous map's creature with the previous map's loot. All three serialise as o.la, o.ch and o.mn, so the junk was written into the saved map, travelled in an export, and shipped inside any world the dungeon was folded into. The fix is one clearAuthoring(cell) closure used by both passes rather than two lists kept in step by hand, since keeping them in step by hand is precisely what failed — the next field added to a cell is now cleared in the one place or not at all. The comment above it records that the list is answering the same question as loadAscii's and has to match it. Tests/test_dungeon_random_map.js gains the property rather than the three fields that happened to be missing: it authors every square with every authorable field, runs the real generator, and requires the grid to come back blank. Each of the nine fields is asserted on its own line, because a list this shape is only ever wrong by omission and "authoring survived" would not say which; dropping any one of them from the wipe was confirmed to fail that field's assertion and no other. A generator that carved nothing would satisfy all nine, so the test checks there is still a map underneath them. BUG-080. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Being States rev. 4. What began as "the states between alive and dead" turned into a design flaw in the daily-routine system, which predates all of it, and the document is restructured around that. Darren's framing is the one that got us here: resolve the flaw with a better design rather than mask it with checks and hooks. §08 now carries the crack. Routines are entirely engine-executed, and they outrank the Game Master: ent.location is overwritten from the routine slot unconditionally and ungated by period, from seven call sites including describeRoom — so a GM's moveEntity is silently reverted on the player's next room entry. Measured, not read, and the measurement corrected a claim this document's author had made while arguing the phase-1 guards were safe: that the GM could move a being anywhere, any time. It cannot, for any being carrying a routine. §09 is the half nobody had looked at. The GM is told only about the room the player stands in — every dossier path is scoped to entitiesInRoom(hereRoom.id) — and routine movement is written to gameLog, the DM console, and never to queueGmNote. So the two systems collide not because either is wrong but because neither can see the other. That re-reads the conversation-hold fix as the first visible symptom of the blindness rather than a movement bug of its own, and the precedent for the remedy is already in the file, written for spawned beings and never extended here. §10 is control as a lease. The obvious take/release shape has an obvious failure — a GM that forgets to release strands a being forever, silently and permanently — and the codebase already solved that once: the co-presence hold has no release directive at all, because it is derived from a condition rather than set as a flag. Generalised into the rule this design follows: a hold must be self-releasing, and explicit release is a convenience, never a requirement. So a hold is an expiresAtGameMs lease, the same idiom statusEffects and granted abilities already use, renewing on use, reported in the dossier every turn so renewal is deliberate, and announcing its own handback so the GM never narrates a being it no longer controls. It also folds four unrelated hold idioms into one reportable question: why is this being's routine suspended. The roster's bound is not invented here. Darren pointed out that regions were designed partly as a practical scoping mechanism for exactly this, and region-files.html already states the contract in its own lede — everything live happens in the current region and the GM is never shown the rest, so a world may be far larger than one prompt could hold. A world of hundreds of rooms costs a prompt a couple of dozen. regionRoomsOf already exists. Recorded as rationale rather than left as an incidental bound, with the room.region-not-regionId trap noted beside it. Decision C keeps its membership rule but gains a standing caveat: the capability flags on top of it are a physics model in registry clothing, and restrained → moves:false overrules a DM who authors a prisoner-transport routine, which is the engine overriding the author rather than the narrator and worse than what it was guarding against. I–L open: lease length, whether moveEntity implicitly takes one, DM visibility, and how thin the per-being roster line should be. Phasing re-ordered. Awareness first because it is independent, cheap and makes every later decision easier — a GM cannot sensibly take control of a being it cannot see. Then the lease. Then re-derive the guards, which is when they shrink, because removing them before the GM has a real override would be a regression rather than a simplification. The 0 HP landing and the authoring control follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both sessions bumped the same summary chips in the same commit window, so the merge kept one side's arithmetic over a file that now holds both entries. Counted from the entries themselves rather than adjusted by hand: 81 ids, 63 fixed. My entry was renumbered to BUG-081 on the way in, since the Dungeon Builder's regenerated-map bug reached origin under 080 first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Darren asked why paralyzed was singled out for exclusion from the being-state registry, and then whether the engine was doing anything with these states beyond putting them in the prompt. Both questions landed. On the first: the reasoning did not survive it. Rev. 1 held four labels and kept paralyzed out because Game Masters already write it. Two things were wrong with that. The INITIATIVE rule in the combat contract has always singled out exactly "paralyzed/asleep/stunned" for a steep penalty or auto-fail, so the prompt told the model those three deny agency while the registry said they did not — an engine contradicting its own contract a few thousand lines away, which is worse than either position taken cleanly. And the principle was not being applied consistently even in the commit that stated it: restrained shipped with bound and grappled as aliases, both already in NEGATIVE_STATUS_WORDS. Promoting those was harmless only because restrained changes nothing, which makes "do not promote familiar words" a rule that bit exactly where it did not matter and was ignored where it did. So membership now follows the principle the design doc already stated and I had failed to apply: agency, and nothing else. Not severity, not familiarity. Eight labels. The loose-usage worry is answered by exact matching rather than omission — "paralyzed with fear" is not "paralyzed" and enforces nothing. Promotion cost no migration, which is the mechanism working as designed: the labels were being carried all along. On the second: almost nothing, and that answer is worth having in front of us. beingBarsTheWay had one caller, beingWouldFight had one, and beingCanAct had NONE — a predicate defined and used only by its own tests. Everything else about a being's state was prompt. That is Decision B behaving exactly as approved, but "enforcement" was doing more work in my description than in the code. So this finds the second place the engine genuinely decides something about a being by itself, and it was already broken. applyNpcRoutines had no alive guard at all: driven against the real function, a dead NPC with an ordinary tavern routine is relocated to the tavern at dusk and has its status rewritten to "nursing an ale". Both halves reach the player, because entitiesInRoom lists whatever location says — so the corpse is present in the room description, the Occupants box and the entities dossier, described as having a drink. It survived because routines are authored mostly on NPCs and the beings that die in play are mostly monsters, so the two halves rarely met. Filed as BUG-081, renumbered from 080 on merging because another session published a Dungeon Builder entry under that id first. The state guard is the same rule one step further: the scheduler moves NPCs with no Game Master in the loop, so if it does not ask about state, nothing does. A being knocked out at noon no longer walks home at dusk. And moving turns out to be a separate question from acting — restrained is precisely why, since a pinned creature can still fight and still cannot walk to the tavern — so every registered state carries an explicit moves: false and the guard asks beingCanMove. The flag is written per entry rather than derived from "carries any state", so a future state that leaves a being mobile has somewhere to say so instead of inheriting a rule nobody restated. test_routine_conversation_hold.js sandboxes applyNpcRoutines in a VM with hand-supplied globals and needed the new predicate; it is stubbed permissive on purpose and says so, because its subject is the co-presence hold and the guards are driven for real in test_being_states.js — over five beings, dead, unconscious, restrained, merely poisoned and upright, with the last two asserted to keep their appointments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Phase 1 of Being States: four condition labels the engine reads, out of the sixty a being can carry. BEING_STATES holds surrendered, unconscious, incapacitated and restrained with their flags, their glosses and a tight alias list, blockingFoeIn gained `&& beingBarsTheWay(e)`, and entityIsHostile stops annotating a subdued foe `would-fight`. Tracing the callers corrected the design doc on the way, and the correction matters because it changes what this phase can honestly claim. Rev. 1 read as though blockingFoeIn and entityIsHostile were two engine decisions. Only the first is: it has one caller, the compound-move walk. entityIsHostile also has one caller — the XP line in the entities dossier — and its own comment says the engine reads it and reports it and decides nothing with it. Whether a fight starts is the Game Master's call, made from aggression under the STARTING rule. So exactly one of the three questions was ever enforceable; the second is a reporting fix, and the third is contract-side. That is not a weakening of Decision B, it is Decision B arriving earlier than expected, since B already said the engine should enforce only what it already decides by itself. The reporting fix is worth its own sentence. `would-fight` means "getting past this normally means killing it", and it is shown to the Game Master precisely so it can weigh a peaceful resolution against what it avoided. Saying it over something already lying down states the opposite of what happened and corrupts that judgement at the source. The state check sits ahead of BOTH routes into the annotation, so a surrendered being carrying the `enemy` class is not marked through the back door. Matching is exact after folding case and separators, never substring. The engine is now stopping beings from blocking roads, so a false positive quietly disarms a live threat, and a label that merely contains "unconscious" is not a promise that the creature is. The most restrictive state wins: a creature both restrained and unconscious is unconscious, and each question asks whether ANY state denies it rather than whether the last one allows it, so order of application cannot decide the answer. restrained is a negative registration and the reason this is a table rather than a list. It is the word a Game Master reaches for when it means one of the other three, and a creature held in place is still dangerous — pinned in a doorway is still in the doorway — so it goes on barring and goes on reading as a fight. Registering it says that deliberately instead of leaving it to be lumped in. paralyzed is deliberately NOT registered though it plainly stops a creature acting: Decision C settled that promoting a word Game Masters already write is the change that surprises them, and the registry exists so it can be promoted later without the data moving. The contract carries the roster interpolated from beingStatesPromptList(), never pasted — the rule this repo learned from the equipment slots and then the item types. It says the exact word matters and that a being in the first three does not act, because that third question is the GM's entirely and if the contract does not say it, nothing does. The test drives the real predicates through a real room, and its five sabotages are each caught by a distinct named assertion. The half that keeps it honest is the reverse property: eight ordinary afflictions the game already ships in NEGATIVE_STATUS_WORDS, paralyzed among them, are asserted to change nothing at all. An implementation that subdued anything wearing a frightening word would pass every other assertion in the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
All eight Being States decisions are settled — Darren agreed with each leaning as written — and this is Phase 0, the part that stood alone whatever the rest landed on. A condition the Game Master cannot weigh is a condition it cannot play. A foe carrying paralyzed at DEX -4 and weakened at STR -2 reached the model as "status: paralyzed, weakened": two bare words, with the engine applying both through effectiveEntityStat the entire time. So it was asked to narrate a creature's state while unable to tell a stumble from a shutdown, or a condition about to lift from one that never would. The player's own line had spelled all of this out since it was written — the same statusEffectSummary, three lifespans, and a standing instruction to re-read it every turn — which made this an asymmetry rather than an open question. entityConditionsList now renders each entry as label (deltas, remaining), from the same summary and countdown helpers, and both blocks call it. The name was the other half. That `status:` was the MECHANICAL collection wearing the free-text field's name; the room dossier called the same data `conditions:[…]`, and ent.status — a wholly different field, owned by the daily routine — was "Current status" in a third place. Three names over two fields, and it misled a careful reader looking straight at the line, which is the argument for changing it rather than documenting it. The collection is `conditions` everywhere now, which frees `status` to mean the activity phrase everywhere; the ambient target profile was the third crossing and was renamed with the other two. That phrase also reaches combat for the first time: knock a being senseless — "slumped unconscious" is the contract's own worked example — and then fight it, and the one sentence describing its condition used to vanish at the moment it mattered most. The contract defines the notation once, with a worked example, rather than glossing it per creature per turn on a prompt that already runs wide: "paralyzed (DEX -4, 9m)" is a four-point penalty with nine in-world minutes to run, "untimed" does not wear off on its own. It also says not to narrate a condition lifting while it still has time on it, which the countdown now makes checkable. The test renders the real block from the real functions rather than grepping the source, because the defect it replaces was invisible in the source and obvious in the output. Its four sabotages — magnitudes dropped, untimed silenced, the collection relabelled status, the activity phrase removed — are each caught by their own named assertion. One of those sabotages initially reported nothing and was not vacuous but misapplied: activeConditionsDossier carries a byte-identical line and comes first in the file, so the edit landed on the player's dossier instead. Worth knowing before trusting a first-occurrence patch in a file this size. Nothing about behaviour changed — no state is enforced yet, no predicate reads a label, and the four registry labels are still Phase 1. This is the Game Master being told what the engine has been doing all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Designs/being-states.html, rev. 1, proposed. Darren asked for the states between alive and dead — surrendered, incapacitated, unconscious — and then read the combat block and asked whether the mechanical status effects reach the Game Master at all. Measuring that rather than reading it turned the design around, so the doc leads with the measurement. They do reach it. A foe carrying both kinds renders as "Gill-Wretch (HP 12/42, AC 6, temperament: hostile-on-sight, size: medium, status: paralyzed, weakened)", and that `status:` is the mechanical statusEffects collection wearing the free-text field's name — while ent.status itself, "slumped unconscious", is absent from the combat block entirely. The same collection is `conditions:` in the room dossier and `status:` in combat, and the free-text field is "Current status" in a third place. Three names across two fields. It misled a careful reader looking straight at it, which is the argument for fixing the naming rather than documenting it. What is missing is authority, not a field. The three questions the engine asks about a being — does it bar this road, does it start a fight, does it act this round — are answered by blockingFoeIn and entityIsHostile from alive, type, aggression and classes, and never consult statusEffects. So a paralyzed creature bars a corridor exactly as it did upright. The only rule anywhere tying a condition to acting is the initiative penalty for paralyzed/asleep/stunned, which fires once at the start of a fight; the rule listing what a foe weighs on every later round does not mention conditions at all. And the labels arrive stripped: paralyzed at DEX -4 and weakened at STR -2 reach the prompt as two bare words, no magnitudes and no expiry, against a player line that spends statusEffectSummary on the deltas, spells out three lifespans and says to read it every turn. That is BUG-068's shape a second time — a label true in the prose, real in the data, and invisible to every decision that matters — and it is why BUG-078 had nowhere to land. So the proposal adds no fourth field. It borrows Properties' doctrine that enforcement is earned rather than declared: four registered labels the predicates learn to read, every other label behaving exactly as it does today, no migration because statusEffects already round-trips, and death as the default landing so nothing existing changes. Phase 0 is deliberately separable and worth doing whatever the decisions land on: magnitudes and lifespans in a being's line, one name for the collection, the free-text phrase carried into combat. Also recorded is why an enforced state must not live in ent.status — the daily-routine system owns that field and drops the GM's override at the next time-of-day change, so an NPC knocked out at noon is sweeping at dusk. Harmless while it is only a sentence in a sidebar; not harmless once a word means "does not act". All eight decisions are open with leanings. E is the one worth arguing: whether a player's stated intent may constrain how 0 HP lands without the Game Master's cooperation. The leaning is no, because reading intent out of prose is the heuristics-versus-narration line this project keeps — but the case that opened this was a player who said "beaten, not dead" and got a corpse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Darren asked me to double-check that statusChanges wasn't a status effect on the being. It isn't, and
the question found a defect. There are three fields and two of them are one word apart:
playerStatusChanges and entityStatusChanges carry { label, effects:[{stat,delta}], durationMinutes } into
a timed condition that recomputeEntityStatusMods folds into effectiveEntityStat, while statusChanges
writes ent.status, a single free-text activity phrase with nowhere to put a stat or a duration, owned by
the daily-routine system and reclaimed at the next time-of-day change.
The per-turn equipped-item-effects rule described each live effect in full mechanical detail — label,
stat deltas, duration — and then told the Game Master to apply it to a struck enemy via statusChanges.
Whichever way the model complied, the effect was lost: sent as {entityName, add:[…]} the handler drops it
without a word, because it opens on typeof s.status !== 'string' and returns; sent as a phrase it paints
a word in the sidebar until dusk. A venomed blade's CON penalty never reached the creature's stats. The
aimed note eight lines above named the right field all along, so an effect the player aimed at a creature
worked while the same effect landing through a sword swing did not — the two halves of one paragraph
disagreeing. The mechanism needed nothing; only the instruction pointed away from it.
The test was pinning the bug rather than the behaviour. test_item_effects.js asserted
/via "playerStatusChanges"[\s\S]{0,200}via "statusChanges"/ under the label "it names the exact channels",
which matched the defect, passed, and would have failed the fix. It now pins the mechanism instead — that
the struck-enemy channel is the one whose handler reaches applyEntityStatusChanges and effectiveEntityStat,
and that the free-text handler demands a string and so cannot carry an effect at all. Reverting the rule
to its old wording fails three named assertions. This is the repo's own rule about tests arriving as
evidence, met in the wild.
Also corrects something I got wrong in the previous commit. I justified the alive === false guard by
saying editor-authored beings carry no alive field; they do. new Entity() sets it, every construction path
runs through makeEntity, and serializeWorld writes rooms wholesale so a restored being keeps it. The guard
is still right for the reason that matters — dead has to be something a being positively is, never what a
missing field defaults to — and the restore path's existing backfills for aggression, size, armor and
statusEffects are what a field arriving absent actually looks like here. BUG-078 and both test comments
now say that instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvVerifying BUG-068 and BUG-074 in Claude19 confirmed both directives fire, and the turn that confirmed the first one contained a third defect. Claude19 re-engaged the Gill-Wretch in a tracked fight and the Game Master reached for combat.end.disposition unprompted; crossing to the Rope-Bridge Span and buying peace off Joss Half-Drowned with the answering half of the canto — a CHA check, no combat block anywhere in the turn — the Game Master reached for stateChanges.setDisposition. Both printed the truce line, both wrote the field. Each entry moves to working on that one observation, which is all one observation is worth against a failure that depends on a model choosing to use a directive. The third defect is BUG-078. The attack was written with an explicit non-lethal intent, and the Game Master honoured it the only two ways available to it: it narrated a knockout and it declared a disposition. It also dealt 42 damage to a 42-HP creature, because the engine has no non-lethal knockout — 0 HP is death and there is nowhere else for a beating to land. So the engine printed "Gill-Wretch is defeated!", then "beaten senseless", then told the player a peace now held with a corpse. Nothing reads aggression on a dead being, so the field was inert; the line was the whole defect, and a false report about the state of the world is the class of thing the ledger exists for. Both disposition paths now refuse a being whose alive is false and report it as skipped rather than applying it. Reported rather than swallowed, because a directive the engine silently discards is one nobody ever learns was wrong. The guard is written `alive === false` and not a truthiness test on purpose: beings authored in the editor and never fought carry no alive field at all, so `if (!ent.alive)` would pass every obvious assertion and then quietly refuse every truce in the game. Both tests sabotage it that way as well as removing it outright, and each break is caught by its own named assertion. entitiesInRoom does not filter the fallen — every other caller in the file filters alive !== false itself — so on the narration path a body stays nameable for the rest of the playthrough, which is what makes the second guard load-bearing rather than symmetrical. The GM contract was told too. The combat form already said a killed foe needs no entry; the narration form, added later for BUG-074, did not. It now says a dead being takes no entry on either route, including one narrated as laid to rest, put down gently or released — the merciful deaths that invite the mistake, and the reason a chorister is the wrong subject to test a truce on: sung to its end it is laid to rest, which is mercy that produces a body rather than a truce. What is deliberately left alone is the gap underneath: there is still no non-lethal knockout, so a player who asks to beat something senseless gets a corpse and a sentence describing unconsciousness. That wants a surrender or incapacitated state for 0 HP to resolve into, which would give disposition something to be for. It is a design question and not a guard, so it is recorded in BUG-078 rather than decided here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported from play: the Builder's Random map was making doors "sticking out from a wall that is not enclosed, just a door sticking out in the middle of a larger room". The generator places rooms, joins them in sequence with L-shaped corridors, derives every wall from adjacency, and then hangs doors where a room cell has an open edge to a cell outside that room. That last test is the right idea and it is not sufficient, because a corridor does not have to ARRIVE at a room to touch one. An L-run drawn between two other rooms can pass straight along a third one's flank, and every cell down that flank is then open to it — a room with one whole side missing. Each of those openings satisfied the test (a room cell, an open edge, a neighbour outside the room), so the 45% roll scattered doors along the gap, joined to no wall at all. Measured before touching anything, by lifting the generator and running it: 2398 of 5272 doors over 300 levels were hung like that. 46% of every door it made, on 87% of levels. The same seed either side of the fix, rooms and corridors identical and only the doors differing: before | . . . . . . . . . . . . D . after | . . . . . . . . . . . . . So a candidate now has to be a gap in a wall: the edge a door sits on runs along one axis, and the two cells that edge's own line carries on into must present something solid on that same face. Off the map counts and so does bedrock. Evaluated as each door is placed rather than up front, so a door disqualifies its own neighbour. Doors are still plentiful — about 9 a level against 17 — and the test checks that too, because a rule strict enough to produce none would pass a no-floating-doors assertion perfectly and be worse than the bug. One condition came out again before it shipped. The first draft also refused a `door` as a jamb, to stop two ending up side by side in a two-cell opening, and the sabotage pass showed it could never fire: two neighbours on one edge line can never both become doors whichever order they are visited in, because whichever is considered second was disqualified by the first still being `open` when the first was considered. A condition that cannot fire is one the next reader has to disprove for themselves, so it is gone and the argument is written where it was. The TEST still checks the outcome strictly — a door beside a door fails it — because that argument is about this loop and a later change to how doors are placed should answer to the map rather than to the loop it replaced. Tests/test_dungeon_random_map.js runs the real function over 200 levels rather than reading it, and draws the offending door with its neighbourhood when one gets through, since "3 unflanked doors" says nothing about which rule let them past. Five sabotages, five distinct named failures: the guard removed, one jamb accepted instead of two, none accepted, anything accepted, and the jambs taken along the wrong axis. Nothing was done about the wide flank openings themselves. A room whose side is open to a passing corridor is loose level design rather than a defect, and it now reads as an archway instead of an archway with a door standing in the middle of it. BUG-077. App suite 727/727. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Three pieces, and the reasoning behind them is Darren's correction of a plan that had it backwards. I had proposed that a firing of snapshotPairingFault would move BUG-072 toward closed. It does the opposite. The guard stops a mismatched player and world becoming durable; something upstream still assembled the pairing, so a firing is evidence the bug is ALIVE. A lock in the code plus a comfortable status is how a live defect gets forgotten, and the entry now says so in as many words: a firing keeps 072 open and resets any quiet window. Which makes the firing the most useful moment there is — the only time anyone stands where the bug happens with the evidence still live. So the guard is now a tripwire. It records the player and world identities, the room that cannot exist, a sample of the rooms that world DOES have (the fastest way to see which world it really is), and what had just happened, into a bounded log. A recurrence should explain itself instead of restarting the investigation at "it happened again". It also tells the player, in the story, instead of only a log panel — and this is the part that was a regression waiting to happen. The honest non-mixture route to a refusal is a DM deleting the room a character is standing in, which is ordinary editing; after that they would play on while nothing was saved and lose everything since, with a red line in a panel they had no reason to open. A guard that silently stops saving is a worse failure than the one it prevents. Announced once per distinct fault, because the autosave retries constantly and a warning every few seconds is one that gets scrolled past. And an audit runs at every login, walking the KEYSPACE rather than the saved-games index. That detail is the whole point: the poisoned entry that predated this investigation was absent from the index and present in storage, so an index-driven audit would have reported all-clear over it — which is exactly how it sat unnoticed for an unknown length of time. The close condition is written down too, since the usual ladder cannot be walked when the thing to observe is an absence: no save pairs a character with a world lacking their room, sustained across real use including character switches, with the tripwire never firing. Either tripping means not closed. Six sabotages, six distinct catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Reported from standalone: "I don't see a provider card for the worldlabs 3D world API key." There is none, deliberately — and nothing anywhere said so, which from outside is indistinguishable from a feature that has gone missing. The 3D-world button is gated on one line, roomWorld3dReady() returning isVaultMode(), and the reason is already written beside it: every other AI slot has a Direct path with the key in the browser, and this one does not, because World Labs' API is cross-origin from the page and whether their CORS policy permits a browser call could not 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. So the whole feature runs through the vault, where the key is already a first-class entry in vault-core's roster. What was missing is the user-facing half. That reasoning sat in a code comment and in a test header, where the person hunting for the key never looks — and the API Keys dialog is exactly where they DO look, having read that the app builds 3D worlds. It now carries a note after the last card naming World Labs, saying the feature runs through the Server Vault, saying its key goes on the vault's admin page under Settings › Keys, and saying why there is no Direct path at all. The absence reads as a decision rather than an omission, which is the whole of the change. Deliberately NOT a card. It has no border and no box, just a rule down the left and a dimmed title, because a card with no input in it reads as a card whose input is broken — and a field that saved a key nothing reads would be worse than no field. The test asserts that shape as well as the words: the sabotage of turning it into a real card with a password input fails two assertions by name. The code comment beside roomWorld3dReady now points at the note, so the pair is discoverable from either end, and Tests/test_room_world3d.js — which already owned this decision — pins them together. Each assertion was checked against a break: the note removed (the reported state), the reason dropped, where-the-key-goes dropped, the vault unnamed, and the note turned into a card with a field. Rendered and read at 2x to check it does not look like a card with a missing input. App suite 726/726. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The login screen came up reading Malina, Mage, LOCATION northern_road, WORLD The Salt Cantos of Verengrad. The location shows a raw room id because resumableRoomNameFromSnapshot falls back to the id when the world has no such room — so "it loaded with no room name", which Darren had seen and shrugged off weeks ago, and this entry are one failure seen from two sides. The reason it kept coming back is not that it kept happening. A per-playthrough save is keyed character + world, so the moment a mixture is saved even once, that key names a pairing that never existed and mints a whole new save entry — a first-class, re-loadable row in the saved-games list. Every repair was being applied to the active slot while the source sat in the library. Two such entries existed. Malina inside Verengrad, deleted with Darren's go-ahead, her real Lost Realms save untouched beside it. And Test2 inside Verengrad, the same shape, which was present in the FIRST storage dump of this session — before any repair was attempted, against a character this session never touched. That one is left in place as evidence, and it is the strongest thing known about origin: the pairing has been minted at least once outside this session's work. Three things the re-test cleared. The picker is sound: driven directly rather than clicked, loadSavedGame(3) put Test2 into both the slot and the fields and they agreed, and row i maps to _loadSaveList[i] throughout — an earlier claim that they disagreed was a click landing one row off. The active slot does not drift: watched for twelve seconds with nothing loaded, it held. And two "it changed again" sightings had causes: a long-lived tab still holding a mixture in memory and re-flushing it over each repair, and Darren creating a character and loading an older world in the same browser while this ran. The browser is shared, which is not a detail — it explains more of the confusion than the app does. The leading explanation is Darren's and it fits: until today, logout left the player and world loaded behind the overlay, and an agent re-entering through that state — navigating, re-selecting, then trying to repair what it believed it saw — could combine one character's player with another's world, with any save flush minting the pairing. That needs no defect in the picker or in restore. It is now closed at the source by the logout purge, with snapshotPairingFault as a second lock. Stays open: a human has seen the symptom, an entry predating this work exists, and no reproduction is in hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The previous commit wrote up "no exclusive forum" as a settled decision. It was not one. It was a judgement call about someone else's legal exposure, presented in the same register as the parts of this document that were verified byte for byte, and it argued only the side that favoured the conclusion. D-9 now records it as an open question with both sides, which is what the folder is for. Three things were wrong with the original. Its factual support — that the Apache ICLA, the model this agreement follows, names a governing law and no venue — was asserted from memory and never checked; two attempts to read the ICLA text failed, so it is marked unconfirmed rather than repeated. Its reasoning, that an exclusive forum deters contributors, is a common view but was cited as though it were evidence. And its premise, that dispute risk rounds to zero, is an assumption about a project whose stated plan is to sell commercial licences, which is exactly where disputes come from. The omitted half is the one that mattered. Leaving out a forum clause does not mean there is no forum; it means the default rules apply, and those run toward the licensor rather than away from it. A claimant can sue wherever they can establish jurisdiction, and Brave You Worlds is the party with a business and assets and so the likelier defendant. A choice-of-law clause standing alone is weaker than it looks, since a foreign court applies its own conflict rules and may decline Virginia law or apply local mandatory protections over it. And the deterrence argument protects contributors — a constituency that does not yet exist here, every commit still being authored by the owner or this project's Claude sessions — against an exposure carried by the party that does exist. Weighing the first against the second and writing up only the first is how the earlier note read as settled. CLA.md loses the clause's own justification. The governing-law sentence now stands alone as operative text; a signed agreement should state what it does, not argue for why it was drafted that way, and the reasoning belongs in the design document where it can be disagreed with. The clause itself is unchanged: no forum is named, which remains the status quo and the reversible direction — adding one later is easy, removing one after signatures are collected is not. The lean stays that way pending a lawyer, and it is bundled with D-2 rather than raised separately, since the same short conversation answers who the Licensor is, papers the assignment, and settles whether the commercial agreements — where the counterparty is known and the stakes justify it — should name a forum even if the contributor agreement does not. App suite 726/726, the static denylist test, and every relative link and CLA anchor across the four top-level documents resolve.
A dungeon monster is a sprite on a grid square with no body of its own, so materialiseDungeonFoe lends it one and parks it in the room the party descended from — that room is still "here" for findEntityAnywhere and for the combat bar. endDungeonFight's own docstring says it takes the body back. It took back the fallen only. The loop opened `if (ent && ent.alive === false)` and everything inside was about turning a corpse into remains, so a monster the party fled, or spared, or that a DM let be with the End fight button kept its borrowed body parked in the entrance room. Probed after a below-ground flee, before any fix: one entity left on loan, Skeleton, alive. A being the author never placed, standing among that room's occupants, listed to the GM when the party climbs out, and read by blockingFoeIn as barring the way through — which is BUG-068's failure arriving from another direction, and precisely the stranding the restore sweep exists to clean up, reached by a door that sweep does not cover. The sweep runs on a reload; this needs no reload. It predates BUG-075. A flee always ended the fight, it just also teleported the party to the surface, which is a louder symptom standing in front of a quieter one. Fixing the teleport made fleeing an ordinary outcome and this reachable in ordinary play. Taking every body back is one line and it exposes a question the old code never had to answer, because materialiseDungeonFoe reuses the body it already lent: what state does the party meet on the next engagement? A fled monster keeps its wounds. A creature restored to full health every time the party backs off makes retreating a way to lose ground, and turns a fight you cannot quite win into one you can never finish. So the body comes out of the room whatever the outcome, while a survivor is kept in dungeonFoes with its hit points, its conditions and whatever is left in its pack, and is lent back the next time the party comes alongside. Lending it back is not decoration, which the sabotage proved rather than the reasoning: beginCombat finds its foe by name in the room, so a body returned without being put back is a fight that does not start at all. Removing the push made "coming back starts a fresh fight" fail before it made anything about wounds fail. Within the session. dungeonFoes is in memory and the restore sweep drops a borrowed body a save happened to catch, so a reload meets the thing whole again — the same boundary dungeonFledAt keeps, and moving it would mean carrying foe state in the save beside dungeonPlay. Worth doing, and its own change; the comment and the ledger entry both say so rather than implying the wounds are permanent. Each new assertion was checked against a break: the survivor left in the room (the original bug), never lent back, rebuilt from the catalog each engagement, and forgotten from dungeonFoes with its wounds. Two tests pinned the old shape. test_dungeon_remains asserted the living body STAYS in the room, which was the bug stated as a property; it now asserts every borrowed body leaves and that what separates the two is what happens next — the dead one gives up its square and its pack to the bones, the living one is held whole to be lent again. test_dungeon_combat matched `ent.alive === false` inside endDungeonFight, a spelling that no longer exists, and now pins both paths through the loop. BUG-076. App suite 726/726. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The governing-law half of D-2 is answered. CLA.md §10 carried a marked placeholder and LICENSE-CONTENT had no choice-of-law clause at all; both now name the Commonwealth of Virginia, which is where Brave You Worlds will be formed. Naming the same jurisdiction in both matters more than the particular state: they are the two documents a single contributor can be bound by at once, and two bodies of law over one person's contribution is a problem invented for no reason. BSL is the exception and cannot be fixed here. Covenant 4 forbids modifying its text, so the software licence names no governing law and no clause can be added to it. That gap closes in a signed commercial agreement instead, which should name Virginia too, and §5.5 says so where a reader of the content licence will find it rather than leaving it to be inferred. No exclusive forum was added, and that is a decision rather than an omission. Choice of law and choice of venue are separate clauses and the Apache ICLA this agreement is modelled on names only the first. An exclusive-venue clause obliging a contributor in another country to litigate in one American state deters contribution in exchange for a dispute risk that rounds to zero; venue belongs in the commercial agreements, where the counterparty is known and the stakes justify naming it. What remains of D-2 is the entity, which is the half that actually matters, and it now has a sequencing consequence worth writing down rather than discovering. Brave You Worlds is intended to be a Virginia LLC and the formation paperwork is not yet filed, so there is at present nobody for the copyright to be assigned TO — formation and assignment are one task and neither is done. The consequence: the repository must not take outside contributions before the LLC exists. A contributor signing CLA.md today grants rights to a named entity that does not exist, and the natural readings — that the grant fails, or that it runs to the individual behind the name — are precisely the ambiguity a CLA is adopted to remove. Those signatures would have to be collected again, which is the retrofit D-1 was adopted early to avoid. Incorporate, paper the assignment, then go public. The documents deliberately still say "Brave You Worlds" and not "Brave You Worlds, LLC". A studio name is true either way; naming an LLC that does not exist would have every licence in the tree misstate its own licensor, and would become false rather than merely incomplete if the filing slips. It is a one-line pass on the day it clears. Also fixed while checking the links these clauses sit among: the two entries under Websites in README.md were written as [text](thelostrealms.ai) and [text](braveyouworlds.com) with no scheme, which Markdown reads as RELATIVE links — GitHub resolves them to blob/main/thelostrealms.ai and returns 404. The three entries under Docs immediately below them carry https:// and work. Both now match. App suite 726/726 and the static denylist test pass; every relative link in CLA.md, CONTRIBUTING.md, README.md and COMMERCIAL-LICENSE.md resolves and every external one carries a scheme.
The ledger's chip row conflicted: BUG-072 moved from observed to open upstream while BUG-075 was added here. Both, and the counts recounted from the entries — 75 ids, 1 open, 1 working, 7 fixed-unverified, 58 fixed, 2 cannot-reproduce, 6 withdrawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
A fight ends four ways and one of them is the player running. On a success the Game Master sets combat.end.outcome "fled" and, in the words of the combat contract, "the engine moves the player to a random visible exit". endCombat did exactly that: currentRoom(), a random exit of it, currentRoomId, describeRoom. Below ground currentRoom() is the SURFACE room the party descended from. That is the pseudo-location model working as designed — a dungeon square replaces the room in the dossier rather than relocating the party, so currentRoomId deliberately does not move while they are underground. It also meant a flee picked a random exit of a room they were not standing in, moved their world position out through it, and described that room at them, while the crawl view went on showing them in the dungeon. Three systems with three answers to where one party was, reachable by typing "I run". Nor could a careful player avoid it. The engine holds movement while a fight is on, so a dungeon fight has exactly three exits: win it, fall to it, or break away. dmCancelCombat exists because that list used to be two long for a DM, and its own comment says so. Guarding the relocation is one line and on its own it makes the flee useless, which is the half that only appears once you try it. The engine cannot walk the party off the square, so the instant the fight ends they are standing exactly where they were, one step from what they fled — and dungeonCombatCheck runs on every change of square OR FACING. Turning to look for the way out handed them straight back to the same monster. A flee undone by the next key press is not a flee. So the square broken away from is remembered, per dungeon, and forgotten the moment nothing is beside them any more. What a flee buys is the distance to get clear, not a monster pacified for good: walk away and come back and it is a fresh fight. In memory only, like dungeonFoes beside it — it is a fact about this standoff rather than about the dungeon, and a save reloaded with the party still adjacent starting the fight again is the honest reading of coming back to something you are standing next to. The reprieve is for the one square and not for the neighbourhood, which is why the adjacency check now walks every adjacent monster instead of reading only the first: standing between the one they fled and a second one, taking near[0] and stopping would let the second go unfought whenever it sorted first. DUNGEON_CONTRACT gains the rule, because the combat contract promises the Game Master a relocation that no longer happens down there and it would otherwise narrate an escape the engine did not perform — the same turn-narrating-a-move-that-did-not-happen defect as BUG-073, from the other side. Narrate the disengage, never the escape; the creature is left standing where it stood and will not be fought again until they have got clear, so a party that lingers beside it is choosing to rather than being spared. Tests/test_dungeon_flee.js was written before the fix and failed on both halves — the teleport and the instant re-engagement — then passed on both. It drives the real endCombat rather than matching source, and it gives the harness a setTimeout that actually schedules: the flee's describeRoom runs behind a 180ms timer, so against the usual no-op stub the "did it describe a room at them?" assertions could never have failed, and that is half of what this looked like from the player's chair. Each assertion was checked against a break: the guard removed (the original bug), the guard firing everywhere (which takes the ordinary surface flee with it), the memory not recorded, never forgotten, ignored, the outcome not passed through, and the adjacency check back to near[0]. Three tests pinned endDungeonFight's signature by spelling and were updated with it. In two of them the new argument is now the assertion — dropping it is one of the sabotages above. BUG-075. App suite 726/726. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Yesterday's fix stopped a logged-out world DOING things: every mutating path in the one-second tick now asks livingWorldActive(), so nothing respawns, expires, ages or reaches the vault once the login overlay is up. That was right and it was not enough, because the session was still THERE — logout marked player.loggedIn false and left the player and the world fully loaded behind the login screen. Two arguments for putting it down properly, and the second is the one that was paid for. Defence in depth: a guard protects the paths that remember to ask, and the bug it was written for was three paths that did not. A world that is not loaded cannot be advanced by a path nobody has audited yet. The app already runs this way — both globals are null from page load until a game is restored — so the login screen has always had to cope with exactly this state. And a half-dead session MISLEADS. Reading `player` on the login screen returned a whole character, `world` a whole world, and the derived clock went on advancing off its anchor: none of it reachable, none of it saved, all of it convincing. That is a large part of how BUG-072 came to be filed — an observer read a logged-out tab and believed the save had changed underneath it. Darren made exactly that point, and it is the better argument: state that cannot be acted on should not be legible either. So logout now nulls player, world, combat, the two catalogs and the message log — last in the function, after the snapshot is flushed and the login screen has been repopulated from the SAVE rather than from these globals. The existing test needed a real change rather than a nudge: its enterGame() shortcut flipped loggedIn on its own object without re-binding the session, which worked only because the globals used to survive a logout. It now re-establishes both exactly as restoreGameState does on the real path, and the file gained assertions that the purge happened at all — the sabotage that removes it fails two of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Darren has independently hit the symptom, before any of this was written and without connecting it at the time: a save that loaded "with no room name", looked corrupt, and came back after some refreshing. He put it down to stale app code. That is the same failure from the player's side — a room the world cannot name is exactly what you see when the player object and the world do not belong together — and it means this is no longer only an agent's report, which is what "observed" existed to say. So it moves to open. The recovery-on-refresh detail is worth having: it says the mixture is transient rather than a broken file, which matches every measurement here. Both per-character mirrors stayed valid on both occasions; only the active slot ever held the mixture. The caveat stays, and is load-bearing rather than politeness. Every sighting sits inside a period of automated playthroughs, and the agent driving them was working from a false model of its own browser tab. The entry now writes those assumptions out, because the same ones will manufacture the same false report next time: the tab is not stateless between tool calls; navigating to the page is not a reset but an auto-resume that starts a game; logging out leaves the session in memory with its clock running; element handles do not survive a reload; and a write to storage under a live page is overwritten by the autosave within seconds, which is why repairs had to be made from guide.html. A human seeing the symptom and the mechanism still being an artefact are not in tension, and the entry says so. What settles it is a deliberate replay through the UI alone, on a freshly loaded page, with nothing else touching storage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
DUNGEON_CONTRACT said NOTHING LIVES HERE YET, and the dossier filtered every monster out of the four places it could have appeared. Both were right when they were written: a monster was then a sprite that stood on a square and animated, so telling the GM about one would have invited a fight the engine could not run. The comment above the filter said as much and named its own expiry — "this is the seam: when a monster becomes real, this filter comes out and the contract's clause goes with it." Dungeon combat shipped. A monster bars its square, the engine starts a fight when the party steps alongside one, the foe is materialised as a real Entity with the author's pack, and a fallen one leaves lootable remains. So the clause had become false, and false in the worst available way: DUNGEON_CONTRACT and the ## Combat block are both in the same prompt, so on every turn of every dungeon fight the GM was being handed "Enemies: skeleton" and "nothing lives here, do not start combat" together, and left to resolve it. The filter had gone worse still, and this is the part that reached the player. The crawler reports a monster the whole way out to the fog, with its distance; the fight only begins at one square. So from two squares out there was a window in which the player was looking at a skeleton on screen, asked what was ahead, and was told plain dressed stone. A dossier that denies what the screen shows is the same defect as the one that used to re-describe the room above, arriving from the other direction. Both sight lines now carry creatures, each marked "(a living creature — it does not move)". That parenthetical is the whole of what a GM handed a monster in view will otherwise get wrong: a creature in the middle distance invites narration of it advancing, and nothing down here advances. Two of the four exclusions stay and neither was ever about visibility — a monster is not inspectable scenery and is not something the party stands over, because it bars its own square. Its one moment as an object is when it is dead and has become remains. The replacement clause says what seeing one licenses rather than denying that one is there. Describing it is the whole of the GM's part; it never moves, turns, wakes, rouses or advances the thing, and it never starts the fight, because the engine does that the moment the party steps alongside. It still forbids inventing creatures, which was always the right half. And it hands over explicitly once a fight has begun, so the rule that governs a dungeon stops arguing with the rule that governs a fight. Considered and not done: qualifying the "so they must walk to it to touch it" preamble when the list holds a creature. It is chest-flavoured rather than wrong — you do walk to a monster to reach it — and one explicit sentence in the contract beats a hedge repeated on every line that has to be kept in step with it. Every new assertion was checked against a break. Putting the filter back, dropping either line's marker, marking everything a creature, restoring NOTHING LIVES HERE YET, and removing each of the contract's four claims in turn are each caught by a distinct named assertion. The pseudo-location test runs the section for real over a frame carrying a creature rather than matching source, and asserts the branch DISCRIMINATES: the same square with the flag off is still a chest and still reads as one, which is what stops a version that called everything a living creature from passing. Designs/dungeon-builder.html records the crossing beside the decision it reverses rather than rewriting it — the account of why the filter was built is still true about the step it describes, and now says what happened at the seam. Its "combat's UI has nowhere to sit" open item is marked done in passing: the bar moves into the crawl view for the duration of a fight, which is what that bullet was waiting on. App suite 724/724. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Two corrections, and the second is the one that matters. The mixture recurred. After the first was repaired, the active slot was found holding Malina in Verengrad at northern_road again, her player about two in-world days past her own mirror — and between the repair and that discovery the only interactions were read-only queries. No clicks, no stale handles, no reloads. The stale-element story explains the first sighting and cannot explain this one, so the entry no longer leans on it. It stays "observed" all the same: what would move it is a human meeting it or a deliberate replay, and two sightings by the same agent is more evidence of the same kind rather than a different kind. The corrupt snapshot is kept under the storage key tlr_bug070_evidence instead of being discarded with the repair. The entry also now says the guard has had NO exposure to either sighting. The browser tab had been running since before snapshotPairingFault was written, so both mixtures happened under code that did not contain it — which I had implied otherwise. And the numbering was wrong. I picked 070/071/072 from the ledger I had in front of me without pulling first, and a session working yesterday had already published its own 070 and 071. The merge brought theirs in after mine were chosen, so the file carried two entries per id. Theirs were published first and keep the ids; mine moved down to 072/073/074, closing the hole rather than leaving one, and the collision is recorded in the entry because this ledger's own rule is that ids are permanent. The test moved with them — and had to be renamed under Tests/, not tests/, because another session's directory rename is invisible on a case-insensitive filesystem until git tells you. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The first five CI runs all failed, on one test, and the test was wrong rather than the code. It asserted that a media pack's non-ASCII entry survives a round trip by extracting the archive with `unzip` and checking that media/été-café.jpg existed on disk. That passed on a machine with no UTF-8 locale and failed on a runner, and neither outcome said anything at all about the pack. Debian and Ubuntu's unzip 6.00 converts a stored name into the LOCAL charset, and measurably does so whether or not the archive sets bit 11. Built both ways and extracted under both locales, the four combinations are: flag set or clear under LANG=C, the raw bytes land verbatim and the name matches; flag set or clear under LANG=C.UTF-8, the name lands as "├йt├й-caf├й.jpg" and does not. The flag makes no difference in either direction. So the filename on disk is a property of the reader's locale, this container has no UTF-8 locale and the runners set C.UTF-8, and that is the whole of the discrepancy. The important half is what that means for the assertion rather than for CI. It would have passed just as happily against a writer that had stopped setting the flag entirely, because under LANG=C the bytes land verbatim regardless — a test that cannot fail for the reason it names, which is worse than no test because a green run was being read as evidence. Reading the archive proves the writer does set it: every central-directory record comes back flags=0x0800 with the name stored as UTF-8, so the pack was always correct and only the check was not. Bit 11 is the property worth pinning, it is what a correct reader uses, and it is the same everywhere, so it is now read out of the bytes. The central directory is walked from the end-of-central-directory record, and the flag is asserted in the central record AND in the entry's own local file header, because a reader may consult either and one set with the other clear is exactly the shape that reads correctly in one tool and mangles in another. That it holds for every entry is asserted separately: a writer that flags only the names it believes need it is a writer that will one day guess wrong. The extraction checks stay. `unzip -t` and a clean extract still verify every CRC and the nested vault/media/3f/deep paths, which are real properties of the archive and not of the locale; only the non-ASCII filename assertion moved. Sabotaged in three directions, since the point of the rewrite is that the old assertion caught nothing. Clearing bit 11 in the local header alone fails "both copies of the flag agree"; clearing it in the central directory alone fails "its central-directory record sets bit 11" and "every entry is flagged"; clearing it in both fails the local-header check. Each names the cause. App suite 724/724 under LANG=C, LANG=C.UTF-8 and LANG=en_US.UTF-8, vault suite 40/40 files, both desktop suites and the generated-reference check.
BUG-070 was filed as open, which claimed far more than the evidence supports. It was seen once, by an automated session, and never replayed by hand. Darren has not met it in ordinary play and asked whether the harness had caused it — and on review it very plausibly did. That session clicked a stale element handle after a reload, so the click may have landed on whatever occupied that node after the re-render rather than on the control that was found; it reloaded mid-session repeatedly; and it hit the row's Export icon by mistake before working out that the row label is the load control. None of those has a human equivalent. Against that, the code reads clean: loadSavedGame writes the chosen save into the active slot before touching the login fields, restoreGameState takes world and player from the same snapshot, and readSavedSnapshot reads that one slot. No pure-UI path was found, and none was demonstrated. needs-repro was the nearest existing state and still not right: it means the observation was confounded, which does not say WHO observed it or that nobody else has. So the ledger now has "observed" — an agent hit this, no human has, it has not been reproduced, and it may be an artefact of how an agent drives the app rather than anything a player would meet. It is the weakest claim the ledger can make, and it leaves in one of three directions: a human meets it or it is reproduced deliberately, and it becomes open; the observation proves confounded but might still be real, and it becomes needs-repro; the harness is shown to have caused it, and it is withdrawn. The entry says how to settle it — continue as one character, log out, pick a second from the saved-games list, continue, log out, press Continue — and the snapshotPairingFault guard stays either way, because refusing to write a player into a world that has no room for them is right whether or not this report survives. That is also why the guard shipped ahead of the repro rather than waiting on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-070. After a logout and a Continue, the game came up as Malina standing in northern_road — a room The Salt Cantos of Verengrad does not contain. One world's header over another's content, Exits reading "none visible", the clock a day out, and not a word anywhere. Both per-character mirrors were intact; only the active slot held the mixture, and restoring it had to be done from guide.html because the running game's autosave overwrote the repair within seconds. It is not root-caused. restoreGameState takes world and player from the same snapshot and readSavedSnapshot reads the one active slot, so Continue was faithful to what was stored — the mixture was written earlier, and the likeliest culprit is the login screen's staged world leaking into a session that then auto-resumed a different character. So the fix here contains rather than cures: a save is where a mixture stops being a bad tab and becomes a bad file, and snapshotPairingFault now refuses to write one. A player is always somewhere, and that somewhere is always a room of their own world, so an unknown currentRoomId is decisive. It refuses loudly and declines to guess which half is wrong, because a save that quietly picks a side is how a wrong answer becomes permanent. BUG-071. The compound move to the reciting crypt was correctly cut short at the Flooded Cloister by the blocking Gill-Wretch — and the engine told the DM console and queued a note for the Game Master's NEXT turn while telling the player nothing. The narration had already described arriving in the crypt, so the screen said one thing and the header said another. That is BUG-066's own thesis reproduced by BUG-066's fix, and the narration cannot be un-written because it is composed before the directives run. One engine-voice line now interrupts it, in the register the other refusals use. BUG-072. Testing BUG-068's disposition directive found it fixed the rarer half. Claude19 beat a respawned chorister senseless with the flat of a blade and let it lie — a complete mercy — and `combat` was null throughout, because the Game Master resolved the whole encounter in one narrated turn with a skill check and an ability check, which is what it does with most encounters. endCombat never ran, nothing recorded the truce, and blockingFoeIn went on treating the creature as a threat. stateChanges.setDisposition now takes the same declaration on any turn, scoped to the room rather than to a fight that did not happen, and the contract tells the GM which of the two routes matches how the scene actually resolved. Five sabotages, five distinct catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Node 18 was added to the matrix to find out whether it worked, and it does not. Server/package.json declared `engines: >=18`, nothing had ever checked that, and the claim was simply wrong: Server/zip-read.js verifies every uploaded world-pack entry against its own CRC with zlib.crc32, which arrived in Node v20.15.0 on the 20 line and v22.2.0 on the 22 line and does not exist in 18 at all. On 18, test_world_pack_upload.js dies with "zlib.crc32 is not a function", and an operator uploading a pack would have hit the same TypeError from the running vault. Everything else does pass on 18 — the app suite at 724/724, the vault's other thirty-nine files, both desktop suites and the generated-reference check — which is the reason this went unnoticed and the reason the narrower claims elsewhere are being left alone. Tests/README.md, Registry/README.md and the Tests section of CLAUDE.md say Node 18+ about the test harness and the registry specifically, and that remains true and worth knowing. It is the vault, and therefore the repository as a whole, that needs more. So the engines field is corrected to >=20.15.0 rather than the matrix bent around it, the README badge follows it, and the README's Tests section now states the split outright, because "the tests run on 18 but the vault does not" is exactly the kind of thing a contributor discovers the hard way. Node 18 left maintenance in April 2025, so the alternative — a pure-JS CRC-32 in zip-read.js, a table and a loop, to restore a floor nobody is standing on — was considered and not taken. The comment above the call records that option and what breaks without it, so anyone lowering the floor again finds the reason rather than rediscovering it. The matrix is now the declared floor and the newest release, so `engines` is verified on every push rather than believed. 20.15.0 is written exactly on purpose: a bare '20' resolves to the newest 20.x, which would silently stop testing the floor the moment the field moved. Both were run in full before this commit — the whole four-step sequence on 20.15.0 and again on 22 — rather than pushed to see what CI made of them. App suite 724/724, vault suite 40/40 files, desktop suites, generated references and the static denylist test all pass on Node 20.15.0 and on Node 22.
The merge brought in README.md, whose CI badge and its link both name the repository. Both were renamed to BraveYouWorlds/TheLostRealms along with everything else on this branch, which matters more for a badge than for ordinary prose: a workflow badge is fetched by URL from GitHub's own endpoint, so one naming the old path renders as "no status" rather than failing loudly enough to notice. The three remaining mentions of the old organisation are the deliberate ones and stay: the sentence in the D-2 decision block that is about the split between the two names being closed, and the two commit subjects in the generated progress report, which are historical facts about merges that happened under the old organisation and would be falsified by rewriting them. App suite 724/724, vault suite 40/40 files, desktop appId consistency and the static denylist test all pass on the merged tree.
The replay tier now grades a beat on a seven-verdict ladder ordered by how much judgement each verdict costs, and the headline number is only the two that cost none. A check that was rolled and fell short of its DC is a fair miss and is reported as one. A beat that stayed locked while the engine state shows the trigger's material conditions were met is an evidence failure. Beat bookkeeping - two unlocked in a turn, a later one before an earlier one, a questUpdate naming an id that does not exist or is already unlocked - is a conformance failure decided by code alone, because every one of those is a rule the prompt states outright. Two findings from the source changed what is possible here. applySkillChecks recomputes every outcome from roll plus modifier plus proficiency against the DC and IGNORES the outcome the GM claimed, so a harness that sees both numbers can count claimed-not-equal-derived as a hard contract violation - the exact failure the rulebook names when it says to judge by the total and never by the bare die, and one nobody has ever counted. That same arithmetic is what makes a fair miss provably fair: without it, a check that missed its DC and a check that succeeded and was narrated as a failure look identical from outside, which is most of the reason this distinction has never been measured. And half the hard case is already built and unread. retrospectiveUnlockFlag - the BUG-059 watch - compares the reason the GM gives for an unlock against the previous six commands and records when an earlier turn explains it far better, in the words of rule 13e itself. It attaches the flag to the captured turn, so it survives into the playthrough script and a replay harness inherits it per model. Its comment also fixes the boundary the harness has to respect and is quoted rather than paraphrased: the engine must not become a second adjudicator of the fiction. So the harness counts violations, reports state evidence, and may ask a judge - but never decides the fiction, and a verdict resting on judgement is labelled as one. Rule 13e is what makes the judged verdict legitimate rather than invented. It does not ask the Game Master what it privately concluded; it hands it a test it can check - if your own narration says the player did what the condition describes and you are not unlocking it, nearMiss is mandatory. That is a bounded question over two short texts, the same shape as the Tier B questions already in the design, and it separates the two cases that matter: a beat declined WITH a nearMiss is an honest disagreement about taste, and one declined without it while the narration says the deed was done is the silent toll the rule exists to end. The gap is recorded rather than glossed. The evaluation plan asserts a beat's OUTCOME and not its precondition, and the triggers themselves are prose written for the GM's eyes, so the evidence verdict needs a state shadow that does not exist yet. Twelve hand-authored clauses for the reference world are an afternoon and are worth it, because otherwise the most damning finding the harness can produce always arrives with an asterisk on it. And the middle stays large: most triggers are about understanding rather than about moving an object, so many beats will come back UNDECIDED. That is the same boundary the nearMiss mechanic is drawn around, not a flaw to engineer away.
The question was whether a replay could be judged on results rather than prose - if the reference run unlocks ten quest beats, does a different model unlock the same ten. It can, and the design was understating how ready the repo is for it: playthroughFingerprint() already returns exactly that object. Unlocked beats, unlocked lore hooks, compendium entries, the item multiset with quantities expanded, level, cumulative XP and wealth - every field read from the live source rather than maintained beside it, on the roster rule. playthroughYields diffs two of them, and that diff is what every captured turn in Tests/playthroughs/ already records. So a cross-run comparison is not a new mechanism; it is that same diff taken between two runs instead of between two turns. The reference figure is checked in as well: bladeward-claude19.json ends with beats 10/12 across 48 turns, each beat visible in the turn that fired it. The ten-beat contract of the question is already in the repo, and nothing reads it. Three refinements stand between that and a grade. Set DIFFERENCE rather than set equality, because a legitimate run can reach a beat the reference missed - and an extra is ambiguous in a useful way, since the rulebook forbids unlocking a beat speculatively, more than one in a turn, or out of order, and two of those three are decidable straight from the per-turn record on the scripts we already have. Discrete outcomes compare as sets while continuous ones do not: xpGain is explicitly the Game Master's own award and prices are improvised, so a candidate reaching the same ten beats with 12% less gold has failed nothing, and the grading rule has to keep the two kinds apart even though the fingerprint hands them over in one object. And a missed beat is usually a symptom rather than a failure - refuse one door on turn 12 and every beat behind it is unreachable, six misses with one cause - so the report leads with the earliest divergence that foreclosed something. Two cautions about the reference itself, which is the part most likely to be skipped. It is one sample of a non-deterministic system: ten beats is what one model did on one day with one set of rolls, not a property of the script, so grading a candidate against it as ground truth reports luck as capability. The honest version re-runs the incumbent two or three times to a band first, which is the control-group argument again in the tier where a sample costs $8-12 rather than pennies. And some variance cannot be removed at all, because the Game Master throws its own d20 for every skill check - which cuts both ways, since forty-eight turns of recorded rolls also let you ask whether a model's fair 1-20 is actually uniform, a measurement nobody here has taken. The tier now has two graders and reports both. evaluation-assert.js scores the save against the world's plan, which is the absolute yardstick with a measured figure behind it; the fingerprint diff scores the run against another run, which is the diagnosis. A number without the second cannot be acted on, and the second without the first has no scale. One small load-bearing note is recorded as an open question: the fingerprint identifies a beat by title before id, which is right for a human reading a yields line and wrong for a diff, because a retitled beat would read as one miss plus one extra.
Rev. 1 argued that a checked-in prompt is a stale prompt, and it was right about the risk. It was wrong about the remedy, because this repo already owns the remedy and uses it elsewhere: item-taxonomy.html is a generated copy of a live roster that cannot drift, because a tool rebuilds it under --check and a test fails when the bytes move. The pack gets the same gate, and in exchange the work splits at the point where the app stops being needed. Generating a prompt takes the whole game; sending one and grading the answer takes none of it. So those are two programs now. Tools/build-llm-prompts.js loads text_adventure.html once and emits Tests/llm/prompts/ — the stable half stored ONCE and shared, both delivery shapes emitted from buildGmTurnPayload rather than reassembled, the four pure salvage functions lifted verbatim, and a generated facts block so that no forbidden string is ever hand-typed. After that the runner reads a directory of JSON, substitutes two values, posts, and grades. No app, no vault, no DOM mock, no npm. That is what makes it usable against a provider this project has never spoken to. Storing the stable half once is not a size optimisation: it makes the shared-prefix discipline a property of the format rather than a rule the runner has to remember. One pack, one stable half; a case that needs a different world is a different pack. The vault is dropped as a transport rather than demoted. It looked production-shaped, but proxyGM forwards the client body verbatim and that is a vault question already answerable offline in Server/test/ — and for candidate models it is actively wrong, since pickGmModel would substitute an unlisted model without erroring. Seven costs come with the seam and they are written down rather than discovered later. The pack is a second copy of the rulebook whose safety rests entirely on one test, which is a single point of failure chosen deliberately. A stale pack can now fail silently at run time, where rev. 1's could not, so the manifest records its commit and the runner refuses to file a scorecard from a checkout that has moved. The prompt happens to be deterministic — measured, byte-identical across processes, because the clock is in-world and the weather derives from it — but one future line reading the wall clock would turn --check into a test that fails on Tuesdays, and the tempting fix is the wrong one. The grader also has to restate the engine's tolerances by hand, since they live inside applyTurnResult as control flow; the accepted directive list is a literal array in the source, so at least that half can be read out and compared. A bounded judge is admitted where a rule genuinely cannot be decided by code. Rev. 1 refused one, and that refusal still holds for judging prose QUALITY. It does not hold for four questions we already ask in the rulebook and currently do not grade at all: does a refusal refuse the right thing (a chest refused for ownership is a different and wrong refusal), does the prose agree with the fields (the family this project keeps hitting), was training denied that the character actually has, was somebody conjured who was never in the room. Eight of the sixteen cases carry such a clause. The judge is blind to which model wrote the answer, pinned and recorded beside the prompt hashes, sampled three times with the split kept, and may never decide a field. Section 10 weighs playthrough replay as a companion tier rather than a rival. Capture, the script format and the objective grader are all built; only a driver is missing. The trap is that turn-by-turn prose comparison is void from the second turn, once a different sentence has moved the world — but grading the save it produces against the world's evaluation plan is decidable however far the run has drifted, and each manifest already records what a script actually reached, measured rather than estimated. That turns divergence from a flaw in the method into one of its two outputs. It sees everything sequential the sidecar is blind to, costs $8-12 a sample against the sidecar's $3.80 a run, and localises nothing. The two compose: a replay failure, frozen at the turn before, is exactly what the generator turns into a cheap permanent case. The replay discovers, the sidecar regresses, and the open questions at the end of that section say what I would do rather than listing options.
Rev. 1 designed a regression suite: the model we ship, measured against the rulebook it already follows. The other use of the same machinery is the decision that puts a model in front of players in the first place, and today that decision is made on a model's reputation and a reading of its card — MODEL_LABELS gains a line, an option appears, and nobody in this project has ever asked the model to answer a single turn of our own prompt. Section 08 is that use, and the finding that shapes it is that most of the work is already done: buildGmTurnPayload takes the model as a parameter and gmFetch sends whatever the body names, so only the PICKER is gated by the roster — getSelectedModel clamps to gameplayModelChoices, and the runner never goes near it. An Anthropic model that is not in the roster therefore rides the native path byte for byte, and its score is directly comparable to the incumbent's on the same cases and the same prompt hash. That is Class A, it needs no abstraction, and it is worth having before anything is designed for the harder case. Class B — another provider — needs an adapter, and the seam it wants already exists rather than having to be carved: buildSystemPromptParts returns two plain strings with no provider in them, and everything Anthropic-shaped starts one function later. Claude keeps the native builder, because the whole value of that path is that it is the exact bytes the game sends. The adapter waits for a named candidate (Phase 5) instead of being built speculatively, since an adapter with nothing behind it is a second rendering of the prompt that will drift from the first. Three ways a candidate run could report a confident wrong number, all written down before anyone builds it. The vault will substitute an unlisted model WITHOUT erroring — pickGmModel falls back to the first sanctioned id it finds — so a candidate run through the vault comes back scored, plausible, and about Opus; candidates use the direct transport, and the existing rule of reading the model off the reply rather than off the flag is what catches it. An adapter bug and a weak model are indistinguishable from a scorecard, so a Class B run carries the incumbent through the same adapter as a control group: if the incumbent loses ground crossing its own adapter, the adapter is the finding. And the rulebook is 44k tokens tuned against Claude over months, down to the curly-quote convention that exists because of how one family of models breaks JSON — so a candidate scoring poorly has been shown to read THIS rulebook worse, which is a different finding with a different remedy, and the report has to say which one it is. Two smaller pieces keep the money and the ledger honest. A Tier 0 screen — context window against the 50k-token prompt, output budget against the 4000-token turn, a forgery-resistant system channel, prefix caching, single-object JSON — runs offline from the candidate's profile and ends most candidates before a call is spent. And a candidate's prices are QUOTED rather than derived: they carry a source and a date, they live with the candidate, and they stay out of Server/pricing.js, which prices real bills and yields null rather than a guess precisely so that a session can never look free. Adoption is spelled out as the six places a yes actually changes, from the picker option to the provider wiring, because the last of those turns "it scored well" into a project with its own design doc. A candidate scorecard is evidence for a decision and never the decision, which is worth writing down now: a number in a table is very good at becoming one.
The GM turn is three things in a row: a prompt this repo builds, a model that reads it, and an engine that applies the reply. The first is pinned by test_prompt_cache.js and the dossier tests, the third by most of the app suite — and every one of those engine tests feeds applyTurnResult a response the test itself wrote. Nothing here has ever asked a model to answer, so both halves pass while the sentence in the middle of the rulebook turns out to be one a model cannot follow, and every failure of that kind has been found the same way: by a person, in play, afterwards. The door directives arriving at the top level while the door stayed locked in the engine; BUG-039's JavaScript expression where a JSON value belonged, byte-identical on two turns; BUG-021's spell preparation narrated and never applied. Each of those is one prompt and one reply, decidable without playing anything if you are willing to spend the call. The design is a PROMPT to SCHEMA test and deliberately nothing more: build the real prompt with the app's own builders for a fixed situation, send it at the game's own settings, grade the JSON through the engine's own salvage path, apply nothing. Six families — movement, pickup, containers, item use, doors, NPCs — sixteen cases, eight in a first cut, and each family shipped as a pair with one case where the action is legal and one where it is not. That pairing is load-bearing rather than tidy: an abstention-only suite scores a model that refuses everything as excellent, and a legal-only suite scores one that never refuses the same way, so judgement is visible only in the difference. The two decisions most likely to be got wrong later are written down with their reasoning. The grader matches the ENGINE's tolerances, not the spec's — the engine reads door directives from the top level, repairGmJson salvages an expression, matchEntityInRoom resolves a being by unique substring — because a grader stricter than the engine reports failures the game does not have, and a suite whose failures are not real is one people learn to ignore; every tolerance spent is counted instead, since the salvage rate is the early warning. And a run holds one fixture world, so all forty-eight calls share one cached prefix: measured at a202982 the stable half is 162,708 characters against the dossier's 20,721, which puts an Opus run at roughly $3.80 rather than $12.90, and makes the cache reads the first live evidence that the split works rather than merely being well-formed — the failure prompt-economics.html §07 calls silent, because a cache that stops working looks exactly like one that works. Section 08 is the part worth reading before building any of it: thirteen ways a suite grading a non-deterministic system against a moving prompt can produce confident nonsense, and what to do about each. Temperature 0 is rejected as a remedy because the game sends no temperature at all and a suite run at 0 would grade a configuration nobody plays. Scores carry a hash of the prompt they were taken against and the runner refuses to diff across a changed rulebook. The model is read off the reply rather than off the flag, since a vault may substitute one. And the fixtures are guarded by a free offline test in the ordinary suite, so the rot that would otherwise accumulate between paid runs is caught on every push. Nothing is built. Tests/llm/ does not exist yet, and Tests/run.js globs test_*.js in its own directory only, so when it does exist the ordinary suite still cannot make an API call.
The guide's Dungeons section said the tab was "a reserved shell", that "the dungeon model isn't defined yet", and that there was "nothing to build here today". Its screenshot showed an empty state whose wording the app no longer contains, over a toolbar with no + Add button. Since that was written the whole system landed: cards with portraits, prompts and lore, a Builder that draws levels, doors, locks, switches, secrets, chests and monsters, an entrance derived from the exit stair, a first-person crawl with persistent play state, and — this week — maps that travel with a world file or a save. Rewritten from the code and from Designs/dungeon-builder.html rather than from the old text. The organising idea is the one that explains the rest of the tab: a dungeon is two things kept in two places, the record here and the map in the Builder, joined by the dungeon's id and nothing else. Then the card field by field, the Builder's three families of tool and its mechanism model, the way in and the way out, what the party can do down there, and what is remembered against what travels. One claim in the old section survives verbatim because it is still true: the GM box on this tab authors nothing, and says so. dmEditDungeons is four lines and one of them is that message. It is called out as a note rather than left to be discovered, since every other tab's box does write. Three screenshots, taken from the real app rather than mocked up: the tab itself with a built dungeon and an undrawn one, the Builder's editor on its demo map, and the crawl view. Framed to match the guide's other editor figures — the detached-editor layout is exactly that crop — and taken at the same 2x as its neighbours. Getting them right needed three fixes worth recording for whoever takes the next one: headless parks the pointer at (0,0), so the first editor tab renders hovered and reads as the selected one; CSS transitions do not finish under --virtual-time-budget, so a tab caught mid-fade photographs in the wrong colour; and --window-size counts browser chrome, so the viewport is 87px shorter than the image and the difference comes out as a black band. A scripted scroll paints nothing under virtual time either, which is why the proofing pass hid sibling sections instead. Two facts were checked against the code and one was wrong in the design doc: TEX_SLOTS holds sixteen built-in slots, not fifteen — the skeleton's sheet arrived after §09 was written and was never counted. Corrected there as well as in both Handbooks, with a note that a created monster adds one apiece, which is what makes its sheet an ordinary uploadable texture instead of a second mechanism. The book edition carries the same material at its own density, with the same three figures. Left alone, deliberately: the design doc's "monsters are cosmetic for now" and DUNGEON_CONTRACT's NOTHING LIVES HERE YET both predate dungeon combat, which is shipped and has its own tests. The contract is a prompt, and rewording it changes how the GM plays; that is a change to make on purpose, not in passing while fixing a Handbook. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Its "Run it" section was three lines — install and start — which is accurate and, on any machine that is headless or running as root, not enough to get anywhere. Three separate symptoms stop the app before it draws anything, and each of them reads like a broken checkout rather than a missing flag. They are written down now, with the reason beside each, because the reason is what makes the flag obvious the second time. The one worth the most words is that npm install does not download Electron. It finishes in a second and reports success with no binary on disk, which looks exactly like a failed install; it is not. Electron has carried no postinstall script since v43 and fetches the binary lazily on the first run instead. That is fine for a developer and not fine for an image being baked to run offline later, so the explicit downloader is named. The opposite mistake resolves to the same confusion from the other side — Cannot find module 'electron' is a missing install, or a test started with node where it wanted the electron binary as its interpreter — so that is named too. The section also records what a bare X server does not give you, since the launch test's own comment now depends on it: no window manager means no decorations and a window clamped to a virtual screen that is 1280 wide by default, narrower than the 1282 these windows ask for. Separately, the icon section ended by stating the app has no Tray, which the section three headings above it describes at length. It was true when written and has not been since; it now says what the file is actually resized to for the tray and why the platforms disagree about that. CLAUDE.md's layout table never listed Electron/ at all. It does now, pointing at that README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
test_launch pinned the window at 1282x772 by re-typing those numbers, and failed on any display narrower than 1282 pixels. Nothing was wrong with the app when it did: a window cannot be larger than the display it opens on, so the OS clamps it, and the assertion was really reporting the size of the screen the test happened to run on. The default virtual screen xvfb hands out is 1280 wide, which is enough to fail it, and so is an ordinary laptop. The claim is now split into the two things it was conflating. That the window is frameless — that its outer size IS its content size, with no title bar added on top of what was asked for — holds at any size, because both numbers clamp together, so comparing them to each other never involves the display. That it opens at the configured size is checked only when the display can actually show it; where it cannot, the test says so on the console and checks instead that the window fits the screen and still respects its own minimums, which is the honest remainder of the claim. The expected size is read from main.js rather than copied into the test, which is what the export at the foot of main.js is for. That is a real trade and the test's comment records it: a change to WINDOW_SHAPE itself moves both sides together and is not caught, while a window given dimensions of its own is, and that is the regression worth catching, since every window here spreads the same shape and the realistic mistake is somebody hand-tuning one of them. The comment also records that the frameless check cannot bite under a bare X server with no window manager, which draws no decorations whatever the window asked for; it bites on a real desktop, which is where a title bar could appear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The repository had no README at all. A visitor arriving at it found a 5.8 MB HTML file, forty-five design documents and no sentence saying what any of it was. README.md is that sentence and the ten minutes after it: what the game is, the three ways to run it, what each directory holds, how to run the suite, the four conventions that have cost real debugging time, and how the two licences divide. It states the licensing position in the terms a reader actually needs — that reading, building, modifying, forking and playing are free and always will be, and that what needs paying for is paid hosting, shipping past the small-entity threshold, and the art. The badges are chosen to mean something rather than to decorate. Build status comes from the workflow this commit adds; the other four say which licence covers the software, that the game content is under a different and proprietary one, that a contributor agreement is required before a pull request can merge, and what Node version is needed. Each links to the document it summarises. The workflow is the substantive half. Until now nothing checked a push: the FTP deploy was the only workflow in the repository, and both CLAUDE.md and CONTRIBUTING.md said in as many words that CI does not run the tests. That was true, it was load-bearing advice, and it is now false, so both were corrected in this commit rather than left to mislead. What replaces it is a more uncomfortable fact worth stating plainly: a push to main DEPLOYS, and the deploy does not wait for the tests, so a red build means the site is already serving the break. CI here is a net under the local discipline, not a gate in front of it, and the new wording says so. It runs the four commands a contributor runs by hand — the app suite, the vault suite, the desktop suite and the generated-reference check — because there is no single entry point to run instead: `npm test` in Server/ runs one file rather than the suite, and the app has its own runner. Invoking them the same way locally and in CI means a green run means the same thing in both places. Both runners were checked for the failure mode that matters before being relied on, since a workflow that reports success over failing tests is worse than none: Tests/run.js exits 1 on any failure and the taxonomy --check exits 1 when the generated doc has drifted. The vault step loops rather than chains, so one run reports every broken file instead of only the earliest. Node 20 and 22, and not 18, deliberately. Server/package.json declares engines >=18 and nobody has ever run the suite on 18, so adding it here would be publishing a badge whose first result tests an untested claim. A badge that goes red on day one from documentation rather than from a regression teaches everyone to ignore the badge. Adding 18 is worth doing as its own change, where the answer is either that the floor holds or that the engines field is wrong. Also corrected in passing: Server/README.md still told the reader to `cd server`, which stopped working when the directories were capitalised, and CLAUDE.md still described the app as 4.7 MB when it has been 5.8 MB for some time. The badge URLs name radiantone/thelostrealms because that is where this repository lives today. The rename branch carries the BraveYouWorlds form and will need refreshing to pick these up. App suite 724/724, vault suite 40/40 files, both desktop suites, the generated-reference check and the static denylist test all pass.
Staged ahead of the move, not applied by it. Every reference in the tree now names the destination — github.com/BraveYouWorlds/TheLostRealms and com.braveyouworlds.thelostrealms — so merging this branch is what completes the rename, and it should be merged at the moment the repository is transferred rather than long before. Until the transfer happens the URLs in it point at a repository that does not exist yet; afterwards, they are the only ones that do. Two hundred and thirty-six references to the repository identity and four to the desktop appId. Most of that is mechanical, and only two parts of it needed judgement. The first is what NOT to rename. Two occurrences read "Merge pull request #2 from radiantone/claude/game-landing-page-review-ne1jfp" in the generated progress report: they are commit subjects, historical facts about merges that happened under the old organisation, and rewriting them would falsify a development log. They also regenerate from git log, so an over-broad replacement would have been undone by the next run of gen-progress-report.js anyway. The identity string radiantone/thelostrealms and the branch string radiantone/claude differ, so replacing the first left the second alone by construction rather than by care. One further mention survives on purpose, in the D-2 decision block, where the sentence is about the split between the two names having been closed. The second is the appId, which is the only part of this with consequences beyond a broken link. It is the Windows AppUserModelID and the macOS bundle identifier — the operating system's notion of which application this is — so changing it makes an installed copy a different program: a second entry in Add/Remove Programs rather than an upgrade in place. That cost is zero today and only today. There is no electron-updater or autoUpdater anywhere in the tree, so no update feed is keyed to the old identifier, and all three package.json files are private, so nothing is published under it. The day a build ships to anyone, this stops being free. Electron/test/test_vault_launch.js already asserted that setAppUserModelId matches package.json's build.appId, and it earned its keep twice here. The literal replacement missed the test itself, whose copy of the string is a REGEX with escaped dots — com\.radiantone\.thelostrealms does not contain com.radiantone.thelostrealms — so the suite failed until the pattern was updated too. Sabotaged afterwards in both directions to confirm the guard is real: renaming main.js alone fails two assertions, renaming package.json alone fails one, and both name the mismatch rather than a symptom. D-2 is half-answered by this and worth being precise about, because the half it does not answer is the one that matters. The organisation, the appId and the studio name in the licences now agree, and the earlier three-way split is gone. But renaming an organisation does not make Brave You Worlds the entity that owns the copyright, and two other names still touch this work: the individual author, and visualagents.ai, the domain every human commit here is authored under. If copyright sits with either, it must be assigned in writing before Brave You Worlds can license it to anyone — which is what LICENSE, LICENSE-CONTENT and every commercial agreement purport to do. The governing-law placeholders in CLA.md §10 and LICENSE-CONTENT are waiting on the same answer. Nothing outside the tree is touched by this commit, and four things there need doing by hand at the transfer: the git remote on every clone; the FTP_SERVER, FTP_USERNAME and FTP_PASSWORD secrets, which should be confirmed present on the new repository before trusting the next deploy, since anything inherited from radiantone organisation settings will not follow; this project's Claude Code repository authorisation, currently scoped to radiantone/thelostrealms; and branch protection, environments, webhooks and deploy keys, which are the usual casualties of a transfer. The CLA signatures need nothing — they are files under .github/, so they travel with the repository and nobody re-signs. App suite 717/717, vault suite 40/40 files, both Electron suites, the denylist test and the item-taxonomy --check all pass.
Every one of the 723 tests ends with an explicit process.exit(ok ? 0 : 1). A test that instead falls off the end of the event loop has not finished, it has stopped — and it stops for one reason here: something it awaited will never settle, because a browser stub returned a value where the app expected a scheduled callback. `setTimeout = fn => 0` is the recurring one. Anything awaiting the save debounce, or nextPaint's 60ms fallback, then waits for ever. Node exits 0 on an empty event loop, run.js graded on the exit code alone, and the file counted as a pass with every unrun assertion counting as one too. run.js now preloads _finish-sentinel.js into each test. It listens on beforeExit, which fires only when the loop drains on its own and never after process.exit(), so it firing means the file stopped short of its own last line; the run reports that file as "stopped early" with the last line it managed, which is where to start looking. A stopped test usually has no failing assertion to print at all, so the old FAIL-line triage would have said nothing. Verified against a test built to stall: green before, and reported with its true last line after. All 723 pass under it, so nothing else in the suite is stopping quietly — including the 54 files that print no ALL PASS line, which no cheaper check could have cleared. The four tests that were stopping now run in full, and the assertions that had never executed were checked against sabotages the way a new one would be. test_dm_override had printed nothing whatsoever. It stalled on its first await, on saveGameState's debounce, so none of its eleven assertions had ever run. With the timer really scheduling, all eleven pass, and ignoring dmOverride in restoreGameState fails four of them by name. test_logout_login_cue_refresh had also printed nothing. Its three ordering assertions pass once logout can complete — and a fourth thing then surfaced: with timers firing, the module's own boot runs far enough to show the login screen and play its cues, and that call was being counted as logout's. Boot does refresh the cue cache first, but before the tracker exists, so the entry read as a play with no refresh in front of it: the exact failure the test guards, arriving from the wrong function. The record now starts after boot has settled. Its two static assertions were failing on a character window — logout has grown past `+ 3200` — so they are bounded by the function's own closing brace instead. The ordering they guard was intact the whole time; the window was measuring the file, and the file moves. test_save_logs stopped after four of ten. The six that had never run cover the size reported in the "Saving game…" line and the two that keep a failed write from reporting success; dropping the size, and swallowing the write error, each fail the right ones. test_world_draft stopped after fourteen of twenty. One of the six was genuinely stale rather than merely unrun: it matched the VAULT_SENTINEL re-adoption inlined in standUpAuthorSession within 400 characters of the storage read, and that line was factored out into adoptVaultKeyIfNeeded, which is now the one place that knows the rule and awaits the vault probe. Rewritten to the ordering property over the function's own text — the read, then the adoption, then anything else — since the dynamic check beside it cannot tell an immediate re-adoption from a late one, both ending with the sentinel in place. Moving the adoption after loadApiKeysFromStorage fails it; removing it fails three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Two things the Gallery was missing, and a timing trap sitting between them. A victory card folds the fight's narration underneath, because the picture can only ever approximate the moment while the narration IS the moment. A rest card had no such block, and the reason was not that a night has nothing to say — the Game Master narrates every sleep and camp — but that the account was being thrown away at paint time. It is kept now, in the same two fields a victory keeps, rendered by one shared builder rather than a second copy of the markup. The trap is why it could not simply reuse lastCombatNarration. A victory is painted from endCombat, by which point the killing blow is already in messageLog, so reading backwards finds it. A rest is painted from inside applyStateChanges — which runs BEFORE the narration is printed, deliberately, so a time skip's new banner lands above the GM's own account of the time passing. Reading the log there returns the turn BEFORE the sleep, and the card would have been captioned with whatever fight the player had on the way to bed. The turn's narration is stashed while it is still a variable; the player's command is still read from the log, which is safe because it is echoed before the model is ever called. And every card now carries a small remove control in the picture's own corner, faint until the card is hovered. It asks first, through appConfirm rather than the browser's, and the question NAMES what it is about to destroy — over a grid of near-identical cards, "are you sure?" about an unnamed one is a question nobody can answer — and says plainly that the picture cannot be painted again. The entry is re-found by id after the dialog closes rather than trusted by index, because the world does not stop while a modal is open: a rest landing behind it evicts the oldest and shifts everything under the grid. Six sabotages. Two of them survived the first pass and were worth the second: the painter quietly no longer recording the narration, which every by-hand test fixture is blind to, and the delete matching by the index it took before the await, which only a test that mutates the list mid-dialog can see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
It shipped as `ability-ed-addrow`, a name invented on the spot with no CSS anywhere in the file, so the control rendered as a browser default. The + Effect buttons it sits beside — on item cards and on ailment cards — are `ability-add-btn`, and that is what it is now. The failure mode is worth the assertion rather than the eyeball: an undefined class does not throw, does not warn, and in a dark panel renders as something that looks almost deliberate. Nothing reports it. So the test now reads the class off the button in the markup, requires it to be the one the neighbouring controls use, and separately requires every class it names to be defined in the stylesheet — which is the general form of the bug and would catch the next invented name on any control. Audited the rest of the row while there: effect-ed-prop-row, effect-ed-prop-badge, ability-row-del, ability-ed-field and ability-ed-hint are all real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A dungeon has always been two things kept in two places. The record — name, location, description, portrait, lore — is world data, written by serializeWorld and read back by normalizeDungeons. The map — levels, cells, doors, chests, monsters, tile art — is written by the Builder into IndexedDB under dungeonBuildKey, and is not world data at all. Only the first half could leave the browser, so a shared world arrived with every dungeon reading "not drawn yet" over a disabled Enter button, and a save moved to another machine arrived worse than that: a save does carry dungeonPlay, so it described doors opened and monsters slain on squares of a map that was not there. Both files carry maps now, and four rules decide the shape. The tile art is optional and off by default, behind a checkbox beside Export World: fourteen tiles at 1024 squared measured 2.19 MB for a single dungeon against tens of kilobytes for the layout, so carrying art by default would put the save-size problem straight back. The autosave carries nothing, which is why none of this is in buildGameSnapshot — that save is written every few turns into the same IndexedDB the maps are already sitting in, on the same machine, and a second copy there is duplication paid for on every write. Gathering never registers, because Crawler.load rewrites the module's live ITEMS and TEX_SLOTS as a side effect: reading every dungeon through it would leave the last one registered over the dungeon the party is standing in, and their monsters would lose their sprites because somebody pressed Export. The crawler therefore grew loadRaw, for readers that are copying rather than mounting. And materialising fills a gap and never overwrites — a carried map is a copy and the local one is the truth, or loading a month-old save would put its map back over the one the DM has drawn since. Where they ride differs between the two files and the difference is not arbitrary. A world file carries them inside the world object, because that is the only place they survive the far side — stageWorldForLogin and saveSavedWorld both keep the world and drop the envelope around it — and because the world is what gets forked and re-scoped on arrival. So claimWorldUid is where they are put down, once the uid they will keep has stopped moving; it is also the one function every path that adopts a world already runs, which beats remembering in four places. A save carries them beside the world, next to dungeonPlay, because a save's world is already identified and is never re-scoped. BUG-071 fell out of reading that machinery. cloneWorldDungeonBuilds copies a world's maps when a fork is given a fresh uid, and it did that by duplicating every localStorage key under the source prefix — which was the whole dungeon before the map moved to IndexedDB, and has been the card-sized index ever since. A forked world got cards reading "3 levels, 14 tiles" over keys with nothing behind them, dungeonBuildMeta answers off that index so the Enter button was enabled, and startCrawler handed no map falls back to loadAscii(DEMO). Every dungeon in a forked world led into the same demo maze and nothing said so. It clones both halves now. Tests/test_dungeon_transport.js pins all of it, and each assertion was checked against a break: art forced on and forced off, gathering routed back through load(), the undrawn dungeon carried, the overwrite guard removed, the cargo left on the world data, materialising moved ahead of the uid, the save's maps folded into the world, the restore's adoption removed, the clone's payload copy removed, the checkbox unwired. The fork case needed a dungeon drawn nowhere locally before it could tell the right scope from the wrong one — with an ordinary dungeon the fork-clone supplies it either way, and the assertion passed against the sabotage. Two of test_custom_monsters' assertions matched the old spelling of the read path and were rewritten to the property: load registers exactly once, on whatever the read returned, and loadRaw registers nothing. Separately, test_export_game's last three assertions had never run. nextPaint waits on rAF and falls back to a 60ms timer, both stubbed to no-ops there, so restoreGameState awaited a promise nobody settled; node exited 0 with a third of the file unrun and run.js, which grades on the exit code, called it a pass. Stubbed, the assertions run and pass. Four other tests are green the same way — test_dm_override, test_logout_login_cue_refresh, test_save_logs, test_world_draft — and are left alone here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Recording the built work as built, which is what the folder's own status index exists to catch. Phases 2 and 3 — properties on beings, the authoring prompts learning the registry, stacking once a second kind exists to stack with, and promotion — remain proposed with all eight decisions settled, so whoever picks it up is choosing when rather than what. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Properties phase 1. An effect gains a third lane beside the stat deltas the engine applies and the line
the Game Master narrates, and the rule that makes it worth having is that ENFORCEMENT IS EARNED RATHER
THAN DECLARED. An author writes any kind they like; a kind the registry knows is coerced, clamped and read
where that kind is read, and anything else is narrated — kept exactly as written, shown on the card, and
told to the Game Master with narrativeEffect's standing. Nothing is refused and nothing is silently
dropped, which is precisely the failure this lane exists to end: `effects` coerces every entry to
{ stat, delta } and discards the rest without a word, so an author who wrote a weight reduction got no
error, no warning and no property.
A bag of holding now exists. Three anvils in a plain sack weigh 92; in a sack whose weightFactor is 0 they
weigh 2, which is the sack — a bag of holding is not a bag that is gone. The factor scales what is INSIDE
and never the container, and it multiplies at each level so a light sack inside a lighter one compounds
the way the fiction expects.
Two things had to give. normalizeItemEffect's guard read "no label and no stat deltas means nothing to
apply", which was true only while an effect could speak in ability scores alone — a bag of holding says
nothing in any of them, and the record would have vanished between the editor saving it and the card
drawing it. And properties are read only from effects that are IN FORCE as standing facts: constant always
is, onCarried is wherever this is asked, and a moment is not a standing property — a sack that is
weightless at the instant it is used is not a thing.
The editor gains a Properties lane in the dialog that already exists, not a second dialog, because a
permanent weight reduction is not a different kind of thing from a permanent +1 and filing it elsewhere
would shard "what does this do" across two places. The kind box is a combo: the registry is offered,
anything else is accepted. The badge is the feature rather than decoration — it is the only thing telling
an author which of the two outcomes they just got — so narrated is badged in the same calm colour as
enforced, since dressing it as a warning would push authors away from inventing, which is the point.
One test outside this work had to be narrowed rather than satisfied. test_world_tone_freetext asserted no
datalist appears anywhere in the file, to prove the Tone field had stopped being a combo box. That made it
a rule the app could never use the technique again, and an assertion that forbids a technique globally to
protect one field is eventually satisfied by deleting the wrong thing. Scoped to the Tone field, which is
what it always meant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe three phase-0 changes are in and each earned its place without waiting for the properties array they were scoped ahead of: `bearer` as a target distinct from `self`, `onCarried` with a pull-model application, and a red mark on an onEquipped effect that has no slot to fire from. Recording it here so the document stops describing built work as proposed, which is the drift the folder's own status index exists to catch. Phases 1 to 3 — the properties array itself, the PROPERTY_KINDS registry, and promotion — remain proposed with all eight decisions settled, so whoever picks it up next is choosing when rather than what. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
onEquipped means occupying an equipment SLOT rather than being carried: equippedItemEffects walks player.equippedItems and applyEquippedItemEffects runs only from the equip path. So an onEquipped effect on a sack, a stone, a relic — anything with no equipmentSlots — is authored, saved, rendered on the card and applied exactly never. Nothing errors, nothing warns, and the item reads as enchanted to every human who looks at it. The example that argues for flagging it is this repo's own: the bag of holding in the properties design document was written that way in draft, by someone who had just finished reading the code that makes it impossible. Trusting anyone to remember is not a plan, so the card carries a red mark and the tooltip leads with the reason. The message names onCarried rather than only refusing, because an author told "no" without being told "try this" edits at random. Two smaller things travelled with it. `openEndedEffectPhrase` gives one answer to how an open-ended effect ENDS, and three surfaces had been saying "while worn" regardless — true of a ring, false of a stone in a pocket, and a false statement about a rule is worse on a card than no statement at all, because the card is where the rule gets checked. And a slot list of empty strings now reads as no slot, which is the shape a non-wearable actually gets written with. The lint deliberately stays quiet on onCarried, onUse, onHit and constant, none of which want a slot. Five sabotages, five distinct catches — including one that makes it fire on a perfectly good ring, because a warning that goes off on healthy data is a warning nobody reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The triggers answered "what must exist for this effect to have a subject" with two states and skipped the middle. `constant` needs nothing, because the claim is about the object and holds in an empty room; `onEquipped` needs a wearer AND a free slot; onUse and onHit need a moment the engine cannot see. Nothing needed only a HOLDER, so a cursed idol in the pack, a lantern that kindles in the hand and — the case that makes it undeniable — an aura had no way to be written at all. A stat boon around the bearer has, lying on the floor, no target whatsoever, and constant + bearer is not the workaround but a different claim: it asserts the effect does not end, so a dropped curse would follow the player home. The application is a PULL, and that is the load-bearing choice. onEquipped pushes a status onto the player on equip and takes it off on unequip, which is safe because equipping has exactly two chokepoints. Carrying has one way in and five ways out — the trade splice, the drop splice, consumeItem, the DM editor and the dungeon path — so a push model needs every one of them to remember, and the failure it produces is a boon that outlives the thing that granted it: sell the stone, keep the aura. Deriving it from the inventory means there is nothing to remember and nothing to persist, and the test drives that with a raw splice exactly as those five sites do. Two things the test found rather than confirmed. The legacy self-to-bearer migration was rewriting onCarried too, which destroyed the lantern — a light that kindles in the hand is about the LANTERN — and the exemption is principled rather than a patch: the migration exists for records authored before `bearer` existed, and onCarried did not exist then either, so a `self` under it was chosen deliberately. Unlike the constant exemption removed earlier today, this one is live and a sabotage does fail it. And the trigger roster turned out to be pasted into three separate prompts, every one of which would have gone stale the moment this landed — the Game Master would never have learned that a trigger it is now expected to author exists. One interpolated helper now, with `constant` filtered out on purpose, since on an item it still has no application path and offering it would invite an effect authored, saved, shown and never applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
`self` meant two different subjects depending on what it was written on. An ailment sits on a creature, so `self` was that creature; an item does not, so `self` was not the item at all but whoever held it. That is serviceable until something wants to be true of the OBJECT — a blade that glows where it lies, a sack whose contents weigh nothing — at which point there is no way to say so, because the word for "this thing" had already been spent on its owner. So `bearer` now says "whoever carries or wears this", and `self` always means the host. Legacy data is migrated rather than reinterpreted, and the rule is mechanical because the host disambiguates: on an item a legacy `self` becomes `bearer`, on a being it stays `self`. Nothing is guessed. The reader that matters — applyEquippedItemEffects — now looks for `bearer`, which is what those effects always meant, and the normalizer's host parameter defaults to 'item' so that a call site which forgets to pass one keeps today's behaviour rather than silently ceasing to apply. Exactly one call site is a being path and it says so. `constant` is exempt and stays `self` on either host. Those records have never had an application path at all — nothing walks anything but the equipment doll — so rewriting an inert record's meaning buys nothing, while leaving them alone is what frees `constant` to mean the object from here on. The first version of that exemption was a condition in the migration guard, and the sabotage pass caught it as dead code: the ternary below forces `self` for constant before the migrated value is ever read, so the guard was a rule that looked enforced and could not fail. Removed, with the comment now pointing at the line that does enforce it — and sabotaging THAT line does fail, by two named assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Checked the ring assumption against the code rather than taking it: EQUIP_SLOTS has thirteen doll positions and exactly one maps to each slot, ring included. So a character wears one ring and two rings can never stack, because two rings can never both be on. Worth having verified rather than assumed, since this is the EQUIPMENT_SLOTS versus EQUIP_SLOTS distinction the working notes warn about — a doll position's key is not its slot, and bootsR and shieldR are exactly that trap sitting in the same list. The consequence belongs in the stacking section. onEquipped can still stack a kind ACROSS slots — silent boots and a silent cloak — so combine is not dead there, but the ceiling is thirteen and realistically two or three. onCarried has no ceiling at all beyond carry weight. A sum rule on a ring that physically cannot be doubled is a very different promise from a sum rule on a stone a player can hold five of, so an author giving a stacking property to something carryable is choosing combine against an unbounded count, where multiply-toward-a-limit, max and first are the safe shapes and sum wants a reason. That also settles what decision H really bought. Moving an effect from a ring to a stone does not only change what it costs to hold; it moves the effect out of an economy with a hard cap of one and into one with no cap, and an author needs both ends to be able to price anything at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Decision H is taken: the fourth trigger is in. Framing it as "when does it end" undersold it, and the aura example is what reframed it. An item throwing a stat boon around whoever bears it has, lying on the floor, no target whatsoever — not a weak one or a suspended one. constant + bearer cannot say that, because constant asserts the effect is simply true and does not end; applied to a bearer who does not exist it is not wrong so much as meaningless. So the better question a trigger answers is what must EXIST for the effect to have a subject at all, and on that reading the four cover a ladder with no gaps: nothing for constant, a holder for onCarried, a wearer and a free slot for onEquipped, a moment for onUse and onHit. Two examples earn their place on the page. The lantern that kindles when lifted is the same glow kind as the rune-blade that lights the floor it lies on — identical mechanism, identical narrated standing, and the trigger carries the entire difference — which is the clearest way to show an author that the vocabulary is theirs to mean something with. And the aura stone is the case with no route today at all: not wearable so onEquipped cannot fire, not a fact about the stone so constant + self is the wrong claim, and constant + bearer asserts a boost on nobody. That hole is in the vocabulary for ORDINARY effects, not only properties, which is why this lands in phase 0 on its own. Darren's last point earned a section of its own, because it is the reason both triggers survive rather than one subsuming the other: a stone in a pocket can throw the same boon as a ring on a finger, and what differs is not the boon but what the object asks of the player. A slot is the game's tightest economy — discrete, few, contested — where weight is continuous and only bites in aggregate. Choosing between them is therefore PRICING the item, and until onCarried exists that choice cannot be made at all: an aura is a ring or it is nothing. The pocket also stopped being free this week, since carried weight now recurses into containers, so the two triggers sit on two genuinely different cost curves. All eight decisions settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The entry claimed in-world time still advances while logged out. It does not, and I wrote that without reading far enough. BUG-058 settled the clock back on 24 Aug: on restore the saved in-world instant becomes the new anchor pinned to the present real instant, so the clock RESUMES rather than catches up — and every re-entry goes through restoreGameState, Continue included, so no in-world time passes across a logout however long it lasts. What was left after BUG-058 was not the clock but the loop: a tick that went on doing things regardless of what the clock said. The two are the same complaint answered at different levels, and the note now says so rather than inviting the next reader to go and "fix" a clock that was already right.
Rev. 3 mis-mapped two answers, because it re-lettered the decision list before recording replies against it. In the rev. 2 list that was actually reviewed, D asked whether an unregistered kind deserves a warning and E asked what happens when a narrated kind is promoted. So "D. No" meant no warning — a badge — and "E. Go with your leaning" meant promotion behaves as the leaning said. Rev. 3 read D as the promotion question and built a mechanism nobody asked for: an opt-in stamp that kept existing properties narrated until an author adopted them one at a time. That is now removed and the decided position restored. A promoted kind IS enforced in worlds already carrying it, because that is what the author asked for by writing the kind — they described a real property of a real thing and the engine has finally learned to keep it, and making them say it twice is not respect for their intent. It remains a live behaviour change, so it carries two obligations: the release note calls it out, and the registry entry records the version it happened in. The rejected alternative is worth keeping on the page rather than deleting, because it looks careful. An opt-in stamp leaves two populations of the same kind inside one world — some glow entries enforced and some not, told apart only by when they were authored. Nobody can reason about that at a table, and it makes the registry a lie: the kind is enforceable, except where it silently is not. The list is lettered as rev. 2 had it, so a future reference to "decision E" means the same thing as the reply that settled it. The dossier question rev. 3 introduced as F was never put to review at all, and is recorded as design position rather than as a decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two conflicts, both from work that landed while this change was being written. The bug ledger: upstream took BUG-068 and BUG-069, so the logged-out-world entry becomes BUG-070 — keeping a duplicate id would have made two entries unreachable by anchor. The header chips are the union: 70 ids, 55 fixed, 6 fixed-unverified, and they reconcile against the s- classes. The test directory was renamed tests/ → Tests/ upstream, so the new test file arrived on the wrong side of the rename. It is at Tests/test_logged_out_quiet.js now, and the edits to test_timeskip_room, test_world_palette and test_combat_timer carried across with it. 719/719 on the merged tree, and the four sabotages that matter — the guard removed, the guard trusting loggedIn, logout leaving the combat clock running, and the clock playing a turn for nobody — are all still caught by the new test after the merge.
BUG-068, found in the vault's own log: it went on recording scene prompts while the player was logged out. The realm clock is a top-level setInterval(updateRealmCalendar, 1000) that nothing ever clears, and every guard inside it asked the wrong question. logout() sets player.loggedIn = false but leaves the player and the world loaded, so "player && world" was still true — and the two checks with no guard at all, the weather banner and the weather block, never even asked. Once a second, with the login screen up, the app re-painted the room banner under a new sky (an image generation: the logged prompts), emitted weather-change and time-of-day briefs, respawned monsters, expired statuses and aged fatigue for a character nobody was playing. livingWorldActive() is the question that should always have been asked, and it already existed — encounters and ambient lines have gone through it since the same class of bug put a turn prompt on the wire from the login screen. It is deliberately not player.loggedIn: a restore sets that flag and loads the world BEFORE anything decides the player is entering, so the honest signal is whether the login overlay is up. The tick now asks it. Guarded rather than started and stopped, because a timer that has to be restarted needs every entry path to remember and a path forgetting is exactly this bug; only the clock face and the countdowns are left running below the guard, being a reading of the current instant that cannot reach the GM, the world or the vault. Looking for others turned up a second leak of the same shape: the combat turn clock, a 250ms interval, was not among the timers logout stops, so a fight abandoned mid-turn went on counting down and then submitted "[COMBAT ACTION] the player hesitates and does nothing this turn" to the GM. Logout stops it now, and its expiry is guarded too — belt and braces on purpose, for the same reason. Logout also re-baselines the sky, as initGameClock already does when a new game starts while an old one is still in memory: holding the weather it left, the first tick back in reads a condition change nobody witnessed and prints a weather scene over the top of the re-entry. tests/test_logged_out_quiet.js drives the REAL tick against a live world and counts what leaves the machine at gmFetch and paintWeatheredBanner — the app's own named boundaries, because the image pipeline compresses through a canvas a DOM mock cannot provide and counting only at fetch would report silence for a generation that was under way. Every case is guarded by its live twin first: a live tick DOES reach the provider, DOES sweep an expired status, DOES bring back a creature whose cooldown has elapsed and DOES play a timed-out turn. Without those, "nothing happened" is a sentence about nothing. Eight sabotages, each caught — including the guard removed outright, which is the bug as reported. Three fixtures had to be repaired before they meant anything, and all three failed the same way: the harness clock runs deep in the negative, so an expiry of 1 was in the FUTURE and the status was never going to be swept; setInterval stubbed to 0 made "the combat clock is stopped" true before anything started it; and the first version of the whole file counted at fetch, where a live tick was as silent as a dead one. Two existing tests also had to stop pinning spellings: test_timeskip_room never said it was in the game (the encounter tests carry that line already), and test_world_palette measured the distance from "function logout()" to a call — a window widened once before, and a comment added to logout broke it again. Both now assert the property. Not changed, and worth knowing: in-world time still ADVANCES while logged out. The clock is anchored to real time and the save carries its epoch, so nothing HAPPENS during the gap now, but a character who returns a day later still finds a day has passed. Whether that is wanted is a design question rather than this bug.
Darren's answers to the seven open decisions are taken in full, and two of them change the design rather than confirm it. B is the substantive one. `self` currently means the CREATURE on an ailment and the HOLDER on an item — one word, two subjects, depending on the host. It now always means the thing the effect is authored on, and an effect meant for whoever carries the thing says `bearer` explicitly. The migration is mechanical rather than a judgement call, because the host disambiguates: a legacy `self` on an item becomes `bearer`, on a being it stays `self`. Exactly two places in the engine read an item effect's target, so this is a rename with a normalizer shim rather than a refactor. It also retires rev. 2's `appliesTo`/`contents` scope, which was doing this job badly with a field of its own. D reverses my leaning. Promotion of a narrated kind to an enforced one does NOT reach backwards: the normalizer stamps a kind it does not know as narrated, promotion leaves the stamp alone, and the editor offers adoption per property. A world full of `glow` that begins lighting rooms because of a patch is a live behaviour change nobody asked for. And the clarification about onEquipped was right and caught a broken flagship example. onEquipped means occupying an equipment SLOT — equippedItemEffects walks player.equippedItems and applyEquippedItemEffects fires only from the equip path — so an onEquipped effect on a sack, which has no slot, would have been authored, saved, displayed and never once applied. The bag of holding is `constant` + `self`, and the "only counts when carried" half is free, because itemWeightDeep only sums what is in the pack. Truth and evaluation are separate things and the engine already had the second. Following that thread turned up a real hole, filed as decision H with a leaning toward yes. The triggers answer "where must this be" with two states and skip the middle: in a slot, in the pack, or neither. `constant` + `bearer` is not the missing answer but a different claim — constant promises no natural end, so a dropped curse would follow the player home. Nothing anywhere walks player.inventory for effects, so there is currently no way at all to make a carried, non-equippable item affect its holder. That is the same shape as aggression being unwritable in play, and worth closing whether or not properties ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Asked whether a built-in kind like weight reduction can sit alongside an authored "glows with a blue light", and the answer is yes in the plainest way: properties is an array, so one effect carries both, beside its stat deltas and its prose line, and nothing distinguishes them at authoring time except the badge saying which the engine kept. The part worth writing down is the follow-on. Both properties in that record hang off onEquipped, so the glow goes out when the sack comes off — which may not be what the author meant. A sack that lightens its load only when worn but glows even lying on the floor is two effects on one item, not one effect with two properties. So the question that decides grouping is not "is this enforced or narrated" but "do these share a moment", which is the question the effect record has always asked. That is also the clearest demonstration yet of why rev. 1's separate array was wrong. Its `while` field could say carried or equipped and nothing else; it had no way to express a blade that lights the room it is lying in, which the existing trigger vocabulary handles without being asked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Rev. 1 of this document proposed a top-level properties array beside the effect record, arguing that an
effect is temporal and personal — a moment, a creature, an end — while a property is simply true, so
nesting one in the other would make every bag of holding carry a trigger, a target and a duration it had
no answer for. Darren pushed back, and the argument does not survive contact with the fields.
durationMinutes 0 is not a phantom, it is the documented standing-enchantment case: the normalizer's own
comment says an onEquipped effect "has a natural end — taking the item off — so 0 means for as long as it
is worn", and calls it the only way an item effect is open-ended. A bag of holding is exactly that, in the
same way a +1 sword is. The trigger is not a phantom either, and is richer than the alternative: onEquipped
against constant separates "only while worn" from "true even lying on the floor", which is a glowing blade
dropped in a dark room, and the two-valued `while` field rev. 1 invented could not say it at all. And
target self was already right — a constant effect lands on whoever has it — while the `contents` scope was
an implementation abstraction rather than the player-facing truth, which is simply that the bag makes your
load lighter, a statement about the bearer of the same shape as +2 WIS.
The deciding argument is not about fields. Splitting the record shards "what does this magic item do" over
two lists, and a permanent weight reduction is not a different kind of thing from a permanent +1 to hit or
from a glow written for the Game Master to narrate. All three are standing enchantments on a worn object.
Filing one somewhere else buys tidiness underneath and charges the DM attention for it every time they
author an item or go looking for what one does.
So: one record, one dialog, one row-list, no new concept. The one piece of rev. 1 that survives is
invisible to authors — properties are a sibling ARRAY within that record rather than new entry shapes
inside `effects`, because normalizeConsumeEffect silently discards anything that is not {stat, delta} and a
second shape in that array is a silent-loss bug waiting to be written. The `while` field is deleted; the
trigger already did its job better. Stacking gets its liveness rule free from the same record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvThe brief was a composable, ad hoc properties system for items and beings — a sack whose contents weigh
nothing, a blade that gives off a blue light — with the engine-enforced and the GM-adjudicated in one
system rather than two that overlap, unless unifying them made both worse.
The starting point is that half of it is already built and nobody had noticed. normalizeItemEffect's own
comment describes narrativeEffect as "the half of an effect the engine CANNOT apply", capped at a line,
passed through verbatim, read back every turn, enforced only by the GM reading it. So the split the brief
asks for exists and works. What does not exist is any enforced vocabulary beyond the six ability scores:
normalizeConsumeEffect coerces every entry to {stat, delta} and drops anything else silently, which is why
"what is inside this weighs nothing" has nowhere to live and why an author who tries gets no error and no
property.
The design's load-bearing move is that enforcement is EARNED rather than declared. An author writes any
kind they like; a kind the registry knows is applied and clamped to its range; anything else is narrated —
kept, shown, and told to the Game Master with narrativeEffect's standing. Nothing is silently dropped, the
author is told which they got, and a narrated kind can be promoted to enforced later without the data
moving. That is what makes ad hoc safe rather than merely permitted.
The first draft unified further than the evidence supports and §03 now does the weighing in the open. An
effect is temporal and personal — a moment, a creature, an end. A property is atemporal and impersonal.
Nesting the second in the first makes every bag of holding carry a trigger, a target and a duration it has
no answer for, which is precisely the mistake the `constant` trigger was invented to fix for ailments, one
level up: as though a sack were a status. Re-reading the brief, what needed unifying was the surface and
the concept, not the JSON — so the panel, the add button and the enforced/narrated badges are shared while
the records stay honest, and a property borrows only an optional `while` instead of three fields. Decision
A records that split as reversible, with the signal to watch for, so taking it back is a decision rather
than a rediscovery.
Phase 1 is deliberately one registry kind: weightFactor, read in the itemWeightDeep() that shipped this
week. A bag of holding is the thing that has no expression today, and it is worth exactly one entry to
prove the shape before inventing a vocabulary — which is how three copies of the item-size scale came to
disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvTwo rules that between them decided whether an enchanted bag could exist, and whether encumbrance meant anything at all. inventoryWeight() summed only the top level of the pack. Six thirty-weight anvils inside a pouch reported the pouch's own half a unit, and a mundane two-copper leather pouch managed it exactly as well as anything enchanted. Carry weight comes from STR and CON and drives the Encumbered badge, so the one stat-backed limit on hauling was optional for anyone who owned a bag — and every container in the game was already a bag of holding, which leaves nothing for an actual bag of holding to be. It now walks the contents to the bottom. Contents count once per container instance rather than per unit of quantity, because a stack of two pouches shares one contents array and scaling it would invent weight no item accounts for; the container's own weight still scales. A cycle guard rides along for data the engine did not author. The nesting rule compared CAPACITY, and the reasoning behind that was sound at the time: nothing prompted for size, nearly every container carried the default 1, and a size comparison would have refused every nesting in the game. Capacity was the only field actually authored. It is still the wrong field. Capacity is how much a thing holds; size is how much room it takes up, and the second is the question being asked. For a mundane chest the two correlate and either works. For a pouch that is small outside and cavernous within they point opposite ways, and the capacity test refused to let a size-1 pouch into a chest on the grounds that it holds more than the chest does — precisely the property that makes it magic. Sizes are prompted for everywhere now, so that objection is spent for anything authored since. It is not spent for worlds already in flight, whose containers carry no size at all and would all compare equal at the default, refusing every nesting in a save that had been nesting happily. So size decides it only when both containers state one, and an unstated size falls back to the old capacity test. New worlds get the honest field, old saves keep their behaviour, and neither has to know about the other. Filed as BUG-069 and marked fixed rather than fixed-unverified on the BUG-066 precedent: this lives in the engine and the test settles it. Five sabotages — recursion removed, contents scaled by quantity, cycle guard dropped, the fallback removed so old saves break, and the comparison reverted to capacity — each caught by its own named assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A wolf died in a playtest, the Game Master minted a wolf pelt, and the pelt came out size 1. That is not a judgement it made badly — it is the value itemSize() reports for an item that never declared one, and the live turn's addItem schema had never listed the field at all. It documents "weight" down to "omit it to default to 1" and left "size" unmentioned, so nothing the Game Master creates mid-play can carry a size it was never shown. The guidance existed and was good. It just lived where the Game Master running a turn never reads: the Items editor tab, the item-completion prompt, and the worldgen digest, in three copies that had already drifted apart in wording. Measured in Verengrad, the split falls exactly along which prompt authored the thing — 36 of 40 catalog items carry a size, against 10 of 43 items live in the world, and the world holds two Bladeward's Plates, the authored one at 6 and the minted one at nothing. All five containers read 1 against capacities of 8 to 12, which is a twelve-unit iron coffer that fits inside itself: precisely the failure the editor prompt warns about, in a prompt the live turn never sees. So there is now one ITEM_SIZE_PROMPT, interpolated by all four — including the live turn, which had nothing. This is the equipment-slot rule again, applied before a fourth copy could appear; I wrote that fourth copy anyway on the first pass and the test caught it, which is a fair account of how easily it happens. The harvest half is new. Loot taken off a creature is sized from the CREATURE rather than from the noun, and the ladder is DERIVED from CREATURE_SIZES rather than typed out, because those bulks are already quoted in container-capacity units — the same units as item size — so the two cannot disagree unless the creature table itself is retuned, which is exactly when this should move too. creatureSizeBulk() had been sitting there with no callers, documented for this and never used. A hide runs about a fifth of the beast's bulk, so a wolf's is ~4 and a bear's ~12, while a tooth or claw is ~0 whatever it came off. The container test's size block asserted against raw source, which broke the moment a phrase wrapped across a template-literal seam — identical in the prompt, invisible to the regex. It now flattens the seams and checks the text the model actually receives, plus the properties the refactor introduced: one definition, four interpolations, the field present in the live shape, and the ladder derived rather than restated. Four sabotages, four distinct catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Decision D-1 in Designs/licensing.html, settled and built. The Lost Realms is licensed two ways at once and sells commercial licences granting more than either, and copyright in a patch belongs to whoever wrote it. Merge an outside pull request with no agreement in place and the most we receive is a licence under the repository's own terms, which do not include the right to licence that work onward commercially. One merged patch would then be enough to make a clean commercial licence ungrantable over the file it touched, and the only remedies are chasing the author for retroactive permission or deleting and rewriting their work. Both are unfair to the contributor, and the second is sometimes impossible to do convincingly. CLA.md is a licence grant, not a copyright assignment. The contributor keeps ownership and may reuse their own work anywhere, forever; what they add is a perpetual, non-exclusive, SUBLICENSABLE licence, and sublicensable is the entire point — it is the one word that lets a contribution travel into a commercial licence. A DCO would not have served, and it is worth writing down why, because projects adopt one believing it does: the Signed-off-by line certifies only that the contributor had the right to submit under the existing licence, and grants no relicensing right at all. The agreement also covers artwork, music, worlds and prose rather than code alone, since most of this repository by weight is content, and it asks contributors to flag third-party snippets and substantially AI-generated material at submission time rather than have either surface later. Enforced by .github/workflows/cla.yml, the contributor-assistant action, which comments on an unsigned pull request and takes a reply comment as the signature. Two things about where it stores those signatures. Its default is a top-level signatures/ directory, which would have been wrong here twice over: the vault serves the repository root through a denylist that defaults open, so the directory would have been served the day it appeared, and Tests/test_static_denylist.js fails any top-level directory that is neither declared app content nor denied — which is precisely the check doing its job. Storing under .github/ instead is refused twice by resolveStaticPath, once by the dot-segment rule and once by STATIC_DENY_TOP, and adds no top-level directory, so a CLA bot required no change to production code and none to the test that would have caught it. Signatures do commit to main, which is the branch the FTP deploy watches, but that workflow is filtered to Handbook, Images, Web and four named files, so signing a CLA does not push the site. Timing was the whole argument for doing this now, and it holds: every commit in this repository is authored by one of two identities, the owner and this project's Claude sessions, so there is no third-party work to retrofit. The cost today is a bot and a checkbox. CONTRIBUTING.md is the contributor-facing side of it — the CLA, how the two licences divide, and the local discipline a newcomer cannot infer, since CI does not run the tests and a push to main deploys. One blocker is recorded rather than papered over: the governing-law clause in CLA.md §10 is a marked placeholder pending D-2, which is now the decision blocking both the Licensor named across the licences and the CLA's own §10. The allowlist likewise names the organisation and needs the maintainers' personal handles before it does anything useful. App suite 717/717, vault suite 40/40 files, denylist test green.
test_media_source_resolve.js failed its last assertion, "and so does 3D", on a clean checkout. The
route it guards was never broken. The assertion read
src.indexOf('const mImage = resolveVaultMediaSource') < src.indexOf('body: { image: mImage }')
and the string on the right stopped existing when text-to-3D landed in 58046f2 (25 Aug 2026): the
call log's body became `mPrompt ? { prompt: mPrompt } : { image: mImage }` so that a description
could stand in for a picture. indexOf returned -1, and `30936 < -1` is false. /vault/model3d resolves
its image at line 455 and logs it at 461, in that order, exactly as it should — the test was
reporting a bug the code does not have, which is the worse failure of the two, because a red
assertion is evidence and this one was lying. The video assertion beside it survived only because
nothing had reshaped its log body yet; it was one edit away from the same fate.
So both now read the property off the route rather than off a literal line of it. Each handler is
sliced out of server.js and three things are asserted about it: that something in it calls
resolveVaultMediaSource at all, that the call precedes the onRequest() that logs it, and that the
logged body carries the resolved variable rather than the raw request field. The third is not
redundant — resolving correctly and then logging req.body.image anyway is the same bug wearing a
disguise, and the ordering check alone passes straight through it. Reshaping the log body is now
free; moving the resolve, dropping it, or logging around it is not.
Confirmed by sabotage, per the convention: moving the resolve after the log fails only the ordering
check, removing it fails the presence check, logging the original field fails only the third, and the
same substitution on the video route fails the video copy of it — four breaks, each caught by one
distinctly named assertion. The control matters as much: wrapping the log body in Object.assign, a
cosmetic reshape of exactly the kind that caused the original false alarm, now fails nothing.
No change to server.js, which was byte-identical to HEAD after each sabotage was reverted. Vault
suite 40/40 files pass, app suite 716/716.
Worth noting for whoever hits this next: Server/ has no node_modules in a fresh clone, and without
`npm install` eleven of the forty vault tests die with MODULE_NOT_FOUND before running an assertion,
which reads as a broken suite rather than a missing dependency.The repository had no licence at all, which reserves every right by default and tells an interested
reader nothing. The brief was to restrict commercial use through a BSL or FSL, sell a commercial
licence for specific commercial use, and let anyone download, test and modify the source to evaluate
it. Three quarters of that is answered by a licence off the shelf. The last quarter is where this
repository stops resembling the infrastructure software those licences were written for.
BSL and FSL were designed for a database or an error tracker, where the artefact is the code and
handing the source to the public a few years later costs little. Here the content outweighs the code
thirty-seven to one — about 18 MB of code, counting the test fixtures generously on the code side,
against roughly 664 MB of art, music, realms, portraits and books, which is some 97% of what a copy
of this repository weighs. A single BSL over the whole tree would not have been
delaying an open-sourcing, it would have been scheduling the giveaway of the art department. So the
two are licensed separately and only the engine carries a Change Date, which is what makes the
four-year promise safe to keep: on any Change Date what converts is an engine without a world.
BSL 1.1 was chosen over FSL because FSL does not restrict commercial use, it restricts competing
use — its Permitted Purpose explicitly blesses internal business use and professional services, so a
studio shipping a commercial game on this engine would have been free to do so and only a rival
hosting service would have been caught. AGPL misses in the other direction, permitting commerce
outright on condition the source is published; it restricts secrecy, not commerce. BSL wins on the
one row that matters, which is that its Additional Use Grant is a free-text parameter: the boundary
between free and paid becomes a business decision written in prose rather than a property of the
licence adopted. And the evaluation the brief asked for first is granted in the licence's own first
sentence, before any parameter is filled in, with no window, no expiry and nothing to switch off.
The Additional Use Grant opens three free lanes — personal play, non-commercial community play, and
entities under USD 100,000 — with a paid-hosting override that survives all three, because
Server-Hosted Worlds, Realm Registry and Vault Media Store all point at hosted realms as the
commercial surface, and a small-entity grant without that carve-out would have given it away to
anyone under the threshold. The community lane says explicitly that recovering documented hosting
and inference costs from the players sharing an instance is not revenue; without that sentence the
lane is a trap, since the game bills a model provider on every turn and a group splitting that bill
would otherwise breach for doing the fair thing. The Change License is GPL-2.0-or-later rather than
the obvious guess of Apache-2.0, which is not GPLv2-compatible and would breach BSL's Covenant 1.
LICENSE-CONTENT is proprietary with no Change Date, and carries four clauses a generic asset EULA
would get wrong for a model-driven game: worlds a player authors stay the player's, model output is
not claimed, the no-training clause carves out sending prompts to a provider so it can narrate a
turn, and monetised streaming is permitted.
Because BSL's Covenant 4 forbids modifying the licence in any other way, a paraphrase of the
operative text is a breach of the terms on which the name may be used, and mariadb.com is not
reachable from this environment. The Terms and Covenants sections were therefore diffed word by word
against the canonical text in the SPDX registry, obtained through npm as spdx-license-list@6.12.0.
That found one divergence, since fixed: the trademark parenthetical read "required by this Section"
where the canonical text reads "this License". Worth recording because Section is what the sentence
ought to say and is what a careful writer reconstructing it from sense would produce — and under
Covenant 4 a defensible improvement breaches exactly as much as a careless typo.
Designs/licensing.html carries the reasoning and eight open decisions. Two of them get harder with
time and are called out as pre-publication work: a contributor agreement, without which one merged
outside patch can make a file impossible to licence commercially, and an audit of asset provenance,
the only item whose failure mode is a claim from someone else rather than lost revenue. Nothing here
has had legal review; the BSL boilerplate is verified, the bespoke prose is not.
The full app suite passes at 716/716 and the vault suite passes but for test_media_source_resolve.js
("and so does 3D"), which fails identically on a clean tree and is untouched by this change.Auditing the guides after the directory rename found nothing to rename — the rename commit's own replacement had already reached guide.html, the Handbook, every design doc and this file, and the only lowercase reference left in the repo is the comment in test_https_setup_doc that exists to explain why that particular "server/" must stay lowercase. What the audit did find was two stale numbers on the very lines it was checking. The table said ~570 app tests and ~25 vault tests; there are 717 and 40. Both are the kind of figure that is right the day it is written and drifts silently ever after, and both sit in the table a new session reads first to learn what the directories are.
A capitalisation alignment with the other twenty-five top-level directories, and it is not only a
rename: three things break silently if the references are not moved with the files, and one of them is
the security-relevant kind.
STATIC_DENY_TOP is a set of bare segment names matched exactly and case-sensitively, and it held
'server' and 'tests'. A rename that touched only path strings would leave both directories SERVED from
the moment the checkout landed — the vault's source, its admin page, its .env and data directory,
and the whole test suite, reachable over HTTP from a running vault. The list and the test that keeps it
honest are updated together; 'tools' was already declared app content and is now 'Tools' there.
Electron searches for a vault to launch, and it searches a PACKAGED BUILD's layout as well as a
checkout's. An installer produced before today ships a lowercase server/ and would have stopped
launching its vault the day this file changed one letter, so findVaultEntry now accepts either casing.
A fresh build ships Server/, which its extraResources entry and the test now pin.
The directories were moved with mv rather than git mv, because Server/data — the encrypted key store —
and Server/node_modules are untracked, and a git mv would have left a live vault's keys behind in a
directory nothing referenced any more. Git recorded all 816 files as renames regardless.
Two false positives were found by running the suites rather than by reading the diff, and both are worth
recording because a blanket search-and-replace produces exactly this kind:
- Electron/tools/ is a DIFFERENT directory that was not renamed, and a rule matching "tools/" does
not know that.
- /never copy it to a server/ is a regular expression, and its closing delimiter is not a path
separator. The rule read "server/" and capitalised prose inside a test's own pattern.
The POST /api/server/public route is untouched — it is a URL, not a directory — and Web/Reports and
Evaluations are left alone on purpose: they are dated, published archives, and rewriting the paths
inside a report changes what the report said.
One assertion was rewritten rather than re-pointed. test_install_world_media pinned the exact text
require(path.join(ROOT, 'server', 'media-store.js')), so it failed on a rename that changed nothing
about its claim — the tool still uses the server's own media store instead of reimplementing its
layout, which is what it now checks."ITEMS 17 WT 12.5 / 40 VAL 84g 4s 5c XP 120" — and the fourth is a different kind of figure from the three beside it, in three ways that are all forced by how the engine actually pays. It mirrors the itemLoreUnlock handler clause for clause: It must HAVE lore. loreHookXp answers LORE_UNLOCK_DEFAULT_XP for any object it is handed, so asking it about a ration prices a hook that does not exist — the readout would put a confident number on the toolbar for XP nobody can ever earn. The handler's own guard is String(target.lore).trim(), and so is this one; the test asserts BOTH that a plain sack contributes nothing AND that the pricer, asked directly about that same sack, answers a number, or the guard would be untested. It must still be LOCKED. The XP is paid once, at the unlock, so lore already read is not value the character still holds. And it is counted ONCE PER NAME, not per copy and not per stack. The unlock is type-wide — unlocking one instance unlocks the item everywhere — so a second tome earns nothing. That makes this the one readout of the four that is not a sum over things: it is a sum over hooks. The price is read from the catalog TYPE because that is where it lives. An Item instance carries lore, loreKey and loreUnlocked but not loreXp, which is BUG-012 — and this test's first fixtures walked straight into it, authoring loreXp on the item handed to makeItem and asserting 40 while the engine and the readout both correctly said 12. The fixtures now price the type, and one case pins the flat default an uncatalogued hook really does pay, since a GM inventing an item mid-scene is a real case. A fourth readout is also where this toolbar ran out of room: at a 640px panel the last figure clipped and the By Type / By Item buttons wrapped two words onto two lines each. The three parts are now told who gives way — the buttons never shrink, being the only controls here; the filter yields first, holding a placeholder rather than data; the readouts wrap to a second line rather than truncate, because half a number is worse than a taller toolbar. Ten sabotages, each caught, including both guards removed in turn and the count made per-copy and per-stack. A dead anchor in the guide was caught on the way past: the lore link pointed at #p-lore, which does not exist. Every href in guide.html now resolves.
Claude19 spared a Gill-Wretch, and three sessions later it was still hostile-on-sight: alive, out of combat, its XP long since awarded, and refusing every compound route through the Flooded Cloister because blockingFoeIn reads aggression and aggression had never changed. The fiction said truce, the engine said threat, and the engine was the one deciding whether the player could walk. Hop-by-hop movement still worked, which is why it survived three sessions unnoticed — only a multi-room move ever asked the question. Underneath it was something simpler than a bug in the gate: nothing in play could write aggression at all. applyNpcSpecToEntity handles the field correctly and always has, but every caller is DM-side — the Beings tab and editor import — and applyStateChanges has no entity-mutation key. The Game Master could narrate mercy and had no way to record it. So combat.end now carries an optional disposition, declared by the GM at the moment it decides how the fight ended, and applied through the same normalizeAggression the load path and the editor use. The alternative was an engine heuristic reading the narration for the word "spare", which would put two systems in one job and eventually have them disagree — and the value is a judgement anyway. The contract steers toward defensive rather than passive: a spared foe starts nothing but still defends itself, which is what a truce is, where passive makes it a bystander and that is more than mercy buys. Two bounds, because a directive that can change a creature's nature should not be able to change much else. It is scoped to combatEnemies() — the beings actually in the fight that just ended — so one narrated duel cannot defang the map, and the scope lives at the call site rather than inside the helper, which is where the test pins it. And it never blanks the field: an unrecognised aggression normalizes to empty, empty reads as "not passive" everywhere the engine asks, so a typo is refused and reported rather than written. Silently leaving a creature hostile while reporting success is precisely the failure this exists to end. Filed as BUG-068, fixed-unverified on the BUG-065 precedent: the engine half is settled by the test, and whether the GM actually reaches for the directive when it narrates a truce can only be seen in a run where something is spared. Also cleared three lines of stray markup that had sat after </html> in the ledger since 5a20568 — all sixty-eight ids are present and none is duplicated, so nothing was lost with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
"ITEMS 17 WT 12.5 / 40 VAL 84g 4s 5c" — how many, how heavy, how much it is worth, in that order. It counts THINGS rather than entries: four rations are four. That is what makes it reconcile with the two beside it, which are both sums over quantity — a count that ignored stacks would leave "6 items weighing 34" not quite a sentence. It therefore does NOT agree with the "(1)" beside a section heading, which counts CARDS because that is what is under it, and which moves when the filter runs. Two different questions, answered differently on purpose, with a tooltip on each saying which. A fixture where the two readings differ pins both at once. Like the worth and unlike the weight it counts the trove, because "how many things do I have" is a question about everything held. Like both it ignores the filter, being the third readout that describes the character rather than the screen. An empty pack shows nothing — the placeholder underneath already says the pack is empty, and "ITEMS 0" beside it is the same sentence twice. Ten sabotages. Two were invalid before they were useful, both the same trap and the same one that bit the last change: the substring I replaced was not unique. "n > 0 ?" matched inside "coin > 0 ?" three thousand lines earlier, so the patch landed on a quest-beat coin field and the test passed for the wrong reason. Re-run against the whole line, it is caught. Chasing the other one showed a `player ?` guard in the same expression was doing nothing — inventoryHoldings is already empty without a player and the zero gate already hides the result — so it is gone, and the comment says why it is not needed rather than leaving the next reader to wonder which of the two checks matters.
"WT 12.5 / 40 VAL 84g 4s 5c". The pair shares the toolbar's middle slot rather than taking a child each, so the space-between stays a three-part layout and the two read as one group instead of being spread to opposite corners. The two follow DIFFERENT rules about the trove, and that is the point rather than an inconsistency to tidy away. Weight excludes trophies because the ENGINE excludes them — inventoryWeight never looks at player.treasure, so a tab that counted them would be the one screen disagreeing with the sidebar and with encumbrance. Worth includes them because nothing excludes them: "what is this lot worth" is a question about everything the character has, and the Treasure section is right there on the same tab with its own total. Both tooltips say which. A single fixture pins both at once — a 500-weight, 900-value idol in the trove — so if the two are ever "made consistent" with each other, one of them fails. Neither moves when the filter runs. These describe the CHARACTER; the Treasure heading's total describes the cards beneath it, and a filter changes what is on screen and neither what is carried nor what it is worth. Both readings are now tested against each other. treasureTotalValue is renamed totalItemValue: it was never treasure-specific, and now that the toolbar sums everything held with it, a name saying otherwise would be the next person's wrong turn. It counts stacks in both places, which is what makes it a total. Eight sabotages, each caught: never filling the readout, dropping the trove from it, letting the filter reach it, ignoring stacks, printing a zero, printing with no character loaded, splitting the pair into two toolbar children, and — the one worth having — quietly making the WEIGHT count the trove so the two agree.
Two bugs, reported the same way — "the bar was already complete when I saw it" — and in both the header clock swept correctly throughout. That symmetry is the whole clue and the doc now leads with it: the bar and the clock are painted by the same function on the same frames, so the only thing that can differ is that painting the clock needs nothing but the frame while painting the bar has to find its card. The first was a card that was off screen for the entire sweep. Two deliberate behaviours combine into it — a notice built showing the COMPLETED state so it reads correctly when nothing can animate, and a sweep that no-ops the bar paint for any frame where the card is not mounted while running to completion on time. Neither is wrong; the pair had simply never been off screen until a freshly mounted picture started reflowing the story above it. The second was an id minted from an in-memory counter while the card's markup, id and all, is written into the saved transcript — so a resumed session already had that id mounted and the sweep animated a card from a previous night. Worth recording not because it was subtle but because the lesson was already written down a few hundred lines away, in recordVictoryBanner, and this code predated it. Both DM's Guides gain the operational half a DM would otherwise misread as a stall: the progress bar waits until its card is on screen, and gives up waiting after a few seconds so scrolling away cannot strand a night. The player books are deliberately untouched. Both fixes restore behaviour those books already describe correctly — you see where you lay down, then watch the night go by — and "the progress bar works now" is not something a player's handbook should have to say.
The bar and the header clock are painted by the same function, on the same frames — which is what made "the clock is right and the bar is not" so hard to place. The difference is that painting the clock needs nothing but the frame, while painting the bar has to FIND ITS CARD, and it finds it by id. The id was a counter: 'rest-notice-' + (++_restNoticeSeq), starting at 0 and living only in memory. The card's markup — id and all — is written into messageLog, which IS saved and replayed. So a session resumed with a previous night in its transcript already has a rest-notice-1 mounted, and the next sleep mints exactly that. getElementById returns the FIRST match in document order, so the sweep spent its five to ten seconds faithfully animating a card scrolled far up the story, while the new one sat at the bottom showing the completed state it is deliberately built with. Both reports fit: the bar looked finished, the clock was fine, and the visibility gate added for the first report was working correctly the whole time — it started a sweep that was painting the wrong node. recordVictoryBanner had already written this lesson down a few hundred lines away: an id "cannot come from a counter — a counter restarts when the save does and would collide with an older entry". The rest notice predates that and never got the message. mintNoticeId now stamps a real-world time and a random tail, and keeps the counter so two minted in the same millisecond still differ. The memorization notice shares the minter and had the same latent collision. The linked half of the request is answered by the fix rather than by new wiring: the bar and the clock were always driven together, and now that the sweep can find its card they visibly are. A sweep that never once finds a card now says so in the Logs when it settles. Twice this has shipped broken and twice the only evidence was a player reporting that the bar looked finished — ten seconds of painting nothing should not be silent. The false-positive side is pinned too: a sweep that DID find its card must stay quiet, or the line becomes noise that teaches a DM to skim past the one that matters. Two existing assertions pinned the old counter expression and are rewritten to the property: the id must not come from a counter alone. The collision itself is now reproduced — two cards mounted in document order, an id minted either side of a simulated reload — and the fake fill records every width written to it, because a sweep ends at 100% by definition and a final reading cannot tell a bar that swept from one that was never touched.
"WT 12.5 / 40" between the filter and the grouping buttons — the middle child of a space-between toolbar, which is what centres it without a width of its own to keep in step. Over capacity it turns the same red the sidebar's WT bar turns: one colour for one condition, in every place that shows it. It asks the ENGINE for the figure rather than adding up the cards on screen, and the two are genuinely different. This tab draws the pack AND the trove, but only the pack has weight — a trophy is kept in player.treasure and inventoryWeight never looks there. Summing what is drawn would put a leaden idol on the scales and leave this one screen disagreeing with the sidebar and with encumbrance itself, so the test carries a 500-weight trophy on the tab and asserts the readout still reports 30. It is unfiltered for the same reason: a filter hides cards, it does not lighten a pack — the opposite of the Treasure total beside it, which describes the cards beneath it because that is what a section total is. The tooltip says the trove is not counted, because a screen showing a reliquary above a weight that excludes it invites exactly that question, and answering it in the interface is cheaper than answering it in a bug report. Eight sabotages, each caught: never filling the readout, summing the drawn cards, summing only what the filter left, dropping the capacity, never marking encumbrance, always marking it, printing "0 / 0" with no character loaded, and moving the readout out of the middle of the toolbar. The figures are asserted against inventoryWeight() and fmtWeight() rather than against numbers, so a change to how weight is counted or shown moves the test with it.
Reported from a live game: the progress banner was already complete the moment it became visible. The timer was not the problem, and neither was the clock — both ran correctly. Two existing pieces of behaviour, each sensible alone, combine into it. buildRestNotice writes its progress markup in the COMPLETED state deliberately, so the card still reads correctly when no animation can run at all. And animateRestNotice, equally deliberately, no-ops the bar paint on any frame where the card is not mounted while the loop runs to completion on time — its own comment says so. Put those together and a card that is off screen for the five to ten seconds of the sweep is never painted once, so the player's first sight of it is the default it was built with: finished. Nothing in either piece is wrong; the pair simply had never been off screen before. It became easy to hit the moment the picture started coming first. A freshly mounted image reflows the story as it decodes, and bindNarrativeImgAutoscroll re-pins to the bottom when it lands — which can be after the sweep has run. A reader who had scrolled up, or who was on another tab, met the same thing. So the sweep starts when the card is actually visible, watched with an IntersectionObserver rooted on the narrative. The hold on the clock and the input spans that wait as well: the authoritative clock is already at the wake hour, and letting the header snap there while nobody has seen the bar move is the same bug wearing different clothes. Bounded at four seconds, because a reader who scrolls away and stays away must not strand the night — a bar seen half-run is a better outcome than a session that will not continue. Every path that cannot gate — no card, no observer, an observer that throws on construction — starts immediately, which is exactly what this always did. Two existing assertions were pinning literal call sites and are rewritten. test_spell_memorize_time matched `animateRestNotice(noticeId, startG, endG)` — which is applyRest's signature, not memorizeSpell's: that one passes an onFrame and an onDone as well, so the assertion had been green because of a line in a different feature and went red the day that line changed. It now matches its own call site. And test_rest_clock_anim's replacement asserts the gate reaches the animator by brace-matching the function's body rather than by a distance window from its declaration. Seven sabotages caught by name. One escaped and produced a fix: an IntersectionObserver constructor that throws — browsers differ on what they accept for a root — would have held the night on every sleep with nothing to show for it, and the fallback covering that was untested until the sabotage asked for it.
Three descriptions went stale the moment the feature moved, and one of them was in the victory banner's own write-up: the DM's Guide said the Gallery keeps a dozen pictures of fights, which is now half the screen. It says shared, merged, newest first. The player books gain what a player notices: the picture arrives before the night rather than after it, it is sized and framed like a room banner and widens when clicked, and every night is kept in the Gallery beside the victories. The DM's Guide and its book edition gain the reasons a DM needs — that a refusal costs no delay at all, so a world with the setting off or no key starts the night exactly as promptly as before; that the clock and the action box are held while it paints, so nothing lands in the gap; and that the two caps are separate, so a week of sleeping cannot evict a campaign's fights. The design doc gains two sections. §07b records what has to be held across the wait and why a hung painter would otherwise be catastrophic rather than merely disappointing, and §07c records the two load-bearing details of dressing a picture as a room banner: the caption rides over it because a fixed-ratio box clips anything beneath it, and the class order decides which resolver claims it. The open-decisions section now records two stores shown as one screen, and why the same-millisecond tie-break in that merge is not decoration.
Three changes to a feature that shipped a day ago, and the third is the one with substance. THE PICTURE COMES FIRST. It shipped as notice, sweep, then a picture arriving under a character who had already woken — which reads backwards: you watch yourself sleep, wake, and only then see the bed you did it in. The notice and its clock sweep now run in the painter's continuation. Two things have to be held across that wait and neither is optional. The authoritative clock has ALREADY jumped to the wake hour and deliberately left the display unsettled for the sweep, so without `_restSweepActive` the 1-second calendar tick snaps the header to morning while the picture paints and the sweep then rewinds and re-runs the same eight hours. And gmSubmit re-enables the action box the moment applyRest returns — which it now does immediately — so a turn could be typed into the gap between lying down and falling asleep. Both of those make a hung painter catastrophic rather than merely disappointing: a frozen clock and a dead input with no way out but a reload. So the wait is bounded, the continuation runs exactly once whichever arrives first, and the input is released whenever nothing downstream took the lock — a notice that threw, or no animator to start a sweep. A decorative picture must not be able to end a session. Every refusal is now decided in ONE synchronous place, restPictureRefusal, which the painter reports and the gate consults. That is not tidiness: with the setting off there is no picture to wait for, and a gate that had to await the painter to learn so would put a tick between the player typing "sleep" and the night starting, on every sleep of every game, for a picture that was never going to be painted. IN THE STORY IT IS A ROOM BANNER. Same 50% width, same 16:7 crop, same click-to-expand — the last of those inherited rather than reimplemented, since the toggle listens for `.room-banner.clickable` and a second implementation would drift from the banners it is imitating. It is a picture OF a room and sits where that room's banner sits; dressing it as a second wide victory scene put it at odds with everything around it. Its caption rides over the picture, because a fixed-ratio box with overflow:hidden clips anything laid beneath it in the flow. Class order is load-bearing: `rest-banner` must come first for the story-picture regex, and the room-banner strip/resolve pair is not fooled because that one requires `class="room-banner` at the START of the attribute and a `data-room-id`. THE GALLERY HOLDS BOTH KINDS, merged and newest first. Stored separately still — nights are far commoner than notable kills, and one shared cap would let a week of sleeping evict a campaign's fights — but shown as one record of the campaign in the order it was lived. The tie-break is not decoration: two pictures painted in the same millisecond compare equal, and a stable sort would then leave them oldest-first, which is the exact opposite of what the screen promises. Seventeen sabotages caught by name. Two escaped the first sweep and both produced a real fix rather than a better assertion: deleting the bounded wait entirely went unnoticed because the test only checked that the constant existed, and deleting the input-release guard went unnoticed because it covered only the missing-animator case, which never happens — the notice-throws case it should always have covered was not handled at all until the sabotage asked for it.
Beside the count, in the app's own coin form: "Treasure (4) · 76g 4s 5c". The section is the one ranked by a number and the cards now show theirs; the heading closes it by saying what the lot is worth. STACKS COUNTED, which is deliberately the opposite of the rule a card follows, and the two disagreeing is the point. A card shows the UNIT price because it sits in a section ranked by the piece, and a card showing its stack would contradict its own position; the heading sums value × quantity because a total that ignored quantity would not be a total. The test pins both readings against a fixture where they genuinely differ — 4000 + 1800×2 + 45 against 4000 + 1800 + 45 — so neither can pass under the other's rule. Summed over the items IN THE SECTION rather than over the whole trove, so under a filter the figure and the count beside it describe the same cards. A heading reading "40g 4s 5c" above one 4s 5c trinket would read as a bug, not as a total. A hoard nobody has priced shows no figure rather than "0c" — the same distinction the cards make, since a zero says the thing is worthless and that is not what an absent price means. No other section carries a total; By Item carries none either, having no heading to carry it. Nine sabotages. One of mine was invalid and worth recording: the string I replaced to fake "quantity read as 0" appears three times in the file, so it patched inventoryWeight instead and the test passed for the wrong reason. Re-run against the whole reduce line — which is unique — it is caught. Chasing it also turned up an untested case, an explicit quantity of 0, now pinned to the same clamp inventoryWeight and consumeInventoryItem already use: an item in the list is at least one of the thing.
Designs/rest-banners.html, and a row in the index. It deliberately records only what DIFFERS from Victory Banners rather than restating the shared machinery: the three prompts and the specific wrong picture each one exists to prevent, why "is there a bed" reuses roomHasBed rather than a second heuristic written for the art, why the mode is read after applyRest's camp-under-a-roof downgrade, and why a feature nobody asked to make optional ships with a switch. Three open decisions are recorded with their leanings, chiefly whether the two galleries should become one screen — which probably depends on whether a third kind of scene picture ever arrives. The Field Guide and the Player's Handbook get the player's half: what it is composed from, that the room comes in as it looks at that hour with its weather, that a bed in the room means that bed and no bed means none is invented, and that a short rest paints nothing because this is a picture of a night. Both DM's Guides get the authoring half — the three prompts as a table, that "has a bed" is the same answer as the bed rest bonus so the fix for a room that reads wrong is the room's own prose, and that this is one generation per night rather than per fight. Both DM's Guides gain a glossary row, and the long edition gains a nav entry beside Victory banners.
A full sleep or camp now paints one picture of the night into the story: the player's own Equipment-tab body render, laid in the bed the room actually has, or asleep beside a campfire under open sky. The victory banner's sibling and built from the same parts, so it uses the same slot, the same refusal wording and the same three-step story-picture dance. Three prompts, not one, and the branch is the point. Outdoors, the campfire is ADDED and said so plainly, because the room picture handed over as a reference has no fire in it and a model told merely that this is a camp reproduces the place exactly as given and leaves the sleeper in an empty landscape; the fire is then named as the scene's light source, or it arrives pasted onto a daylit field. Indoors WITH a bed, they go in the bed that is already in the picture and not a second one added in a clear corner. Indoors with nothing, no bed is asked for at all and the model is told so outright: the room picture is the evidence and it can see it, and painting furniture into somebody's authored room is a worse outcome than an awkward picture. Whether there is a bed is roomHasBed — the SAME answer that already decides the bed rest bonus — rather than a second heuristic written for the art, so the picture and the mechanics cannot disagree about what is in the room. The mode is read after applyRest's camp-under-a-roof downgrade, or the model would be asked to light a fire in an inn. The room reference is the banner on screen — a weathered repaint, else a pinned slot, else the hour — so a night in the rain is not painted on a dry afternoon. The resolve/strip pair that keeps a scene picture's bytes out of the saved transcript is now written ONCE, driven by a registry of kinds, and the victory names delegate to it. There were two copies coming and that dance has exactly one way of going wrong — half of it implemented, and a picture broken either immediately or on the next reload — which has already been found once in this feature's older sibling. It has a setting the request did not ask for, and defaults ON. A night comes round every in-world day, so this is one generation per NIGHT rather than one per notable fight, which is a materially different bill; a player should be able to stop it without giving up the feature. Defaulting it off would have made a feature that was asked for arrive looking broken. One correction travelled with this. The victory banner's markup carried a `clickable` class and a "Click to toggle full width" tooltip, and both were inert: the width toggle listens for `.room-banner.clickable` only, and a victory banner has no half-width state to toggle to. The promise is removed rather than implemented — these are wide scenes and full width is right for them — and the rest banner does not repeat it. Eighteen sabotages, each caught by a distinct named assertion. One escaped the first sweep and is worth recording: the fire-lighting check matched /firelight|warm light/, and "warm light spilling across the ground" three lines earlier kept it green with the entire lighting direction deleted.
All four books gain the feature, and one of them needed correcting rather than extending: the Player's Handbook opened its Time chapter with "It keeps ticking while you read and type", which was true until this shipped. That chapter now carries the substance — what a hold actually stops, which is not the narration but the clock and therefore everything the clock drives: the hour, routines, ambience, wandering encounters, ageing, restocking, respawns. The realm is as you left it when you come back to it. The part worth saying plainly in a player's book is what lifts it. Your next action does, by itself, picking up at the instant it was held with nothing having happened in the gap — but asking the app a question does not, because a "/" line is you talking to the app rather than acting in the world. And it is unavailable during a fight, which runs on its own clock and its own turn order. The Field Guide gets the same as a row in its Story-panel tools table. That table listed the dice bag, voice input, Make a Book and the text-size control and had never mentioned the Music toggle sitting in the same row, so a new row reading "left of the Music button" would have named something the book did not have. Music is documented now too — a one-line row in a table this commit was already editing, and leaving the row incomplete beside a new entry would have been the odder choice. Both DM's Guides get it under Testing your world, where it earns its place for a different reason than it does for a player: reading a scene you just wrote, checking a room's data against what the GM narrated, or stepping away mid-playtest all outlast a turn, and without a hold the realm carries on — an encounter firing into an empty chair, a merchant restocking, the hour turning and repainting the banner being assessed. That a "//" debug line does NOT lift it is the detail a DM needs, since poking at state between turns is exactly what the console is for. The note beside it names Settings › Pause World when Inactive as the same mechanism on a timer, which no guide had mentioned at all.
Treasure is the one section ranked by a number, and until now that number was nowhere on screen — a hoard ordered by worth with no worth showing is a ranking the player has to take on trust. The figure goes under the name, where the position it explains is. Only there. A price under every sword and ration would turn the tab into a ledger, and what a carried thing is worth is a question for its popup. The card decides that from the ITEM, through isTreasureItem, rather than from the section it is being drawn into — By Item has no sections at all, and a flag passed down from one would lose the price exactly where the headings go. Written in the compact coin form, which is what copperToCoinText exists for: a card is 132px wide and the long "4 gold, 5 silver" wraps to three lines in it. Never the raw stored copper — value is kept in copper because that is what the mechanics count in, and 4000 of them reads as a number rather than as money. The UNIT price, matching how the section is sorted, so a stack of five cannot show a figure that contradicts its own position in the ranking. A piece with no price shows nothing rather than a false "0c". The value can genuinely be absent — an old save, an import, an object a GM never priced — and printing a zero says the thing is worthless, which is a different claim from not knowing what it is worth. Six sabotages, each caught: dropping the price, showing it on every card, using the long form, printing raw copper, showing the stack's total, and drawing a zero for the unpriced. The formatting assertions compare against the app's own formatter rather than a spelling, so a world that renames its coins moves the card's figure with it instead of breaking a test.
The Frayed Rope Coil asks the player to cross the rope-bridge span during a storm, and until now the world could not produce a storm on any day of any season. It fired this run — Tide-Brace against DC 14, item lore unlocked, sixteen experience between the check and the unlock — on the 2nd of Frostmark under a heavy gale, with the header reading HEAVY STORM and the transept prose opening on sleet driven sideways across black water. Two things the run confirms beyond the weather itself. The bespoke drowned_sound climate reaches the player through the ordinary chain rather than through anything special-cased: the room named its region, the region named the climate, and the sidebar chip said THE DROWNED SOUND while the sky said storm. And the articulation rule held again — the crossing command said what the coil was FOR, not merely that it was used, and the narration answered the meaning: the fray in the strands letting go a thread at a time. Also recorded: blockingFoeIn still treats the Gill-Wretch spared three sessions ago as an obstacle, because sparing leaves aggression at hostile-on-sight. Walking hop by hop is unaffected and this run went straight past it, but a compound move through that room is refused for good, against a creature the player already made peace with. Left as a note rather than a ledger entry because which way it should go is a design decision, not a bug with an obvious correct answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The previous commit claimed Verengrad had never assigned its rooms to regions and had therefore been running temperate throughout. That was wrong. All twelve rooms are in The Drowned Sound and always were. The field is `room.region`; I inspected `room.regionId`, which no room carries, read the undefined back as an absent assignment, and wrote a confident correction on top of it. The original diagnosis it replaced was the right one. What makes `regionId` so easy to reach for is that it is not a misspelling of anything. It IS the parameter name weatherClimateFor and resolveWeather take, and it is the id regionOfRoom resolves `room.region` into — so the same word is correct one call up the stack and wrong on the room, and nothing errors either way. The test now pins both spellings explicitly: room.region resolves, and room.regionId reads as no region at all. So the chain is what it always was, and this documents it: a room names its region, regionOfRoom resolves that by id or by name, the region names its climate, the climate's year or else the calendar selects the month's pattern, and the climate's weights bias the pick inside it. Every consumer reaches the sky through weatherForRoom(room) and both forecast paths pass weatherRegionIdForRoom(room), so there is one route and nothing bypasses it; the defaultClimate fallback is a net under a link that fails to resolve, not a second way in. The Verengrad case is restored to what actually happened and is now driven end to end rather than asserted: a region on moor, whose authored year maps the first three months to autumn, in a world opening in Frostmark that the calendar calls winter — running an autumn pattern that carried no storm. The last assertion is the one worth keeping, because it is the part that wasted the time: adding a storm to WINTER leaves the month stormless, since the pattern being edited is not the pattern being run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The previous commit's account of why Verengrad's storm hook never fired was wrong, and wrong in the way this repo keeps warning about: it was evidence for something never actually checked. The world file showed a moor climate whose year array remaps the first three months to autumn, that fully explains a winter that behaves like autumn, and it stopped the search. Moor was never consulted. The world has two authored regions and twelve rooms and not one room carries a regionId, so weatherClimateFor matched no region, fell through to world.defaultClimate, and returned temperate for every room in the game. So there are two silent fallbacks stacked here, not one. Which climate is in use is decided before the climate's year ever gets a vote, and the region's climate — the field an author would reach for, and the one I set — is inert for a room that never named its region. The real cause of the dead hook was the plainest possible thing underneath both: the temperate winter pattern carried no storm day. That is what the earlier engine change fixed, so the fix was right while the reasoning behind it was not. The test now pins both links, including the one assertion that would have ended this in a minute: a room with no regionId gets temperate rather than the region's climate. The doc's Verengrad paragraph is rewritten to tell the true story, since a design doc asserting a diagnosis that was disproved is worse than one that says nothing. Verengrad's live save, its mirror and the editor draft now set defaultClimate to drowned_sound rather than relying on the region field, and the engine confirms it in play: climate in use drowned_sound, pattern The Bell-Dark, and the next storm two in-world days out where there had been none reachable at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both are documented features working as designed, and both cost an afternoon each because nothing in the code or the UI says what they do to everything downstream. A climate's `year` array is consulted before the calendar's month-to-pattern binding, so a month bound to winter, labelled winter in the sidebar and named winter in the GM dossier, can be running the autumn pattern. That is the feature — it is how an endless-winter climate exists at all — but the disagreement is completely silent. It bit Verengrad. The world authored a moor climate mapping its first three months to autumn; a storm day was later added to the winter pattern so a rope-coil hook asking the player to cross a bridge in a storm could finally fire, and it could not have, because Verengrad's winter was never winter. What made that expensive rather than merely wrong is that no shipped preset carries a year array, so reading WEATHER_CLIMATES to explain the behaviour tells you nothing at all and sends you back to the patterns. The second is the same shape from the other direction: the pick multiplies a day-type's chance by the climate's weight for its condition, so a zero does not make a day-type rare, it makes it impossible. Coastal's snow: 0 means no snow whatever the winter pattern says, including a snow day a DM adds afterwards — which is usually exactly what is wanted, and is worth being able to rely on deliberately. The test drives both against the engine rather than asserting on the catalogs, and the veto is probed with a snow day and a heat day bolted on at nine times the chance of anything else. That last part was not the first draft: asserting "no snow" against patterns that contain no snow day passes just as well on an engine with no veto at all, which would have made the test evidence for something it never checked. Removing the year precedence, flooring the weight at one, and dropping the year in the normalizer each fail it by a distinct named assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two changes to the Inventory tab. Treasure alone is ordered by what a piece is WORTH, highest first: a trove is a hoard, and the first thing anyone wants of a hoard is its best piece. Every other section keeps the order the pack is in, which is the order things were acquired — sorting those by value too would reshuffle the whole tab on every purchase. It sorts by the PIECE rather than by the stack's total, because "value" is what one of the thing is worth and is the figure the item's own popup shows, so five copper trinkets must not outrank a sapphire on weight of numbers. A piece carrying no value at all falls to the end rather than floating to the front: Number(undefined) is NaN, every comparison against it is false, and a comparator that lets that through leaves the item wherever it happened to start. And it sorts a COPY — the bucket is built fresh today, but sorting whatever array arrives here would one day reorder player.treasure itself, and this tab only means to display the trove, not to own it. The By Type / By Item choice now survives a reload, through the ordinary settings store — global rather than per-world, like the theme, because it is about the reader and not the realm. It is read through a function rather than cached into a variable at load: no boot step to wire up and therefore none to forget, and the coercion happens on every read, so a stored value that has been corrupted falls back to By Type instead of sticking there. The FILTER is deliberately left out of that. A filter restored on load would hide most of the pack behind a box the player does not remember typing in, which reads as items having gone missing rather than as a setting still being applied. Nine sabotages, each caught. The reload is tested by actually reloading — the app is evaluated a second time against the same localStorage the first one wrote to, and the fresh instance is asked what grouping it is in, so a session variable fails it the way it would fail in a browser. One fixture in this test had to be fixed twice before it meant anything. makeItem defaults `value` to 0, so a spec that omits it does not exercise the missing-value path at all; and once the value was really deleted, leaving that item LAST in the input still let the NaN comparator pass, because a NaN comparison reads as "equal" and the item simply stayed where it started. It is first in the fixture now, where only a comparator that reads a missing value as 0 will move it.
A ⏸ toggle sits in the story footer, left of the Music button. Pressed, it holds the whole game: the in-world clock freezes and the living world stops, so nothing narrates, spawns, ages or wanders while the player reads back, plans, or steps away from the keyboard. Their next game action starts it again with no gap — the clock picks up at the instant it was held. It is the inactivity pause asked for on purpose, and it shares that mechanism entirely rather than standing a second one beside it. That made the hold a SET OF REASONS instead of a boolean, which is the substance of this change. Both can be in force at the same moment — a player pauses, then walks away long enough for the idle countdown to fire — and a boolean cannot say so: releasing either one would restart the clock underneath a pause that was never lifted, with nothing on screen to say the world came back. The freeze now happens when the set becomes non-empty and the release when it becomes empty, and each holder only ever speaks for its own reason. The scale in force is captured on the FIRST hold alone, because a second reason arriving while the world is already frozen would capture the frozen scale of zero and restore the world to a permanent standstill. setupEncounters' guard moved with it, from the idle reason by name to "held for any reason". That guard exists because several unrelated things re-run it — an encounter edited, a save restored, a world re-entered — and asking about the wrong reason would have let any of them restart the living world behind a pause the player can see is still lit. It lifts on an ACTION and on nothing else, which is why it cannot ride on noteActivity() the way the idle pause does: that fires on mousemove, so a manual pause wired to it would release itself the moment the player reached for the mouse to read the thing they paused in order to read. The release sits at the two points a real turn passes through instead — in handleSend, deliberately BELOW the "//", "/usage" and Field Guide routes, because asking the app a question is not the player acting in the world; and in gmSubmit, for the turns that never touch the input box at all. It happens before the turn is processed, since the clock is at zero while held and a move or a rest resolved under it would consume no in-world time. Out of combat only, as asked. A fight runs on its own scale and its own round countdown, and freezing time under one would stop that countdown with a foe mid-swing, so the button refuses while one is on and says so in the story rather than appearing to do nothing — the lesson the victory banner's silent decline taught. beginCombat releases a manual hold on the way in. That is unreachable today (nothing can start a fight while the world is held, since the player must act first and acting releases it) and it is one line against a class of soft-lock, which is a trade worth making. Fifteen sabotages, each caught by name. Two existing assertions were rewritten rather than accommodated: test_combat_opens_spellbook matched openSpellbookLoadoutForCombat within 1500 characters of beginCombat's declaration, and test_rest_sweep_watchdog read gmSubmit's first 1200 characters — both measure distance from a declaration, so a comment added anywhere near the top of either function failed an assertion about a line that had not moved. Both now brace-match the function's whole body.
A "treasure" item never reaches player.inventory. acquireItem files it in player.treasure instead — a trove of things kept to be admired rather than used, which is why the type can never be equipped or consumed. So a tab built from the pack alone silently omitted every trophy the character owns, and the Treasure section the taxonomy already had a name for could never appear. Merged at the source rather than bolted on as a section of its own. inventoryHoldings() is now what the tab reads, and both stores take the same road from there: one bucketing, one filter, one place in the taxonomy's order — which puts Treasure after the kinds it knows and before the leftovers, exactly where a trophy belongs. A special section appended at the end would have made it the one heading whose position was a separate rule, and would have missed the filter besides. A trove item mis-typed as something else lands under whatever it says it is, the same as a mis-typed carried one. Deduped by IDENTITY, never by name. Two different objects legitimately share a display name — that is why the item schema quotes refs — and collapsing those would hide one of them; the only thing worth guarding against is one object somehow sitting in both lists, which would draw it twice and break the invariant this tab exists for. That invariant is now across both stores: every held thing lands in exactly one section. Two consequences it would have been easy to miss. The popup resolves against the holdings rather than the pack, or every Treasure card would click through to nothing. And the empty state is keyed off the sections rather than off player.inventory, so a character with an empty pack and a full trove is not told their pack is empty while their trophies sit on screen above the message. Seven sabotages, each caught by its own assertion: not merging, bolting the trove on after the leftovers, resolving the popup against the pack, deduping by name, not deduping, filtering before the merge, and keying the empty state off the pack.
The toolbar copies the Spells tab's next door — filter on the left, tool group hard right, same geometry — so the two Character tabs that carry one do not sit at different heights. By Type is the sectioned view; By Item lifts the headings off and lays every card out in one grid. By Item FLATTENS the sections rather than re-sorting them, so toggling changes only whether the shelves are marked, not what order things stand in. A test asserts the two views hold the same items in the same sequence, because a toggle that reshuffles the pack underneath the player is a different feature from the one that was asked for. The filter is applied once, where the sections are built, so both groupings narrow by the same rule; a second filter in the flat renderer is exactly how the two views would come to disagree about what is being carried. It matches the item's DISPLAYED name and never its true one — typing the real name of something the character has not identified would single its card out and tell them what it is, which is the whole point of the identity gate. It also matches the KIND, both by type id and by the heading word the player can actually see, since the heading is on screen and the type id is not. And a filter that matches nothing says so, quoting what was typed, rather than reporting an empty pack: they are different facts, and one of them the player undoes by clearing the box. Also styles the selected button in a tool group, which nothing did. Two call sites already toggled `active` on .npc-tool-btn and no rule anywhere matched it, so the Spells tab's Known / All toggle has been shipping with no way to tell which scope you are in. Found only because the new toggle would have inherited the same silence. One rule fixes both. Fifteen sabotages, each caught by its own assertion — among them removing the active rule again, renaming the clear button off the prefix syncFilterClearBtn builds, re-sorting the flat view, lighting both toggle buttons at once, matching a gated item's true name, and reporting a fruitless filter as an empty pack. The clear button is driven through clearInventoryFilter rather than by setting the state, because its job is two things — resetting the filter and emptying the box — and setting the state directly would have tested neither.
Spellbooks leads, keeping the cards it had, and below it one section per item TYPE — Weapons, Armor, Consumables, Keys, Books, Miscellany and the rest, each with a count, headed in the manner of the Editor's Art tab. The card is the same shape in every section, so the tab reads as one thing rather than as a shelf with a list bolted underneath it. The sections come from itemTaxonomy(), the LIVE roster, never from a list written out at the render site. That is the rule this codebase has already paid for twice — the equipment-slot roster and the item types were both pasted into prompts and both went stale the moment a value was added — and here it buys something concrete: a type a DM adds on Editor › Items › Taxonomy grows a section on this tab with nothing kept in step, and one they remove stops having one. The test drives a world-added type rather than asserting the built-in list, so a hardcoded copy fails it. Nothing carried is ever silently dropped. An item whose type answers to no taxonomy entry — a legacy type, or one a world retired after items were authored with it — still gets a section named after itself, and one with no type at all lands in "Other", last. The assertion that matters most is the dull one: every carried item lands in exactly one section. A screen whose whole job is to show what you are carrying must not be able to lose something to a type nobody recognised. Headings are plural because they head a shelf rather than label one thing, and the taxonomy's labels are singular by design. The plural is derived, with the four that derive wrongly — Armor, Miscellany, Staves, Treasure — carrying an explicit `plural` on the taxonomy entry itself, beside the label. A table of plurals kept next to the roster is precisely the copy that goes stale when a type is added; a world-added type declares none and takes the derived form. The identifiers follow the label this time, unlike the Spells tab. Nothing persists them (activeCharacterTab is module state, never serialised) and the tab was hours old, so there is no data behind the old name to protect — and leaving it would have meant "spellbooks" naming a container whose contents are mostly not spellbooks. The popup could NOT take the obvious id: "inventory-item-popup" was already the sidebar's Inventory popup, and two elements sharing one id makes getElementById a coin toss, so this one is "charinv-item-popup" and a test pins that both ids appear exactly once. One portrait resolver for every card. itemCardPortraitUrl takes the Compendium shelf as a parameter rather than always deriving it, because the two callers genuinely differ: an ordinary item is looked up under whatever itemCompendiumCategory says, while the Compendium's own spellbook card is filed under 'magic' whether or not the tome is enchanted. spellbookPortraitUrl is the named door onto it and keeps that 'magic', so a plain tome cannot resolve one way here and another there. Twenty-four sabotages, each caught by its own assertion — among them building the sections from a hardcoded list, filtering an unrecognised type away, dropping the untyped, ordering by first-seen, ignoring an explicit plural, reusing the sidebar's popup id, dropping the popup id from each of the three hand-maintained lists in turn, and deriving the spellbook portrait's category.
One card per spellbook in the pack: the tome's portrait above, its name below, and clicking the name opens that book's detail popup in the top-right corner of the view rather than the sidebar's, so the detail appears in the tab the player is looking at. It reads player.inventory rather than anything spell-side, because what belongs here is the OBJECT in the pack and not what it teaches — the Spells tab covers the spells, and each spell card already links back to its tomes. Deliberately NOT gated on Spellcasting, where the Profile's Spellbooks SECTION is: a tome is a carried object before it is a caster's tool, and a character may be hauling one to sell or to hand to a mage. That matters more than it first sounds, because the Profile's Inventory list omits spellbooks on the grounds that they have their own section, and that section is caster-only — so for a character without Spellcasting a carried tome appeared nowhere on the sheet at all. An always-present tab with a real empty state is also what Skills and Progression already do. The portrait is resolved by spellbookPortraitUrl, extracted rather than copied and now shared with the Compendium's spellbook card: the catalog TYPE image wins, so a DM's Upload/Regenerate shows up here immediately, and the instance's own art is the fallback. Two copies of those three lines would drift and the same tome would come to show two different pictures in the two places that draw it. Adding a floating popup to this app means registering its id in THREE lists kept by hand, and the first was missed: the shared CSS selector that gives every popup its position, ENTITY_POPUP_IDS, which stops one subject standing open in two popups, and ITEM_POPUP_IDS, which refreshes an open popup after the item behind it changes. Missing the CSS one is invisible in code — the markup is right and nothing throws; the popup simply renders full-bleed in the BROWSER's corner instead of the tab's, which only a screenshot found. The card name also needed its own colour: the bare .item-link rule carries the cursor and the hover underline but no colour, so the one thing on the card you are meant to click was the only thing that did not look it. test_spellbooks_tab.js pins all of it, each of the three lists separately and with what its absence costs. Seventeen sabotages, each caught by its own assertion — including dropping the id from each list in turn, labelling the card with a gated tome's true name, and wiring the subtab into the class toggles but not the render dispatch. Its identity-gate fixture asserts that it really is gated before testing what the gate does; the first version set neither `magic` nor `revealed` and passed while testing nothing.
A label change and nothing more. The button keeps the id chartab-spellbook, the click keeps routing
switchCharacterTab('spellbook'), the subview stays character-sub-spellbook and activeCharacterTab still
compares against 'spellbook'. That split is deliberate and is the same rule the Guide tab follows: the
id is read by applySpellbookTabVisibility to gate the tab on the Spellcasting skill, the string routes
the switch, and a saved tab position is stored by it — so a rename that chased the label through those
would silently break the caster-only gate and every save that remembered where the player was. A short
comment above the button says so, because the mismatch reads like an oversight to anyone who does not
already know the rule.
Nothing else was touched. "Spellbook" remains right in three places that are not this tab: the Mage's
class equipment SLOT, the item TYPE a field spellbook declares, and the Spellbooks inner tabs in the
Compendium and the DM editor, which list the tomes rather than the spells.
test_spellbook_tab_visibility.js now pins the split rather than only the behaviour: the button is found
by its id, its label reads Spells, the click still carries the identifier, and the subview keeps it.
Sabotaging each of the four independently — reverting the label, and chasing it into the id, the click
argument and the subview id in turn — is caught by its own assertion.
The player's books and the spells design doc follow the label where they name the tab, and are left
alone where they name the slot or the item.Two defaults changed, and the second is the one that mattered. A world that says nothing about when it starts used to open on the 1st of month zero, which is deep winter. That would be harmless except that winter's pattern carried no storm and neither did autumn, so every storm in the year sat inside spring and summer — six months on, six months off. Combine the two and any hook wanting a storm was three in-world months out of reach from the first turn of every playthrough of every world, with nothing anywhere saying so. Found by comparing a hook against the calendar rather than by playing at it. Verengrad has a rope coil whose lore asks the player to cross the rope-bridge span DURING A STORM. Four sessions across three characters had seen fog, cloud and wind and never once a storm, and it had been written up as bad luck twice. It was not luck: the world starts in Frostmark, Frostmark is winter, and winter could not produce one. At the 24x scale the first storm-capable month was about ninety hours of logged-in play away. So the default start month is Greentide, and winter gets a gale. Neither is a tuning nudge. A world that genuinely wants to open in deep winter authors it and is untouched — this is only what happens when nobody said. And a winter storm is not an indulgence: it is the storm most places actually get, and a coastal winter is the stormiest season there is, which is the whole climate of a drowned sea-city that had been given a generic inland calendar and never noticed. The chances are rebalanced rather than appended to, 45/30/25 becoming 35/25/20/20, because a pattern's chances are read as a distribution — adding a day without taking the room out of the others silently re-weights every other day in the season. The test now pins all four seasons summing to 100 as well as the gale itself, since that is the mistake the next person will make. Autumn is deliberately left without one. Rain and wind are its character, and what is being fixed is that a storm exists somewhere in the cold half of the year, not that it exists in every month of it. Verengrad carries its own materialised copy of both the calendar and the patterns — they were byte- identical to the built-ins, never authored for it — so the same two edits were applied to its draft and to Claude19's save. The start month is moot for a character already underway; the gale is not, and the coil's hook is reachable for that run now rather than in ninety hours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Filed as fixed, which overclaims it. The ledger's own rule is that where a fix lives decides what can prove it, and this one lives in two places. The engine half is settled — tests/test_consume_item.js drives consumeItem through the real applyStateChanges, and its sabotages cover putting the item on the floor after all and decrementing inline instead of through the shared writer. The half that decides whether the defect is actually gone cannot be tested at all: whether the Game Master reaches for consumeItem when its prose says something was drunk, rather than dropItem, which is what it reached for in the first place and is still sitting right beside the new field. Marking that fixed is exactly what the status exists to prevent — a prompt change that never worked, sitting unnoticed behind a green suite. Same shape as the sealExit half of BUG-065, and marked the same way. The note now says what a run has to show to close it: drink a potion, then check the room is empty and that "Potions consumed" has gone up. Both were wrong before, so either one still being wrong means the field is not being used. The header chips are derived from the s- classes and move with it: 53 fixed, 5 fixed-unverified.
BUG-067, found in play. There was no directive that DESTROYED anything: every field that empties the
pack moves the item somewhere — dropItem to the room floor, sellItem to a buyer, giveItem to a being,
containerChanges into a chest. So a Game Master narrating a potion being drunk had exactly one field
that could take it out of the pack, and dropItem does precisely what its name says. Measured on a real
turn through applyStateChanges, drinking one of two potions left a Health Potion on the ground at its
full value, ready to be picked up and drunk again; and where the GM set no field at all, which was the
other common outcome, the potion simply stayed in the pack and could be drunk for ever.
What let it survive is that the half that shows was right the whole time. The pack decrements, HP goes
up, the inventory panel and the sidebar both read exactly as they should — nothing about the drink
itself looks wrong, and you have to go and look at the room to see otherwise. The reliable tell was
elsewhere: "Potions consumed" on the Statistics tab could never leave 0, because the one function that
counts a drink, consumeInventoryItem, had a single caller in the skill-book reader and no potion had
ever reached it.
consumeItem destroys the item rather than moving it, and is routed THROUGH consumeInventoryItem one
unit at a time rather than decrementing on its own. A second copy of "take one away and count it"
would drift, and that counter is the only evidence a player has that a potion was ever drunk — the
same rule applyEquipItem follows by sharing equipItemIntoSlot with the drag path. It takes a bare name,
a {name, quantity}, or an array of either, matching dropItem's shape so the GM is not taught a second
one. Two guards the field note also states: a container is refused, because destroying one takes its
concealed contents down with it and says nothing; and when the last unit of something WORN is
destroyed the paper-doll slot is cleared through unequipFromSlot, since equippedItems holds an id
rather than a reference and would otherwise draw a phantom whose worn effects went on applying.
dropItem still means the floor and is untouched — the field note warns off it by name, because it is
right there and looks close enough, which is exactly the mistake that produced the bug.
The equipment dossier's pack exception now points at the field. It already said a quaff was legal;
saying so without naming what settles it is what left dropItem as the obvious reach. The inventory tag
on an item with an authored consumeEffect says it too, since that tag is where a GM is told what eating
the thing does.
Also fixes the ledger's own rendering while adding the entry: `code` is styled for inline use and sets
white-space:nowrap, which every multi-line <pre><code> sample in BUGS.html inherited, collapsing a dozen
of them onto one overflowing line. Blocked and re-set once in the stylesheet rather than per entry.The container's checkout had gone shallow, so a regeneration run against it would have quietly dropped from 2989 commits and 60 active days down to 186 commits and 7 — the exact loss the repo's own notes warn about. Unshallowed against origin/main first, then regenerated: one new day (August 28th) and August 27th's count corrected from 2 commits to the 14 that actually landed.
The login screen's title row carries two things: the shipped app icon (icon.svg) and the world's name. applyLoginBranding already swapped the NAME for a world's own logo when it had one, and left the icon standing beside it — so a world that had brought a whole piece of branding got someone else's mark stapled to it, two logos for one world, at the first thing a player sees. The icon is now hidden with the title it accompanies and restored with it, in the one function every branding path already goes through — the built-in world at startup, a staged world, a resumed save's world, and the editor's live re-apply after an upload. The title row shows exactly one identity at a time. Worth knowing for anyone who wonders which behaviour was intended: the editor's own Login preview has always drawn it this way. renderWorldLogin paints the logo OR the world name and no icon at all, so the preview and the real screen have disagreed since branding shipped, and the preview was the one telling the truth. The background and the logo are independent settings and stay that way. A custom backdrop with no logo leaves the built-in title row alone, because a backdrop is scenery and says nothing about whose mark belongs above it; a logo with no custom backdrop still replaces both the name and the icon. Four assertions rather than one, and deliberately not folded together: hiding the title but not the icon is precisely the bug that was reported, so a check that asked about both at once would have passed while it was live. Four sabotages caught by name — the icon never hidden, never restored, following the background instead of the logo, and looked up by the neighbouring element's id.
Claude19 goes 34 to 41 of 106, walks the last three rooms for a full 12 of 12, and answers the question it was carrying. The Bone Needle wants "a kill made in total silence". A Tide Cantrix spent six rounds on it, landed nothing at all and lost thirty-two hit points, because silence takes away everything she is good at. A Bladeward did it in ONE turn with no combat rounds: the chorister sags into the plate like a man letting go of a rope he has been holding since before your grandfather was born. So the hook is not broken and it is not class-hostile — it is class-APPROPRIATE, and the earlier reading of it as unreachable was a reading from one build. That distinction matters for the plan, because it is the difference between content nobody can have and content this character cannot. The movement gate shipped an hour earlier and both halves of it showed up. Asked to go north and straight down through a chamber never visited, it re-routed four rooms the long way through known ground rather than refusing — a legal route exists and it found it. Later, six rooms out to the transept, it stopped in the Flooded Cloister because the Gill-Wretch was standing in it, said so in the log, and the fight had to be had. That second rule was the addition argued for rather than the one first proposed, and it is the one that did the work. One thing to watch: the re-route is silent, so the narration described the way that was asked for while the engine walked a different one. BUG-059 now has THREE positive observations rather than one, all from this session — An Accounting for the Deep, the Bone Needle, and the Drowned Longsword. Each is a nearMiss that fired correctly on a turn where the doing was done and the meaning unsaid, and each was then paid by saying what the thing meant. The Drowned Longsword one is the best of them: the nudge arrived unprompted for a sword that had been carried the whole run and used for every single thing the character had done down there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-065 and BUG-066 were both still marked open while the code that closes them was already shipped and tested. That is the failure the ledger exists to prevent, so it is worth naming: a status that lags the repository is worse than a missing entry, because it sends someone to write a fix that is already there. They do not land in the same state, and the difference is the one the Status section already argues. BUG-066 is engine code and a test settles it, so it is fixed: the routing is driven directly and the sabotage removes the visited requirement to confirm a route through unseen rooms becomes legal again. BUG-065 is half engine and half contract. The engine half is tested the same way - remove the last-exit guard and a room seals with no way out at all. The half that matters in play cannot be tested at all: whether the Game Master reaches for sealExit when it means a way is shut, rather than asserting it in prose as it did in the Chancel. Only a run where something closes a way will say, so it sits at fixed-unverified and becomes working on the first positive observation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both from Claude19's run, and both settled the way they were argued rather than the way they were first described. SEALING (BUG-065). revealExit promoted a hidden exit into a real one and had no inverse, so a Game Master that decided a way had closed could only say so — the exit stayed in the data and a player who ignored the prose walked through it. sealExit is that inverse: the exit moves back into hiddenExits, so revealExit is exactly what re-opens it, and the reason is recorded on the room so it survives a reload. The contract makes it say which of two things it means, because that distinction IS the bug. A way that is merely dangerous, costly or terrifying is NOT sealed — narrate the danger and let them pay it, which is good play and is what the retry actually got. Seal it only when the way is genuinely no longer there. And the last way out may be sealed — a room closing around you is a real thing for a world to do — but only when the same directive says how it opens again, as a time or as something to find or solve. The engine cannot verify that a puzzle exists, so it enforces the half it can: without a stated way out it refuses the seal outright and tells the Game Master why. Otherwise a model that will not remember this next turn can author a soft-lock live. WALKING (BUG-066). A non-adjacent moveToRoom is now a route rather than a teleport. It must follow open exits through passable doors across rooms the player has already visited — the destination itself may be new, because that is the arrival, but the ground between must be known. Each room walked is marked visited, which is the half that was actually observed: one room crossed four times across two characters and never once entered. A hostile in an intervening room stops the walk THERE. That was the addition worth having — otherwise compound movement is a way to skip a fight, and a respawn that puts something back on a path the player has walked before would be stepped over without a word. The player arrives in that room instead, and the Game Master is told they did not reach where they were going. Refusals and short stops are always spoken to the Game Master, because the failure this whole family shares is a turn that narrates an arrival that did not happen. Both guards are sabotage-tested by driving them, not by matching source: remove the last-exit check and a room seals with no way out at all; remove the visited requirement and a route through unseen rooms becomes legal again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Three observations from Claude19's run, answered rather than left as notes. The narrative gate is a real gap, and BUG-065 says which half of it is. The Anchor Saint stopped reciting because she was told her own name, and the Game Master closed the corridor she had been holding — in prose. The exits were untouched in the data, and walking south again worked, at 22 hp. The engine has revealExit, which promotes a hidden exit to a real one, and NO inverse: a Game Master that decides a way is shut has no field to say it with. Same family as the sale that never settled. Worth separating two readings before fixing it. A soft gate — "you would have to run it and it will cost you" — is good play, correctly yields to a determined player at a price, and is not a defect; that is what the retry actually got. A hard claim — "the only road out is down" — is the defect, because it is false and the player disproves it by walking. The fix is either a sealExit directive mirroring revealExit (the structures are already the same shape, and the world plainly wants reactive geography) or a contract rule forbidding the claim outright. Either way the Game Master should have to say which of the two it is doing. Transit-is-not-a-visit turned out to be the smaller half of BUG-066. moveToRoom's own contract says it must match a direction against the CURRENT room's exit keys, and a compound move breaks that by setting it to the final room three hops away. Nothing checks. So it is a teleport: the rooms between are never entered, never marked visited, and never do whatever an arrival does. The Drowned Narthex was crossed four times across two characters and only counted on the turn that stopped in it. Filed not as "compound movement is wrong" — it is the thing that makes this world walkable — but as: the engine accepts a moveToRoom its own contract forbids and has no opinion about the ground in between. The rule worth arguing for is allowing it only where every room on the path has already been visited, which also makes the intermediate-visit question moot. The level-up dialog needed no fix: it does not auto-close, and its confirm button stays disabled until every point is placed, so a player can move points around with the minus and plus buttons for as long as they like. Only the LABEL was wrong — it read "Next", which is true during creation where a skill pick follows, and misleading at a level-up where nothing does. A button promising a next step on the last step is exactly the doubt that makes someone press it twice looking for the rest. It now reads "Confirm" outside creation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Claude19 reached the ending. The Last Descent is 6/6 and the world's main quest is demonstrably completable by a class other than the one that first walked it — which was the open question, because the Bone Needle had already shown that a hook can be structurally out of reach for a build, and nothing had yet proved the main line was not. Ten of twelve beats. The two missing are the fork arms this run did not take, which is precisely what the per-run ceiling of 104 exists to say, so the quest score is effectively complete rather than short. The ending is a real decision and it stayed open. Offered a Canticle one syllable short, where finishing it drowns the city and handing it over passes the problem along, Claude19 did neither: it broke the thing on the boards in front of Mira, on the grounds that the missing syllable is a crypt full of people held mid-word and both other roads are somebody else deciding what the drowned owe. That paid the beat, the reliquary's own lore, a world-lore entry, a level and four stat points. Two things worth recording from the run. The world answers back and it can cost you: telling the Anchor Saint her name made her STOP, and she had been holding the Hush-Choir corridor open by reciting, so the Game Master then refused the southward road out of the Chancel and said the only way was down. The exits were intact in the world data, so it was a narrative gate rather than a structural one — crossing anyway worked and cost 22 hp. Not a soft-lock, but a kindness closed a corridor behind the player, which is exactly the kind of consequence this world should have and exactly the kind that could strand somebody. And transit is not a visit: the Drowned Narthex was crossed four times in compound moves without ever being marked visited, and only counted on the turn that stopped there. That may well be correct, but it means a run can cross the whole world and satisfy no room objectives. The level-up was driven by clicking, and applied once — CON 23 to 24, panel closed itself, nothing remaining. Further confirmation that BUG-064 was the harness rather than the engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The ledger could say a fix had landed but not been tried (fixed-unverified) and it could say a fix was settled (fixed), and it had nothing for the state in between — which is where a prompt fix actually spends most of its life. BUG-059 has been sitting as "open" since the day its fix shipped, which reads as though nothing had been done about it. working means: changed, and observed doing its job at least once in play, and not yet often enough to close. It carries both halves of the truth at once. One correct firing is exactly what an intermittent bug looks like on a good day, so claiming fixed would be a guess; but a fix that is live and behaving is not open either, and calling it open hides work that has been done and tested. The transitions matter more than the label. open to fixed-unverified when a fix lands; to working on the first positive observation in play; to fixed when there have been enough of them to say so — a judgement, and one the entry should justify rather than assert. And working back to OPEN the moment it is seen broken again: not to needs-repro, because it plainly reproduced, and not left at working, because it is not. A fix that regresses that way should be re-argued rather than patched twice, since the first version was written against an understanding the recurrence has just disproved. BUG-059 is the first entry to use it, and its record is written out: the 13e restatement and what it changed, the watch and why it is observational, and a table of what each run since has actually shown — Claude18's 38 turns proving nothing either way because 13e was never engaged, and Claude19's ledger handover drawing the nudge correctly and being recorded for it. The note says plainly what would close it and what would re-open it. The header counts are now derived from the entries rather than typed, which is how they came to read "61 ids" and "49 fixed" against a file holding 64 and 52. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
tools/adopt-world.js has been able to write an exported world back into WORLD_DATA since it shipped, and nobody has ever run it, because doing so turns three tests red while changing no data at all. Each of them matched a regex against the literal's SOURCE TEXT — spacing, key order and line breaks included — so what they actually asked was whether the literal was still formatted the way it had been typed by hand. The tool rewrites it faithfully and reformats it, and 1890 lines become 5576. The Mage's equipment op, the Barrowking's Signet and the Iron Sword's subtypes are now read as data. Measured end to end: adopt a clean export of the built-in world and the suite is 708/708 either way. Two of the three needed more than a change of lens, and the sabotage sweep is what found it. Reading the BUILT world hides exactly the shape these tests exist to forbid, because buildWorld migrates it away: with the Signet reverted to "type": "magic" in the file, migrateItemCatalog rescues it into magic:true plus a natural type and a built-world assertion passes; the same is true of the Iron Sword's kinds moved back into "classes". The migration is not under test and has its own assertions. What is under test is that the file no longer ships the shape the migration exists to rescue, so both read the AUTHORED literal — parsed as data, through adopt-world's own literalSpan/withoutComments, so the one tool that rewrites this literal and the tests that guard it cannot disagree about how it is read. Both catalogue spellings are accepted, because the engine accepts both and a serialized world uses the other one. The negative assertion in the subtypes test was the instructive one. It looked for the legacy shape as a literal string, so the reformatting made it find nothing — and passed. It would have gone on passing with every item in the file back in the old shape. Both catalogue claims are now made over the WHOLE catalogue rather than one fixed example, which is the rule they were always meant to express: a pinned example says nothing about the item somebody adds next. Two smaller things the sweep turned up. Removing the Mage's op made test_class_equip_slots THROW on a [0].id, ending the run and taking every later assertion with it — a crash names a line, not a cause, so that one reads through defaults now. And two of these files defined check() with no third parameter, so the diagnostics written beside the new assertions were being silently dropped; both signatures now carry the reason. Ten sabotages, each caught by a distinct named assertion: the op removed, pointed at the wrong slot, relabelled or given the wrong glyph; the Signet unflagged or reverted; a DIFFERENT item shipped in either legacy shape; the sword's kinds moved back; the sword renamed out of the catalogue. Removing adopt-world.js itself reports a named failure rather than an unhandled require.
The equipment dossier's standing rule — only equipped gear does anything, never from the pack — is right about gear and flatly wrong about a potion. A consumable declares no equipmentSlots, so there is no slot to equip it into and the sentence, read literally, is a standing instruction to refuse a quaff. Left unqualified the GM had to pick between two readings of one line: refuse the drink, or allow it and quietly decide for itself what it cost. Both were seen. So the exception is named — a thing used up in the using, and a thing flung at a target, are used straight from the pack — and the in-combat branch prices it: a quaff or a throw is the player's whole action for the round, exactly as a gear change is. The two are enforced in different places and the prompt now says which is which. A gear change is charged by the engine: relayCombatEquipChange submits the change as the turn, so the player cannot also swing, and an arriving [COMBAT EQUIP — …] message tells the GM the round is gone. A quaff or a throw goes through no engine path at all — the GM narrates it out of the pack — so nothing can charge that round on the GM's behalf and the dossier says so, with what holding it looks like: resolve the enemies' round in the same response, and never let a drink-and-swing or a throw-and-swing stand. A thrown object also has to be named in dropItem or it never leaves the pack, because nothing else removes it and a dagger still carried after being thrown is an infinite supply of daggers. Its damage stays the GM's entityDamage: the engine computes damage only for the equipped weapon, so the Phase 2 weapon-damage exception does not reach a throw and no damageRollRequest belongs to it — worth saying outright, since a character with a greatsword equipped and a dagger in hand is the case where the GM would otherwise reach for the engine's field. One gap is deliberately left open rather than papered over, and is recorded in the code comment and in Designs/combat.html: there is no directive that destroys a consumable. A drunk potion can only leave the pack through dropItem, which puts the full vial on the floor to be picked up and drunk again. That wants a consumeItem field of its own; the engine half, consumeInventoryItem, already exists and is used only by skill books. Naming a field that does not exist in the prompt would be the exact failure this dossier was written to prevent, so the block says nothing about consuming one.
Asked whether the double-applied stats were a real defect or an artifact of automating the dialogs instead of clicking them. It was the artifact, and checking took ten minutes that should have been spent before filing rather than after. Driven with clicks alone on a fresh character the flow is exactly right. Creator Next opens the stat panel with nothing committed; allocating three to STR and pressing Next CLOSES the panel and leaves only the skill pick; picking a skill and pressing Confirm applies STR 16 to 19 and leaves no modal open at all. Three points allocated, three applied, once. There is no lingering panel, so there is no second button to press, so the scenario the report was built on does not exist. What actually happened was two harness mistakes compounding. The Character Creator opens on Begin Your Journey and was never answered — commands were fired straight past it — so eighteen turns were played with creationPending still true and no race set. A level-up then re-entered that unfinished chain and the panels stacked. And the allocation was committed by calling confirmSkillPick and confirmStatAllocation as FUNCTIONS rather than clicking their buttons, which re-applied a draft the UI would have cleared. The guards are sound on their own terms: confirmStatAllocation returns early unless a draft exists and is fully spent, and nulls the draft on commit, so a plain double-click does nothing the second time. Only a re-entered creation chain restores a draft and only direct invocation reaches it. Kept as withdrawn rather than deleted, because the lesson is the useful part. A bug filed against the engine for a fault in the harness is worse than no bug — it becomes evidence, and it would have sent someone hunting for a guard that is already there. The instruction to drive these dialogs by clicking, including the dismissal, is now in the working notes with the reason attached. Claude19 keeps one mark of it: no race, because the creator was never answered, which may put the race lore hooks out of reach for that character. Its stats were corrected by hand to the allocated values and are right. The throwaway character used for the test has been deleted from the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Claude19 settled it on turn 24. Two Tide-Cult Tokens came off the two placed choristers WITHOUT killing either of them — hold the jaw shut until the recitation stops, take the token while the thing is quiet, set it back down — and the third had been carried since the start. All three went to Ys the Broker together, in one handful, and the hook fired: item lore unlocked, plus a world-lore entry on what the cult minted them for. So the hook is satisfiable exactly as authored, and the original diagnosis was half right. The world does hold only two placed tokens, but that was never the obstacle: the obstacle was that taking both appeared to cost the two creatures carrying them, and killing a chorister forecloses its own hook. Subduing takes the token and leaves the hook. The respawn work still earns its place — it makes a third token recoverable for a run that kills a chorister, or spends a token, or arrives after someone else has — but it is a safety net rather than the mechanism, and the confirmation did not need it. Getting there meant getting past the Bell-Warden, who cannot be argued past and says so: "I have been argued at by better than you, in vestments, in the first year." He asks instead for the name of someone who came down and went back up. That is Mira, who is standing on the scaffold landing this whole time and cannot say what she saw. Giving him the name unlocked his own lore and a world entry with it. Also files BUG-064, hit while allocating nine banked points. confirmStatAllocation applies the pending allocation every time it is called and never marks it spent, so a second Confirm — on a panel that stays open, still reading "Points remaining: 0", still offering its button — hands out the same points again. Nine became eighteen: STR 18 to 24 where 21 was earned, CON 18 to 28 where 23 was. unspentStatPoints reads 0 throughout, so nothing in the state records that it happened, and carry weight moves with it. Silent, repeatable, and it rewards the most natural response to a modal that will not go away. Claude19's stats were corrected by hand to what was actually allocated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two things land together because they are the two ends of one link. A registry card's Play button opens the vault's public address with ?world=<uid> on it; the manifest decides whether that lands in an installed app or a browser tab, and the login screen decides which realm the player ends up in front of. The login half was WRONG, and it was wrong in the direction that costs the most. applyRequestedWorld ticked New Game unconditionally, on the reasoning that pressing Play means "play this realm". It does — but ticking New Game is precisely what HIDES the continue path, so a player who pressed Play on the realm their own save is in was handed a fresh character and no visible way back to their game. A registry is a place people come back through, not only one they arrive through, and the first version only understood the arrival. So the tick is now for a realm that is not the one being continued, and the returning player is told what the screen decided and how to override it — Begin continues the save, ticking New Game starts over. Compared on the world's NAME because that is what the resumable-save cache holds; the uid did its own work a few lines above, resolving which library world was asked for, and this is a second question about the save rather than about the realm. The manifest asks the OS to open an in-scope link in the installed app rather than a tab, and to navigate a window already open instead of spawning a second — pressing Play twice should not leave someone with two windows of the same game. It is a declaration and nothing more: link capture is the browser's and the OS's to honour, Chromium's behaviour here has moved around, and the older url_handlers is gone entirely. The test says the app ASKS, and deliberately claims nothing about any browser agreeing, which nothing in this repo could show. That still wants confirming on a real install. tests/test_registry_arrival.js drives all four cases through the real function rather than describing them: no save, a save in that realm, a save in another realm, and a realm this browser does not hold. It builds its own page because REQUESTED_WORLD_UID is a const read from location.search at parse time, so the arrival has to exist before the app is evaluated. Its <select> mock reflects its options into .value like a real one, so "selected a world that is not in the list" cannot pass as a success, and it takes the world back out of the library through the app's own deleteSavedWorld rather than reaching into storage under a key it would have to guess — a guessed key that stopped matching would empty nothing and pass. Thirteen sabotages, each caught, including the exact bug being fixed here. test_world_picker_boot lost three assertions to apply.slice(0, 1800): the new branch pushed the staging call past 1800 characters, so a correct change went red over an arbitrary number. It is bounded by the function's own closing brace now, which is the house rule this file had been quietly breaking, with a fixture check so an empty lift cannot pass instead.
A fresh Bladeward, rolled to settle the four things no continuation of Claude18 could reach. Three of them are done in eighteen turns. The Gill-Wretch is ALIVE and its lore is banked. It cannot be driven off — "whatever holds its leash is not letting go" — but it can be borne down and pinned, and subduing it unlocked the hook that Claude18 destroyed by winning the same fight the ordinary way. The answer was never to fight it better. It was to stop trying to end it. Both forks went the other way. The ledger was dredged blind, refusing the arch-verse outright, and handed to Ys rather than carried back to Hesk — Cold Hours, Blind Hands and An Accounting for the Deep, the two beats that are permanently foreclosed for Claude18, and the quest completed down the road she closed. Subdue-don't-kill turned out to be a general key rather than a one-off. The same move on a Drowned Echo-Chorister — holding its jaw shut until the recitation stops — is what let the word underneath be heard, and paid three hooks at once: the Chorister, the Anchor Saint, and a world-lore entry about the one who chose to stop. And rule 13e fired correctly, with the watch catching it. Handing Ys the ledger drew the nudge — "you have done the thing itself, what it means is still sitting there, unsaid" — and saying what it meant completed the beat. It is recorded as `nearMiss fired: An Accounting for the Deep` on the captured turn, which is the first positive BUG-059 evidence the instrument has produced: the contract being honoured, observed rather than assumed, and carried out into the script rather than dying with the log. BUG-060 is two tokens of three. The last is on the Reciting Crypt chorister, past a Bell-Warden currently blocking the stair, and the run stopped there rather than open that fight bone-cold at level 4. The script is also the first captured from turn 1 with no gaps — a fresh character, its own baseline, sixteen of its eighteen turns recording what they earned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-062. Three kinds of objective were inflating the denominator, and since a playthrough script is graded against that total and a drop is read as a regression, an unreachable total quietly made every score wrong. Containers were emitted as things to ACQUIRE. "Acquire the Offering Basin of Drowned Coin" asks the player to carry off the basin at the shrine, and the same went for a strongbox on a market floor, an iron coffer in a crypt, a reliquary ark and a chest on a shelf. They are scenery with a capacity; the player searches them. The compiler now carries each placed item's type and skips containers, while leaving their LORE counted — a coffer's maker's mark is a real hook, and one of them was collected in play. An item placed in two rooms was counted twice. Objective ids are keyed on the name, so these were identical entries: acquiring the thing satisfied one and left its twin permanently unmet, reading afterwards as content the run had missed. Three of Verengrad's items were placed twice. The forks are the interesting one, and they could not be removed. Verengrad's ledger is recovered either by deciphering the arch-verse or by dredging blind, and then either returned to Hesk or carried to Ys; both arms are real objectives because the world really contains both, and taking one forecloses the other for good. So the plan publishes a per-run CEILING beside the total and the grader prints both. That is the half that actually cost something: a session went hunting for two beats it read as unplayed content before working out they were the roads not taken. The groups are authored rather than inferred, via branchGroup on a beat — the field the spare-branch finding already looked for, and which Verengrad had set correctly on both pairs the whole time. The evaluator simply never counted it. Nothing guesses which beats are alternatives from their wording, because a wrong guess would silently lower the bar a run is held to, and that is the one direction this must never fail in. Verengrad goes from 114 objectives to 106, ceiling 104. Every earlier figure is on the old ruler: Claude18's 91/114 is 88/106 here, the whole difference being three duplicate objectives it had been credited with twice. The manifest and the character notes are restated on the corrected ruler and say plainly that figures either side of today are not comparable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A card's only control was the vault's hostname, set as a link at the end of a row of badges — so the thing you press looked exactly like the four facts beside it, on a page whose entire purpose is finding a realm to play. It is a filled button now, reading PLAY, which is the one thing on a card that should look like a control. The address has not gone anywhere. It is the link's own href, which every browser shows on hover and before a click, and it is said again in both the title and the accessible name. That mattered enough to assert: this is a directory of other people's servers, and a page that replaces a visible hostname with a word must not cost the reader the ability to see where they are about to be sent. What makes it PLAY rather than a link to a server is ?world=<uid>. A vault's public address resolves to its login screen, and that screen has a world picker with no idea which of the listed realms the player pressed — so a crossing that got everything else right ended at an undifferentiated menu. The uid and never the name: a name is a display string two realms may share, while the uid is minted with the world in the browser that made it and is the identity this whole registry rests on, so a realm renamed since it was published is still the realm that was asked for. It is appended through URL rather than by splicing a "?", because a vault published with a path or a query of its own is not a case to hand-splice. The login screen matches that uid against the uid inside each saved world rather than against the picker's option text, and selects through the picker's own change handler rather than by assignment — choosing a world STAGES it, and a dropdown naming one world while the screen is still built for another is worse than no selection at all. It runs after populateWorldSelect and only after: a value assigned before the options exist does not select the world, it silently blanks the row, which is the failure pickWorldFromMenu already carries a paragraph about, reached here by a different road. A uid this browser does not hold is REPORTED, not ignored. Nothing is fetched and nothing is trusted — this only chooses among worlds already saved here, and a vault has no public route that would serve one (every world route is requireAdmin, and `public` today means only "announce this to the registry"). So a player who crossed for a realm they do not have is told which realm was asked for, because a picker sitting on Default with no explanation is the worst available answer: it looks like it worked. Two tests were pinning spellings again and both went red over changes they had nothing to say about. test_registry_browse matched `function hostCell(vault)` and lost its whole slice to a second parameter, failing three assertions about https-only linking and noopener that were all still true; it matches the function by name now, with a fixture guard so an empty slice can never pass quietly. test_world_picker_boot pinned the microtask as a single written line and went red when a second step was chained into it; it reads the block and asserts what the block does. Both gained coverage of the new behaviour. Fourteen sabotages, each caught: the button reverting to a fact, the realm not carried, a bare ?world= on a record with no uid, the query spliced instead of parsed, the destination host hidden, a non-https address given a Play button, the glyph built with innerHTML, the opener handle restored, and on the other side the parameter ignored, matched by name, applied too early, shown without being staged, and an unknown realm swallowed in silence.
Two clauses told the image model to change "what is visible through windows, doorways, or other openings to the outside", and every model read "doorway" as any door. Interiors came back with weather and daylight painted behind ordinary interior doors and corridor doors — a cellar door opening onto a sunset, a hallway lit like a street. The phrase was not wrong, it was uncheckable. "An opening to the outside" is a question about the building's architecture, and a picture of a door does not answer it: what lies beyond a door is exactly what the model cannot see, so it was free to decide and it decided generously every time. The test is therefore made visual rather than semantic. An opening is now a window, or an open portal, archway or doorway through which outdoor scenery is ALREADY PLAINLY VISIBLE in the picture — a question about the pixels in front of it and nothing else. Everything else is named and excluded: interior doors, hallway and corridor doors, closed and partly-closed doors, an open door or archway whose far side is another room or a passage or stairs or darkness, cupboards, hatches, curtains, mirrors and pictures. Reasoning that a door "must lead outside" is forbidden outright, and so is adding, opening, widening or brightening any aperture — told not to paint behind a door, a model that wants weather will otherwise put a window in the wall. That definition lives in one constant interpolated into both interior clauses rather than typed into each. Two prompts describing the same rule in their own words is how this drifted, and the item side of this file has the same lesson written on it about equipment slots. The time of day was the other half, and it was worse: the exterior sentence — "paint the sky, the level of daylight or darkness, and the shadows to match dawn" — was appended verbatim to the interior clause, which had just finished forbidding any change to the indoor lighting. The paragraph contradicted itself and a re-weathered tavern came back with its lamps out and dawn across its floor. Indoors the hour now applies only to the sky beyond a qualifying opening and the light coming through it, and says in as many words that the room's own lamps, candles and hearth and the shadows inside it are not to be touched. Two existing assertions were rewritten rather than re-matched. Both pinned the literal phrase "through windows", which is to say they passed against the exact wording that caused the bug; they now assert that the shared aperture rule is present in both clauses. What the rule has to SAY is asserted clause by clause, because a single "mentions doors" check passes for a rule that names doors and then permits them.
Reported as "the GM narration drop cap disappears in the story after a page reload", and that is exactly what it was. The save was never at fault: addMsg stores the flag on the entry, buildGameSnapshot carries it, and reading the snapshot back out of IndexedDB shows dropCap:true still on it. The restore threw it away. _restoreGameStateInner rebuilt every messageLog entry from scratch, naming the fields to carry — and the list was written when `crawl` and `crawlHead` were the only extras an entry could have. `dropCap` arrived afterwards, nobody added it, and from that day every reload stripped the storybook initial off every passage of narration. The book icon that opens a passage in a larger font rides on the same flag, so it had inherited the bug the day it was written. The list is gone rather than extended by one. That is the third hand-maintained allowlist in this file to lose a field this month — serializeWorld and rebuildWorldFromSnapshot were the other two, and the second of those lost six at once — and adding `dropCap` to it would have fixed this reload and left the next field to be discovered the same way, by a player noticing something missing. So the direction is inverted: the entry is COPIED and only the two fields that need reworking are named, the defaulted type and the migrated html. A flag added to an entry tomorrow survives a reload tomorrow with this line untouched. Safe because addMsg is the only writer of these entries and everything it puts on one is a plain scalar meant to outlive the session. Verified as an A/B through the real _restoreGameStateInner over a real save, reading the drawn story back out of the DOM: without the change the cap and the icon are both absent after restore, with it both are there. Three tests are corrected, and all three failed for one reason: they pinned a spelling rather than a property. test_narrative_effect.js pinned the Effect field's placeholder verbatim and went red on a commit that did nothing but reword it — the author's own text change, which is the author's to make. It now asserts what the field must TELL the author, since the normalizer clamps to one sentence and the GM contract forbids pronouns: the label states both rules, and the placeholder, whatever words it uses, obeys the rule stated an inch above it and fits in the box. test_dungeon_story_episode.js pinned the very line this commit removes. Its own detail line was already warning that a field not named there is silently dropped, which is the bug that then happened to a different field. It now runs the real rehydration and checks that an episode head, a member and a line that was never underground all come back correct. And test_drop_caps.js had the gap that let this ship. It claimed the decision "survives a save and a reload" while only ever testing the save: the entry did store the flag, and nothing here read it back. It now drives the rehydration too, over an entry carrying a field the test invented, so the next flag is covered before it exists. Five sabotages, including the original bug restored and the near-miss fix that adds dropCap to the list while still forgetting everything after it.
It read `html: stripStoryBannerBytes(html)` verbatim and went red on the merge that wrapped a second stripper around it — an edit the assertion should have been indifferent to, since what it is about is what the record CONTAINS, not the pipeline the html goes through on the way in. It now reads the literal's keys: type and html, and nothing else. Both sabotages it exists for still fail it — a control appended to the html, and a third key added beside it.
# Conflicts: # text_adventure.html
A stretch of the Game Master's prose is set at 13.5px, dim gold and italic, in a column shared with the
sidebar. That is right for a story you are moving through and wrong for a paragraph you want to stop and
read — and the − / + control beside the story scales EVERY message, so enlarging one dense passage
enlarges the whole log and has to be put back afterwards. This is the other answer: leave the story as it
is, and give one passage a bigger room to be read in.
The icon is offered on exactly the lines the drop cap is, and that is the whole selection rule rather
than a coincidence. `dropCap` is the only thing in the record that says "this is the GM's authored
narration": the message TYPE cannot say it, because 'narrator' is also addMsg's default and some forty
engine one-liners wear it, so an "open in a reader" control would end up on "You cannot cast while you
are down." Pinning it to the cap also means it appears only on passages already judged long enough to
open a chapter, which is the same judgement about when a passage is worth enlarging. Both surfaces are
tested in one file for that reason — a change to the rule now has to satisfy them together instead of
drifting between them.
It is drawn at RENDER time and never stored, like the episode fold control beside it. messageLog is read
by the story-book export, the transcript and the playthrough capture, and a button written into
entry.html would reach all three — a glyph printed in a bound book that nobody can click. Both renderers
draw it, because addMsg builds its live node separately from messageNodeHTML (its inner html still
carries the banner bytes), and that is the drift the comment on messageNodeAttrs already warns about.
The reader takes no id and no index. It finds its passage with closest('.msg') from the button that was
pressed, because capMessageLog drops the oldest entries as a session runs long — any index baked into
the markup eventually names a different passage, and a reader that quietly opens the wrong one is worse
than one that does not open. The node is cloned and stripped rather than copied whole: the controls come
out because they are controls and not prose, and every handler the linkifier wired into the chips comes
off because a live spell chip inside the reader opens its popup BEHIND the overlay — the player clicks a
word and watches nothing happen. The words and their highlighting stay exactly as they are in the story.
Two rules do the real work and neither is decoration. `min-height: 0` on the body is what lets it scroll
at all: .modal-box is a flex column, and without it the body grows to fit the passage and the box runs
off the screen, which is precisely the case this dialog exists for. And `position: relative` on the box
is what keeps the ✕ inside it — .room-popup-close is absolutely positioned and .modal-box sets no
position, so the first version put the close button in the window's top-right corner over the game's own
title bar. Caught by looking at it in a browser, which is also where the enlarged passage, the drop cap
carried across, and the 1709px-of-content-in-a-740px-box scroll were confirmed.
The drop cap is shared with the story rather than copied — the reader body joins the same ::first-letter
rule, which is the argument that rule's own comment already makes for the time-of-day brief.
Twenty-five sabotages, each caught by a distinct assertion. The strip logic is exercised against a small
purpose-built node graph rather than asserted from source: only the four operations openStoryReader
performs are implemented, so what runs is the function's own logic and not a paraphrase of it.Size shipped documented in the two DM's Guides and the Field Guide, and that left the two books a player actually reads about creatures saying nothing about it. The Player's Handbook gets it in the place the question is first asked rather than the place the field is first seen: Chapter Fourteen, Containers, where "put the rat in my pack" is a real question with a real answer because a creature's size is measured on the same scale a container's capacity is. Chapter Seventeen adds a paragraph for where it is read — the Compendium entry — including the part that is easy to get wrong from the player's side too: a creature with no size stated is not average, it is one nobody has measured. The Environment chapter of both DM's Guides listed a fauna card's fields as "stats, hp, routine, ambient, portrait" and now names size and aggression among them, which they carry and always did once those shipped. Size earns the mention there more than anywhere else: a wildlife roster is where the difference between a thing that fits in a pack and a thing that does not is most often the whole question being asked. The Monsters GM box gains one example, because setting sizes is the sort of thing a DM wants to do to a whole bestiary at once rather than card by card.
Asked whether the separator could be changed. It should not be. NUL is the one byte that cannot occur in a character name or a world title, so the two halves of the key can never run together into something that means a different playthrough. Every printable alternative can collide — a world called "Salt::Cantos" under "::" would share a key with another run — and two playthroughs quietly overwriting each other is far worse than a key that is awkward to type. It is genuinely awkward, and it cost a confused half-hour: the key was hand-typed with a space, the read returned null for it, JSON.parse(null) returns null rather than throwing, and writing that back wiped the active-game pointer. Worth being clear that changing the separator would not have helped at all, because the fault was a null round-tripping in silence and every separator fails that way. So the comment records the reasoning AND the near-miss, and names the two things that actually prevent it: build the key with savedGameKey rather than by hand, and check a payload parses and names the character you expect before writing it over a save. The playthroughs README gains the other half, which is procedure rather than code: check which character is actually loaded before the first turn. The login screen defaults to whoever it likes and Continue restores the active-game pointer rather than the name in the setup field, so typing a name there changes nothing — a run was started as the wrong character that way, and a turn spent, before anyone noticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The banner reference was read straight out of the time-of-day slot, which quietly skipped the weathered layer: a fight won in a downpour came back painted over the dry noon version of the room, which is a picture of a different afternoon. The banner IS the scene, so the reference has to be whatever the player was actually looking at when the fight ended. A room banner has three layers and they already resolve in one order everywhere else in the game. The reference now asks the same two functions the room header asks: weatheredBannerFor, which returns a weathered repaint when one exists for this room's present sky and '' otherwise, then currentShownBanner, which is itself the pinned slot if the DM pinned one and the current hour's if not. Two calls rather than three — a getBannerImageFor fallback behind currentShownBanner would be unreachable, since that is what currentShownBanner already does, and an unreachable fallback reads like a safety net while catching nothing. Weathering sits DOWNSTREAM of a pin rather than in competition with it. The pin decides which banner shows; the weathering repaints whichever one that was. Getting those the other way round would be an easy mistake and is asserted directly: a weathered repaint of a pinned banner wins over the pin. The reference's own caption changed with it, from "the place this happened" to "the place this happened, exactly as it looks right now, weather and hour included". That sentence is true whichever layer supplied the picture, so it cannot drift out of step with the resolution above it. The layers are tested one at a time with the ones above them removed, because "the weathered one wins" and "a pin is honoured at all" are separate claims and a fixture with everything set proves only the first. The weathered entry is seeded through the engine's own weatherKeyFor rather than through a string this test made up, and the test asserts the key came back non-empty first — with weather imagery off the key is empty, the seed writes nothing, and the layer would otherwise have reported success against a cache nobody read.
Tried it, and it failed for a reason worth keeping. The needle came off Joss in a straight swap for the Drowned Saltbloom, so the purchase cost nothing in objectives. Its hook wants "a kill made in total silence", the choristers respawn now and one had its lore banked already, so the kill itself was free of consequence — and it still did not happen. Six rounds, not one hit landed, thirty-two hit points gone, disengaged at 113 of 145. That is not bad dice. Silence removes a Tide Cantrix's entire kit, because her competence IS voice, and what it leaves is a caster swinging a blade on STR 7 and DEX 8 while a Chilling Grasp lands nearly every round and stacks chilled grip and verse-gnawed on top of her. A Bladeward would walk this hook. She cannot, and no amount of retrying changes that, so the notes say do not retry it on her. It belongs with BUG-062's family without being quite the same thing. The containers and the duplicated placements make the denominator wrong arithmetically; this one is wrong per CHARACTER — a condition that is ordinary for one class and structurally unreachable for another, with nothing in the plan expressing the difference. Recorded rather than filed, since a class being bad at something is legitimate design until it collides with a completion count that assumes it is not. The equip directive added an hour ago was verified in the same session, on the way in: "I draw the sable undertow and hold it ready" put it in the weapon slot, which is exactly what narrated nothing yesterday. The notes entry that recorded it as broken is struck through rather than deleted. Also worth recording for whoever picks this up: the login screen defaults to whatever character it likes, and "Continue Your Journey" restores tlr_game_state rather than the name in the box. It loaded Test2 and played a turn as her before I noticed. Claude18's library mirror was untouched, and restoring it is a matter of copying that mirror back over tlr_game_state — the key has a NUL between the character and the world, not a space, which is worth knowing before writing anything back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both DM's Guides carried a row asserting that "hostility is expressed via type/kind and faction standing — there's no separate aggression number", which stopped being true when the aggression field shipped and is now wrong twice over. That row is replaced by the two dropdowns a being card actually has, with the point that matters for both: they are traits of the KIND, so setting either changes every being of that name and the catalogue entry the next one spawns from, and blank is a real answer on both rather than an unset one waiting for a default. The Field Guide gets its own Size section beside Aggression, in the same shape — the six steps with heights and examples, then what the field decides. It is a player-facing field because the Compendium popup shows it, and because the two questions it settles are questions a player asks out loud: can I carry this thing off in my pack, and why did the banner paint the rat that size. The victory-banner sections of both guides and the design doc gain the same paragraph, because the mechanism is worth stating once and not obvious: the banner's scale sentence is written rather than gathered, since every reference picture is cropped to fill its own frame and a rat's portrait and a hero's portrait are therefore the same size on the page. That is also why an unauthored creature gets no scale sentence at all — the honest silence is the design, not a gap.
Two separate systems were guessing at how big a creature is, and both guessed wrong. The Game Master was asked whether a wolf could hide in a backpack and had nothing to reason from but the word "wolf" — nothing in the world data said how big a wolf is, so the answer was whatever the model felt like that turn. And a victory banner painted a rat at the same scale as the hero. That second one is not a prompting failure and no amount of better prose about the rat fixes it: every reference picture the banner is handed is cropped to fill its own frame, so a rat's portrait and a person's portrait are the same number of pixels and nothing inside either says which subject is smaller. So size stops being something to infer and becomes a field, exactly as aggression did before it and for the same reason. CREATURE_SIZES declares six values once — tiny, small, medium, large, huge, gargantuan — each carrying a display label, a height range, a person-relative sentence for the art, and a bulk quoted in the SAME units an item's size is, which is what makes "can a rat hide in a backpack" arithmetic rather than an opinion: an ordinary container holds eight units, a tiny creature is two, a small one six, and a wolf is twenty and does not go in. Change those numbers and you have changed what can be carried, which is why the comment says to change them knowing that. An unrecognised word normalizes to '' — unstated — rather than to a guess. That is the whole design decision, and it is why the alias table is short: "giant" is not on it and will not be, because a giant is Huge and a giant rat is Small, and a guess landing two rows out is worse than an honest blank. The same principle runs to the end of the pipeline. An unstated size prints no size key in the dossier rather than "size:none", the rulebook tells the GM to judge an absent one from the creature's nature and explicitly not to assume Medium, and the victory-banner prompt says nothing whatever about scale when it has nothing to say — because a confidently wrong scale asserted to an image model is how the rat came out person-sized in the first place. The field reaches every surface that needed it. It is on the shared BEING_AUTHORED_FIELDS registry, so every prompt that authors a being asks for it and none of them spells the vocabulary out by hand — the equipment-slot lesson applied before it could happen a third time. It is on ENTITY_KIND_FIELDS, because how big a rat is, is a fact about rats: a world where this rat can be pocketed and the one next door cannot would be a bug wearing the clothes of authorship. It is a picker on every being card, a row on the Compendium popup when authored, a bare value beside each being in the Entities present dossier and beside each foe in the ## Combat block, and rule 4c defines the six once in the cached half of the prompt rather than glossing them per being per turn. The size select gets its own class rather than borrowing .npc-agg-select. A test counts aggression pickers to prove there is exactly one per card, and a second control wearing that class would have made it count two and call it a pass. One fix travelled with this. fillEntityGaps stored a completion's answer verbatim, which for a vocabulary field means a GM answering "Hostile on sight" — answering correctly — left a field that looked filled on the card and matched nothing anywhere else in the game. Both vocabulary fields now go through the same normalizers the load path uses, and a word neither table recognises is no longer counted as a fill at all. Two existing assertions were rewritten rather than accommodated. test_npc_tasks matched a reputation within eighty characters of an NPC's name; the roster line grows whenever a being gains a field, and it grew past that window today, so it now reads the being's own line. test_entity_card_update's allow-list is hand-written on purpose and gained the field deliberately.
Until now nothing in the app could equip anything except a drag of the mouse in the Equipment tab, and the model has no hands. So a narration that said the armour went on changed nothing at all. Observed in play yesterday: "you pull on Hesk's leathers and lace them from ankle to throat" — and afterwards the doll was empty, the AC unchanged, the item's worn effects never applied. The same shape as a sale that never settled and a gift that never moved: the prose right about something the engine did not do. equipItem and unequipItem now exist, taking a bare name or an object, singly or as an array. The GM is not asked to learn the paper doll — every piece of gear declares where it can go, so the engine resolves the slot from the item and prefers a free one, which is what makes "she puts on both rings" fill two fingers rather than one twice. Unequip is applied before equip in the same turn, so a swap frees the slot before the other piece reaches for it. The move itself was EXTRACTED rather than copied. equipItemIntoSlot is now the one thing that puts a pack item on the body, and the drag path calls it too — because a class gate, worn effects and the combat cost that applied on one route and not the other would be worse than not having the directive. The one other assignment left is the worn-to-worn swap, which has to preserve the displaced piece and so genuinely is a different operation; the test pins that there are exactly two and that both are gated. Refusals are logged rather than silent, which is the whole lesson of the bugs above: equipping something not in the pack is refused and said (equipping is not acquiring), so is gear with no slot, so is off-class gear. The class-gate test needed repointing and is better for it — it pinned the gate by where the lines sat inside equipDrop, and now asserts that the shared writer holds it and that every route goes through. One thing worth recording, because it cost the sabotage its meaning for a while: an item declaring `classes` and no `subtypes` is treated as LEGACY, where `classes` meant the taxonomy label — so the restriction silently resolves to none. The fixture was wrong, not the engine, and the tell was that the sabotage passed while the real code equipped the item too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The run resumed Exhausted and could not have gone anywhere: fatigue cuts effective stats, carry capacity is STR×2 + CON, and 17 against 24.1 carried means every pickup fails silently and the narthex refuses the ladder. A night's sleep put it at 25 and the session became possible — worth writing down, because the same wall stopped the previous one and was diagnosed there as a content problem rather than a tired character. Sleeping also unprepares the verses, which is its own small tax. Then the dive. The Diving Leathers hook wanted a full descent to the cathedral floor and got one, with what the leathers MEANT said out loud in the same breath — nobody boils hide and packs the seams with tallow to fish off a scaffold, so somebody has been going all the way down on purpose and often enough that the trade has proper clothing for it. That paid the item hook and a world-lore entry with it, "The Suits That Came Back Empty". The Gasping Kelp came up on the way back. 89 → 91. The BUG-059 watch ran across all thirty-eight turns and flagged nothing, and that is a weaker result than it sounds: no nearMiss fired either, so the run never put the Game Master in the position rule 13e governs. The instrument is live and proven by test; it simply had nothing to see. Evidence continues next run. Two findings came out of playing rather than looking. The Bone Needle's condition asks for a kill made in total silence, so it is declined rather than missed — this character has resolved every encounter without violence and a kill costs a being hook, which is what the Gill-Wretch already taught. And the Game Master has NO directive for equipping: it narrated lacing the leathers on, and nothing happened, because the only writer of equippedItems is the Equipment tab's drag-and-drop. The same shape as the sale that never executed, and worth its own entry. The two captured scripts collapse into one. Capture persists in the save, so this session appended to the last one rather than starting a new record — run2 was a strict prefix of run3, and keeping both would have filed the same thirty turns twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-059, both halves. The rule already covered the case that failed — it says to set nearMiss when you judge a locked hook satisfied and hold it back — so the Gasping Spire turn broke it as written. That makes it a compliance failure rather than a gap, and the reason it can fail is in the wording: "when you judge" names a private mental state, and a model that does not notice it judged anything cannot act on it. So the trigger is restated as the one signal always available: READ BACK WHAT YOU JUST WROTE. If the narration says the player did the thing the condition describes, and the hook is not being unlocked this turn, nearMiss is mandatory. The observed case goes in beside it, with the narration that was written and the two nulls that followed it, and with the fact that settles it — the hook unlocked a turn later and the reason given credited the earlier turn. It lands in the stable half of the prompt, so it costs nothing per turn. A contract change cannot be tested, only played, which is the other half. The defect is SILENCE and has nothing on screen by construction; both recorded cases were caught only by reading raw GM responses, which a player cannot do. But it leaves one trace, and it is decisive: when the hook finally unlocks the GM states its reason, and if that reason describes an EARLIER turn then that turn was judged sufficient at the time and paid out nothing. That is exactly how the Gasping Spire case was proven. So an unlock's stated reason is now compared against the command that just ran and the six before it, and an earlier turn explaining it clearly better is flagged — to the DM log, and onto the captured turn so it travels into the exported script rather than dying with GAME_LOG on reload. When the nudge DOES fire that is recorded too: a run that flags nothing proves little on its own, since it may have met no hooks, while one that also shows nearMiss firing is evidence the contract is being honoured rather than untested. Observational only, and deliberately so. Nothing is altered, no nudge is synthesised, the GM is never overridden — the engine must not become a second adjudicator of the fiction, and word overlap is far too crude to be one. It cannot judge whether a condition was met and is not asked to; it answers only whether a reason sounds like this turn or an earlier one, and it is tuned to miss rather than to guess, because a false flag would put a defect in the record nobody can reproduce. Compared on a five-character stem, which is not cosmetic: the real reason says "counted ... breath" where the command said "count ... breathing", and whole-word overlap scores those as unrelated. The sabotage case removes the stem and confirms the real case then goes undetected. Two existing tests broke on the reformatting of a line they pinned verbatim while its behaviour was untouched — the nearMiss-beside-a-reward guard. Both now assert the guard rather than the line it happens to be written on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A card in Journal › Gallery says what was beaten and, until now, said nothing more about it. The name is the obvious thing to click and it did nothing. It now opens that creature's detail popup — what it was, its health, its aggression, its lore — beside the card that commemorates killing it. Resolved by NAME, because a victory record keeps foeName and nothing else identifying: no uid, no catalog ref. Adding one now would only help victories won after today, and a gallery is history — it has to work for what is already in it. The live world is consulted first, since a defeated foe usually still exists there as a dead entity and the live one carries this run's own hp, state and inventory rather than the template's; only a creature removed outright falls back to the catalog, built through makeEntity because buildNpcDetailHTML calls methods on what it is handed and a plain spec would throw. A name that resolves to nothing stays plain text rather than becoming a link that opens nothing. That distinction is the point: an inert link invites the click and then says nothing, and the player cannot tell that from the popup having failed. So the underline means there is something behind it. The popup is #sidebar-entity-popup, chosen for the reason the equipment doll had to learn twice this week: it lives outside every view-*, so it shows wherever it is opened from. The Gallery subview has no popup slot of its own and giving it one would be a second place this works rather than one place it always does. The test asserts that structurally — it walks the markup tracking div depth and requires the popup vgShowFoe names to have no view-* ancestor — so moving it into a tab later fails rather than silently rendering into a hidden pane. Keyboard-reachable too: the name is a span rather than an anchor, so it carries role and tabindex and answers Enter and Space as well as a click. Checked against the old source by reverting only the card markup and leaving the new functions in place, so the assertions actually ran rather than the harness throwing on a missing export — which is how the first attempt at this verification failed, and is the same trap as the sabotage that failed to parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Yesterday's change gave a filled slot in the sidebar doll its own popup, and sent it to #equipment-item-popup — which lives inside #character-sub-equipment, inside #view-character. That popup is hidden along with the tab that contains it. So the fix worked on the Equipment tab, where the popup was never needed, and did nothing at all anywhere else: clicking a worn item from the Story tab set display:block on an element inside a hidden pane, and nothing appeared. The sidebar is visible from every tab, so anything it opens has to be too. #sidebar-entity-popup already is — a direct child of #app, outside every view-*, anchored just left of the sidebar, and already what the Inventory and Occupants blocks open for this same gesture. The doll now asks for that one. equipShowSlotItem takes the destination as a parameter rather than being duplicated, defaulting to the tab's own popup so the Equipment tab's slots are untouched. One handler, two places to draw it, and the difference is the DOM each is drawn into rather than anything about the behaviour. My verification was what let this through. The test checked that the popup element's own style.display had changed, which was true and meaningless: the element was inside a hidden ancestor. It now measures a bounding rect and walks the ancestor chain for display:none, and it was re-checked in the running game across Story, Character, Journal, Compendium and Maps rather than on one tab. The suite gained the invariant that was actually violated, expressed structurally rather than by name: it walks the markup tracking div depth and requires the popup the sidebar names to have no view-* ancestor. The counterexample is asserted alongside it — the Equipment tab's own popup IS inside a view — so the reason the obvious reuse is wrong is recorded rather than remembered, and moving either element into a tab later fails the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Equipment block exists so a glance at what is worn does not cost you the Story tab — in a dungeon, the corridor you are standing in. Clicking an individual item then charged you exactly that: the slot had no handler of its own, so the click fell through to the figure and opened the full tab. Asking about one item and being handed the screen is a worse answer than the question deserved. A filled slot now opens that item's own popup. The figure underneath still opens the tab, which remains the right answer for the body, for an empty slot, and for the gaps between them — so both readings of the doll survive, and which one you get is the one you pointed at. The two only coexist because the slot stops the click reaching the figure; without that the popup opens and the tab opens on top of it, which would have been the original complaint plus a popup. The test pins the stopPropagation, and pins that it comes FIRST in the handler, so a throw inside the popup call cannot let the tab open anyway. It reuses equipShowSlotItem, which is the Equipment tab's own handler for exactly this gesture, rather than growing a second one beside it. That is the same rule the block already lives by for its slot table: one definition, two dolls. It targets #equipment-item-popup, anchored clear of the sidebar's width so it lands beside the doll rather than under it — and the test checks that id is in both dismissal registries, because a popup opened from a new place still has to close from it. Verified in the running game as well as in the suite: with an item staged into a slot, clicking it left the tab on Story and opened the popup on the item, and clicking the figure still switched to Character. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A save records where a character is standing and what is in its pack. It does not record why, and why is what a session picking the character up actually needs. The Bladeward's Plate is lying in the Drowned Narthex; the save says so. What it does not say is that it was abandoned there because the room refuses the chain-ladder while twenty-eight weight of it is in a pack rated for twenty-one, and that going back for it is therefore not a plan. Hesk refuses to buy three books; the save does not say that this is the residue of BUG-063 narrating a sale that never executed, and that Ket Drybound will buy them happily. Two quest beats are locked; nothing in the save says they are locked forever because the opposite arm of each choice was taken. Every one of those was rediscovered the slow way at least once this week, twice in one case. So each character now gets a notes file beside its scripts, written for whoever picks it up next — which is usually a session with no memory of the last one. Five sections: where it stands, what is cached and where, what is permanently foreclosed, what was tried and did not work, and what to open first next time. The foreclosed section is the one that earns its keep, because the alternative to knowing is hunting for content that is not there any more. Claude18's is written from its save rather than from recollection, which caught something worth having: the Gasping Kelp is still lying on the floor of the spire's lower chamber, an objective the last session never got back down to and would not have remembered. The convention says to update the file before logging out, in the same breath as the logout — a session that ends without updating its notes has spent its findings and kept none of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both scripts minted a single self-signed leaf. That encrypts perfectly well and is vouched for by nobody, so the browser stops you at a warning every time and the desktop app refuses the connection outright - and a better self-signed leaf never helped, because the objection was never to the encryption. It was that the chain ended at a stranger. They now produce two certificates: a local root authority, and a localhost leaf signed by it, with server.cert holding the chain leaf-first. This is not a new procedure. Settings > Server > "Serving over HTTPS without the browser warning" has documented it by hand for some time, and test_https_setup_doc actually executes that recipe. The scripts simply never did what the page next to them described. So make-cert.sh automates that page exactly - same file names, same subjects, same extensions - which is also what stops the two drifting into describing different things. The two scripts differ in HOW, and the difference is the whole point of the feature. openssl can mint a root but cannot install one, so the shell script prints the trust-store commands per platform and leaves that step to the operator. mkcert does both halves, so make-cert.ps1 uses it: mkcert -install creates a per-machine root AND puts it in the Windows store (and Firefox's, where it can find certutil). That install is what actually removes the warning. Writing the root into server.cert, which both scripts now do because it was asked for, is tidy and changes nothing on its own - a browser trusts a root because it is in the trust store, not because a server offered one. Both scripts say so in as many words, because a DM who believes the file did it will report the warning as a bug. Three decisions worth naming. The shell script REUSES an existing rootCA rather than replacing it: a root's whole value is that a machine has been told to trust it, and minting a fresh one on every run would silently invalidate that and send you back to the warning page with nothing to explain it. Half a root pair is refused rather than repaired, because guessing which half to regenerate either orphans a key that is still trusted or issues leaves nothing can verify. And make-cert.ps1 now REQUIRES mkcert, failing with the install command, rather than falling back to the old self-signed path - that path produced exactly the certificate this change exists to stop producing, and a script that sometimes does the new thing and sometimes the old is worse than one that says what it needs. That is a capability change on a Windows box without mkcert, and the openssl route remains documented and automated in make-cert.sh. test_make_cert.js runs the shell script in a temp directory and reads the result back with openssl: the chain is two certificates leaf-first, the leaf carries its SANs and CA:FALSE, the root is a CA that may sign, the bundled second certificate IS that root by fingerprint rather than by looking like one, the leaf verifies, extra host arguments land as the right kind of SAN, a re-run reuses the root, and both refusals refuse. Eleven sabotages verified against it. Two of my own assertions were vacuous and are recorded rather than quietly fixed. The mkcert -install check matched `-install` anywhere in the file, so it passed against a script with the call deleted - the comment block above it explains what mkcert -install does, and an assertion that a script is DOCUMENTED to do something reads identically to one that it does it. It now strips comments first. And the chmod assertions do not move when the chmod lines are deleted, because openssl already creates a -keyout key as 0600; they assert the mode the file ends up with, which is the thing that matters, and the note says so rather than implying coverage the test does not have. server/.gitignore also gains *.srl, the serial bookkeeping x509 -CAcreateserial leaves beside the root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
BUG-060 is marked fixed on a mechanism that is unit-tested and has never been fired in play: nobody has killed a chorister, waited an in-world day, looted the one that returned and traded three tokens to Ys in a single exchange. That confirmation is owed, and the note recording it lived in the ledger, which a run starting fresh has no particular reason to open. So it goes in the playthroughs README instead, next to the two other things only a fresh character can do: take the opposite arm of both Saltmonger branches, which no continuation of Claude18 can ever reach because it already chose the other road, and leave the Gill-Wretch alive. Also repairs a link the rename broke — the label was updated to evaluation-plans and the href still pointed at the old walkthroughs directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-060 closed. The hook asks the player to trade three tokens to Ys in a single exchange and the world places two, both on Drowned Echo-Choristers — one in the Gasping Spire's lower chamber, one in the Reciting Crypt. The obvious fix was to scatter a third somewhere, and it would have left the arithmetic exactly as brittle: a fixed count that any earlier run can spend, and that nobody re-checks when an item moves. Both choristers now respawn on a one-in-world-day cooldown instead, so two placed tokens yield as many as a patient player needs and the hook stops depending on a census. Doing it that way meant finishing respawn first, which returned creatures empty-handed — a respawning chorister with nothing in its hands would not have fixed this hook at all. That is the previous commit. The token also went onto the drowned_echo_chorister catalog entry so the species canonically carries one. Worth recording that this alone would NOT have reached the two placed choristers: both carry their token on the room instance with no ref, and makeEntity only consults a catalog template when a ref is set, so the catalog is inert for them. It matters for choristers placed in future; the two that exist were switched on directly, in the draft and in the live save both. One wrinkle handled while applying it. In Claude18's save the spire chorister is alive and already stripped, so letting the sweep snapshot its loadout would have baked an empty pack in as its baseline and quietly made the fix a no-op for exactly the creature that needed it. Its baseline was seeded from the authored draft instead. Marked fixed rather than open, but the note says plainly what is and is not verified: the respawn cycle with its restored loadout is driven twice round in the test, and nobody has yet killed a chorister, waited an in-world day, looted the returned one and actually traded three tokens to Ys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Respawning was implemented — the sweep, the per-being cooldown in in-world days, the editor controls, the clock tick, the materialization message — and it was missing the two things that make it worth having. respawnEntity promised in its own comment to restore a creature "whole, as if never slain" and returned it empty-handed. Health, statuses, position and home all came back; the pack did not. So a respawning monster was a repeating fight over nothing, and the single most useful thing a respawn can be — a renewable source of whatever that creature carries — did not work at all. That surfaced while looking at BUG-060, where a lore hook asks the player to trade THREE Tide-Cult Tokens and Verengrad places two, both on Drowned Echo-Choristers. Respawning them only fixes the hook if they return holding one, so the loadout is now remembered and given back. Remembered WHILE THE CREATURE IS ALIVE, which is the only moment it can be read reliably: after death the corpse gets looted, and the existing death stamp is written lazily on the first tick that sees the body, by which time the pack is usually already empty. A snapshot rather than a lookup against the entity catalog, because a placed being need not come from the catalog at all — both of Verengrad's choristers carry their token on the room instance with no ref, so makeEntity never consulted a template for them and neither can this. Restored through makeItem so each cycle hands out a fresh item; handing back the remembered spec itself would let looting one mutate what the next respawn restores. The Settings toggle is also now real. "Enable respawns" was drawn, written by its checkbox and read back to render itself, and never once consulted by the sweep — turning it off did nothing and the world went on returning the dead. A control that does not control the thing it names is worse than no control, because it is believed. The existing test covered the fields, the timing, the message and the editor card, and said nothing about the inventory, which is how the gap survived. It now drives a full cycle twice over — remembered alive, corpse stripped bare, still dead at half a day, back with its token at a full day, and back again on the next cycle — plus the toggle, plus a sabotage confirming the restore line is what carries it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Claude18 goes from 76/114 to 89/114 in thirty turns: the Hush-Choir floor cleared, the Anchor Saint's brass verse-key earned and read, the drowned vestry door given back the line the choir behind it refused, the reliquary ark opened, and the cloister harvest carried out. Four lore hooks and nine items. The script beside it is the first one nobody transcribed. It was captured as it was played, it carries its own baseline — the level-8 state the previous script ended at — and so it is the first script here that needs no companion save. Nine of its thirty turns record what they earned and the rest earned nothing, which is the normal shape of a run and is why that field is allowed to be empty. Its first eleven turns are blank for the dishonest reason instead: they were recorded before the capture defect was found. Two findings go into the manifest rather than into a report nobody re-reads. Item objectives assert inventoryHas, which the grader reads as CURRENTLY CARRIED, and this class hit its carry limit holding 20 of 35 of them — then the Drowned Narthex refused the chain-ladder outright while the Bladeward's Plate was in the pack, which at 28 weight against a limit of 21 is the world enforcing in fiction what the arithmetic already said. The item objectives of this plan are not jointly satisfiable by a Tide Cantrix, so 38/38 is not a target any single run of her can be graded against. That belongs with BUG-062's other reasons the denominator lies. The second is smaller and worth watching: the Fish-Oil Wick and the Brined Patch-Cord are authored into the Bell-Tower Market's stock and are in no live inventory at all, so they cannot be bought at any price. Most likely consumed by an earlier run and never restocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-063, found in play while trying to fund the Diving Leathers. Hesk was offered three books, the prose said he counted the coin out onto the wood, his reputation went up eight points, and afterwards the books were still in the pack and the purse still held nothing. The model was right every time. It emitted a well-formed sellItem array naming the buyer and the three items. The engine threw on it: "Cannot access 'hereNow' before initialization". The sellItem loop passes hereNow to applySellItem, and the const hereNow it passes was declared fifteen lines below the loop, so every sale ran inside the temporal dead zone of its own binding. applyStateChanges caught the throw, abandoned the rest of the directive block and let the turn continue, which is why it looked like a rich confident narration attached to nothing rather than like an error. It arrived as the fix for something else. The comment above the loop records moving sellItem onto hereNow so a sale on a turn that also travelled would resolve the merchant against the room the player is now in rather than the one they left (BUG-049) — and the call went in above the declaration. Selling has been completely non-functional since, on every path, for every item. test_sell_item.js passed throughout, and would have gone on passing forever, because every case in it calls applySellItem directly: the unit was correct and the call site was dead. It now asserts the ordering across the whole of applyStateChanges rather than two line numbers — every use of hereNow must follow its declaration — so it survives the block moving and also catches the next directive that reaches for it from above. Driven against the old source it fails and names the offending line. Third time on this ledger for the same shape, so it is worth saying plainly: the failure is never the narration being wrong, it is the narration being right about something the engine did not do. A gift, an item hook and now a sale have each failed this way, and each time the model was suspected first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Found by reading the record a live run was producing: every yields line was empty, across turns that had taken three items, unlocked two lore hooks and gained a hundred and thirty XP. The commands were right, the rooms were right, the baseline was right, and the one field the record exists to explain was blank. buildGameSnapshot flushed the capture before writing it, on the reasoning that a save should not contain a half-finished turn. But saveGameState is debounced at 500ms and a GM turn takes about ten seconds, so that flush never once ran at the end of a turn — it ran in the middle of every one, roughly half a second after the command was recorded and long before the model had answered. It diffed the world against itself, wrote an empty yields, and set _closed, which made the emptiness permanent because closePlaythroughTurn refuses to reopen a closed turn. The feature shipped yesterday with its central column quietly full of nothing. The mistake was treating a save as an ending. It is not one: a turn is finished when the next command arrives, and that is the only cheap moment at which that is true, which is why capturePlaythroughTurn closes the previous turn and why nothing else should. The snapshot now writes the record as-is with the open turn still open. Its _before rides along in the save and survives a reload, so a session resumed mid-turn still closes that turn correctly rather than losing it. buildPlaythroughScript still flushes, because an export IS a real end. The test that should have caught this was pinning the wrong thing — it asserted the flush was present, so it passed the whole time and would have gone on passing. It now asserts the opposite, checks the snapshot body for any flush at all rather than one exact line, and gains a behavioural case that drives the actual sequence: record a command, take a save before the changes land, land them, send the next command, and require the yields to survive. That case fails against the old code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Extends BUG-062, and it is the part that matters. The Saltmonger's quest branches twice: the ledger is recovered either by deciphering the arch-verse or by dredging blind without it, and it is then either returned to Hesk or carried to Ys instead. The evaluator emits an objective for each arm, so a plan of 114 contains at least two pairs where satisfying one member permanently forecloses the other. This was found while answering a plain question — whether Claude18 could still finish, or whether a fresh character was needed. It cannot finish, and not for any reason play can fix: it deciphered the verse and returned the ledger, so the two beats it is missing are the roads not taken. Reading them as "content still ahead" would have sent a run looking for something that no longer exists in its world. The consequence is that "objectives satisfied" reads as a completion percentage and is not one. A world with branching quests has a per-run ceiling below its objective count, and nothing in the plan expresses that ceiling. For Verengrad it is 103 rather than 114 once the containers and duplicates come out, and 101 for Claude18 in particular, which also killed the Gill-Wretch and closed a being hook that way. The fix is either to mark mutually exclusive arms and count the group once, or to publish the ceiling beside the total; either way the grader should stop implying a score unreachable by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-062. Rebuilding the plan surfaced two defects in the evaluator that inflate its objective count. Every container sitting in a room is emitted as something to ACQUIRE - a strongbox, an iron coffer, a wooden chest, and an offering basin at a shrine - which asks the player to carry off the furniture. And objectives are emitted per placement rather than per item, so the three items placed in two rooms each appear twice, where acquiring the object satisfies one of the pair and leaves the other permanently unsatisfiable. Nine objectives in Verengrad, which sounds minor and is not. The plan is what a playthrough is graded against, and the argument for grading at all is that a regression becomes a number that dropped rather than a judgement call. A denominator carrying objectives no run can satisfy makes every score wrong by a constant - harmless while someone remembers the constant, corrosive the moment two classes or two worlds are compared and the difference is read as content rather than as arithmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The checked-in evaluation plan was compiled on 31 July from a clean-start save and never rebuilt, while the world went on being edited for most of a month. It carried 48 objectives against the current 114, and 11 rooms against 12. Grading against it did not merely undercount, it flattered: the Claude18 run scored 44/48 and read as 92% complete, and against the rebuilt plan the same save scores 76/114 — 67%. The number was real and the ruler was wrong, which is the more dangerous of the two failures because it looks like success. It is rebuilt from the authored world draft rather than from a save. A mid-run save has lore already unlocked, items already moved and beings already dead, so compiling from one bakes the run's own progress into the thing that is supposed to judge it. The draft in the editor carries the same world uid the run is playing, which is what makes it the right source. Both figures in the manifest move with it, and the manifest now says outright that grades taken before today are not comparable to grades taken after — the ruler changed, not the run. That note matters more than the numbers: without it the drop from 92% to 67% reads as a regression rather than as a correction. Rebuilding also exposed that 114 is not a fair denominator either, and the manifest records the arithmetic rather than quietly carrying it. Five of the outstanding objectives ask the player to ACQUIRE a container that is bolted into a room — the Saltmonger's Strongbox, the Coral-Crusted Reliquary Ark, a wooden chest, the Verger's Iron Coffer, the Offering Basin of Drowned Coin — and four more ask for those containers' lore. Three items are emitted twice because they are placed in two rooms. Filed as BUG-062. Netting those out, and the Gill-Wretch foreclosed by a kill and the rope coil that wants weather the run never saw, the honest figure is 26 objectives of genuinely unplayed content still ahead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A victory banner is painted 896x512, and at full column width one card filled the panel - the tab read as a scroll of posters rather than a gallery. The cards are now a responsive grid: auto-fill with a 260px minimum, so two or three sit across a normal panel and one across a narrow phone, with no media query. Each tile CROPS its picture to a common 16:9 rather than letterboxing it, because a grid of equal tiles is what makes a gallery scannable - and because the crop loses half of a wide scene, the uncropped picture is one click away in the lightbox the tiles already opened. The other half of the report - that gallery entries vanish on reload - is NOT fixed here, and I would rather say so than ship a guess. What I checked: player is saved whole by buildGameSnapshot, restored through reInstance, which is Object.assign onto the prototype and keeps every field; and a round trip of record > snapshot > JSON > reInstance keeps the entry and its picture. The save path prunes nothing. I did ship a fix for it and then took it back out, which is the part worth recording. The theory was that the banner lands inside SAVE_COOLDOWN_MS (20s) of endCombat's own write, so an ordinary debounced save could still be pending at reload. That is wrong: addMsg already calls saveGameStateSoon, so the write was urgent all along. The test I wrote alongside it passed for exactly that reason rather than because of the change, and a sabotage removing the painter's save entirely was caught by nothing - a vacuous assertion guarding a fix for a bug that was not there. The assertion now says only what it can tell apart, and the wrong theory is left as a comment because it is the obvious answer and someone will reach it again. What remains most likely, and what I cannot check from here, is the save failing outright on quota: the handler trims the story log and retries, and if that still does not fit it writes NOTHING and logs "Could not save the session - browser storage is full". Twelve wide scenes on top of a fully arted world is a plausible way to cross that line, and it would present exactly as everything since the last good save disappearing. That line in the Logs settles it either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Regenerated from a non-shallow checkout of main, so every commit through today is reflected instead of the truncated set a shallow clone would see. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APphZJDbxWhZ6jN5hsqQ9y
Reported with a screenshot: the picture generated, appeared in the gallery, and rendered as a broken
image in the story. The cause was mine and it was half a pattern. addMsg is STORED LEAN, MOUNTED WHOLE -
its own comment says so - "the log keeps a reference to the banner rather than its bytes, while the live
node below is built from `html` with the real image already in it". Moving the bytes into the gallery, I
wrote the story html with src="" and left the mount-time resolver to fill it. But addMsg builds the live
DOM node from that string, so there was nothing to mount: the resolver only ever ran on a LATER render,
and the picture the player was waiting for was broken the moment it arrived.
So it now does what a room banner does, all three steps rather than two: the html is written with the
bytes, addMsg strips them for the stored copy through stripStoryVictoryBannerBytes beside the room-banner
stripper, and resolveStoryVictoryBanners fills them back at mount. The live node shows the picture, the
log stays lean, and a reload still resolves.
The test could not have caught this and that is the more useful finding. It asserted the STORED entry was
lean, which was true throughout - the bug lived entirely in the half it never looked at. It now records
the html addMsg is HANDED and asserts the picture is in it, checked on what the painter produced rather
than on victoryBannerHTML in isolation: a sabotage that stopped the painter passing the url through was
caught by nothing until that changed.
Building that spy produced a small lesson of its own, worth the comment it got. Wrapping addMsg on every
scenario builds a chain of spies, so one message is recorded once per layer - ten banners from a single
paint, and an assertion failing against correct code. It wraps once now.
One sibling test broke honestly and was pinning a literal: test_save_debounce_and_cap matched the exact
expression `const entry = { type, html: stripStoryBannerBytes(html) };`, which a second stripper changed
while the property it names stayed true. It now reads the expression and asserts both strippers are in
it, and was checked against removing the stripping to confirm it still bites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLA gallery is the record the banner never had, and having one settles a question the feature shipped without an answer to. The note where its old cap lived said as much: "a victory banner cannot use stripStoryBannerBytes because it has no room record to be re-resolved from", so it kept the pixels in the story log and dropped the oldest three deep. Journal > Gallery is that record. The bytes now live there once and the story entry carries only the id, resolved at mount time beside the room-banner resolver - exactly the arrangement room banners have always had, and for exactly the same reason: the log is capped at 500 entries, every one of them is saved, and a wide scene is not a small thing to keep two copies of. Aging out is the gallery's business now rather than the transcript's. Twelve is a judgement rather than a measurement - enough to feel like the record of a campaign, small enough that a save carrying twelve wide scenes is comparable to a world carrying banners for a dozen rooms, which every world already does. An entry is dropped whole rather than stripped of its picture, because a caption with no image is not a gallery entry; the story line it belongs to then says the picture has aged out rather than rendering the broken-image glyph an empty src produces. Each card folds the GM's own account of the kill beneath the image - what the player typed, and what the Game Master said back. That pairing is the point of the tab rather than a decoration on it: the picture is a COMPOSITION and can only ever approximate the moment, while the narration IS the moment, in the words it actually happened in. Folded rather than shown, because a wall of prose between pictures is a transcript and not a gallery. A victory with nothing recorded behind it offers no control at all, since an empty fold promises an account of the fight and delivers a blank. The id cannot come from a counter: a counter restarts when the save does and would collide with an entry already stored, and the story addresses these by id - a collision shows the wrong fight. player is saved whole and restored through reInstance, so the gallery survives a reload without an allowlist entry. Ten sabotages verified, including the two that would be invisible in play: the gallery dropping the newest rather than the oldest, and ids colliding. One sibling test broke honestly - test_dungeon_story_ episode extracts messageNodeHTML and stubs its dependencies by hand, and this gave it a second banner resolver to stub. All three guides and the design doc are corrected; they all said the banner lived in the story and nowhere else, which was true when written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported from a live game, and the report was the whole finding: "I was not using nano banana." The banner resolved its provider from the IMAGE slot - the one labelled "portraits & banners" - so the only way to switch this feature on was to point that slot at Nano Banana, moving every room banner and every item picture onto it too, at its rates rather than the free Pollination tier. Hundreds of images repriced to enable one. That was the wrong slot and it was mine. The app already had the right answer. `gallery` and `weather` exist precisely so a reference-taking provider can be configured for reference work alone; the API Keys dialog says so in as many words - "the portrait Gallery and weathered banners need an image-to-image provider, so they stay on Nano Banana". A victory banner is the third member of that family. It now runs on `gallery` rather than `weather` because the two differ in kind: weather RE-SKINS one existing picture, while gallery COMPOSES a new one from references, which is exactly this. The capability check and the paint both read that slot, and the test asserts they read the SAME one - checking one and painting on another would be worse than either. Both slots are hard-wired to Nano Banana Pro: it is the only provider declaring them and NANO_MODEL_SLOT_DEFAULT sets 'pro'. So there is nothing to choose, and the refusal message now asks for a KEY rather than a provider - telling a DM to pick one would send them looking for a picker that offers exactly one option. Their Image slot is untouched, and the message says that too, because "you need Nano Banana" reads like "change everything" otherwise. The coherence argument for staying on `image` is weaker than it looks and is worth writing down: the banner composites the portraits that slot already painted, so it inherits their look through the attachments whatever it is generated on. Three sabotages verified - the slot reverted, the check and the paint split across two slots, and the message going back to naming a provider choice. All three guides and the design doc are corrected: they all said "an Image AI that accepts reference pictures", which was true and pointed at the wrong picker. The design doc gains a section on the slot choice and one on why a correct refusal still has to speak in the story, since both were found by the same afternoon's play rather than by reasoning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported from a live game: a rat killed with "All Victory Banners" checked, the recap printed, and no
banner. Nothing was broken. The chosen Image AI cannot carry reference pictures, so the banner declined
exactly as designed - and the only record of that was a line in the DM-only Logs tab. From the story it
was indistinguishable from a feature that does not work.
Reproduced before changing anything: with the Image slot resolving to Pollination, canAttachRefImage is
false and endCombat's paint returns { skipped: 'no image input' } having written one Logs entry. The
rest of the chain is fine; the refusal is correct. The silence is the defect, and it is mine - the
commit that shipped this predicted "why did my fight not make a banner" would be the most-asked question
about it and still left the answer in a tab nobody opens to find out why something they did not know was
coming did not arrive.
So every refusal now prints a dim line into the story, immediately under the victory recap, saying what
happened and what would fix it. generateStoryBanner already learned this for provider substitution - its
comment says the fallback is "surfaced to the player below so the substitution isn't a mystery
(previously only a Logs line recorded it)". Same fix and the same reason, and this one names the provider
ACTUALLY IN USE rather than saying "your image provider", because the common case is that Nano Banana is
selected but has no key and resolveImageProvider quietly fell back to Pollination - two very different
things to go and fix, and the DM cannot tell which happened from a message that names neither.
Three paths speak: a provider that cannot attach pictures, a world with no art to paint from (naming the
foe whose portrait is missing, since each is separately fixable), and a painter that fails (carrying the
reason it gave). One deliberately does not: a kill that simply was not notable with the setting off. That
is the setting working as intended, and a line after every rat is nagging rather than information - the
Logs entry is enough for a DM who asks. The comment beside it says so, so nobody "fixes" it later.
Four sabotages verified for the new notes, plus a fifth that exposed a gap in my own first pass: the
failed-paint note had no assertion behind it at all, so removing it changed nothing. It has one now,
covering the note, the reason it carries, and that no empty banner frame is left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLThe feature reaches a player and an author differently, so the three books say different things about it rather than the same paragraph three times. The DM's Guide gets a section of its own in Part IV, because a victory banner is the one picture in the game a DM does not author and the one that most directly repays having authored everything else. It is assembled from art that already exists, so the table naming its five ingredients is the useful part: a world whose monsters have no portraits and whose rooms have no banners produces no banners at all, and a spell's image prompt earns its keep twice - once on its own card, once in every fight the spell ends. That makes this section an argument for the dressing pass in the section above it, and it says so. The Field Guide gets two paragraphs under "The art you see", where a player learns what pictures the game makes for them. It answers what they will actually notice: that the banner reads what they typed, that they appear as their Equipment render when they have one, and that a monster with no portrait means no banner. The book edition condenses the same material into the Art chapter's prose. All three name the setting and what it governs - how often, not whether - and say the cost is one image generation per banner and a provider that takes reference pictures. The DM's Guide additionally records the three refusals, since an author is the person likely to wonder why a fight against five produced a duel. It links out to Designs/victory-banners.html for the reasoning rather than repeating it. The new DM's Guide heading is picked up by the "//" question channel, so "// why did my fight not make a banner" now has somewhere to land - which is the question this feature will generate most, because every way it declines is deliberate and silent apart from a Logs line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
When combat ends in victory the story now carries one wide scene: the player as they actually look, the
foe as it actually looks, in the room it actually happened in, doing what the story actually said they
did. A player who typed "stomp on the rat" gets a boot; one who typed "stab it with my dagger" gets the
dagger.
The whole feature is an ASSEMBLY, and that is the property worth protecting. Nothing here invents
anything: four things that already existed are gathered, labelled and handed to the image model
together. The hero is the Equipment tab's full-body render when there is one, because it already shows
the kit; without one it is the face PLUS the equipment written out, since a face alone paints an unarmed
person and what they were holding is the point. The foe comes through portraitImage(), which falls back
across the conversation close-ups, the Compendium image and a spawning encounter's art - anything
narrower shows blanks for worlds whose art lives in those fallbacks. The place is the room's own banner
for the hour. The deed comes out of the story log, because the engine knows a rat died and only the
transcript knows how. A spell brings its picture, its rules text and its authored image prompt, which is
the only one of the three that says what the magic LOOKS like.
The references are sent in that order and the prompt numbers them in that order. The order is the
contract - buildBodyRenderPrompt learned this first, and its comment says why: several pictures with no
key is worse than one, because a model handed four unlabelled images cannot tell the hero from the rat.
Two deliberate refusals. No model writes this prompt: the narration already IS the description of the
scene, so a paraphrase would cost a call, add latency at the exact moment a fight ends, and could only
drift from the words on screen. And a provider that cannot carry reference pictures is refused rather
than sent the prompt anyway - it is written around its attachments, and "Image 1 is the hero" with
nothing attached is a worse prompt than one that never mentioned them, which is the rule
canAttachRefImage exists to enforce.
Settings > Imagery > "All Victory Banners" governs how often, not whether. Off, only a notable victory
earns one, so a corridor of rats does not become a corridor of banners each costing a generation;
notable is two plain facts already on hand - what the fight paid, and whether the thing that died was at
least the player's equal - rather than a cleverer notion of drama the engine would be bad at judging.
It lives in the story and nowhere else, but the log is capped at 500 entries and the save already fights
the storage quota, so the most recent three keep their pixels and older ones keep their line. A victory
banner cannot use stripStoryBannerBytes for this: a room banner's bytes are dropped and re-resolved from
world.rooms, and a victory scene has no room record to resolve from - which is also why it carries its
own CSS class rather than reusing .room-banner.
Two recordings make it possible, both taken at the only moment the app knows the answer: the foe whose
death ends the fight, as it falls, because endCombat cannot work it out afterwards from a set of enemies
that are all simply alive:false; and a spell cast in combat, as it is cast. endCombat captures both
before the teardown and calls the painter WITHOUT awaiting it, because the fight is over and the input
is about to come back.
test_victory_banner.js asserts what the prompt says and what the transport is handed rather than that a
picture came back, since every failure mode here still produces one. Seventeen sabotages verified.
Designs/victory-banners.html records the reasoning and four open decisions, chief among them that a
battle against five is commemorated by one duel.
Unrelated and pre-existing: test_narrative_effect.js fails on main as of 9b1ff75 ("Text change"), which
reworded the effect-editor placeholder from an example of the shape wanted to a description of it. The
test pins the old wording. Left alone - which of the two is right is the author's call, not mine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLReported from a live Art tab: "it generates the image but the portrait prompt is empty. Whenever the generate button is pressed and there is no portrait prompt, the prompt needs to be generated and stored on the object, then the portrait generated from that." Two independent causes, each enough on its own to produce exactly that, and both reproduced before anything was changed. The throwaway prompt. Every painter in this family reads obj.<promptField> || <derived>, and the derived half was computed inside the painter, used once and dropped. The picture appeared and the box it was supposedly painted from stayed blank - which leaves a DM nothing to edit and nothing to regenerate FROM, because the regenerate button refuses without a prompt. Confirmed for races, classes and factions: painted true, image stored, prompt "". The vault gate. Every prompt writer tested the bare client apiKey. In Vault mode the provider keys live on the server and that key is deliberately empty - the whole point of a vault - while gmFetch works because the vault injects the key on the way out. So on a vault-hosted realm the "write me a prompt" half of every Generate silently did nothing while the picture still painted, since paintImageFromPrompt goes through the vault too. The batch on the Art tab had it worse: it skipped each card with "no prompt". Seventeen writers gated this way, every one a DM authoring handoff through gmFetch; they now share gmReachable(), which is false when the vault has not been probed, so the fallback is the old message rather than a wrong answer. The order is now: ask the GM, store what it writes, paint from the stored text. When the GM cannot be reached the derived fallback is stored instead - it is the honest record of what made this picture and a DM can edit it into what they wanted, where an empty box behind a finished portrait is the complaint itself. Regenerate deliberately does none of it: it means "another take on THIS prompt", and writing a new one first answers a question nobody asked. Skills and spells keep refusing to paint when no prompt can be obtained, because they have no derived fallback and painting from "" renders the world art style and nothing else. It lives in runCardPortraitPaint, the one runner all five card kinds already share, with each kind declaring its prompt field, its GM writer and its last resort in the registry beside its painter. The test asserts every registry kind declares all three, so a kind added later fails there by name instead of quietly painting pictures with no prompts. test_card_generate_prompt.js drives the real runner for all five kinds and the real skill handler, against a stub that records what paintImageFromPrompt was actually asked to paint - so "painted from the stored prompt" is checked rather than assumed. Eleven sabotages verified. Two of my own mistakes are worth recording: one sabotage was a no-op because writePrompt ignored the argument I was corrupting (the parameter is now gone), and two assertions failed only for classes because buildWorld hands back the DEFAULT class records by reference, so a field written in one scenario was still on the object in the next. The fixture now clears them explicitly. That sharing is worth a look on its own: editing a class in one world appears to reach the defaults the next new world is built from, within a session. Not touched here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Reported from a live Art tab: pressing Generate on a skill card there "just seems to hang and return nothing, and the portrait prompt also does not get created", while the same button on Editor > Player > Skills worked. Nothing hung and the prompt was written. generateSkillImage ran end to end - asked the GM for a prompt, wrote it to the skill, painted the image, saved - and then finished the way every card handler finishes, by re-rendering its own home tab. That redraws the Skills tab. The DM was on Art > Missing, where the same card lives in a different container, so nothing touched it: the button stayed disabled reading "Generating..." for good, and the prompt box went on showing the empty string it was built with, which is the one place a DM would look to check whether the prompt had been written. The Missing tab is unusually exposed to this because it does not draw cards of its own. It renders the REAL editor cards, which is exactly why it needs no per-roster code and exactly why every one of their handlers redraws somewhere else. Six rosters had it - skills, races, classes, factions, spells, encounters - and four were confirmed by hand before anything was changed; all four hung identically, including the classes I added two commits ago. Six others did not, and that is the whole fix: renderNpcs, renderMonsters, renderFauna, renderItemKind, renderDungeons and renderAilments already ended with reRenderArtIfActive(). The line existed and half the rosters had never been given it. Rooms are deliberately left out. The Art tab builds room cards with buildArtRoomCard rather than the Rooms tab's builder, and their banner handlers call reRenderArtIfActive themselves, so adding it to renderRooms would fire a full renderArt on every room change during play for nothing. The test asserts that exception rather than assuming it, so if renderRooms ever grows the call the note saying it should not have one fails too. The tidier fix is a trap and is recorded in the source rather than left to be rediscovered: setCardViewHTML is the one funnel every roster renderer passes through, so putting the refresh there would cover all of them at once - and recurse forever, because renderArt draws entities through renderEntityCards, which calls setCardViewHTML. The test pins that too, by applying it and confirming the stack overflows. test_art_card_refresh.js reproduces the reported click end to end, asserting both halves separately: that the prompt and picture land, which always worked, and that the card the DM is looking at stops offering to generate, which did not. It then asserts the property for every bucket the Art tab lists, mapped from artMissingLists so a section added later fails there naming the bucket rather than being found by a DM pressing a button that appears to do nothing. Eight sabotages verified. Not filed in Evaluations/BUGS.html: its own section 03 scopes that ledger to defects only a playthrough reveals, and this one was found clicking a button in the editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Below ground a lock came off in exactly two ways, and both were things the crawler could answer for itself: a key the party carries, or a switch elsewhere throwing a named lockId. This is the third, and it is the one the crawler cannot answer at all — the author writes down an ACT, and the Game Master judges whether the party performed it. It is the lock the room doors upstairs gained last, wearing the dungeon's own address format, and almost all of the work here is joinery rather than new mechanics. The release is keyed by EDGE and never by lock id, which is the whole difference between the two ways a lock lifts. A switch throws a NAME and every door wearing it opens wherever it stands, which is what makes a lever in the guardroom raise the portcullis at the gate. An act is performed AT one door and opens that door; two doors that happen to answer to the same word are still two doors, and saying the word at one of them is not saying it at the other. Putting the release into releasedLocks would have been one line shorter and would have opened doors on other levels. And unlockAct lifts the lock and stops. Below ground the party works doors themselves — that is the rule the whole first-person view rests on, and DUNGEON_CONTRACT states it before anything else — so there is deliberately no dungeon openDoor. The difference from a room door is real and is not worth hiding: a GM that swung this one would be reaching past the player to push a door they are standing in front of. The contract says so twice, because the surrounding rule is that doors are not the GM's at all and this is the one exception to it. Two sentences had to stop being told to people. The face line said a locked door "will not budge without its key or a switch thrown elsewhere", which is a lie about a door that answers to a spoken word and the particular lie that sends a party hunting for a key nobody made; it now says there is no keyhole in it and points at the GM-only block. And openDoorAhead said the same thing to the party, so it makes the same distinction. Neither says WHICH act — that is the puzzle — and the act is published on the frame only while the lock is still on, so a door already lifted stops advertising its password to a model that no longer needs it. The Builder's row is labelled "Act" rather than "Narrated act", which is what the room-door dialog calls it. The props popup's label column is a fixed 74px shared by four panels and every label in all of them fits on one line; "NARRATED ACT" was the only one that did not, so it wrapped and stood its row taller than its neighbours. Widening the column would reflow four panels to fix one row. The full term is on the tooltip and in the Faces help, so an author meets it named and then abbreviated. Three existing dungeon tests broke on the new field, all of them loudly, because each keeps a hand-written copy of the crawler's cell shape. They are fixed rather than rewritten — a shared fixture is a bigger change than this earns, and the copies failed as crashes rather than as false passes, which is the failure mode you want from a copy. The new test does derive its cell from the crawler's own newCell, so a field added tomorrow is in that fixture tomorrow. Forty-one sabotages, forty caught. The one that was not is removing unlockAct's `if (d < 0) return ''`, which changes no behaviour: a -1 index reads edges[-1] as undefined and the door check refuses it a line later. The guard is hygiene, not correctness, and the test asserts the behaviour rather than the line.
The class portrait and its two Art dashboards shipped across two commits; this is the documentation half. Each book gets the version its own audience needs rather than the same paragraph three times: the DM's Guide gets a "The class portrait" section under Player - Classes explaining the controls, the loadout-derived fallback and the auto-paint on a GM-created class; the book edition folds the same material into one paragraph after the class-field table; and the Field Guide, which is a reference to the surfaces rather than a walkthrough, extends its Editor table's Classes row and leaves it there. All three field tables gain portrait and portraitPrompt. The reason the fallback is worth a sentence in every edition is that it is not obvious: leaving the prompt blank still generates, and what it generates from is the class's STARTING LOADOUT as much as its description, because gear is what tells two classes apart in a picture. A DM who knows that will author the loadout before pressing Generate. Two roster lists needed classes adding and were stale beyond that. The Field Guide's Art section still described Missing as gathering "people, monsters, items, flora, fauna, magic, and places" - six categories out of date, predating races, encounters, spells, skills and factions - and still called Review "reserved for a forthcoming review workflow", which it has not been for some time. Adding classes to a list that wrong would have been worse than leaving it alone, so both are now what the code actually does, Review included. The DM's Guide roster was current and needed only the one word. Verified rather than assumed: both roster sentences were checked against renderArt's section order and renderArtReview's group order, and read in the same sequence the tabs draw them. The new DMG heading is picked up by the "//" question channel, so "// how do I give a class a portrait" now lands somewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A playthrough script is an exact log of every command typed, and until now it was reconstructed by hand from a finished save's messageLog. That source destroys itself. capMessageLog drops the oldest entries past MESSAGE_LOG_CAP permanently, so a save only ever carries the trailing 500 — and the Claude18 run outran it, which is why the script checked in yesterday begins mid-story with its opening gone. GAME_LOG is no refuge either: capped at 800 and cleared outright on every load. The record was being taken from the two places in the app specifically designed to forget. The cap is not raised. It is a narrative concern — how far back the reader can scroll — and raising it to serve a recording need would hold the GM's full prose in memory for a purpose that wants none of it. The record is kept somewhere the cap does not reach instead: an append-only array beside messageLog in the snapshot, deliberately adjacent so a reader comparing them finds them together. It runs always rather than behind a toggle. A captured turn costs ~219 bytes against messageLog's ~532, measured on the Claude18 script — the command and a short yields line, none of the narration — so 500 turns is ~107KB against a save already 739KB. The failure worth preventing is a run turning out to be worth keeping AFTER it was played, and an opt-in switch is one more pre-run step to forget, the same class of mistake as the inactivity-logout toggle that BUG-055 cost an hour to. Such a switch would have been armed on the runs we already have scripts for and off on the one we actually wanted. Two decisions are worth naming because they look arbitrary and are not. A turn's yields are computed as a DIFF of the world rather than by tapping the events that produce them: there are a dozen emitters for XP, lore, beats and loot, and a capture wired into each is a roster that goes stale the first time a thirteenth is added. And the diff reads player.stats.xpEarned rather than player.xp, because xp RESETS on level-up — diffing the raw field reports nothing on the single most interesting turn in a run, which the test demonstrates by sabotage rather than by assertion. Capture also records the state the run STARTED from. That is the half that makes a script standalone and closes the gap the manifest had to write down: Claude18 resumed a level-7 save that was never exported, so the script needs a companion file that does not exist. A baseline living inside the script cannot be lost separately from it. At the cap the record stops appending rather than dropping the oldest, and says so once in the log. A ring buffer would reproduce the exact defect this exists to end, and the head is the half that cannot be reconstructed since it is what the baseline pairs with. A cap nobody is told about reads afterwards as "the run was only that long". Export Game's tooltip called the save "the whole playthrough", which was the muddle CLAUDE.md now has a section about and was actively misleading beside a menu item that exports a real one. A save is a save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Class cards grew a portrait, and the two Art dashboards did not follow. That gap has a history here: factions had exactly it, and the comment left behind records why it went unnoticed — art could be generated one card at a time and never by the batch, and nothing said so, because the card itself worked perfectly. Closing it for classes the same day rather than discovering it later. Classes now appear as a Missing section between Races and Encounters, built by the Classes editor's own card builder, and as a Review gallery group in the same position. Neither needed a line in processArtCardGeneration: compendiumTypeContext already resolves a class to its prompt and image fields, which is the whole contract that path reads. The one thing that needed thought is that a class is addressed by its KEY while a human reads its label. Every surface here passes a subject around as `name`, so worldClassList puts the key there and carries the label separately for the gallery caption — a label in that field gives the batch a name findClassByName cannot look up, and the failure would be a card that renders perfectly and generates nothing. The Missing tab's filter matches either, since the key is what the list is keyed by but the label is what the DM read on the card they are hunting for. compendiumDetailBodyFor gains a classes branch for the same reason races needed one: classes are not a Compendium category, so there is no discovered entry to fall back on and a gallery cell would open nothing at all, silently. test_art_classes.js pins all of it, and adds one assertion the existing art tests do not have: that the sections RENDER in the same order the work list runs. The two lists are already compared to each other, but the pulse is applied by index into the rendered DOM, and a section can be moved without touching either list — so that particular mistake was invisible to the whole suite. Seventeen sabotages verified, each caught by its own named assertion; two of them were found only because the first sweep let them through, including that one. Three sibling tests needed updating and each said so precisely, which is the point of how they are written: the faction test named the unmapped bucket, the gallery test's empty-world fixture needed classes cleared, and the art-tab fixture needed them arted. The fourth failure was an assertion that matched the all-done sentence in full, so it broke on a section being ADDED — a failure about the roster wearing the label of a test about the empty state. It now asserts the message rendered, and the roster stays pinned where it belongs, by the word map that fails naming the bucket it forgot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
tools/build-walkthrough.js has been the world-EVALUATION pass for as long as it has existed. It reasons about achievability over world data — objectives, reachability, cost-locks, wealth distribution — and decides all of it without playing a turn. server/server.js already called it "the world-evaluation pass" in the comment directly above the import of compileWalkthrough, which is the tell: the code knew the right word and the filename disagreed. The name was left alone once before on the argument that renaming would churn the import, the checked-in plan and every test that reads them. That argument was wrong in the way such arguments usually are — the churn is a fixed one-time cost that does not grow, while the confusion is paid by every person who opens the tree and has to work out that the file named walkthrough is not the walkthrough. It had already cost enough to earn a vocabulary section in CLAUDE.md explaining that three words were being used for each other, and a directory README opening with a paragraph of disambiguation. Documentation explaining why a name is wrong is not a fix. So: build-walkthrough.js becomes build-evaluation.js, walkthrough-assert.js becomes evaluation-assert.js, compileWalkthrough becomes compileEvaluation, tests/walkthroughs/ becomes tests/evaluation-plans/ — plans, not evaluations, so it does not read as a second Evaluations/ holding reports — and the seven test_walkthrough_*.js become test_evaluation_*.js. 52 files, 158 occurrences. Only the compound tokens were rewritten, never the bare word. "Walkthrough" is correct English in a dozen places and means the player-facing guide there: the registry's refusal to enumerate rooms because that hands any reader a walkthrough of every published realm, and the hint directive's instruction to give a hint and not a walkthrough. Those are untouched, and a blanket substitution would have quietly inverted both. Nothing keyed on the old names in data. The one place a name reaches a file is plan.meta.generator, which is a provenance string written and never read — checked rather than assumed, along with the denylist, which matches on the top-level directory and so does not care what the subdirectory is called. The two documents that argued for keeping the old name now record the rename with its date instead, so the reports and commit messages that predate today stay followable rather than appearing to reference files that are missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Classes were the last roster whose cards carried no picture. A class had a description, base stats, a loadout, inherent skills and a progression timeline, and nothing to look at — which matters more for a class than for most things, because a class is what a player picks from at the login screen, sight unseen. So the card now draws the same portrait column every other editor object card draws: Generate on an empty slot, upload beside regenerate once there is a picture, and a Portrait prompt box in the shared Prompts section with the art-style override and the GM writer beside it. Almost none of that is new code. CARD_PORTRAIT_KINDS exists precisely because four tabs once carried four copies of these three handlers, and compendiumTypeContext is the whole of what the prompt-writer reads; a class needed a descriptor in the first and a branch in the second, and the buttons work. The one thing that could not be borrowed is identity. A class is keyed by name in world.classes and that key is what every control on this card already passes — data-class-name, setClassStat, the progression dialog — while classDisplayName humanises it for display. So the resolver tries the key first and the class's own name field second, never the humanised form, and the GM directive carries the label separately. Wiring the label instead would have left the buttons inert on exactly the worlds where the two differ, which is the failure mode that reads as "nothing happens when I click". The derived fallback prompt folds in the starting loadout rather than only the description, because gear is most of what tells two classes apart in a picture; a warrior and a mage differ in the frame by what they are carrying far more than by any adjective. The class-edit contract now names portraitPrompt and requires one on creation, and a GM-created class is auto-painted the way a GM-created race already is — not awaited, since the class itself has already landed and saved and a DM should not watch a spinner for pictures. test_class_portrait.js drives the real card, resolver, context, painter and upload path against a recording stub; twelve sabotages were verified, each caught by its own named assertion, including one rewritten after the missing-registry case crashed the run instead of failing it. test_class_progression had pinned the Progression button by the template's own spelling of its interpolation, which the portrait work hoisted into a local; it now asserts the rendered card instead, and was checked against a removed button to confirm it is not vacuous. One gap left deliberately: classes are not in the Art tab's Missing dashboard, so a class portrait can be generated one card at a time and never by the batch. That is the same hole factions had, and the comment there records what it cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The replay scripts have moved out of Evaluations and into tests/playthroughs/, one subdirectory per world, because they are run rather than read. An evaluation report is prose a person opens in a browser; a playthrough script is an input to the engine, and keeping it beside the reports made it look like documentation of a run instead of a test of one. Each world's directory carries a manifest.json, which is the part that makes them a suite rather than an archive. It records, per script, the objective count that run actually reached, and for every objective still outstanding, why. That turns a regression into a number that dropped: replay the script, grade the save with walkthrough-assert, compare. Without the number a failing playthrough is a judgement call about whether the run "went well", which is not something a test can make. Every figure in the manifest was measured rather than estimated, and the command that produced it is recorded next to it. The TideCantrix script graded 33/48 against the save it resumed from and 44/48 against the save it produced, so the eleven-objective delta is what the overnight coverage run actually bought. Of the four still outstanding, two are BUG-060 — the Warding Charm exists only in SaltBroker's starting kit, so a TideCantrix can never hold it and its lore hook, which is scoped to room and pack, can never fire. One is a Gill-Wretch killed in an earlier session. One is a rope coil whose condition wants a storm the weather never produced. None of those are failures, but all four are things that had already been rediscovered more than once, which is the argument for writing them down beside the count. The Claude6 script is deliberately given a null figure rather than a plausible one. It predates both the evaluation plan and most of this world's content, and no save from that run survives, so there is nothing to measure; an invented baseline would make any later comparison against it meaningless. CLAUDE.md's vocabulary section gains the reason the three words drifted in the first place — the features arrived at different times and the distinctions were not visible until all three existed — and records why the Guide tab is correctly named. "Guide" is the short form of "Walkthrough", chosen because the tab strip is the app's tightest horizontal budget; the Guide is a walkthrough, abbreviated, and not a fourth thing for someone to reconcile later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Three artefacts with three audiences, and the prose around them has been using each other's names. A PLAYTHROUGH is an exact replayable command log, for the ENGINE. A WALKTHROUGH is a prose guide to solving the story, for the PLAYER. An EVALUATION is achievability over world data — objectives, reachability, cost-locks, wealth — for the DM and for CI, decidable without playing a turn. The UI already had this right and nothing there changes: the tab reads Guide and the setting reads Show Guide, while the ids, the world.playthroughGuide data key and the GM's [PLAYTHROUGH GUIDE …] prompt still say playthrough underneath. That is the identifiers-are-not-labels rule working, not an oversight, and CLAUDE.md now says so explicitly with the reason: a renamed data key breaks every save carrying one, and renamed prompt wording changes GM behaviour. What was actually missing is that the vocabulary was written down nowhere, so CLAUDE.md gains a short section for it, sited before Tests because that is where the confusion bites. It names the trap that cost time in this session: tools/build-walkthrough.js is the evaluator rather than a walkthrough — server.js already calls it the world-evaluation pass — and its plan's route[].commands is empty on purpose, because it knows what to achieve and not what to type. That empty field is exactly the gap a playthrough fills, which is why the two are complementary rather than alternatives. The two scripts in Evaluations now call themselves PLAYTHROUGH SCRIPT rather than REPLAY SCRIPT and carry a line saying which of the three words they are. tests/walkthroughs/README.md is re-headed as evaluation plans and says plainly that the directory name is historical while the contents are an evaluation — renaming the directory or the generator would churn server.js's import, the checked-in plan and every test that reads them, which is not worth doing for a word. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Ninety-five turns of Claude18, extracted from the save's own messageLog rather than from memory, in the shape the Claude6 scaffold-scout script established. The Tide-Cantrix had no script; it is the class this world leads with and the one every run so far has played, so it is the obvious baseline to compare the other four against once they are played. Two things the header says plainly rather than leaving to be discovered. It is PARTIAL: messageLog is capped at five hundred entries, so turn one begins mid-run with the reliquary already in hand and everything earlier trimmed by the cap while the run was still going. And it is CLASS-SPECIFIC, like its predecessor — the route assumes Echo-Calm and the shaped note and answers both the Bell-Warden and the Drowned Echo-Chorister without violence, each resolved at full health, which a Bladeward cannot copy. The regression value is the part worth keeping. Four engine fixes landed while this run was in progress, so prerequisites names the commits: a replay against an earlier build reproduces the bugs rather than the outcomes, most sharply BUG-061, where three turns look like a stubborn Game Master and are nothing of the kind. The unreproducible section is equally deliberate — the crypt fight happened because one Echo-Calm roll failed, so a replay that passes it never fights at all, and the Sodden Psalter-Leaf that satisfies Vell's hook does not exist in world data and was minted by the GM on the spot. notReached records what is left and why, including that the Gill-Wretch is foreclosed on this save rather than merely unvisited, and that money rather than access is what now blocks most of the remainder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Filed last night as "the world contains two". It contains five: two placed, one on each Drowned Echo-Chorister, and three in the SaltBroker's starting kit, which makes the condition satisfiable on turn one by that character. The sweep behind the original claim covered rooms, beings, containers and the player's pack, and never looked at class starting inventories — the one source build-walkthrough.js keeps a dedicated classKitNames block for, whose comment says in as many words that Verengrad's Cantor's Hook lives nowhere but a class kit. I had read that code earlier in the same session. Two other items were mis-read the same way and are also kit rather than unplaced: the Cantor's Hook and Warding Charm from CantorAdept, and the Salt-Broker's Ledger. Only the Reliquary Lichen is genuinely placed nowhere. What survives is narrower and more interesting than what was filed. For a TideCantrix — the class this world leads with, and the one every run so far has played — only two tokens exist, so twenty-five XP of authored lore is unreachable unless you picked a different character at the login screen. That is the argument the evaluator already makes about MONEY in its wealth-distribution note, applied to lore: gating content behind a build is the point of gating, and gating it behind one build means every other character is playing a poorer world than the author thinks they wrote. The arithmetic class the entry was filed for is untouched and still real. Hesk's "buy from him five separate times" against a four-item stall was the same failure, and it only stopped being one because restocking and two staples arrived for unrelated reasons. The coverage report carries the same correction, since it led with the wrong claim too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
An overnight content-coverage run on Verengrad: walk the authored lore and see how much of it a determined player can actually collect. Place hooks went 2 of 12 to twelve of twelve, being hooks to nine of ten — the tenth is the Gill-Wretch, foreclosed by a kill in an earlier session and not recoverable on this save — and item hooks from four to eighteen. Level seven to eight, compendium sixty-three to ninety-eight. The report leads with the find rather than the numbers, because the find explains weeks of apparent GM stubbornness: lore on an item somebody else was holding was never sent to the Game Master at all. It also records, at some length, that I filed that as a rule 13e contract violation before reading the handoff, and that the tell was sitting in my own results the whole time — every item hook that fired was in the pack or on the floor, and every one that failed was in an NPC's hands. The A/B that settled it is in there too, since the same sentence that failed three times unlocked immediately once the dossier carried the hook. What survives of that mis-filing is one genuine 13e case on a place hook, where the dossier was never in question, and it is recorded as the narrower thing it actually is. Also in: BUG-060's arithmetic class — a hook asking for three of something the world made two of — and a section on what worked, which is most of it. Defeat-as-solve held all night with two beings resolved at full health; two-part hooks pay when the item travels with you; the return-visit hook this project expected to go uncollected was collected; and the save machinery was silent in the good way, every logout verified with no NOOPs, mismatches or refusals. The last section says where the next run starts and what now blocks it, which is money rather than access for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-061, and a correction to BUG-059 filed a few hours earlier in the same session. The Item Lore dossier was room.items.concat(player.inventory) — the floor and the pack, and not what the people standing in the room are carrying. So an item in an NPC's hands was never listed as having lore, never carried its unlock condition, and could not be unlocked, because the Game Master cannot set itemLoreUnlock for a hook it has never been shown. The comment beside that line said this could not happen: an item's hook needs no reachability test, it is listed whenever the thing is on the floor or in the pack, so items never suffered the visibility half of the loreLinks problem. That is the race-hook failure word for word — unlockable in principle, unreachable in practice — sitting in the one place the source declared itself immune to it, which is why nobody went looking here. The sentence is corrected rather than quietly extended, and says which half of it was wrong. Three authored hooks were behind it: the Primer of the Second Reading in Vell's rack, and the Widow's-Thread Vine and Salt-Broker's Ledger on Hesk's stall. In play it read as the model stubbornly refusing conditions the player had performed word for word, and I filed it as exactly that before reading the handoff. BUG-059 is corrected in place rather than deleted, because the mistake is the instructive part: the tell was available the whole time and I walked past it three times. Every item hook that fired that session was in the pack or on the floor; every one that failed was in somebody else's hands. What survives of 059 is the one case that really is a rule 13e violation, and it is a PLACE hook, where the dossier was never in question. The fix is floor plus carried-by-the-living-here plus pack. Only the living, since a corpse's satchel is room loot and arrives by whatever scatters it. The crawl branch is untouched and still the pack alone, because below ground those items are lying in the entrance room rather than on the party's square. It is cheap where cheapness matters — this is the live half, rebuilt and re-billed every turn — since the dossier already keeps only items that have lore and dedupes by name, so it adds a line per lore-bearing carried item and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-060. The Tide-Cult Token wants three traded to Ys in a single exchange, and a sweep of every room, every living inventory, every container and the player pack finds two in the whole world. The offering basin at the shrine is a real container and it is empty, and the token is not marked pooled, so nothing says a third was ever meant to be improvised. Twenty-five XP of authored lore behind a condition the authored world cannot satisfy. Filed as a class rather than an incident, because the same shape had already turned up on Hesk earlier in the run: buy from him five separate times, against a stall of four items, one of which he refuses to sell in fiction and two of which cost more than the purse. That one is satisfiable now, but only because restocking and two cheap staples were added for unrelated reasons. The arithmetic was never checked; the fix was incidental, which is the part worth recording. The evaluator already reports an item that exists in no room, on the grounds that a thing which cannot be reached is a bug. This is that bug with a number in front of it, and both figures are in hand when the pass runs, since it counts placements to decide the unplaced finding. It will not catch every phrasing and should not try — the conditions are prose — but three and five separate are a narrow, high-yield family, and a warning on a countable mismatch would have found both of these before either was played. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-059, filed open, found twice in one session of the Claude18 coverage run. Rule 13e exists for one situation: the Game Master judges that the player's action satisfied the letter of a locked hook, decides to hold it back anyway, and must therefore set nearMiss so the player gets one quiet nudge. The holding back is good play and the doc says so. The silence is the defect. The first case was the Primer of the Second Reading, whose condition is "asked Vell directly where the primer's readings came from". The player asked exactly that and Vell answered at length. The response carried itemLoreUnlock null and nearMiss null. Asked again, the GM set a nearMiss at last — naming Vell the BEING, whose hook is an entirely different condition, so the item hook was never in view. The primer appeared twice in that prompt, Item Lore section included, so this is not a visibility problem. The second case proves it rather than merely repeating it. The Gasping Spire's lower chamber asks the player to time a descent to the moment the water drops lowest; the player counted the spire's breathing from the stairhead and went down on the draw, and the GM narrated it as unambiguously achieved — the water drawn down further than she had ever seen it — while setting loreUnlock null and nearMiss null. A further turn spent saying what the low water MEANT unlocked it, and the reason the GM gave for that unlock credits the EARLIER turn's action. That reason is only legible because the why field shipped the day before, which is the second time in two days that field has turned an argument into evidence. So the GM judged the turn sufficient, held it back deliberately, and did not set the nudge. That is rule 13e's trigger stated in the rule's own words. The leaning recorded with it is deliberately not more engine heuristics over the prose; that argument is settled and the engine must not become a second adjudicator. It is a contract problem. Worth weighing, though, is whether nearMiss should be required whenever a turn's narration asserts the condition and no unlock accompanies it — judging the condition is the GM's, but that PAIRING is mechanically detectable in the response.
Regenerated from a non-shallow checkout, since the tool reads git log and git ls-files directly: 2903 commits across 56 days now, up from 2878, with the line-of-code and busiest-day stats recomputed to match.
A defect in yesterday's BUG-058 fix, found by watching it not happen. Claude18's save predates the fix, so loading it should have printed the note saying its time would jump once — and the DM log had nothing. The branch ran; the note did not survive. The restore clears GAME_LOG about fifty lines below where the clock is restored, before refilling it from the snapshot. So the warning was written and then wiped, on exactly the loads it exists for, which is the same shape as the thing it was written to prevent: a one-off time jump nobody is told about. It is deferred through a zero timeout now, which puts it after the synchronous restore — the same trick the lore-unlock notices already use. The test asserts the deferral rather than the call, since an immediate call is the broken version, and it also checks that the GAME_LOG reset really does still come after this branch, so the day that stops being true the comment stops being a lie. Also dropped, as asked: the Designs/merchant-restocking.html path from the Restock note on a being's card. It is a repo path in a DM-facing panel, unclickable and not the DM's concern; the explanation above it already says what ticking a line does.
BUG-058. Claude18 was logged out Rested on the 5th of Emberwane, sat closed for 48.8 real hours, and came back on the 26th of Thawmoot: forty-nine in-world days older and Exhausted, having taken no turns. Fatigue had accrued for closing the tab. currentGameDate() is epochGame + (Date.now() - epochReal) * activeTimeScale, and the restore adopted the saved epoch pair verbatim — so every real second between logging out and logging back in was multiplied by the time scale and added to the world. At the default scale of 24 that is an in-world day per real hour of a game nobody was playing. What it quietly broke is the part worth recording. The evaluation notes tell a run to log out at every pause so the clock stops; logging out stopped turns and never stopped time, so the advice was sound and the reason under it was half false. Every endurance figure in the reports was already flagged as suspect for sessions that stayed logged in, and was equally suspect for the gaps between them. It also mis-explains a finding already in the record: the Claude15 report put its CON 6-7 readings down to time spent with the tab open while nothing was being played, and some of that was time spent with the tab shut, which nobody could have seen. And it had just started affecting merchant restocking, built the day before on world time — a two-day gap silently refilled every shelf in the world. The fix is that the clock RESUMES rather than catches up. The snapshot records nowGame, the in-world instant at the moment of saving, and the restore makes that the anchor pinned to the present real instant. That is what reanchorClock already does for a scale change, which is where the shape comes from, and it is scale-independent: a save written mid-combat carries the combat scale in its epoch pair, and an instant does not care what rate produced it. Play still moves time normally — an hour of playing still advances a full in-world day at scale 24. Only the hours nobody was there stop counting. Legacy saves keep the old behaviour for exactly one load and say so in the DM log. A save written before nowGame existed has no record of where its clock had REACHED, only where it was last anchored, and anchoring to that would REWIND the world by however long the session ran — a worse failure than the jump, since quests, respawns and restock cooldowns all read this clock. The test drives the real restore branch against a controlled clock rather than matching on it, and pins the old 49-day jump as a counterfactual so the ledger's figure is arithmetic rather than recollection. Section 04 of the evaluation notes is corrected in place rather than rewritten, since its advice survives and only its reasoning was wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
server.certBAK was listed in the root .gitignore by name, which is how somebody noticed the cert backup and stopped it being committed. Its key was never given the same line, and server/.gitignore's *.key is extension-anchored, so server.keyBAK matched nothing and was committed. server/.gitignore now covers the shape rather than the instance — *BAK, *.bak, *.key.* and *.pem.* — which catches the cert too, so this line is redundant. It is removed rather than left because a by-name entry is worse than nothing once a general rule exists: it reads as evidence that backups are handled, which is exactly the impression that made the missing key line easy to miss. The file also gains the trailing newline it never had.
The Field Guide is the app reference for both roles, not the player's book — Part II covers every authoring surface a DM touches, and its Editor table walks the World tab's inner tabs by name. Evaluate was missing from that list, so a DM reading the reference straight through would never learn the tab is there, let alone that a gold button on it can hand a whole remedy plan to the Game Master. What goes here is deliberately not what went in the DM's Guide. That book gets the walkthrough: why the pass reads the draft, why cards are ordered by leverage, what the actor badges mean for how you work. This is the reference entry — where the tab is, what a report contains, what the three badges are, what Execute and Plan placement do, and what Fix World will and will not run. Someone who wants the reasoning follows the same path every other section here offers, to the Guide. The two notes are the ones a reader can be harmed by not having: that Fix World edits the world and not the save, and that an empty report is not a verdict on the world. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The guide documented every Editor tab except the one that tells an author what is wrong with their world. That gap mattered more once Fix World shipped: a gold button that quietly hands a batch of edits to the Game Master is not something to discover by pressing, and the part worth reading is precisely the part a button cannot say — which cards it will not run, and why refusing them is the feature rather than a shortfall. So this adds a section to Part V rather than to Part III. The Evaluate tab lives under Editor > World, but evaluating is testing, not authoring: you reach for it in the same loop as the // console and the Logs tab, between playing the world and growing it. World > Profile now points across to it, so someone walking the tabs still finds it. The section runs from the pass itself — that it compiles on the vault with the same code as build-walkthrough.js, that it reads the draft rather than the published world, that silence is not a clean bill of health — through the action plan, to Fix World. The actor badges get a table, because the DM edit / Ask the GM / Your call distinction is what decides which half of the dialog a card lands in, and a reader who has that straight can predict the plan before opening it. The warn note says plainly that this edits the world and not the save, and recommends an export before a long plan; the tip records what the report is really measuring, which is the GM's authoring as much as the world. The book edition gets the same material condensed into three paragraphs and a callout inside Chapter Thirteen, rather than a fourteenth chapter that would renumber the appendices for one section's sake. Both glossaries gain Evaluator, Action card and Fix World. Because the "//" question channel answers from this guide by h2/h3 section, the three new headings are searchable from inside the game as soon as they exist — "// what does Fix World do" now has somewhere to land. The PDFs beside these files are exports and are now a revision behind; they need regenerating from the HTML by whoever owns that step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Every lock this engine had named a thing that could be looked for: an item id, a magic item id, a spell in the grimoire. That is the property the whole system rests on — the engine checks, so the GM cannot declare a door open and make the key decorative — and it is also the reason a whole family of doors could not be built. Speak the word and the gate opens; hold the lantern up at midnight; lay a bloodied hand on the sigil. None of those is in an inventory, and no check the engine can run will find them. Doors-and-barriers §05 drafted this as `method: "condition"` and §10-D settled it a year ago — the GM is the judge, because the condition is prose and inventing a parser for "roll the boulder aside" is the heuristic-over-prose trap this project keeps stepping out of. This is that decision, built. It ships as `narration` rather than `condition`. The draft's name described the data; the field is written by someone deciding what the PLAYER must do, which is an act and not a state. "Condition" also collides with the status-effect vocabulary already in use for a chill or a poison, in a file where EQUIPMENT_SLOTS and EQUIP_SLOTS being two different things has cost real debugging time. The lock accepts openDoor AND unlockDoor on a locked door, and that is the part that needed deciding after §10-D. Sealed already had a GM path, and it is deliberately a two-step: unlockDoor as a story beat, then an ordinary open. Making narration work that way would be the engine asking for a receipt — the player said the word, the gate should swing. Which of the two the act deserves is the GM's call as well, because "the bar lifts" and "the gate swings" are different sentences about the same word being spoken. Picking stays available beside it, since a door answering to a password can still have a bolt worth working; only `sealed` excludes it, because sealed is the statement that there is no mechanism. The act is GM-only, and that is enforced at three surfaces rather than asserted once. The refusal line says there is no keyhole and that whatever answers it is not something you carry — the shape of the answer, never the answer. The examine card had to be brought into line with that: it was still saying "shut, and locked" like a keyed door, so a player turned back and then looking at it was shown two different doors, and reading as `sealed` would have been worse, since sealed says give up. And the dossier publishes the act only while the door is LOCKED, so a door already open stops advertising its own password to a model that no longer needs it. Two lines in the dossier are written against the risk §10-D recorded and left as a thing to watch. The entry says outright that narrating it open without returning the directive leaves it locked and the player will be told so — four other locks teach a model to wait for an engine answer, and this one has to say that nothing is coming. And it says that if the player did not do the act, the door stays shut and the GM says nothing about what would have worked: no hinting, no accepting something near it, no inventing a second way in. A blank act is reported as unopenable rather than left as an empty condition a model would satisfy generously; the editor refuses that save outright, on the same rule that already refuses a key lock naming no key. Thirty-three sabotages, each caught by a distinct assertion. Three of them found the work rather than confirming it: the far-side placeholder test (an authored narration lock read as the untouched twin, so "same door on both sides" defaulted on and the next save would have overwritten it); the schema assertion, which /"narration"/ satisfied off the interpolated method roster while the field itself was deleted; and the examine card, which had no test at all until the sabotage that should have failed did not.
The Evaluate tab compiles a world into findings and an action plan, and every card that plan raises already carries a remedy — for the ones the Game Master can enact, the prompt and the editor roster to run it against, behind an Execute button. Walking that checklist card by card is the careful way through. Fix World is the fast one: a gold button in the tab's top-right corner, drawn only once a report exists, that opens the whole plan in one dialog and runs it on a single press. It orchestrates what was there rather than adding a second way to fix anything. The cards run in the order the pass put them in, which is ordered by LEVERAGE rather than severity — the pass already knows that declaring an economy before grading one saves three later cards, and re-sorting here would be inventing a worse order. The dialog stays up while it works, since the editors it drives are behind it and each roster navigates the tab bar as it goes; a dialog that closed would leave the DM watching tabs flip with nothing saying why. It stops taking input, because pressing Execute twice would run every roster again over a world the first pass has already rewritten. And it re-runs the evaluation at the end, so the figures behind it describe the world that now exists rather than the one that was compiled. WHAT IT WILL NOT DO is the part worth reading. A remedy belongs to the Game Master only when the fix needs meaning rather than judgement. Writing a number into a field is mechanical; conceiving the right number is not, and neither is deciding what a world is for. So two kinds of card are left alone. An actor:'dm' card turns on an intention only the author holds — which of two colliding names survives is the standing example, and the remedy table's own note says handing that to a model would risk it inventing a third. An actor:'decision' card carries its prompts behind a choice deliberately: running one unasked enacts an answer the DM never gave, which is the failure that actor exists to prevent. Both kinds are listed in the dialog, greyed, with the reason, rather than skipped quietly. A dialog showing only what it will do cannot say how much of the report it leaves untouched, and a button that closes a third of a plan while looking like it closed all of it is worse than one that closes nothing. On today's table that is seven of twenty-eight remedy kinds, and the design doc now carries the open question of which of the other twenty-one should be promoted — the boundary in ACTIONS was drawn as GM-versus-DM, and the better test is whether the fix needs meaning conceived or only a value written. Fix World needs no change to pick a promoted card up: it appears in the plan the moment it carries a prompt and a target. One roster failing is not the plan failing. The steps are independent edits against different editors, and stopping at the first would leave a half-run plan with no way to finish it but to start again from the top — so a failure is marked on its own step, carries what went wrong, and the run carries on. A step that failed is deliberately NOT recorded as having edited the world: that note is what stops the checklist implying a card is settled because a prompt was sent, and it has to mean it. The write itself moved out of executeEvalPrompt into a shared runEvalRosterPrompt, so the single Execute and the batch cannot answer differently the first time either changes. The only difference between them is that Execute asks first and the batch was approved once, for all of it — which is what the dialog is. test_eval_execute.js pinned that ordering by looking for the write inside executeEvalPrompt; it now pins the property that nothing reaches a roster before the confirm, and separately that the helper is the one thing that writes.
Adding "the target" did not narrow onUse to it, and nothing in the change intended to — an onUse effect still defaults to the bearer, still keeps it when authored, and still leads the drop-down. Plenty of used items do something to the person holding them and to nobody else: a horn that steadies the one who blows it, a philtre, a torch that lights its own bearer's way. But nothing said so out loud. Every assertion that touched it passed for the wrong reason — the FALLBACK for onUse is also the bearer, so "an onUse effect lands on the bearer" is true whether the authored value was honoured or quietly discarded, and a rule excluding it would have been caught only by the drop-down equality check three sections away. That is the shape of coverage that holds until somebody tidies, and the aimed case is exactly the sort of tidying that invites it: it is the one being thought about, and this is the one already working. So the bearer gets its own name here. It is asserted allowed under every trigger, read off the trigger roster rather than listed again; an onUse effect authored onto it is asserted to survive normalization; and the item card is asserted to call it "the bearer" in the words the label roster gives it, which is now a lookup rather than the else-branch of a ternary that happened to be right. Verified against a rule that excludes the bearer from onUse and against a tooltip that prints the raw value.
The two overlap under onUse, and the overlap is deliberate. `target` is the hard-wired partner to onHit — a blow landed and there is an entity at the end of it, always, with hit points and a status list — and its label says "the one struck" because that is the mechanic underneath, which is clearer about the intent than a merged "the target" would be. `aimed` is the loose one: it may land on a person, a creature or a door, and what it lands on is not known until the moment of use. Folding them into one option is a live possibility once there has been more play-testing, and the thing to watch for is whether GMs actually treat an onUse `target` differently from an onUse `aimed`. If they do not, the distinction is only costing an option. The note beside the roster says so, and says what a merge would have to do rather than leaving the next reader to work out why the menu asks a question it has already answered. What it must NOT be is done in passing. An onUse effect authored against `target` would move to `aimed`, and vice versa, which rewrites somebody's world to tidy a drop-down; a merge needs the treatment room regions got when they stopped being keyed by name — tolerate both shapes on read, migrate what resolves, compare through one helper — and the labels re-thought, because a merged "the one struck" would have to cover a door. The test that pins the onUse option list now says out loud that failing it is the point: it is what a quiet merge would trip over.
"Shine the lantern on the door" was not a thing an item could be authored to do. Who It Lands On offered two answers — the bearer, or the one struck — and both name a party that is known when the effect is WRITTEN. The thing a lantern is shone at is not: it is decided at the moment of use, by the player if they name something and by the Game Master if they do not. So the Effect Editor has a third option, "the target", offered under onUse and nowhere else, because nothing is being pointed at when a ring goes on, when a blade lands, or when a sickness simply is. It is a third value rather than a second use of "target", and that is the decision the rest follows from. A struck enemy is always an entity with hit points and a status list; the thing a lantern is shone at is very often a door, which the engine does not track at all. The two therefore need different instructions, and instructions are the whole of the mechanism here — onUse effects are not applied by the engine, they are listed in the per-turn dossier for the GM to place. So the dossier now says, of an aimed effect, that it lands on whatever the player names in their action; that an unaimed use is theirs to resolve and to say out loud what it fell on; and where to put the result — an entity status for a being, the player's own for the player, and nothing but narration for a door, a lock or a pool, where sending a status field would be inventing a record the engine has nowhere to keep. That paragraph is emitted only when an aimed effect is actually equipped: a rule that cannot apply is tokens spent in every world that has no such item. Three prompts named this field and all three hand-copied the roster, which is the trap this repository learned from the equipment slots — pasted into two prompts, stale the moment a slot was added, and gear authored for the new ones silently unequippable. They now interpolate it, and the test counts the hand-written copies rather than merely confirming that one site interpolates: a stale roster in one prompt is invisible next to a correct one in another. The editor's target list is now built for the trigger rather than written out in the markup, since a select cannot reliably hide one of its own options. That surfaced an ordering bug that had been latent: opening an effect ran the trigger handler afterwards, and that handler resets the target to the trigger's default — which was harmless while the only two values happened to BE the defaults, and would have silently turned every authored aimed effect into one landing on the bearer the first time the dialog was opened on it. The authored target is now restored after the reset rather than before it. Two test helpers in this file and its neighbour took no failure hint and were dropping the third argument of every assertion written below them. Both now print one. Four of the assertions here read the source rather than the generated prompt and passed against sabotages that made the branch unreachable; they now build the dossier twice, with and without an aimed effect equipped, and compare.
The Calendar tab's opening date gains its last field: an hour, beside the day, listing all twenty-four with the part of the day each falls in. A world whose story begins at dusk, or at midnight on the eve of something, can now say so — and the opening scene, the weather and the NPC routines all follow, since startGame applies the routines for whatever time-of-day label this hour lands in. The field itself is the simplest of the four: a fixed 0 to 23 with nothing beside it to depend on, unlike the day whose bound is the month next to it. Its default is where all the care went. Every world authored before this existed carries no startHour, and the clock has always opened them at nine in the morning; a zero default would have moved every one of those worlds to midnight — the darkest hour, the wrong routines, a story-appropriate morning turned into a cold open at the far end of the Midnight window — with nothing in any changelog connecting the symptom to the cause. So the fallback is nine in all three places that can reach it, and because zero is falsy every read uses ?? rather than ||: an hour a DM deliberately chose must not be mistaken for one they never set. There are three separate places that mistake could have been made — the clock, the model and the picker — and the test makes midnight its own case at each. The options are labelled rather than bare, and derived from timeOfDayLabel rather than written down a second time. This world's table is not the obvious one — Dusk comes AFTER Evening, and Midnight begins at 22:00 — so "20:00" and "20:00 · Dusk" are very different amounts of help when what is being chosen is the light the first scene opens in. One assertion in test_calendar.js quoted the row's hint verbatim and moved with it, since the hint now describes a date and an hour rather than a date.
Settings › Server answers "is TLS on" in its environment read-out, and until now the next question had no answer on that screen. The README covered generating a self-signed pair, which is the part that was never the complaint: a self-signed certificate encrypts perfectly well and is vouched for by nobody, so the browser stops you at an interstitial on every visit, again after every profile reset, and the desktop shell refuses the connection outright unless it is told to accept anything. No better self-signed certificate fixes that. The objection is not to the encryption, it is that the chain ends at a stranger. So the section is about the root CA specifically. Install one root into the machine's trust store once, sign a localhost leaf from it, and the chain now terminates somewhere the machine already believes — the connection is byte-for-byte what it was and the warning stops coming back. mkcert is the short way and is given first, with the install line for each of Windows, macOS and Linux and then the same two commands everywhere. Underneath it the same thing by hand in openssl, for a machine where mkcert is not packaged or where the operator wants a root they can read, followed by the trust-store command for each platform and a note that Firefox keeps a store of its own and will go on warning after the system one is done. Three collapsed blocks rather than an open wall of shell. Expanded, four platforms of commands push the read-out this tab exists for off the screen; buried in the README, the answer is not where the person reading "TLS: off" is standing. The block sits outside #server-settings on purpose — renderServer() assigns that element's innerHTML wholesale, so anything static inside it renders on first paint and disappears the moment the page's own fetch returns. The test executes the openssl recipe rather than proofreading it. It lifts the three command blocks out of the page, runs them in a temporary directory, and verifies the resulting leaf against the root it was signed by, checking the subject alternative names are on it — because a leaf signed without -extfile carries none and is refused by every browser on that alone, and that is a failure the prose could develop silently. The file names are read from vault-core's TLS_DEFAULT_KEY and TLS_DEFAULT_CERT rather than spelled out, so renaming the pair fails the test instead of leaving a page of commands that produce files nothing reads. Eighteen sabotages, each caught by a distinct assertion; two of them found the assertion weak rather than the page. Checking the mkcert command against the whole <details> block let both a wrong file name and a dropped ::1 through, because the paragraph under the command repeats it with extra hosts appended — a worked example that quotes the thing it illustrates is not evidence about the thing, so the command line is now lifted on its own. The README keeps one paragraph pointing at the page instead of a second copy that would drift from it.
The Current month picker gained a Current day beside it: a list of the days that month actually has, 1 to 31 or 1 to 28 depending on which month is chosen, and initGameClock opens a new game on it. The day is the same field as the month one level down, with one rule the month does not have — its upper bound is the field beside it. Phase 1 sits on the Gregorian substrate, so the months here are freely NAMED but not freely LENGTHED: the second month has 28 days however a DM titles it. That length is read off the epoch rather than written down, because 437 is not a leap year and a hard-coded 28 would become wrong the day the epoch moved, with the symptom appearing somewhere else entirely. The reason the bound exists at all is that Date.UTC does not refuse an impossible date, it rolls one. Date.UTC(437, 1, 31) is the 3rd of March, so a world set to open on the 31st of a 28-day month would begin in the wrong month, under the wrong month's weather pattern, and nothing anywhere would say so. There are three clamps for that single failure and each covers a path the others cannot reach: the select never offers the date, normalizeCalendar refuses it on the way in, and initGameClock refuses it again at the moment of use. The third is not belt and braces — the day and the month are stored separately and either can be moved after the other, so a world can be holding a date that was legal when it was chosen and is not any more. Moving to a shorter month pulls the chosen day back with it, and corrects the STORED day rather than only the list on screen: a picker showing the 28th over a world still holding 31 is the state where the screen and the save disagree, and the save is the one that wins the moment nobody is looking at the screen. Moving to a longer month leaves the day alone — a day the DM chose is not one to restore because a bound moved out of the way. The list is restocked in place rather than by re-rendering the tab, which would rebuild twelve month rows and seven weekday inputs, and throw away the scroll position and any half-typed name, to change one drop-down. Two assertions in test_calendar.js were counting numbered options across the whole rendered view, which was right until a second drop-down of numbered options appeared beside the first; both are now scoped to the picker they are about. A third — that the handler clamps to the month's length — was being exercised in a 31-day month, where a flat bound and the month's own give the same answer, and passed against a sabotage that replaced one with the other. It now runs in a short month.
Re-ran tools/gen-progress-report.js against the full, unabridged history (a prior shallow checkout in this environment would have truncated it) to bring the report current through 2026-08-24: 2,878 commits across 56 active days.
The Calendar tab let a DM name the months, bind each to a weather pattern, and set the year — and every new game began in the first month regardless. A world whose story opens at harvest, or in the depth of winter, could say so in its prologue and nowhere else: the clock started in month one whatever the fiction claimed, and since every month is bound to a weather pattern, the sky started there too. So the Calendar tab gains a Current month picker beside Current year, listing the world's own month names, and initGameClock anchors a new game to it. It is a different kind of field from the three beside it, and the difference is worth naming because it governs where the code went. The year, its label and the month names are read every time a date is DISPLAYED, so a change to any of them shows up at once and everywhere. This one is read exactly once, by initGameClock, and its whole effect is on games that do not exist yet — a DM who set it and watched the header clock not move would reasonably conclude it was broken, so the field carries a hint saying that a game already under way keeps the date it has reached. It is also the one calendar field that is not cosmetic, and that decided what the clock anchors to. The displayed year is an offset added at render time over a substrate left at the epoch, so moving the substrate year as well would count the same difference twice and a world set to year 1000 would open in 1563. The month has no such offset — it IS the month — so it is anchored in the substrate and the year is not. The test sets the world year away from the epoch before checking, because with a default calendar the two are both 437 and an implementation that got this wrong would look identical. The value is clamped in both places that write it rather than defaulted, because the failure is silent either way and the two are not equally recoverable: Date.UTC rolls month 12 into January of the NEXT year, so an out-of-range index would open a world a year after its own calendar says with nothing reporting it. And it is named in normalizeCalendar's returned literal, which is the allowlist every calendar passes through — the constructor, serializeWorld's round trip, every import — so a field left out of it is authored, saved perfectly, and gone on the next reload. Renaming a month re-labels it in the picker, which sits directly above the list being renamed. Just the option labels: re-rendering the whole Calendar view would work and would also throw away the scroll position and any half-typed weekday, on every rename, to change one word. test_calendar.js's check helper took no failure hint and silently dropped the third argument every assertion below it was passing. It now prints one.
server/server.keyBAK was committed by mistake and is removed from tracking here. server/.gitignore already refused *.key, *.pem, *.crt and *.cert, and the root .gitignore had picked out server.certBAK by name — but every one of those patterns is extension-anchored, so a file renamed from server.key to server.keyBAK walks straight past all of them. The cert had been noticed and listed individually; its key had not. So the patterns now cover the shape of the mistake rather than the one file that made it: *BAK and *.bak, plus *.key.* and *.pem.* for the other common way a backup gets named. The key itself must be treated as exposed and regenerated — it reached a remote, and removing a file from tracking does not remove it from history.
The Equipment tab could paint a full-body render and crop a face out of one, and nothing in between. A player who wanted the cloak a different green had to repaint the whole thing from the prompt and hope a fresh generation landed somewhere they liked as much as the picture they already had. Update sends the render ITSELF alongside the instruction, so what comes back is that picture with one thing altered. The prompt is written against the trap the Extract Portrait prompt already records: a model handed a picture and a sentence treats the sentence as a commission unless it is told otherwise, and answers with a fresh painting that happens to include the change — new pose, tidier face, new backdrop, the armour subtly redesigned. It is plausible on its own terms, which is exactly what makes it read as the model being unhelpful rather than the prompt being wrong. So the operation is named in the first words, the player's instruction is quoted as the only sanctioned change, everything that must survive is enumerated rather than implied, and the specific ways a model helps — cleaning up, sharpening, re-lighting, restyling, idealising — are forbidden one at a time. The instruction itself goes in verbatim and unparsed: it is prose for the image model and nothing here tries to understand it. It is deliberately not a variant of generateBodyRender, and the difference that matters is the provider check. That one WARNS and carries on when the Image AI cannot take pictures, because a render painted from words alone is still a render. An edit without the image it is editing is not a worse edit — it is an unrelated new picture quietly replacing the one the player was happy with, having asked only for a darker cloak. So this refuses, as Extract Portrait already does for the same reason. Sharing a function would have meant one of the two behaving as the other. The result joins the render gallery before it is worn, which matters more here than for a repaint: an edit can go wrong in a way a fresh painting cannot, since the picture it replaces is the one the player already chose. One unlucky instruction would otherwise spend a billed call to destroy a good render. The field and the button appear only once a render exists, Enter submits so the keyboard can reach the action, and the field is disabled while the call is in flight — otherwise a second Enter fires a second billed call against a picture that is about to be replaced. The finally block looks both nodes up by name rather than through the references captured before the await, because renderEquipment has replaced the whole column by then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Editor › Items › Taxonomy was the one card tab with no GM request box at the foot of it, and the absence was a position rather than an oversight: the note beside the New button says that asking the GM to invent a type is asking the wrong author, since what KINDS of thing a world contains is the DM's decision and a vocabulary that grew by itself would be a vocabulary nobody chose. That argument is about the "+ Add" button, which means "invent one, I have no brief". It does not cover the case this box is for, where the DM already knows the world needs a reliquary type and would rather describe it in a sentence than fill five fields; and it does not cover editing at all. The New Item Type dialog can only create, so changing a type the world already had meant Export, hand-edit the JSON, Import. The two hard rules are both about the built-ins, and both are enforced twice. The engine branches on weapon, consumable, treasure and others by name, so a world that redefined one would not be adding a kind of thing — it would be changing what damage dice mean, in every world that imports it. The prompt says so and the applier refuses independently, on the create path and on the remove path, because a prompt is a request and only code is a guarantee. The refusal is reported rather than swallowed: "add rapier to weapons" is the most natural sentence a DM can type at a screen whose whole subject is item labels, and an empty result would send them off to rephrase something that was never going to work. Facets replace wholesale when the GM names them and are inherited untouched when it does not. Merging two facet arrays needs a rule for a facet named in one and not the other, and both available answers are wrong half the time — keep it and a deletion becomes impossible, drop it and every partial edit is a silent amputation — so the prompt asks for the complete list and the applier takes it at its word. A "drives" key is dropped on arrival: it marks a facet the engine reads, the engine matches labels it already knows by name, and a GM-written one would print on the card as a rule that nothing enforces. The icon now has one writer. There were two callers and the new one made three, and the icon is the field that decides whether a type is usable at all, since every type dropdown is built from ITEM_TYPE_DEFAULT_ICON rather than from the taxonomy — a type with no entry there is one the GM can author and the DM can never pick, which is the hole contraption sat in. setWorldItemTypeIcon writes the module map and the world's own rows together, defaults the glyph, and updates a row rather than appending, so an icon edited twice does not leave two records disagreeing. Removal takes the type out of the taxonomy, the icon rows, the icon map and the collapse set, and counts the catalog items still declaring it. Those items keep a type the roster no longer contains, which is survivable and is the DM's call, but is not a thing to discover later by finding a boxed item in an inventory. tests/test_taxonomy_gm_box.js is behavioural — it drives requestItemTypeEdit against a stubbed GM — and was checked against fifty-one sabotages of the implementation, each caught by a distinct named assertion. Two of those found weak assertions rather than weak code: "a failed call comes back as an error" passed with the status check deleted, because the parse then throws anyway, so it now names the status; and the id-less case had never been exercised at all.
The Map tab's World view drew every room in the realm and offered no way to look at part of it. On a world of any size that is the map being least useful exactly where it is most needed: a DM adding rooms to the Ashen Reach cannot see the Ashen Reach, only the whole world with the Ashen Reach somewhere in the middle of it. So the map's top-right corner gains a Regions drop-down of tick-boxes — one per region, plus No Region for the unfiled — and unticking one takes its rooms off the map. It follows the two conventions the Items facet filter set, both of which are load-bearing rather than matters of taste. It stores what is switched OFF rather than what is on, so a world starts by showing everything and a region invented after the filter was last touched arrives visible; stored the other way round, authoring a new region would make its rooms silently absent from the map they had just been added to. And the rows come from the ROOMS rather than from world.regions.list, so a region nothing is filed under is not offered at all: it could only ever hide nothing, and an option that cannot change the map is one a DM has to read and dismiss every time the menu opens. Each row carries its room count, because a filter whose options are bare names gives no sense of what unticking one will cost. Two cases this filter has that the Items one does not have to think about. A room can name a region the world does not define — an import from elsewhere, or a rename that lost its rooms before room.region started storing ids — and those rooms are the ones most likely to need finding, so roomRegionKey's lower-cased fallback gets its own row rather than being invisible. And hiding rooms can hide the SELECTED room, whose detail popup would then describe something no longer drawn, with nothing on screen saying which room it belonged to; the popup closes when its room leaves the map, and is left alone when it does not. The control stands down inside a building. Every room in there belongs to the same region as the building holding them, so the filter could only show all of them or none, and a control that can only do nothing or everything invites the second. It is hidden rather than disabled, and closed on the way out so an open panel is not left hanging over a map it no longer governs. The state is session-only on purpose. This is a way of looking at the map for a minute, not a property of the world: persisted, a DM would open the Map tab weeks later to a map with rooms missing and nothing saying why. The badge on the button is the only thing that explains a partial map, which is why it appears the moment something is hidden and why closing the menu does not take it away. test_map_background.js pinned its two buttons at a character distance from the controls div they sit in, and the new control moving into that corner pushed the second past it — a red test about how much markup sits nearby rather than about the buttons. It now slices to the div and asserts both are inside it.
The Edit Effect dialog hangs a short inline hint off most of its labels, and that reads well when the label owns a full-width row. Minutes does not: it sits in .ability-ed-bonus, an 84px column, so forty characters of hint wrapped to three lines, pushed the label from 18px to 70px, grew the whole row from 66px to 118px, and left a band of empty panel under Status where the neighbouring fields did not need the height. The hint was also the shortest of the three, which meant the least-explained field was paying the most layout for its explanation. The text moves into the themed tooltip instead, on the same .we-info badge the World Builder uses for its field help, so this is the app's one help affordance rather than a second one invented here. That buys room for the whole rule rather than the abbreviation: what 0 actually means (the standing enchantment of a ring, ending when the item comes off) and that a constant trigger ignores the number entirely, which the old dash-separated fragment could only gesture at. The badge takes tabindex="0" so it is reachable without a pointer, and carries an aria-label saying the same thing, since a tooltip that only appears on hover is not an accessible name. The other two hints stay inline. Their labels have the width, and moving them would trade a legible sentence for a hover. The test in tests/test_item_effects.js reads the data-tip attribute's own value rather than the label it hangs on. That distinction is the test: the first draft searched the whole label element, which the aria-label copy satisfied on its own, so deleting the sentence from the tip left the assertion green. A tip and its accessible label agreeing is the point of having both; it is not evidence about either.
Reported: ailments created on the Environment tab are gone after a refresh. The write was never the problem — serializeWorld carries them, and the round-trip test I wrote for them passes. It passes through `new World(snapshot)`, and a reload does not go that way. A stored world comes back through rebuildWorldFromSnapshot, which is a reInstance() over an object literal naming each field one at a time — a SECOND hand-maintained allowlist, opposite the one in serializeWorld. It did not name ailments. So the tab worked, the save worked, and the sickness was dropped on the way back in, every time, silently. That function's own comments say what happens when a name is missing from it, three separate times: races "must be restored explicitly or Races-tab edits are lost on reload"; the sound and dungeon lists "must be copied explicitly or they're lost"; every weather field "must be restored EXPLICITLY here, or DM edits … are silently dropped". Three warnings is three people who were caught, and each answered it by adding their own field and their own test. Twenty-four test files pin one field apiece through this path, which is a hand-maintained list guarding a hand-maintained list. So the fix is one line and the point of this change is the other thing: a general check that writes a world with every field populated, puts it through the real reload path, and requires that everything the writer emitted is still there. Four keys are excused — the two catalogs arrive via the globals, and rooms and quests are rebuilt after the literal, element by element — and the excuses are themselves asserted, since a field excused and then absent is the worst of both. It found five more on its first run, all lost on every reload: the world's OWN extra item types and their icons (so a DM's custom type came back unknown, taking every item of that type with it), what its merchants pay, whether the GM asks before deciding, and the cached playthrough guide. All five are restored now. sellRate's clamp became one function used by both builders rather than two copies of it, because the interesting part is that an absent rate must mean the default and not zero — two copies is two answers to that, and the wrong one makes every merchant in an older world pay nothing. The guard is worth what it is because there is exactly one reInstance(World.prototype, …) in the file and the editor windows boot through it too, via standUpAuthorSession — which is where these ailments were authored and lost. Both facts are now pinned, so a second rebuild path cannot quietly appear outside the check. Not verified in a browser, and I would rather say so than imply otherwise: three attempts to hold a save across a real reload in the headless harness gave three different answers — one of them the service worker serving the previous build — and none of them trustworthy. What is verified is that the snapshot carries the ailments, that the rebuild returns them, and by reading that every path from a store to a live World goes through that rebuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Saving in the editor stops and tells you: a status line, a flash on the button, and a dialog naming the world and where it went. Publishing had the two unhappy halves of that and not the happy one. The vault push raised a dialog for the faults that block it and a dialog when the server refused, then said nothing at all when it worked beyond a line at the foot of the window that clears itself in six seconds. The outcome most worth confirming was delivered most quietly, and "did that go?" was a question answered by publishing a second time. Both publish paths now confirm, because both are publishing and the editor calls them that. The vault one reports REPLACED apart from published — different events to the person who pressed the button, one putting a world somewhere it was not and the other overwriting a copy people may already hold, which it says, along with the fact that a replacement does not reach into what was already downloaded. It carries the room count the server reported back, which is the cheapest proof that what arrived is what was sent. The library push needed it more, and had nothing on EITHER outcome. Its failure path wrote a status line and stopped — and that was worse than quiet, because the "Unpublished changes" chip is cleared by the success path only. A DM who missed a line that clears itself had no signal at all, except a draft that went on looking published, which is a signal pointing the wrong way. It now confirms what publishing to the library MEANS — new games seed from this copy, your own game in progress is untouched — and says so plainly when it fails, that the library still holds what it held and the draft is unchanged in the window. Found in the browser with both on screen at once: the dialog said "1 room" and the status line one call above it said "1 rooms". Same sentence, same event, disagreeing. Fixed, and asserted together. The existing publish test read the confirmation as "the last dialog", which was the only one before and is the wrong one now — it names which of the two it means throughout. A cancelled publish is asserted to raise exactly one: the second dialog belongs to the upload, not to the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The ailment-authoring directive offered "lore" and "loreKey" and asked nothing about what earning the lore was worth, so every ailment secret in every world fell to the flat default — the same twelve points for a rumour about a cough as for the truth behind a plague that emptied the fen villages. Every other authored kind is asked: beings, items, plants and places all get a "loreXp" clause, and the wording is careful about the same thing each time. It now asks, with that same wording. Required whenever lore is written, described as a PRICE rather than a flourish, with the bands spelled out, and on the ABSOLUTE scale the rest of the world shares — a place's secret, a being's, an item's of the same weight all score the same, so a sickness is priced against THOSE and never only against the other ailments. What silence costs is named as a number rather than as "the default", because 12 is a thing a model can weigh against 30 and "the default" is not. Lore and its unlock condition are demanded together while I was here: a secret behind no condition can never be reached, and a price on it is beside the point. The second half is a lock that was missing. normalizeAilment read the price through a bare Number() and stored whatever arrived, while the DM's own stepper on the very same field clamps to 0..100 and rounds. Two doors onto one value, one of them without a lock, and the unlocked one is the door the GM writes through — a model answering 500, or 12.7, or -3 put 500, 12.7 and -3 onto the world. It goes through normalizeLoreXp now, as races already did after the same class of bug was found there, and it reads the capitalised spelling like every other field on the record. The spread stays a spread, and that is load-bearing rather than tidy: loreHookXp reads an ABSENT price as "pay the default" and a present 0 as "worth nothing", which are different answers. So an unpriced hook must not become 0 — that would silently reprice every ailment a GM did not think about, down to nothing — and a deliberate 0 must not be dropped as falsy. Both directions are asserted, and both sabotages were caught by the assertion for the other direction as well, which is the shape you want when one line has to satisfy two opposite requirements. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Asked whether the GM will invent this line or hand back one of mine. It would have handed back one of mine, and the prompt said so in as many words: "A WORKED ONE, copy this shape". "Copy this" is an instruction, and the words are part of what it points at. Nothing anywhere told it not to reuse them, and there were four complete, plausible answers — a wasting fever, a jaundice, a palsy, a lung sickness — sitting in front of a model being asked to invent a sickness. For a large share of what a world would author, an almost-right line was already written. Three of the four are gone. What taught the form was never the whole lines; it was the rules and the contrast pairs inside them, and those are fragments — "cannot rise before noon, vomits after eating", "walks slow, cannot run" — too partial to be lifted as an answer while still showing the register. In their place the clause now says where the line must COME from, which is the thing a model can act on: what the disease attacks, how it was caught, how far along it is, what a sufferer cannot do this morning, what somebody across the room would notice. "Be original" is not an instruction; "ask what this one attacks" is. And it gives the GM a test to apply to its own answer — two sicknesses in the same world should not read alike, and if the line shares its wording with the example it is the wrong line, because it has described sickness in general when this catalogue exists to describe THIS one. The one that remains has nothing to do with prose. The object it sits inside is the only valid JSON in the clause — a shape line full of <placeholders> cannot be parsed, so it proves nothing — and a malformed spec is exactly what emptied this field the first time it shipped. It is labelled at the point it appears as being there for the JSON and not as an answer. Belt as well as braces, because the prompt is a prevention and not a guarantee: the example is one list, interpolated into the directive AND watched for on the way back. A line that returns identical to it, ignoring case and punctuation, is reported to the DM in the same errors channel as a nameless ailment. It is not rejected — a detector that silently blanks a field is a second thing to debug when it is wrong — and it does not fire on a line that merely opens the same way, which would accuse the GM of copying something it wrote itself. Hand-copying the example into the directive would disarm it silently, so the test pins the interpolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Contraction shipped with contractedBy and nothing facing the other way. The Game Master was told to lift a sickness when the fiction plainly said something had, which is honest as far as it goes and leaves the DM no way at all to state the route they intended — sleep two nights in a dry bed, drink from a running river, a successful Field Medicine check. curedBy is the exact mirror of contractedBy, and deliberately so, down to being prose for the same reason: the Game Master reads it, judges whether the player has done the thing, and answers with cureAilment so the engine performs the removal. Nothing here evaluates the sentence, which is decision A of the design doc applied to the other direction — an engine that tried to parse "a successful Field Medicine check by someone who has the skill" would be a second adjudicator arguing with the first. It appears as a Cured By textarea directly beneath Contracted By on the card, because the two are a pair and a DM reading one will look for the other immediately below it, and it reaches the GM as a CURED BY line under the CAUGHT BY one. Both halves of the instruction paragraph now turn on it: when the player has done what CURED BY describes, lift it — and, restated because a Game Master that assumes a fever passes will simply never cure anybody, an ailment never wears off on its own and no timer will ever end one. Two things it deliberately does not do. An ailment with no curedBy is NOT thereby incurable, and the section says so outright, because a GM that reads a missing field as a locked door will never lift anything; it means only that there is no authored route out. And the authoring prompt asks for one on every ailment while allowing it to be hard, slow, expensive or far away, on the grounds that a sickness with no way out is a punishment rather than an event. It also travels where the other one already does: the tab's filter searches it, since "what does Field Medicine actually fix?" is a question about curedBy rather than about names; an ailment-edit pass is shown what each cure already says, so a GM asked to make the fevers curable by rest can see which already are; and description generation is given it, because "drink fresh running water" says waterborne as loudly as the contraction line does and a description that contradicts the cure reads as two diseases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Tested in play, and the GM's answer came back "every step comes down wrong on sores that split and weep, so he walks slow and heel-first and cannot run at all, nor bear a boot laced tight; folk downwind of him smell the peat-and-carrion stink of h". Three faults, and every one of them was in the worked example it was copying, which I wrote. TOO LONG. The example ran to three clauses across 137 characters and the answer dutifully went further. The field is a note the GM narrates FROM, not a passage anybody reads, and the ask now says so first and states one sentence as a numbered rule with the character limit in it. The examples beside it are all short, and the test holds them to the rule they state — which is the assertion that would have caught this before it reached play, since a model copies the example far more reliably than it follows the prose around it. GENDERED. "he walks slow", of a character whose player may be anyone. My example said "him", "he" and "his" three times over. Pronouns are now forbidden outright, with the reason and with the wrong form shown beside the right one, and the test refuses a pronoun in any example the clause offers. WRITTEN AS PROSE. It arrived as a scene, because it was shown one. The rule now says the line is a note to narrate from and that narrating it is what happens later; the actual bad answer is quoted in the prompt as the counter-example, since a model recognises a shape it is shown better than a description of one. Two engine changes go with it. The cap is 160 rather than 200 — the answer to a field somebody filled is a shorter ask, not a bigger box — and it no longer cuts mid-word. That truncation is the second defect visible in the reported line: it ends "the peat-and-carrion stink of h", which is not a shorter sentence but a broken one, and it was then read back to the GM in that state on every turn thereafter. clampNarrativeEffect cuts at the last whole word and marks the cut, falling back to a hard cut only for a single unbroken 160-character token, where there is nothing sensible to do either way. All three places that describe this field now say the same three rules, because a rule stated in two of three is not a rule — the ailment spec, the item spec, and the in-play status spec. The Field Guide carries them too, for the DM typing into the box, who is told none of what the GM is told. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The Effect line landing under an ailment's chips met the commit that added the field it shows. Both touched the same chip builder: origin/main put `narrativeEffect` into the chip's hover title, and this branch was setting a line out beneath the chip — of itemEffectText, which was the wrong text. Resolved by keeping the tooltip on both cards, where the item card's chip still needs it, and rewriting the line beneath the ailment chip to carry `narrativeEffect` instead: the field the effect editor labels "Effect", which is what was actually asked for and had not existed an hour earlier.
The Effects section of an ailment card drew a chip per effect carrying the trigger, the label, the stat deltas and how long it lasts. That is four of the eight things the effect editor asks a DM to decide. The target, the grade, the chance and the condition were all authored, all stored, and all handed to the Game Master, and on the card they existed only inside a title attribute — which is to say not at all on a touch screen, and not while comparing two effects, which is exactly the moment the difference between "a third of the time, while the fever is unbroken" and "always" is the thing being decided. Two effects that differ in nothing but their chance drew two identical chips. So each chip now carries the written-out line beneath it, and the line is itemEffectText: the same sentence the effect editor previews while the DM types it, and the same one buildSystemPromptParts hands the GM about that ailment. The card shows what the GM will read rather than an abbreviation of it, and there is one function to change if that sentence ever changes rather than two spellings of it to keep in step. The container had to change with the content. The chips sat in .char-statuses, which wraps, so a line of prose dropped into it is laid out beside the NEXT chip rather than beneath its own — the one arrangement where the words attach to the wrong effect. They now sit in a column of chip-and-line pairs of their own.
Reported: two ailments authored by the GM came back with full stat tolls and an empty Effect. None of
the plumbing was at fault — driven end to end with a canned answer that carries a narrativeEffect, the
field lands on the record intact. The fault was in the prompt, and it was mine.
The placeholder read:
"narrativeEffect": "<what having it FEELS like, one short line — e.g. "aches and pains, and
sleeplessness"; you enforce this by narrating it, the engine applies none of it>"
Those inner double quotes CLOSE THE JSON STRING. A model reading that sees a malformed spec for one
field inside an otherwise clean object and does the reasonable thing with it: it omits the field it
cannot work out the shape of. Everything else in the object was well-formed, which is exactly why
everything else came back. The same break sat one clause away in the item-effect spec.
Beside it, and older than the field: the object example itself was not valid JSON either. `"effects":
[ { "stat": …, "delta": … ], …` is missing the inner brace, and has been since before there was a
narrativeEffect to leave out. A model copies the shape it is shown.
So the clause is rebuilt around being COPYABLE rather than around being read. A shape line, then a
complete worked example with no placeholders in it at all — models copy an example far more reliably
than they follow a prose description of one — and both are now valid JSON once the placeholders are
filled, which the test checks by parsing every object in the clause rather than by looking for words.
The item spec says the rule outright, since it is the mistake the GM was actually shown; the same
lesson is already written into this prompt twice about HTML attributes.
And the field is now asked for as two things, per the follow-up. WHAT IT DOES TO THEM — cannot rise
from bed before noon, vomits after eating, hands too unsteady for fine work. WHAT IT LOOKS LIKE TO
OTHERS — grey and sweating, yellow about the eyes, a rattling breath. The second half was worth asking
for only if something reacts to it, so the borne-ailments line now tells the GM that a sufferer
described as grey and sweating is one the people around them can SEE: an innkeeper, a guard, a
physician, a companion may remark on it, keep their distance, offer help or refuse them a room,
unprompted. The condition read-back says the same of any visible symptom, and the Field Guide explains
what the visible half is for, because without a reason it reads as flavour and gets left out.
One of the new assertions was vacuous: it accepted "SINGLE quotes" anywhere in the file, and that
phrase appears twice more, about HTML attributes, so it passed with the sentence it was guarding
deleted. It matches the sentence now. Two prompt tests had bounded the clause to its first newline and
failed against text that says everything they ask for — the clause is several concatenated lines now,
and they are bounded by the field that follows it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRSmain landed inflictAilment/cureAilment while this branch was adding the narrative line, and the merge is where the two meet. It is a better meeting than the one this branch had built for: the GM no longer relays the line at all. It names the sickness, and the engine reads the toll out of the catalog — the stats and the words together — so there is nothing to mistype and nothing to drift. That is the strongest possible form of "authored once, then enforced by narration", which is what the field is for. Three things follow. inflictAilmentOnPlayer puts the authored narrativeEffect on the status it applies, beside the stat deltas it already read from the catalog. The borne-ailments section — the one that answers "how is this character faring right now" — reports it next to the toll, because for most sicknesses the words ARE the answer and a fever with a -2 CON and nothing else said is a fever the player never feels. And the relay instruction this branch had written into the catalog paragraph is gone, since there is no longer anything to relay. That paragraph needed rewriting anyway, and this is the part worth flagging rather than folding in quietly. It still said "Set playerStatusChanges with an add entry whose label is the ailment's condition" — while the response format added in the same commit says, of inflictAilment, "do NOT also set playerStatusChanges for the same illness and do NOT restate its stat penalties". Two instructions in one prompt telling the model opposite things, and the second one explaining why the first is harmful. It is rewritten onto inflictAilment/cureAilment. The assertion covering it went on passing through all of that, because it only looked for the word "playerStatusChanges" — which the corrected paragraph still contains, in "Do NOT set playerStatusChanges". A check that matches the name of a thing rather than the instruction about it cannot tell the instruction from its negation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The previous commit's closing note is already out of date, and the correction is worth making rather than leaving in the log. It said there was no ailments section of the dossier and no way for a player to be afflicted, so the narrative line could not reach the case the request named. Both arrived on main in the same hours: the catalog is now sent to the GM as a stable section, and it is told to give an ailment to the player through "playerStatusChanges" when the authored condition has genuinely been met. Half of the wiring was already there by construction. The catalog renders each toll through itemEffectText, which the previous commit taught to end in "felt as: …", so the authored line reaches the GM with the catalog and nobody had to remember the field — which is the argument for rendering it through the shared helper rather than by hand. The half that was not there is the relay. The GM hands an ailment to the player as a status, and a line it is never told to pass on is a line the catalog describes and the condition then arrives without: the dossier would say what marsh fever feels like, and the fever the player actually caught would carry nothing but a label and a CON penalty. It is now told to pass it through verbatim, why — most of a sickness is that rather than a stat — and that nothing in the engine enforces it, so it is true only for as long as it keeps writing it. From there the existing path finishes the round trip: applyPlayerStatusChanges stores it on the condition and activeConditionsDossier reads it back, word for word, on every turn the player carries it. One assertion failed against text that was exactly right, matching a hard-wrapped sentence with a literal space where the template literal has a newline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Contraction, built to the two leanings the ailments design doc had been holding open since the catalog shipped, plus the ailment lore unlock that the dossier work last night showed was unreachable. Decision A was a GM directive validated against the catalog rather than an engine rule that reads contractedBy, and that is what inflictAilment and cureAilment are. The judgement stays entirely with the Game Master — the contract says so in as many words, that the judgement is yours alone and no rule here second-guesses it — because deciding whether somebody drank the swamp water is the one thing only the GM can do, and an engine that tried would be a second adjudicator arguing with the first. What the engine owns is the half the model should not be trusted with: the sickness must be one this world actually defines, and its toll is read from the catalog rather than from the directive. A name the world does not know is refused loudly, naming what it does know, and the GM is told not to restate the penalties or double up with playerStatusChanges — the same rule as never hand-copying a roster into a prompt, applied to numbers, which drift when they are retyped. Decision B was that a borne ailment lives beside the player's statuses rather than inside them, since a status is a timed effect while an ailment is a thing you have that emits them. Its statuses carry src: "ailment:<id>", which is what lets a cure take the sickness and its toll together while leaving an unrelated condition standing. The doc left "the id alone or a small record" undecided; it is a record, because the stamp costs one number and both diagnosis and progression will want to know how long somebody has been ill. The live dossier reports it as borne 3d rather than as a date, which is the form the GM reasons with rather than one it has to do arithmetic on first. Both spellings of an ailment's toll are honoured. Every ailment authored before the constant trigger existed says onEquipped, and those worlds must not go inert the moment this ships. The lore half closes the gap this doc recorded yesterday. loreUnlock now accepts "ailment", loreUnlockCensus counts them — a category nothing counts is one BUG-034 could empty in silence — and an ailment joins the lore hooks when the player is CARRYING it, which is when its lore is earnable and avoids putting every disease in the world into every room's cached prompt. That here-ness is written down rather than assumed, because a hook that is never "here" is dropped by loreHookDossier without a word, which is exactly how every race hook in Verengrad went unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A status' effects array can only speak in six attributes, and most of what a condition IS cannot be said that way. "Aches and pains, and sleeplessness" is not a number. So an effect now carries one authored line of what it feels like, and the Game Master enforces that line by narrating it — there is no engine behind the field, which is stated wherever it appears rather than left to be discovered. Its lifecycle is what shapes everything else here. The line is written ONCE, when the effect is authored — in the Effects Editor, or by the GM authoring an item or an ailment — and does not change afterwards. In play it is relayed and read back, never rewritten: an onEquipped effect carries it onto the status the engine applies, an onUse or onHit effect reaches the player only through the GM so the equipped-effects dossier hands over the exact line to pass through verbatim, and a condition the GM invents from what just happened has no authored line and sends none. Whatever carries one is read back to the GM, word for word, in "Active conditions on the player" on every turn it is there — which is the entire mechanism, and the reason it lives on the status rather than only on the effect that produced it. By the next turn the item may be three rooms away and the status is all that is left. Called narrativeEffect and not `effect`, which is what the editor's label says. `effect` beside `effects` is one keystroke from a silent bug: both parse, both look right, and one is always undefined. Bounded at 200 characters on both doors into a status, because a model handed an open text field writes a paragraph, and that paragraph would then ride in every prompt this effect appears in for the life of the save. Four spellings are accepted on the way in, since a model does not reliably echo a two-word field name. The field sits directly below Status — the same question asked twice, first as the name the engine tracks and then as the thing the sufferer feels — and above the stat grid, because most conditions worth authoring are more this than they are a number. An effect that is only words is a real effect; a line with no Status name is not, since the engine tracks conditions by label and one with no label can never be added, found or removed. Both GM authoring paths were told about it, and the ailment one insists: an ailment is the case where the words ARE the effect, and a stat penalty with nothing behind it is a sickness the player never feels. Both effect chips carry the line in their tooltip, so it reads without opening the editor. Two stale things surfaced and are fixed. test_constant_effect sliced the constants block by character count and a new constant after it landed the slice mid-declaration, so the file died with a syntax error rather than a failing assertion; it is bounded by the function that follows now. And the Field Guide still said an ailment's trigger was onEquipped, which stopped being true when `constant` landed — worse, test_ailments pinned that sentence, so correcting the guide failed the suite. An assertion that holds a stale claim in place is the opposite of what pinning a claim is for; it names the trigger the code uses and refuses the old one outright. What is NOT here: the request also asked for the line in the ailments section of the dossier when the player is afflicted. There is no such section and no such state — nothing in the engine yet gives a player an ailment, which Designs/ailments.html §06 records and §07 leaves as four open decisions, contraction and cure among them. Building that inside a request about one text field would be deciding those for you. Everywhere an effect DOES reach a player today, the line reaches the GM with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
With the TTL fixed, what was left was the volume: sendToLLM resends up to forty messages of transcript on every turn, and every one of them was billed at full price. The design doc had this parked as §10-D, a small optional extra worth "roughly 40 times the average message, small beside the rulebook". Both halves of that were wrong. It was not optional, because it was not possible. The prompt renders tools, then system, then messages, and a breakpoint hits only when every byte ahead of it is unchanged. The live dossier sat in system, which is in front of the entire transcript, and it differs between any two consecutive turns by definition — that is what makes it the live half. No breakpoint placed among the messages could ever have matched, whatever else was done to them. The dossier was not merely uncached where it sat; it made the history uncacheable too, and three of the four available breakpoints were unspendable rather than merely unspent. buildGmTurnPayload puts only the stable half in system and appends the dossier behind the transcript, which is the whole of the change and the only arrangement in which a second breakpoint can hit at all. And it is not small. Half the messages are the GM's own replies — structured JSON carrying narration plus every state field the contract allows, under a four-thousand-token ceiling. Twenty of those is the same order as the rulebook, not a footnote beside it. The dossier had to keep its channel on the way out. Sitting in system it was unforgeable by construction: nothing a player types can appear there. It now travels as a role:"system" message, which is the same guarantee one level down, on Opus 5 and Opus 4.8. Sonnet 5 does not accept one, so there it folds into the player's own turn and a player who writes a convincing "## Current World State" is writing in the same voice the engine writes in. That is a real if narrow loss, accepted because the engine rather than the narration adjudicates money, prices, inventory and combat, so a forged line misleads the story rather than the game. A vault can also rewrite the model to its own ceiling under a running session, so a request built for Opus 5 can arrive at Sonnet 5; rather than keep a roster in step with somebody else's server, the 400 that refusal returns is caught and the turn resent folded-in. Two smaller things fall out of it. Every transcript message is rendered in the same block shape whether or not it carries the marker, because a string content and a one-block content are different bytes and the marker walks forward two messages every turn — a representation that changed as it passed would rewrite the prefix underneath it on every turn, which is the same bug one level down. And the trim now happens in chunks: slice(-40) on every turn shifted the whole prefix by one from turn twenty onward, which with a breakpoint in the history is the worst possible shape, hitting for twenty turns and then missing for ever while still paying the write. The transcript grows to forty and is cut back to thirty, so the front of it moves four times in twenty turns rather than twenty, and the window the GM reads breathes between thirty and forty messages instead of sitting at exactly forty. The breakpoint goes on the GM's previous reply rather than on the player's current message, which looks like an off-by-one and is not: this turn's user message may carry engine notes that are deliberately never written into the stored transcript, so next turn's copy of it differs and a marker there would write an entry nothing can ever read back. Four existing tests pinned the old shape of sendToLLM and are rewritten against the new one, keeping what each actually defended. test_prompt_cache.js gains two sections that run the builder rather than reading it — the shape of this payload is the entire feature, and a source regex would pass against one that assembled it wrongly — including the property the whole thing rests on: that two consecutive turns share a byte-identical prefix up to the marker.
A DM could author a marsh fever in the Ailments tab, write down that you catch it by drinking the swamp water, give it a -2 CON toll — and the condition could then be met in front of the Game Master a dozen times with nothing happening, because it had never been told the sickness existed. There were zero mentions of ailments anywhere in buildSystemPromptParts. ailmentList's own comment called it "what the editor lists and the GM dossier reads", and the dossier read nothing. The ailments design doc had recorded the state of this as "the catalog's value today is exactly the value of the GM reading it — which is real, since the GM narrates from the world dossier". That last clause was false for the whole life of the document, so the paragraph is struck through and corrected rather than quietly replaced: the value was not smaller than the engine charging for one, it was nil. There is now an Ailments section carrying each one's name, description, contractedBy condition and toll, with the instruction to apply it through playerStatusChanges and NO durationMinutes — an ailment's toll is constant, which means until something removes it rather than until a timer runs out — and to clear it through remove. It is bounded in both directions: the GM may not invent sicknesses beyond the authored set, and is told in as many words not to hand one out for atmosphere, with the converse stated too, that a player who has been careful should stay well. Without that, "when the condition is met" drifts into "when it would be atmospheric". It goes in the STABLE half, by the only test that matters: nothing in it can differ between two consecutive turns. It is the catalog, not who currently has anything, and who has something is a player condition that already lives in live. A world with no ailments contributes nothing at all rather than an empty heading in a 36k cached prompt. Hidden lore is withheld deliberately and the reason is recorded beside the code. Ailments carry lore and loreKey, but loreUnlock's kind has no value for them, so the GM has no legitimate way to award it, and a model shown hidden lore it cannot unlock narrates it for free. That is a second gap and the doc now says so plainly: an ailment can be authored with lore no player can ever earn. What remains unbuilt is what section 6 of that doc is actually about — the engine still gives nobody anything. The GM can now judge the condition and apply the toll, which is the half that only needed wiring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Equipment tab's full-body render is a slow, billed image call, and until now re-generating overwrote the previous answer with no way back. A player who changed a sword, repainted, and preferred the first picture paid for it twice — and only if they could remember it well enough to ask for it again. Every render is now filed as it is worn, and a strip of thumbnails under the render column is the way back to an older one: click to wear it, at no cost and with no waiting. player.bodyGallery is its own list and deliberately not a corner of player.gallery, which is the load-bearing decision here. The two hold different SHAPES for different slots — gallery is head-and-shoulders and its pictures are adopted as player.portrait, bodyGallery is a standing figure head to feet and its pictures become player.bodyPortrait. One list would let a full figure be dropped into the round portrait frame on the Profile and a face into the tall render column, and neither is a thing anybody meant to do. The test asserts the separation from both ends: a painted render never reaches player.gallery, and none of the portrait gallery's three writers touches bodyPortrait. Uncapped, like the portrait gallery beside it. These are large pictures and the save carries them, but a cap silently deletes work the player asked for; the ✕ on each thumbnail is the honest version. The render being worn stays IN the strip rather than being filtered out of it — it was painted into the list, and a strip that omitted one entry would read as a picture that had gone missing — and is outlined in gold instead. Removing the worn one leaves it worn: the ✕ tidies the strip, and clearing the frame as a side effect is not what it says it does. Selecting redraws the Profile as well as the tab, because Profile's "Show Full Render" can be showing this same picture and would otherwise go on showing the render the character is no longer wearing. Selecting the one already worn does nothing at all, since a redraw that changes nothing still costs the tab its scroll position. player is snapshotted wholesale, so a new array rides along on its own; the explicit line beside gallery in both rehydration paths is what backfills [] for a character saved before the field existed. bodyGalleryList() is the one funnel every reader goes through, so none of them has to remember that guard. Verified in Chromium against a live Equipment tab: the strip is absent until there is something in it, three renders draw three portrait-shaped thumbnails with the worn one outlined, clicking an older one swaps the picture in the frame without changing the list, painting the same URL twice adds one row, the ✕ on the worn render leaves it in the frame, and a player object with the field deleted renders without throwing. Two of the new assertions were vacuous before being rewritten. The Player class was sliced by a character count and its constructor's comments are longer than the count, so three assertions failed against a correct implementation; it is brace-matched now, with a length assertion in front of it. And the guard test for a missing bodyGallery called the reader directly, so the failure it tests for — a throw — took the whole file down with it and reported one broken thing as forty-one missing ones. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Anthropic wrote to the account about a low prompt-cache hit rate, and the first suspicion was the one this repo is built to catch: something turn-varying leaking into the cached half of the system prompt. It had not happened. tests/test_prompt_cache.js was green, holding all ~39,872 tokens of the stable half byte-identical across ten ordinary turn mutations. The prefix was never the problem. The expiry was. A cache entry lives five minutes unless a ttl says otherwise, and five minutes is the wrong unit for this game, because the clock it runs on is human reading time. A player is handed a paragraph of narration, thinks about it, and types a considered reply; a turn that takes longer than five minutes is an ordinary turn rather than a slow one. Every one of those wrote forty thousand tokens into a cache at 1.25x and then read nothing back, which is a hit rate near zero on a prefix that was provably perfect. So buildSystemPromptBlocks asks for ttl: '1h'. The trade is the write premium, 1.25x to 2x, which moves break-even from the second call to the third; a session is dozens of calls and clears that in its opening turns. Designs/prompt-economics.html had already considered this as §10-C and parked it, wanting a real distribution of inter-turn gaps before choosing, with a recorded lean toward staying at five minutes. That lean was wrong, and the evidence that settled it arrived from the opposite direction the section anticipated — a bill rather than a measurement. It was enough without the distribution, because on a prefix already proven stable the symptom has only one other cause. The open decision is now locked with the reasoning written down, and the diagnostic table that told a reader "a gap of more than five minutes writes a fresh cache, that is the TTL, not a fault" now says the same thing about an hour, which is a much stronger signal when it appears. Both cost readouts had to learn that there are two write rates, and this is the part that mattered more than the one-line change. The design doc claimed the vault needed no change at all because pricing.js already billed cache_creation_input_tokens at 1.25x; that was true of a five-minute entry and false of an hour's. The API reports the split under usage.cache_creation, and a flat 1.25x under-reports an hour's write by 37.5% — on the Usage tab an admin would open to decide whether this change was worth making. A cost model that is wrong in the direction of the change you just made is not a rounding error, it is a rigged experiment. server/pricing.js and the client's gmCallCostUsd now weight each bucket at its own rate, fall back to the five-minute rate for a response carrying only the total, and charge rather than drop any remainder the breakdown does not account for, so a TTL tier that does not exist yet cannot be billed at nothing. A cross-check pins the two files to the same figure for a mixed-TTL call, because agreeing on both multipliers and still blending them differently is a way for one session to have two bills.
Reported of the detached editor: some of the header buttons open their tooltip on top of the buttons rather than below them. Its File, Tools and Settings buttons did, at every window size, while the three beside them without menus flipped below correctly — the same arbitrary-looking split that test_tooltip_menu_buttons.js was written about, inverted. The cause is the clamp that file introduced, and its own comment had already named the case and then not acted on it. A menu button prefers ABOVE, because its menu opens downward and a tooltip below lands on the items; where there was no room it clamped to the top of the viewport instead of flipping. But a clamp is a placement only while the clamped box still ends before the control begins, and where a button sits flush with the top of the window it does not. The detached editor's header is exactly that: it is the first thing in the window, because the tab bar that would sit above it is one of the things a detached window hides. Measured rather than reasoned, and the measurement widened the bug. The same defect was in the MAIN window all along, wherever a tooltip wrapped to two lines: at 700px wide Library, Export and Sidebar Blocks each covered themselves, while Sidebar Toggle — same row, no menu — did not. At 1280px nothing wraps and nothing is wrong, which is why it read as a detached-editor problem. So preferAbove now yields when above is no longer above: it clamps only while `pad + height + ARROW` still clears the top of the control, and otherwise goes below. The arrow counts because it is drawn outside the box, and a tooltip whose body clears the button while its pointer does not is still touching it. Where the clamp genuinely fits nothing changes — at 1280px every tooltip on the main window's top row is 37px and is placed exactly where it was. What makes below safe for a menu button is a third fix. showAppTooltip already refuses to draw one for a button whose menu is open, but that governs the NEXT hover only: a tooltip already on screen when the button is clicked stays where it is, and now that a tooltip with nowhere to go above lands below, "where it is" would be on top of the menu. A capture-phase click listener on any aria-haspopup control hides it. Capture, because every one of these toggles calls stopPropagation on its own click; and a keyboard Enter on a focused button fires a click too, so that path is covered by the same line. Verified in Chromium in both windows. In the detached editor all six header buttons now place below and none overlaps; hovering Tools then clicking it opens the menu with the tooltip gone, and hovering again while it is open still draws nothing. In the main window the placement is unchanged at 1280px and the three self-covering tooltips at 700px now sit below. Two of the new assertions passed against a deliberately broken implementation before being rewritten. Both located the click listener as "the first one in the file": deleting it outright still matched another listener's opening line, and flipping its capture flag still ended the slice on a later `}, true);` belonging to the scroll listener. It is found by its body now — the listener that both names aria-haspopup and calls hideAppTooltip — because an anchor that matches something else is not an anchor. The detached header's own assertions read the row out of the markup rather than naming its buttons, so a seventh is covered on the day it is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The description was the only full-brightness paragraph on the browse page, sitting under a gold heading in the body ink, and at three or four cards it stopped reading as a list of realms and started reading as a wall of description with titles in it. Someone browsing a registry is scanning for a NAME, so the summary is now dim like the facts around it and the heading carries the card on its own. That takes away the thing the "its keeper has not written a description" placeholder used to be told apart by, which was being the one muted line among the written ones. The italic now carries it alone — which is the more honest signal anyway, since an unwritten description is a different kind of text rather than a quieter one. It is asserted rather than assumed, because the two rules now overlap and the .none rule sets exactly one property: a tidy-up that reads it as redundant would leave the page unable to say which descriptions nobody has written.
The World Builder asks for a one-line description when a world is made, and that was the only moment it could ever be written. A world is then renamed, rethemed, hosted on a vault and published to a registry, and the line strangers read it under was still whatever was typed on the New World screen — or whatever the Game Master invented if that box was left blank. Changing it meant editing the world JSON by hand, which is not a thing a DM should have to know how to do to fix a sentence. So the Profile tab, which already mirrors the New World screen, now carries the same field in the same place: under World Name, above Theme & Premise, bounded at the same 200 characters. It is EDITABLE, unlike the framing around it, for the reason Economy and Implied actions are — those fields are records of what the world was forged from, and this one is a claim about what the world is now. It writes loginDescription, which is where the Builder's field already lands, and deliberately not a second field beside it. A world carrying two descriptions would publish one to a registry and show the other on its own title screen, and the vault's summary reader has already had to be taught to work around exactly that kind of divergence once. One value, two places to write it. The line is trimmed on the way in and the box is redrawn from what was stored. The title screen writes this with innerHTML and a registry renders it as text, so a whitespace-only description shows as an empty tagline in one place and as "its keeper has not written a description" in the other; storing the empty string makes both say the same thing. Two comments beside the panel had gone stale and are corrected rather than left: the intro paragraph still claimed only Economy, World Rules and Prologue could be edited there, and the CSS note above .wprofile-ro still spoke of "the two editable ones". The test that pinned that intro sentence was pinning the literal list, which is why it went red the moment a field on that panel became editable — it now asserts the claim it meant to.
The effect editor is shared between items and ailments, and every trigger it offered named a moment an ITEM produces — worn, used, landed a blow. So a marsh fever had to claim it was "onEquipped", and the ailment-authoring prompt told the GM to do exactly that, in those words: use "onEquipped" for the toll a sufferer carries the whole time they have it. `constant` names no moment. It is true of whoever has it from the turn the GM applies it until the GM, a cure, a rite or a device takes it away. The ailments design doc had already noticed the wart and rejected the fix, on the grounds that renaming onEquipped for one catalog would fork the effect model at its root to repair a word. That reasoning was half right: renaming would have forked it, and ADDING a fourth trigger beside the three item moments did not. The distinction earns a trigger rather than a label because it is real. The three "on…" triggers all name a moment and all of them end; this one names none and does not. So it is open-ended by construction: an authored durationMinutes is overruled rather than honoured, because a constant effect that expires in twenty minutes is a contradiction and not a refinement, and ITEM_EFFECT_MAX_MINUTES — "long-lived is fine, permanent is not" — is a rule about items. Its target is forced to self, since no blow was struck and nothing was wielded, so there is no second party for a fever to aim at. The chip reads "until removed" rather than "while worn", which is an item's kind of open-endedness and would be a lie here. In the editor the two fields it cannot use are disabled rather than left to be filled in and quietly discarded — a control that accepts a value it will not honour is how an author comes to believe something is true — and opening an existing constant effect now goes through the same handler that picking it from the dropdown does, so it arrives in the same state rather than painting a correct preview above two editable fields that lie. It is also kept out of the equipped-item moments in the per-turn dossier, because it is not a moment and listing it under "apply this when the player swings" invites the opposite of what it means. No new machinery was needed underneath. playerStatusChanges already documents "omit or 0 = lasts until you remove it", with remove to clear one, so what was missing was the vocabulary to author an open-ended toll rather than any means of running it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Worlds tab could list a world, hand over a copy of it, and delete it, and that was the whole of what an admin could do to one. Changing a hosted world meant finding the browser that had published it, hoping its draft was still there and still current, editing that, and publishing again — and if no such browser existed, which is the ordinary case for a world adopted off disk or uploaded from a file, there was no route at all. So each row now carries three icon buttons: Download, which it already had in words, Edit, and Publish. Unpublish keeps its label, because an icon is for the thing you do often and can undo, and destroying a hosted world is neither. Edit opens the app at ?detach=editor&vaultWorld=<uid>, which is a third authoring document beside the two that existed. A save editor edits one playthrough and a draft editor edits a working copy held in this browser; a vault editor edits a world that is not in this browser at all. Its home is the server, so it is fetched through the same requireAdmin route Download uses — the uid is all that travels in the URL, and the gate is therefore checked by the server, in the window that will show the result, rather than being inferred by the page that drew the button. Routing the fetched world through the drafts store on the way in was tried first and is wrong. saveWorldDraft calls claimWorldUid, which mints a fresh uid the moment any other stored world holds that one — the right rule for a fork, and here it would mean the publish back created a second world on the vault beside the one Edit was pressed on, with the admin's changes in the new one and the old one still listed. So the vault editor holds no draft and writes nothing locally. That last part matters more than it looks: every detached editor that is not a save or draft editor writes the session snapshot on its autosave timer, and this window holds a throwaway World Author, no story, and a world from the server, so falling through to that writer would overwrite the game the player has open in another window with a stranger's world, silently. _flushGameSave now names the case and writes nothing, and Save in the header means the publish, because the vault is where this document is saved. The Publish button is the same request the Public checkbox makes, through one shared function rather than a second copy of it. A registry listing is a snapshot taken when the box was ticked, so a world renamed or described since is still listed under whatever was true that day; the heartbeat refreshes it once a day if the vault has been running, and this is the press that says now. Sharing the call is what keeps the key-file signing step working from both controls — written twice, the second copy is the one that forgets it. Four existing tests pinned source shapes this changed, and three of them were pinning distance rather than behaviour: the detached window's title ternary grew a fourth arm, the publishWorldToVault call count grew a legitimate second caller, and a comment added inside _flushGameSave pushed its two clock stamps more than 700 characters apart. Each is now asserted against what it meant — the four window kinds are run and compared for distinctness, the new caller is named, and the stamp count is taken within the function's own body.
A player crossing from a registry to a vault is moving between two halves of one service, and until now the registry tab carried the browser's blank sheet while the vault tab carried the game's icon. `GET /favicon.ico` now answers with the repository's own favicon.ico — the same file the vault serves at the same path, read once at boot the way browse.html already is, and served with nosniff and an hour's cache. Not `immutable`, which is a promise about a URL whose bytes never change, and an icon is a thing people replace. Deliberately the same file rather than a copy kept beside the registry. Two copies of an icon are two things that can drift into being different icons with nothing to notice, and the ask was for the vault's favicon, not for one that looks like it. A registry running from a directory with no icon beside it serves 404 here and is otherwise unaffected, exactly as it is when browse.html is missing. This needed the browse page's Content-Security-Policy widened from `img-src 'none'` to `img-src 'self'`, and that is the part worth reading twice, because the directive was not there by accident. §07 refuses to let a registry know who is looking, and `img-src 'none'` is one of the things that made the refusal structural rather than promised: a page that could load a picture named by a stranger's server would hand that server the IP of every reader who scrolled past the listing. `'self'` keeps that refusal whole — a stranger's vault is not this origin, so a listing still cannot name an image — and allows exactly one picture, this service's own. Measured rather than assumed, in both directions. Under `img-src 'none'` Chromium refuses `<link rel="icon">` outright, with "Refused to load the image" in the console and no request made, so the route alone would have been a favicon nothing ever fetched. Under `'self'` the live page loads its own 32×32 icon and still refuses an image from elsewhere.example, with the refusal logged. The browse test grew a section for the icon and its declaration, and its CSP assertion was rewritten from a literal `img-src 'none'` to "'self' and nothing wider" — so a later hand loosening it to a host list fails here rather than passing on a changed literal. The icon is compared as BYTES against favicon.ico rather than by content-length: the repo already carries a second favicon.ico under Images/, identical today, and a length check would call any same-sized icon equal. That meant reading the response as buffers rather than accumulating a string, which would have decoded each chunk as UTF-8 and handed back something the same shape as the file but not the same bytes — the exact comparison being made. A companion assertion pins that the page reaches for no URL other than the favicon, so the file cannot quietly acquire something the CSP would have to be widened for again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
A world published to a registry arrived with no description, and the browse page said "its keeper has not written a description" about a realm whose title screen carried one. There were two faults behind that, and only one of them was in the vault. The vault's half: the summary was read from `editor.description` first and then from `world.description` and `world.premise`. The first is the Builder brief, which world-store.js already records as absent for an Editor publish — its own comment says a publish from the Editor has no brief to read — and the other two are fields serializeWorld does not name, which in an allowlist means fields no world has ever carried. Two of the three sources could never have produced anything. That derivation now lives in one tested function in registry.js, reads the world's own tagline first, and strips markup, because the tagline is injected into the title screen with innerHTML while a registry renders text. The other half is that there was nowhere to WRITE a description. The nearest thing was `loginDescription`, which only the Game Master ever authored, buried in generated JSON. So the World Builder has a Description field under the World Name: one line, optional, and the only input on that screen whose words leave the machine unchanged — everything else there is a brief FOR the GM, and this is prose BY the author. It is the title-screen tagline and the registry listing, which are the same sentence written for the same reader. It follows the rules the neighbouring fields already follow. The author's line is stamped onto the generated world and beats the GM's, exactly as authored rules and narrative do, while a blank field keeps whatever the GM wrote rather than erasing it. Generate Brief is asked for one and the writer that applies the answer names it, since a field the contract requests and the writer omits is reported as filled and lands nowhere. And the brief carries it in both directions, so Save World and Import World round-trip it — with the world's own tagline as the fallback, so a world imported from an Editor publish shows its description rather than an empty box beside a title screen that has one. Twelve sabotages, each caught by a distinct named assertion, and the twelfth is the reason the test now RUNS the writer rather than reading it: putting `if (false)` in front of the line that sets the field left the string in the source and passed a regex looking for it. Running it turned up something else — the protection against a Generate Brief answer that omits the field belongs in the caller, not in the writer, because Import deliberately replaces the form. So the test drives applyGeneratedBrief against a fake form and asserts the typed line survives an answer that says nothing about it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Restocking shipped author-declared in data only, which is the same gap the pooled flag had before it got a checkbox: a field nobody can reach from the UI is a field only whoever wrote the engine will ever set. A being's Inventory section now carries a Restock block — one tick per carried item, with a target quantity and a cooldown in hours beside it, editable only while the line is ticked. It mirrors Respawn, which sits directly above it on the same card and is the same shape, down to reusing its CSS. Two idioms for one idea is how an editor rots, and there was nothing here that Respawn had not already answered. Unticking strips the fields rather than leaving them dormant, because a stale target that reappears when somebody re-ticks the box months later is a surprise rather than a memory. All three controls drop the being's learned restockBase entry for the line they touch. The engine learns a shelf once and then trusts what it learned — that is precisely what lets a slot bought out to nothing come back — so without this a DM could change the target, watch it save, and see the shelf go on refilling to the old number forever with nothing anywhere saying why. Dropping the learned copy makes the next turn re-read the authored line. It also restarts that slot's cooldown, which is the right way round: the shelf has just been changed, so timing it from now is more honest than crediting it for hours it spent being something else. Building the control exposed a defect in the engine it drives, and it was reachable only through the control, which is a fair argument for building it. The refill loop matched a slot with `&& e.restock`, so an entry the DM had just unticked was invisible to it — and an invisible slot reads exactly like a bought-out one, so the next cooldown would push a fresh flagged entry and resurrect the thing they had turned off, beside the copy already sitting there. A slot is now matched on its key alone and the flag is checked afterwards, where "switched off" can be told apart from "bought out"; the first case makes the being forget the slot entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Publishing from a local vault failed with "that vault could not be reached — fetch failed", which is the whole of what Node says about DEPTH_ZERO_SELF_SIGNED_CERT. The vault was answering perfectly in a browser one tab away. TLS is auto-detected from the key/cert pair beside server.js, so the moment anybody runs make-cert.sh their development vault is https with a certificate nobody signed — and the origin check, which is a fetch from the registry to that address, refuses it. REGISTRY_ALLOW_LOCAL already permits a loopback ADDRESS for exactly this arrangement, so it now permits that certificate as well, and only it does. A public registry checking a public vault verifies the chain exactly as before: there is no path from here to a relaxed check on an address that is not local. The relaxation is a second HTTP client rather than a flag on the first, because `fetch` has no way to say "this one connection may be unverified" without reaching for process-wide state, and NODE_TLS_REJECT_UNAUTHORIZED is a setting that turns verification off for everything the process ever does, including the parts nobody was thinking about. A second HTTP client is also exactly where the guards quietly fail to be reimplemented, so it keeps all of them: no redirect followed, the body capped WHILE IT ARRIVES rather than after, the caller's timeout. Two of the sabotages aimed at those survived the first pass and both were the test's fault. A cap removed from the streaming path still produced the same refusal, because the outer length check caught the fully-buffered body afterwards and said the same sentence — so the assertion now watches the server notice it was cut off mid-body. And nothing at all covered WHICH client gets chosen: a prover that reached for the relaxed one unconditionally would verify no certificate anywhere in the world and pass every assertion in the file, so the choice is now asserted directly by counting calls to the global fetch. The reason a failed check gives is also the cause now rather than the symptom. Node reports every connection-level failure as those same two words and hides the code on `cause`, so a certificate, a refused connection and a DNS miss all reached the operator as one sentence with nothing in it to act on. A certificate failure additionally names the switch that would accept it, since a development vault is the only place that error means anything but trouble. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
TLS is auto-detected: drop a server.key/server.cert pair beside server.js — or regenerate one that was already there — and the vault comes back up on https, on the port it was serving http on. Every tab, bookmark and cached URL still says http://, and a plaintext request to a TLS port gets no HTTP answer at all. The browser reports ERR_EMPTY_RESPONSE, or ERR_CONNECTION_RESET, or ERR_SSL_PROTOCOL_ERROR depending on timing and whether it reused a socket — three faces of one cause, and not one of them says that this port speaks https now. Reported as a vault plainly running and plainly unreachable: the admin page rendering fine, every request behind it failing, and the Public checkbox coming back "Failed to fetch". The page was being served by a service worker out of its cache, which is fixed separately, and underneath it nothing had reached the server since the certificate was regenerated. Three theories were wrong on the way there, including mine — I tested a single plaintext request against a TLS port, got ERR_EMPTY_RESPONSE, and told the operator the scheme mismatch was ruled out when what I had actually seen was one of its faces. They diagnosed it themselves in the end. A TLS ClientHello starts with 0x16, so anything else arriving on this port is somebody speaking HTTP at a TLS listener. One byte of inspection is enough to answer them in the one dialect they can read: a 400 in plain HTTP naming the https URL, port and all, and saying whether the TLS was auto-detected — so an operator who never chose to serve https can find the pair of files that decided it for them. It has to be a front door rather than a listener on the https server, because tls.Server consumes the raw socket inside its own handle and a JS data listener on the connection never fires. The first version that did work in that respect did not work in another: reading the socket in flowing mode buffers the bytes on the JS side where the TLS state machine never sees them, and the vault came up answering nothing at all over https. So the front door is a net.Server with pauseOnConnect, taking one byte deliberately and unshifting it back. The test checks the handshake on a fresh connection and on a keep-alive agent for exactly that reason: a test that only proved the plaintext answer would have shipped a vault that could not serve https at all. The banner also warns when VAULT_PUBLIC_URL's scheme disagrees with the scheme actually being served. That is the same flip one layer out, and it sends everybody else — an Auth0 callback, a registry listing pointing players at a realm — to a door this vault does not answer. Ten sabotages, each caught by a distinct named assertion, including both of the ways the front door can be subtly wrong: peeking without putting the byte back, and reading in flowing mode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The default bind is 127.0.0.1 and the boot banner invites you to http://localhost:PORT. On a machine where localhost resolves to ::1 first, which is most of them, those are two different addresses: the browser connects to [::1]:8787, nothing is listening there, and the report is "this site can't be reached" while the operator is looking at a terminal that says the vault is running. With a service worker holding a cached copy of the app it is worse, because the page renders and nothing on screen says the server was never reached. So a loopback bind now listens on both, from one app. A machine with no IPv6 fails that second listen with EAFNOSUPPORT and carries on, which is not an error — it means there was never a second stack to be unreachable on. EADDRINUSE gets a warning rather than silence, because something else holding ::1 on this port is exactly the case where http://localhost reaches a different server than the one that printed the line. Not the cause of the bug being chased when this was written — that vault answers on localhost fine, and the failing request turned out to be a stale service worker resolving Response.error() in front of /admin. This is the inconsistency the investigation walked past on the way: printing an address we do not necessarily answer on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A service worker registered from text_adventure.html has the whole origin as its scope, so this network-first handler was also sitting in front of /admin, every /admin/api/* call, /vault/config and every /vault/media/* file — caching each successful answer and serving it back when a later fetch failed. The report was a screenful of failed requests attributed to sw.js on the admin page, with the page itself rendering fine, and the reporter's guess was a race with some session key. It is neither a race nor about credentials: the initiator column reads sw.js because the worker re-issues the page's own requests inside respondWith, so those rows are the outgoing leg of requests the page made, and a failed leg beside a page that looks right means the cached copy answered instead. Which is the actual problem, and it is worse than console noise. Measured in a browser: start the vault, open /admin, stop the vault, reload — status 200, the full admin page, seven tabs, the key store reported as active. Every number on it is a photograph of the last time it worked, and the gate that decides who may see any of it is server-side, so it did not run at all. An admin page that cannot reach its server has to say so; looking exactly like one that can is the failure. The same interception also cached API answers whose whole meaning is "as of this moment", and copied the vault's media store into Cache Storage — one page load measured 31 MB of it, out of a store the server already serves immutable and the browser's own cache already keeps. The fix is the one the cross-origin case took a while ago, for the same reason: decline the event. /admin, /admin/… and /vault/… are not the app, this worker exists for PWA installability and an offline fallback FOR THE APP, and declining lets the browser do its own job so a real failure arrives as a real failure. Measured after: the same reload with the vault stopped now refuses with ERR_CONNECTION_REFUSED and renders nothing, and the worker's cache holds five entries — the shell — with zero from /vault and zero from /admin, where before it accumulated every file the media store held. The cache version is bumped to v5 as part of the fix rather than as bookkeeping. Declining from now on does nothing about the copies already on somebody's disk: a browser holding v4 keeps answering /admin out of it. Only the version change reaches those, through the activate handler that deletes every cache that is not the current one. Eight sabotages, each caught by a distinct named assertion. The test lifts the predicate out of the worker and runs it against real URLs rather than matching the source, which is what catches the two that a regex would wave through: a prefix test written with includes instead of startsWith, which would decline an app art directory called vault, and a check that forgets /admin has no trailing slash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Starting both halves in two terminals and ticking Public failed on a rule about the public internet. A listing has to name an address other people can reach, so the vault refuses to send a descriptor naming http://localhost:8787 and the registry refuses to accept one. Both refusals are right and neither is any use to somebody with both processes on their desk, which is everybody the first time. The ways around it without a switch are a tunnel, a fake hostname with a certificate to match, or commenting out the check — and the third is the one people actually reach for. A check somebody has learned to comment out is a check that will be commented out on the day it matters, so this is a switch: REGISTRY_ALLOW_LOCAL, off unless an affirmative value says otherwise, announced at boot by both processes and reported by the registry's /health, and named in the refusal itself so the operator hitting it is told what to do rather than left to work it out. It has to be set on both processes, and that is the design rather than an inconvenience. The vault validates before it sends and the registry validates again on arrival, because a registry that trusted a publisher's validation would be trusting every vault on the internet — so neither half can quietly relax the other, and a registry with the switch off refuses a local listing however the vault feels about it. What it does not do matters more than what it does. It does not relax https for a PUBLIC address: a listing is an invitation to send a login over that URL, which has nothing to do with whether somebody is developing, so that rule stands whatever this is set to. It does not skip the origin check either — the registry still calls the vault back and still requires it to confirm it hosts that world, so the switch changes which addresses may be named and not whether they are asked. And the SSRF guard it loosens for the prover is reached through the same one function that decides what counts as on, passed explicitly from the entry point rather than defaulted anywhere, so there is exactly one place where a service decides to fetch a private address. Nine sabotages, each caught by a distinct named assertion, aimed at the failure that matters for a development switch: one that is on when nobody asked for it. A default of on, a pattern that matches any value, a hard-coded true at the entry point and a boot log that says nothing are all caught, as is a switch that quietly took the https rule with it. The test's own first version was the instructive part: it wrapped an async block in a synchronous set-and-restore helper, so the environment was put back before a line of the block ran and every assertion failed with the refusal it was written to prove had gone away. Verified by running the pair: a vault at 127.0.0.1:8799 published a world to a registry at 127.0.0.1:8798, the registry called the vault back and confirmed it hosts it, and the record carries originProvedAt with the local address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Regenerated from the full, unshallowed git log rather than a partial clone, so the report now covers all 2,822 commits across 55 days back to June 30th instead of stopping wherever a shallow checkout happened to truncate.
Four editor tabs — Factions, Dungeons, Races and Ailments — draw the same portrait column: a Generate button on an empty slot, ⬆ Upload beside ♻ Regenerate once there is a picture. Five kinds — those four plus beings — offer the same ✨ that asks the GM to write a Description or a Detailed Description. Each carried its own copy of the whole thing: twelve portrait handlers of fifteen lines apiece, and five description functions of fifty-five, differing only in which resolver finds the record, which field the picture lands in, which painter makes it, which tab to redraw, and the few context lines peculiar to that kind of thing. The copies had already begun to drift, which is the argument for doing this now rather than the next time. Four description handoffs logged a capitalised noun and the fifth logged a lower-case one. Four read res.error off the painter's result and the fifth guarded against the painter returning nothing at all — a real difference, because reading .error off undefined throws inside an async function, which escapes as an unhandled rejection and leaves the button disabled for the rest of the session with no message anywhere. Nobody chose either divergence; they are what five copies do. Drift in a prompt is the expensive kind. A prompt that has quietly become a different prompt still returns text and still looks like it worked, so nothing fails and nobody looks. The four ask sentences here are already subtly different from one another on purpose, which makes an accidental fifth difference invisible. CARD_PORTRAIT_KINDS and TYPE_DESCRIPTION_KINDS now hold what is genuinely per-kind and the two runners hold the rest. Every tab keeps its own named handlers as thin delegations, because ~780 controls are wired by inline onclick attributes that resolve by NAME at click time: a shape that stops publishing the name — an assignment into a table, a method on an object — loads fine and does nothing when clicked. What a tab no longer keeps is its own implementation. Beings supply the one override, a write() that goes through applyEntityTypeField, because a being's description belongs to the type and every live copy of it rather than to the one card that happened to be open. Deliberately not extended to the NPC/Monster portrait, which writes a prompt with the GM first when the being has none and propagates the finished picture across the type, every instance, the Compendium and any open popup. Same three buttons, genuinely different job; folding it in would put two fields on the descriptor that exist for nothing else. Nor to the spell and skill descriptions, which are single-field, carry no world canon and refresh their own views afterwards. Behaviour was pinned before the change and diffed after: all ten description directives are byte-identical to the ones the five old functions produced, as are the fields written, the paint and redraw order for all four tabs on success and on failure, and the upload path. The only difference is the two entity log lines, now capitalised like the other four. tests/test_shared_card_controls.js is the guard, and it asserts in two registers. That each named handler is a delegation and not a body, read off the source — a re-inlined copy behaves identically on the day it is written, which is why it gets written, and drifts on some later day nobody connects to this one. And that the shared implementation does the per-kind thing: paints the right record, redraws the right tab, does not redraw on failure, keeps an ailment's picture in image where the other three use portrait, sends each kind's own context lines and none of another kind's, and writes a being's description onto every live copy of it. That last one is checked against a world holding two Bog Wraiths, because with one instance a type-wide write and a write onto the open record are indistinguishable and the assertion would pass against an implementation that had lost the override. Net 217 lines out of text_adventure.html, most of what remains being the reasoning above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The last phase of the registry was the only one that is a product rather than a mechanism, and it is now a page. GET / lists the realms the registry holds — a search box, a Proved filter for listings that were signed, a Gone quiet filter for the ones that have lapsed, and paging — reading the same public /api/worlds a game reads, so there is no second source of truth and no private door. Anything on the page is something any client could already have asked for. Almost none of the work was listing realms. It was what a page does when every string on it was typed by a stranger and served from their machine. Nothing from a record goes near innerHTML: the page builds DOM and assigns textContent, which is the difference between rendering a name and running it, and the test asserts that over the source rather than by trying one payload, because the payload you thought of is never the one that lands. The single attribute anywhere built from a stranger's string is the link to their vault, and it takes https only — the registry refuses anything else at publish time, and this is the second lock on the same door, because a javascript: address that ever reached a record would otherwise become a script on this page for every reader who clicked it. Confirmed in a real browser against a realm named with an img onerror and a script tag: it renders as the literal text of its name, and no image, iframe or script exists on the page afterwards. Two of section 07's three refusals are enforced by the response rather than promised by the file. A registry must not know who is looking, so there is no login, no cookie and no analytics — and the page is served with default-src 'none', connect-src 'self' and img-src 'none', which means it cannot reach another origin whatever anybody later pastes into it. img-src is not incidental: a listing carries no art, and a page that could load a picture named by a stranger's server would hand that server the IP of every reader who scrolled past it. Referrer-Policy: no-referrer, and rel=noopener noreferrer on the link itself, mean a player crossing to a vault does not hand it the address of the registry that sent them, still less the search they were on when they clicked. The third refusal shapes what there is to show: no rooms, because there are no rooms in a record to show. And the page does not dress up an unproven claim. Proved against Unproven, with "this name was claimed first, not proved" behind the second, because a page that showed both kinds identically would quietly make the whole key binding worthless. Proved requires both a key and a verdict rather than the presence of a field, since a record can carry a key and still be unproven. Eighteen sabotages, each caught by a distinct named assertion, three of them only after the assertions were tightened: a badge check that read a string literal still passed with the badge behind if (false), a "the publisher is never shown" check proved nothing against fixtures that had no publisher, and a total-versus-page-size check needed a sabotage that actually bit at four records. The browser found the one thing no source assertion could: the More button styled display:block outranks the browser's own rule for [hidden], so it sat there offering a next page that did not exist. That is a CSS rule which is really a behaviour, and it has a test of its own now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Editor › Environment gains a third inner tab, Ailments, and the world gains a catalog to go with it: world.ailments, an id-keyed map of the illnesses and diseases a place can inflict — marsh fever, the grey rot, miner's lung. Each carries a name, a short description and the skill-gated detailed one, a portrait with its prompt and the shared art-style override, the usual lore fields, an effects list, and the field the tab exists for: contractedBy. It is deliberately NOT a thirteenth item type, and the reason is not taste. Flora are items because a plant is a thing you can pick up, and the item pipeline is built on that: an item can be carried, dropped, sold, weighed, equipped and put in a chest, and it is offered every one of those unconditionally. A disease authored as an item is a disease that can be sold to a merchant and shut in a strongbox, and each of those would have to be refused one at a time, at every site that offers it, forever. An ailment is a thing that happens to you, so it sits beside world.races and world.factions instead. contractedBy holds a sentence — "drinking the swamp water", "staying out in the cold more than three days running", "breathing the fumes of a burning corpse-flower" — rather than a trigger structure the engine could evaluate. The Game Master is the only thing in the system that can judge whether a player has done any of those, and a schema that handles the cold-exposure case but not the corpse-flower one would give an author a field that works for a third of what they want to write and silently ignores the rest. Six spellings of the field name are accepted on the way in, because the name is a phrase, models do not reliably echo a phrase, and a silently-blank contractedBy is the one failure this catalog cannot survive. The tab's filter reads it as well as the name, so typing "swamp" finds everything the marsh gives you; the question a DM asks here is almost never what a sickness is called. Effects are borrowed whole from the item model — normalizeItemEffect, the same effect editor, the same chips — rather than growing a second vocabulary. onEquipped means "for as long as they have it" here, which reads oddly on a disease, so the Field Guide says so and the chip renders "while borne"; renaming the trigger for one catalog would fork the effect model at its root to fix a word. The GM box is scoped to ailments and declines anything else. Its directive interpolates the world's own room and weather-condition names from the live source, so a sickness comes out of this world rather than out of generic fantasy, and it requires contractedBy on anything it creates — an ailment nobody can catch is a card the world never uses. GM edits and file imports both merge over the existing record, so a change naming two fields does not blank the portrait it said nothing about. Two functions the card named by string did not exist. buildDescEditFields wires generateAilmentDescription and the Portrait section wires suggestAilmentPrompt; both drew correctly and threw a ReferenceError on click, with nothing in the console leading back to the card that wired them. Both are now written, and compendiumTypeContext gained an 'ailments' branch — without it that category fell through to the ITEM lookup, found no such item, returned a null obj, and compendiumSuggestPrompt returned immediately, leaving the button to spin down and change nothing. test_ailments.js reads the handler names off the RENDERED card and asserts each resolves to a function, so the next one cannot be added silently. Three existing tests failed on a correct implementation, each because it held a list the app had grown past. test_big_prompt_box counted the GM request rows against a literal 18 — the same staleness its own header comment warns about, arriving in the test — and now partitions the rows instead. test_editor_card_state resolved collapse sets through a hand-written key → set map, so a set with no entry there read as an orphan whatever the app did; it now resolves them by name through a direct eval in the harness scope, and cannot fall behind. test_flora_fauna pinned the inner-tab dispatch as a source string naming Flora and Fauna, and now walks ENV_INNER_TABS and checks each tab's own view is written to, which catches a missing branch falling through to another tab's renderer. Designs/ailments.html records the reasoning and, at more length, what is NOT built: nothing in the engine yet gives an ailment to anybody. The catalog's value today is what the GM reads from it. Contraction, diagnosis and cure are the open decisions, with the held-condition model from Designs/weather.html Decision K as the likely substrate for the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Every descriptor is a claim about a server that may already be gone, and a registry with no expiry becomes a directory of dead links — where a dead link is a road the player is told exists and cannot walk. So a listing is live while something has confirmed it inside a week, and lapsed when nothing has. Two things confirm one and either is enough: the vault says so, or the registry asks. The design imagined the heartbeat as a re-publish on a schedule, and the key binding rules that out. Re-publishing a keyed world needs a signature, and the private half belongs to its author rather than to the vault hosting it, so a vault could not refresh its own listings without its authors standing by with key files. The heartbeat that shipped therefore carries no content at all: no descriptor, no proof, no key. It cannot change a name, a summary or an address — only confirm one — and that is exactly what makes accepting it unsigned safe, and what lets a keyed world stay listed while its author is asleep. With origin proof on, a heartbeat makes the registry ask the vault back rather than taking its word, so a forged one buys nothing but a rate-limited callback, and one for a world the vault has stopped hosting is refused rather than honoured. The other half needs nothing at all from a vault operator. The registry sweeps hourly, re-running the origin proof it already had against the listings it has not heard from in a day, oldest first and bounded per pass — unbounded, the day this holds ten thousand worlds it becomes an hourly outbound flood against ten thousand strangers' servers, and the first symptom is somebody else's firewall. A vault that published once and went back to writing its world is already a good citizen without being told, because the proof endpoint is already there. Lapsing is derived rather than an event, which is the decision the rest of this rests on. Nothing writes a delisted flag, so a sweep that never runs leaves the registry less fresh but never wrong, where a flag would be a second representation of one fact with a way for the two to disagree. Lapsed listings drop off browse — browsing is how somebody decides where to go, and a realm silent for a week is not somewhere to go — and still resolve by uid, because an exit authored years ago deserves the address it points at and a crossing can narrate a failure it cannot narrate from a 404. Nothing is ever deleted, so a lapsed uid keeps its bound key, its publisher and its address: a vault down for a weekend does not lose its claim to whoever notices first, and coming back is a publish rather than an appeal. R5 is answered at a week, with a heartbeat a day, both published by /health so a vault need not guess either. A vault restarted on Friday and looked at on Monday has lost nothing; one gone for a week is off the map. The recheck window is one heartbeat interval, so a listing nothing has confirmed for a day gets asked rather than assumed — which is what keeps the TTL from being a deadline nobody was told about. The vault sends its first heartbeat shortly after boot rather than waiting a day, because the failure this exists for is a vault that was down and is now back: exactly when its listings are closest to lapsing, and exactly when nobody is watching. Twenty-eight sabotages, each caught by a distinct named assertion. Three survived the first pass and one was a real defect: the store's liveness filter called Date.now() rather than the clock it was given, so browse and /health answered about the present moment while every other part of the service answered about the one it was asked about — invisible in production and fatal to any test of a TTL. The other two were assertions that could pass without the code running: the sweep's first-failure rule was only covered on the heartbeat path, and the throwing-prover assertion ran at a moment when nothing was due, so it passed against a sweep with its try/catch removed. Both now assert the counts as well as the outcome. Verified live as well: a vault and a registry started side by side, the vault found its one public world and said it was still there, and the registry logged it still listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Every other way rooms enter a world files them somewhere. A chunk did not: whether the GM authored it, a stub skeleton laid it out, or a DM pasted it in, the rooms arrived belonging to no region — invisible to the Regions tab's room list, to the Rooms tab's region filter, and to the weather, which then fell back to the world default for a marsh that should have had its own sky. The Chunks tab now has a Region select above Stitch onto, and every room a merge CREATES is filed under it. It is one option on mergeWorldChunk rather than three pieces of wiring. Two of the tab's three paths go through a GM worker, so the option travels a parameter as well as a call, and the third merges directly — but all of them end at the same line, which is where the room is tagged. Tagged BEFORE the room is installed, so it enters the world already belonging somewhere: nothing has to re-render, and a merge that fails half way cannot leave untagged rooms on the map. Three boundaries worth stating, because each is a way this could have over-reached: Rooms already in the world are never re-tagged. Merging is additive everywhere else in that function and a select that quietly re-filed existing rooms would be the one part of it reaching backwards. Callers that pass nothing are unaffected. Six paths merge chunks; the Region Builder and the "//" room-addition path tag their own rooms, and reading a dropdown from a tab they are not on would override that from off-screen. A test asserts no other path was wired to it. A chunk that names its own regions is overridden — the selection is the more recent and more deliberate statement — but not silently: each re-filed room appears in the merge findings. A chunk naming the SAME region by name rather than id is not reported, since nothing was overridden, and a finding per room of a correctly-tagged chunk is noise that hides the real ones. Two things only the browser showed. The select shrink-wrapped to its placeholder at 94px, clipping any region name longer than "— none —"; it is full width now, like the multi-select it sits with. And the first CSS used a var(--bg-deep) that does not exist — caught by test_css_tokens_defined before it ever rendered, which is the cheaper of the two ways to learn that. The mock <select> in the new test also had to learn that replacing innerHTML resets `value`, as a real one does. Without it the mock kept the value by itself, and the keep-and-restore that exists precisely to survive that reset could be deleted with the suite still green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Merchant restocking, built to the design doc. Until now buying was a one-way drain — transferItem splices the goods out and nothing ever put one back — so a world played long enough emptied every stall in it, and Hesk's "buy from him five separate times" was not reachable against a shelf of four items. Author-declared per entry, per the doc's shape B: an inventory line carries restock: true, optionally restockTo and restockHours. Unflagged means unrepeatable, so a quest object or a named blade is safe without anyone having to think about it. The cooldown resolves entry, then world, then a default of a day, measured on currentGameMs so it ages correctly through rests and time skips rather than through the wall clock. It runs lazily at the top of each turn on the player's current room, which costs a loop and no model call. Two things the proposal did not foresee, both discovered the same way. The baseline could not live on the inventory entry, because buying the LAST of something takes transferItem's whole-stack branch, which hands over the entry object itself — an entry-only baseline would be carried off in the player's pack at exactly the moment it was needed, and the shelf would forget the slot had ever existed. So it lives on the being as restockBase, learned from the authored entry the first time the shelf is seen, which is always before a purchase can resolve. The second edge of the same fact: those fields describe a slot on a shelf and not the thing standing in it, so left on the acquired object they would ride into the player's inventory, and an item that refills itself in a player's pack is a money press. They are stripped at the moment of acquisition, the only place that can see the object leaving the shelf. The hook is in sendToLLM, before the prompt is built, and deliberately not in buildSystemPromptParts even though that is what renders stock into the dossier. It is a prompt builder, the suite calls it repeatedly, and a function that mutates the world every time it is asked what the world looks like is a trap for the next reader. The three hazards the doc names are tested rather than asserted: selling pushes the player's goods into the same array on purpose, so a refill tops up only the slots it learned and never resets the array; it tops up TO the baseline and never by an amount, since the buy/sell spread forbids a money loop only while a refill cannot overshoot; and an over-full slot is left alone rather than trimmed, because trimming would confiscate what the player put there. Still open and recorded as such: there is no editor affordance, so restock is authored in data only — the same gap the pooled flag had before it got a checkbox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Saltmonger's lore hook asks the player to buy from him five separate times, and his stall could not support that: four items, one he refuses to sell in fiction because the player returned it to him, and two priced at 900 and 400 copper against a mid-game purse of 346. The hook is good. The shelf was the problem. Two staples authored into the draft, both the sort of thing scaffold-town buys the way dry cities buy bread: a Fish-Oil Wick at 3 copper, because every lantern on the scaffolds burns one and every one of them burns out, and a Brined Patch-Cord at 5, cut for splicing a failing lashing before it becomes a fall. Neither carries a lore hook — they are not content, they are the thing a merchant always has. They are stocked six deep, which is the interesting part. Buying is transferItem, and its splice branch decrements a stack rather than removing the entry, so a quantity of six supports six separate purchases and the hook becomes reachable today with no engine change at all. That workaround exists because restocking does not. There is no restock, replenish or resupply anywhere in the engine — zero occurrences — so a merchant's shelf is a one-way drain and a world played long enough empties every stall in it. Designs/merchant-restocking.html proposes the fix and records why it is a design question rather than a timer: once the splice has run the world no longer records that the merchant ever stocked the thing, so there is nothing to restock TO and a baseline has to live somewhere other than the live inventory. It weighs three places that could be, leaning on the author declaring it per entry for the same reason the pooled checkbox exists — the engine should not have to guess what the author meant. It also writes down the three ways this can go wrong, the worst of which is that selling pushes goods into the buyer's stock deliberately, so a restock that RESET the array to a baseline would silently delete whatever the player had sold them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two contract changes from the Claude18 run, and a third directive found holding a binding that was fixed twice already. BUG-057 gets roomUpdates.moveEntity, the mirror of moveToRoom. Until now items could move five ways and the player one way and a being not at all: the nearest thing was removeEntity, which does not relocate anybody but sets alive to false, so a Game Master narrating someone walking away had to choose between killing them and simply saying it happened. It always said it happened, which is right, and left the world disagreeing with its own fiction — Joss was narrated down the chain-ladders into the nave across three turns while the engine kept him on the rope-bridge. It moves the object rather than rebuilding it so inventory and status travel along, resolves against the live room, and refuses what it should refuse: an unknown destination, a name two beings answer to, and the dead. The contract now also says outright that removeEntity is death and not travel, because the two were interchangeable-looking and that is how a departure became a killing. The lookup deliberately avoids matchEntityInRoom on its first attempt. That logs an error on every miss, and the commonest legitimate case is a being who has already moved ahead of the player — the ordinary shape of a chase, which is precisely the hook that exposed this. The second change answers a question the run could not: two counted hooks fired a step early, Mira's on the second of three asks and Hesk's on the second of five purchases, and there was no way to tell a miscount from an adjudication against history the log does not show. nearMiss already had the right shape for this, so loreUnlock and itemLoreUnlock now carry the same "why" clause, DM log only. The absence of a reason is recorded as loudly as its presence, because a hook that fires for no stated reason is the case worth noticing. The contract explicitly invites the GM to say when earlier trade or conversation already covered part of a count — the aim is to make good adjudication legible, not to argue it into rigidity. Also fixed: applySellItem was still receiving the stale room binding, making it the third carrier of BUG-049. Selling to a merchant on a turn that also travelled took the goods, paid the coin and never gave the merchant the stock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-057, filed open rather than fixed, because the answer is a contract change and that is the owner's call to make. Joss Half-Drowned's lore trigger asks the player to follow him down into the nave without being noticed, and the run played it exactly as authored: a stealth check on the rope-bridge, Joss waiting a long minute before going south at a fast hungry clip, a chase down the chain-ladders into the black water, and the hook unlocking on what he read aloud. The whole time the engine had him standing on the Rope-Bridge Span, and he is still there. There is no field that moves a being. Items can move five ways and the player can move with moveToRoom; for a being there is only roomUpdates.removeEntity, which does not relocate anyone — it sets alive to false. A Game Master narrating an NPC walking away therefore has two options and one of them is killing him. That is BUG-046's shape precisely, and BUG-046 already said it best: a refusal that only lives in the prompt is a request, except here not even the refusal existed. The model was not disobeying a contract, it was working around the absence of one. What makes it worth filing rather than shrugging at is that it did NOT visibly break, which is the dangerous kind. The unlock landed only because loreUnlock is keyed on the catalog rather than the room. Anything resolving through matchEntityInRoom would have missed in silence instead, which is BUG-049's failure mode now reachable with no bug at all in the directive: give something to a travelling NPC, or rob him, or resolve him, and the engine hunts for him in a room he was never recorded as leaving. The leaning is a moveEntity directive built as the mirror of moveToRoom, and the entry raises the question that goes with it — whether a being who has moved should be announced to the GM on the next turn the way a meta-spawned being already is, so the dossier and the narration cannot drift apart again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-056, found in play. The player pressed a salt-verse shard into Ys the Broker's hand unasked, which
is precisely her authored lore trigger, and the Game Master did everything right: one response carrying
the handover, the reaction and the unlock — giveItem, reputationChange and loreUnlock, all three well
formed and all three naming her correctly. Nothing happened. The shard stayed in the pack, her inventory
stayed empty, her hook stayed locked, and the log said the response could not be parsed.
The reply had opened with a line of the model's own reasoning, and that line contained a brace.
extractJsonObject began its balanced scan at the first "{" in the reply, which was the one in the prose:
depth went to 1 there, 2 at the real object, and back to only 1 at that object's closing brace, so the
scan never terminated early, ran to the end, and handed JSON.parse the prose and the object together.
The reason this survived so long is that the function already promised, in its own comment, to be
tolerant of a stray prose prefix — and it was, for prose without a brace. The tolerant case and the
broken case are the same three lines of source, so reading it tells you nothing; only a reply shaped
like this one does.
The opening brace is now ranked and parse-tested rather than assumed. A real object's brace is followed,
after whitespace, by a quote or a closing brace, which a brace in prose almost never is; each candidate
is scanned and then actually parsed, and the first that parses wins. The two halves earn their places
differently and the test says so, because the first draft of that test credited the wrong one: the parse
test is what recovers the turn, while the ranking decides which object repairGmJson is handed when
nothing parses at all — under the old code it received the prose and could never have succeeded.
Left alone deliberately: the "didn't act on that" message, which is a different path taken only when the
reply contains no brace anywhere. That one is a genuine prose decline and reads correctly already.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvIt was a centred gold pill fixed to the viewport, because that is the class the overlay already had —
the Gallery "Use" button's. The two are not the same kind of control. Use is the one decisive act the
overlay offers: take this picture as your portrait, and the overlay is done. Regenerate is a tool that
acts on the picture and leaves you looking at it. Tools belong in the corner with the other tools, and
the 3D lightbox already had that corner.
So it moves inside the frame, bottom-right, and the styling is SHARED with the 3D viewer's controls
rather than copied — the same rule now sets the corner, the background, the border, the colour and the
size for both. Two overlays that are the same kind of thing, a picture in a frame with tools in its
corner, and a reader who has used one should not have to hunt for the other's buttons. Use keeps the
gold pill it always had, since nothing about it changed.
The status line went with it, to the frame's bottom-left, where the 3D lightbox puts its hint and
opposite the control that writes it: a message about the picture belongs on the picture. It is
width-capped so a long failure wraps above the bottom edge instead of running under the button, and
collapses when empty.
Verified in Chromium rather than by reading the stylesheet, which is the habit the last change earned:
the button falls inside the picture's own bounds at 12px/14px, and every computed style that matters —
background, border colour, text colour, font size, radius, height, letter spacing — is identical to
the 3D lightbox's own button. The status renders with real height and does not overlap it.
One existing assertion needed rewording, not fixing: it matched `.model-lightbox-controls {` and the
selector is now a group. It asserts the sharing itself now, which is the stronger claim anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRSEverything the registry checks about a publish has been about the publisher: an account says who is asking, a signature says the world is theirs. None of it can see the case R4 exists for, which is not hijacking but reflection. A publisher who owns a uid honestly, signs for it perfectly and passes every other check can point that uid at a machine belonging to somebody else, and the registry then aims every arriving player's fetch at a third party who never agreed to any of it. Nothing about the claim is false except the address, so only the address can catch it. So before a world is listed, the registry asks the address. The vault answers on /vault/registry-proof?uid=&nonce=, and the shape of that answer is the whole mechanism. The leaning recorded in the design was a well-known path echoing a challenge the registry issued, and an echo is exactly what it must not be: a path that returned the nonce it was handed would answer identically from every vault on the internet, so pointing a uid at a stranger's server would prove precisely what pointing it at your own does. The vault answers a narrower question instead — do you host this world, and have you offered it publicly — and a reflected address fails on the first request. It proves hosting and not authorship, which is why the signature exists as well; anyone who imports a pack can host a world and answer this honestly. The route is ungated deliberately. The asker is a registry this vault has no account with, arriving from an address nobody can predict, so a login gate would mean a vault behind Auth0 could never be listed at all — the same trap the PWA manifest fell into when a credential-less fetch was redirected to a cross-origin login and reported as blocked. What it gives away is bounded the other way: it answers only for worlds an admin has already marked public, which are the worlds they are asking to have listed, and its two refusals are deliberately the same sentence. A caller who could tell "not hosted here" from "hosted but private" could ask a vault what it holds privately, one uid at a time. The nonce is reflected, so its grammar is checked rather than its content trusted; an endpoint that echoes arbitrary bytes to an unauthenticated caller is a gadget waiting for a use. Most of the registry side is the SSRF boundary rather than the protocol, because a registry fetching a URL a stranger supplied is the shape of every cloud-metadata theft. https only; the hostname resolved and refused if any answer is loopback, private, link-local or carrier-grade NAT, since a name that resolves to one public address and one private one is not a name with a public address but one that will sometimes be connected to the private one; redirects refused rather than followed, because a 302 is the publisher choosing a second URL that nothing checked; the body capped at 16 KB whatever content-length claims, that being a claim by the same stranger serving it; five seconds. The check runs last, after every local check, so a publish that was going to be refused for a bad signature does not first spend five seconds asking a server about it. Withdrawing is exempt, and has to be: an address being unreachable is often exactly why somebody is unlisting a world. Records carry originProvedAt rather than a bare flag, because "true" would say the same thing five years after a host changed hands, and the field is absent rather than false on a registry that does not check — we did not ask and we asked and it said no must not read alike. Origin proof is on unless REGISTRY_VERIFY_ORIGIN turns it off, which is the opposite of how the token map defaults: tokens off means anyone may publish, a choice an operator makes for a private network, while origin proof off is not a choice anybody makes on purpose but one they forget to make. Twenty-two sabotages, each caught by a distinct named assertion. Four survived the first pass and one of them was a real defect rather than a gap in the test: ::ffff:93.184.216.34 was being refused as unreadable because it contains dots in the wrong place for the v4 parser, so a perfectly ordinary public address was rejected as gibberish while the mapped-address branch below it never ran. The mapped form is now unwrapped first and a public one is asserted to be allowed. Of the three test gaps, the flood assertion was the instructive one: read loosely it passed against a build with no cap at all, because the whole body was read into memory and then failed to parse as JSON — a refusal that arrives after the damage it exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The image lightbox showed a bare <img> with its close button pinned to the viewport's top-right corner — yards from a centred picture, looking like it closed the app rather than the image — and no title at all. The 3D viewer has both right, and the difference is structural rather than stylistic: it has a FRAME. Absolute chrome needs a positioned ancestor, and without one the close button positions against the overlay, which is the whole screen. So the picture is wrapped in one that shrink-wraps it, and the title and close sit inside. The two rules are SHARED with the 3D lightbox rather than copied — "the same as the 3D viewer" is then a fact about the stylesheet instead of two declarations that agree today and drift tomorrow. The unframed overlays that borrow the same close class (room video, character video) are untouched: their button is still a direct child of an overlay, and their positioning is still right for that. The title is read from the image's own ALT rather than passed in by each caller. The alt already says what the picture IS — "Health Potion icon", the character's name — every caller sets one it means, and a caller with no name to give leaves it blank, which an :empty rule collapses rather than drawing an empty gold strip over the corner. A browser is what caught the one real bug in this. The frame first carried `line-height: 0`, the usual fix for the inline descender gap under an image; it is inherited, and the title's line box collapsed to nothing. The text was laid out, correctly positioned, reported a sensible bounding box, and drew zero pixels — so the unit tests passed, and so did a geometry probe that asked where the title was without asking whether it had any height. A screenshot showed an empty corner. The gap is closed on the IMAGE now, with `display: block`, which inherits nothing, and a test refuses the frame that zeroes it. Verified in Chromium at the end rather than by reading CSS: the frame hugs the image exactly, both the title and the close button fall inside the image's own bounds, and the close sits at 12px/14px — the 3D lightbox's numbers, from the rule it now shares. Also hardened while here: openCharacterPortraitModal drives this shared overlay directly instead of going through openItemImageModal, so every invariant that opener maintains has to be repeated in it or it is not an invariant, only a habit two of the three openers share. It sets the title and clears the icon controls now. Nothing reachable put an armed icon repaint in front of the character portrait — that was a fact about which screens can be open at once, not about this function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
Reported from play, with the tooltip that showed it: Generate from Description was sending an item's entire authored image prompt, styling and all — "Oil painting, traditional fine art style with visible brushwork and rich impasto texture, classical fantasy illustration. Subject: a close-up still-life study of a Health Potion — a small stoppered glass vial of deep crimson liquid… Moody atmospheric lighting, painterly detail, muted earthy palette… Single object centered on a dark, softly-lit background, no text or watermarks." Only the Subject clause describes an object. Everything around it describes a PICTURE, and a mesher reads its input as a description of a thing to build: "visible brushwork and rich impasto texture" is a request for a model of some brushwork, which is the same failure the mesh icon's fixed realistic styling exists to avoid, arriving by the one route that was supposed to sidestep it. The mistake was mine and it was a reasoning error, not a slip. I claimed in the commit that added this that `it.prompt` is "the style-free description the portrait is painted from", because the world-gen directives ask for "a plain style-free prompt" for flora. They do not ask for one here: itemImagePromptDirective asks for "a single vivid text-to-image prompt", and the GM writes exactly that. I generalised from the wrong sibling and never looked at a real stored prompt. So the subject clause is lifted and nothing else is sent. The label is not something the directive demands, only something the GM reliably writes — so its absence is not an error, it means this prompt cannot be trusted to be subject-only and the item's OWN description fields are used instead. Those are subject by construction: game data, authored as what the thing is. A prompt that cannot be parsed is not sent, rather than sent hopefully. Two details the reported prompt made visible. The sentence split skips a full stop inside an abbreviation, or "the reliquary of St. Alder" is handed over as "the reliquary of St" — wrong, and silently so. And HTML entities are decoded, because that prompt really does carry "The Lost Realms": this text goes to an API as plain text, where a literal " is four characters of nonsense rather than a quotation mark. The real prompt is the regression fixture, pasted verbatim, asserted for an exact match. Its own " sits in the styling tail that gets dropped either way, so it did not exercise the decode — the sabotage pass caught that the decode could be deleted with everything still green, and a second fixture now puts an entity inside the subject where it counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The popup's Icon field carried a ✨ whether or not an icon existed. It now has the same two states the
Model field beside it settled on: the ✨ while there is nothing to show, and once there is something,
no button at all — the second attempt moved onto the enlarged icon instead.
That is a better home for it than the field. A field that keeps offering to make the thing it already
holds spends its one control on a job that is done, and the moment a person decides an icon is wrong
is the moment they are looking at it big rather than at a 32px chip. So the icon opens into the image
lightbox with a Regenerate pill under it, which repaints in place and leaves the box open: closing to
reveal the result would hide the very thing being judged.
The lightbox is shared by half a dozen unrelated callers and knows nothing about what it is showing, so
the item is held beside it rather than added to a signature serving all of them. openItemIconModal is
the one caller that knows, and it resolves the item exactly as every other popup control does. The
controls are cleared in openItemImageModal — the shared door every caller comes through — rather than
in each opener, so a room banner opened after an icon cannot offer to repaint that item's icon. Same
invariant, and same reasoning, as the 3D lightbox's own item.
Regenerate paints through paintIconForItem, the helper the ✨ already used: a second icon prompt would
be a second answer to what an icon looks like here. It stores through applyItemIconEverywhere BEFORE
repainting the enlarged copy, so the big picture is showing the icon the item actually has rather than
one that exists only in the overlay — and a failure says so, because a button that quietly returns to
its old label is indistinguishable from one that did nothing.
generateItemIcon's in-place swap had to move with it. That path writes the icon markup by hand for
popups outside the re-render list, so it now writes the icon-lightbox opener and removes the ✨ it was
clicked on — otherwise an icon painted from the popup enlarges into a viewer with no way to repaint it,
and only on the popup that painted it, which is the hardest kind of difference to notice.
Two existing tests failed on distance-bounded regexes (`[\s\S]{0,900}`) that the added comments pushed
past, though what they assert never changed; both are bounded to the FUNCTION now, since the gap
between two calls is prose. And one of my own new assertions was vacuous: it compared indexOf on a
bare helper NAME, which matched the comment mentioning that helper above both lines and was therefore
true whatever the order. It matches the call now, and the sabotage it exists for fails as it should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRSPublishing a keyed world has stopped at a challenge since the registry service landed: the vault cannot sign for a world it merely hosts, because the private half is in no world file and no export, which is the whole reason a key proves anything. The vault fetched the challenge and returned a sentence saying what would answer it. This is the answer. Ticking Public on a keyed world now puts a Choose key file button in the Registry cell; the page reads the key file the author kept when they minted the world, signs the challenge, and posts back a nonce, an expiry and a signature. The private key never leaves the browser, and that is the property the whole step exists to have. A page that uploaded the key file so the server could sign would hand the vault the ability to claim every world it hosts, which is the exact arrangement the keypair exists to end, and it would look identical to the author using it. The file is read locally, imported non-extractable and for signing only, used once, and dropped when the function returns; three fields go back, and the route takes those three by name rather than forwarding whatever a page felt like sending. The page knows nothing about what a claim is. The exact string to sign and the Web Crypto parameters for the world's algorithm both come down with the challenge, built by the module that verifies them, so this file contains no claim construction and no algorithm roster. A third spelling of the four parts would fail closed for every honest author while telling neither end why, and a roster here would go stale the moment an algorithm was added, with the symptom being a key file this vault can verify and cannot sign with. The verifier's roster and the browser's now sit beside each other in registry-keys.js and a test asserts they name the same algorithms, because adding one to only half is the way this breaks. A key file for another world is named as such before a signature is made. The registry refuses it too, but with a sentence that cannot tell an honest mix-up from a stolen key — and an author with three key files in their downloads has no way to know which one it wanted. The uid and the public half are both checked, and the second catches the case the first cannot: a re-minted key, or a key from before a fork, for the world it is looking at. The test runs the page's own signing function, sliced out of admin.html by its braces and executed against Node's Web Crypto, and verifies what it produces with the module that will verify it in production — including the P-256 path, where a signature is a raw r-s pair rather than DER. Then it publishes to a real registry over a real socket and takes it back with a second signature, because a keyed world that can be listed and not unlisted is one its author cannot take back. Fifteen sabotages, each caught by a distinct named assertion; one crashed instead of failing, and the assertion it hit is now null-guarded so a missing roster entry reports a cause rather than a stack. Driven in a real browser as well, which is the only way to know a button works: tick, wrong key file, red sentence naming the world it belongs to, right key file, published and proved, button gone and the box still ticked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Three changes, one of them a default and two of them a second way to make a model. Tripo's default model version moves to v3.1-20260211, which the list already offered. The floor that choice has to clear is unchanged and now asserted rather than restated: texture_quality is valid only from v3.0-20250812 on, so a default below it ships a Texture setting that does nothing until you find and change a different row. The test evaluates the real version guard against the real default instead of matching a literal, so the next bump is checked rather than merely noticed. Anyone who has already chosen a version keeps it — this moves the fallback, not a stored setting. Generate from Description meshes the item's words instead of a picture. Tripo takes either, and the difference on the wire is one `type` and one field, so both transports branch rather than fork: the direct path and the vault each pick the task kind from whichever arrived, and everything after the task body — the upload, the polling, the statuses, which output file wins, the timeout — stays shared, because a copy of it is a second place for a status name to go stale. A request carrying both is the image request it looks like, stated in both places so they cannot drift; a request carrying neither is refused with an error naming both things that were missing. WHAT IS SENT IS THE DESCRIPTION AND NOTHING ELSE. No styling, no background instruction, no negative prompting, no world art style. The item's own portrait prompt is what goes over, because that field is already exactly this: the style-free description the portrait is painted from, with the art style applied separately at paint time. Absent one, the written description stands in behind the name — "a squat figure" and the item's actual name are not always the same object. A name on its own is a usable prompt and is sent as one, so the button is disabled only for an item that is genuinely empty. It is a button rather than a setting because the choice belongs in front of one disappointing model rather than once for a world. The two routes fail differently and neither is reliably better: meshing a picture inherits whatever that picture got wrong, meshing a description inherits nothing and invents the rest. The guide says that in those terms, because a DM told one is "better" will use it always and be wrong half the time. The button's tooltip shows the exact words before they are sent, which is how the claim that nothing is added stays checkable from outside. Both Regenerate buttons now run one shared sequence differing only in what they ask for, since the box handling — drop to waiting, write the mesher's progress into it, store and show what comes back — was about to exist twice. Two sabotages missed and both were my aim rather than the tests: one targeted the wrong copy of a string that appears twice, the other used the wrong indentation and never applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The Model field beside Icon had one permanent "View" button in both states. It now has two states with one control each: a ✨ while there is nothing to view, and nothing at all once there is, because the thumbnail itself becomes the way in. The empty state borrows the Icon field's own button rather than a lookalike. Standing beside that field it is doing that field's job — make the thing this field is for — and two buttons that do the same kind of work in the same row should not need two vocabularies to say so. Pressing it still opens the lightbox and meshes there, which is unchanged and deliberate: a mesh takes minutes, and a click that leaves the page silent is indistinguishable from a broken button. Once a model exists the button is gone. A field that keeps offering a control for work already done spends its one button position on the wrong thing, and a picture you can click is a plainer statement than a word beside it. The thumbnail takes a pointer cursor so it reads as pressable, which is the whole difficulty with making a picture into a control: one that opens something and looks exactly like one that does not is a control nobody finds. The click goes on whichever element is drawn, not only on the <img>. A model whose still failed to render shows the ◈ instead — and that is precisely the item whose model someone wants to look at, so putting the click on the image alone would have left exactly the broken-preview case unopenable. Both paths call the same handler, which already opens an existing model and generates a missing one, so neither surface needed logic of its own. The rationale that argued for the old single button is replaced rather than deleted, since it was sound for the design it described and reads as an oversight otherwise. Its CSS class goes too: a class with no element wearing it is a style nobody can find the source of, and a test now asserts it is gone from the app entirely. Two of the sabotages missed on the first attempt and both times the test was right and the sabotage was wrong — the ✨ swap hit the Icon field's button rather than the Model field's, and the lift used for the markup assertions started at `const modelField`, below where the two states are now decided. The lift starts at the first line of the field's construction now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The registry has been a stub with a design behind it since P0. It is a service now: Registry/, run with
node Registry/server.js, no dependencies, plain node:http. GET /api/worlds/:uid resolves a world uid to
the vault currently serving it, which is the errand the whole design exists for — an exit into somebody
else's world cannot carry an address, because a world that moves takes every stale address with it, so
the world data carries the uid and this answers where that uid lives today. GET /api/worlds browses,
POST /api/worlds/:uid/challenge issues a nonce, and PUT and DELETE write.
The service reuses server/registry-keys.js and server/registry.js rather than copying them, and that is
the load-bearing decision. The claim string, the algorithms, the branch table, the URL rules and the
text cleaning are a protocol, and two copies of a protocol do not diverge loudly — they diverge
silently, and the symptom is an honest author whose signature does not match. Keeping both ends in one
repository is the only arrangement in which they can be changed in one commit and checked by one test.
The day the service moves out, that pair becomes a package.
It trusts nothing a vault sends. Every stored record is rebuilt from an allowlist and the uid comes
from the path rather than the body, so a descriptor arriving with a rooms array of room names, a lore
field, a publisher of its own choosing or an unproven flag contributes none of it — a registry is only
as good at refusing to hold a walkthrough as its worst-behaved publisher, and our own vault cleaning
before it sends is a convenience for its operator rather than a property of the internet.
What the nonce endpoint adds beyond the signature is the part a signature cannot do for itself. The
registry chose the nonce, so a signer cannot name their own expiry and hold a claim that never goes
stale: the verifier rebuilds the claim with the expiry it issued, and one signed over any other value
does not verify. And a nonce is spent once, on a write that actually happened, because the damage of a
replay inside the five-minute window is not the repeat but the revert — capture a publish, wait for the
author to correct it, resend the old one. The first version fell back to the proof as sent when the
nonce was unknown, on the reasoning that the signature still has to verify, and that quietly made the
whole challenge store optional; the test that publishes twice with one proof is what caught it, and it
is the reason the store is now the branch that ends the request rather than a hint.
Accounts are a publisher-token map, which is the seam an Auth0 tenant arrives through and changes one
function when it does. A key outranks an account throughout: a record keyed from the start answers to
its key and not to whoever published it, which is R14 doing the thing the accounts cannot. With no
tokens configured the service runs open, and says so at boot and in /health, because the failure mode
of a quiet default is that nobody remembers which one they are running.
The vault's seam kept its shape exactly as the design promised. publishWorldToRegistry returns the same
{ ok, stub, descriptor, problems, detail } the admin page has always printed, with stub: false once
REGISTRY_URL names a registry and stub: true with the same honest sentence for every vault that names
none, which is most of them. A keyed world cannot be published by the vault alone, and that is the
design working rather than a gap in it: the private half is the author's, so the vault fetches the
challenge and hands it back to be signed. A vault that could answer its own challenges would be a vault
that can claim every world it hosts.
Registry is a new top-level directory, which this repository treats as a decision rather than a
default: it is denied in STATIC_DENY_TOP, because the vault serves the repository root through a
denylist and must not become a second route to a service holding publisher tokens and a record of who
owns which world name. Its tests live in server/test/ so the two suites in CLAUDE.md still cover
everything — a third suite is a suite somebody forgets to run, and there is no CI to notice.
Both suites are green, 673 in the app. Twenty-three sabotages were run against the service test and
every one is caught by a distinct named assertion; two survived the first pass. Storing a descriptor's
own unproven flag survived because the allowlist had already dropped the field, and the eviction test
could not tell the oldest challenge from the newest because challenges issued in the same millisecond
share an expiry — that one was a real hole, and the fix is to stagger the fixture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLTwo buttons in the lightbox's bottom-right cluster, beside the zoom controls. Regenerate paints a fresh mesh icon and meshes the item again. It passes reuse:false, which is what makes it a regeneration rather than a re-download — the same flag the card's Re-Generate uses, and the one that repaints the mesh icon, since re-meshing an identical picture is the one thing that cannot produce a different model. The box drops back to its waiting state while it works rather than keeping the old model on screen behind the progress: that is BUG-051's lesson applied to a case it did not cover, and worse here than the case it did, because the stale picture is not another item's model but the very one the reader pressed the button to be rid of. Icon shows the mesh icon the model was generated from. That picture is deliberately never drawn in the game — it is a working image, not art — and this is the one place that should be an exception, because the question a reader is asking in front of a disappointing model is precisely "what was it given?", and the answer was otherwise unreachable without opening a save file. It toggles a panel over the canvas rather than opening a second lightbox; a modal in front of a modal to look at one small picture is a worse answer than a panel. Hiding it drops the src rather than just the panel, because a mesh icon is often a multi-megabyte data URI and a hidden element holding one keeps it decoded for the life of the page. With no mesh icon the Icon button is shown DISABLED rather than hidden, and its tooltip says the model was meshed from the inventory icon instead. Its absence is the diagnosis: it means the fallback path ran because the Icon AI cannot take a reference picture, which is the likeliest reason a model disappoints and is otherwise invisible. A button that quietly vanishes cannot say that. Both need to know which item is on screen, which the lightbox did not track — it takes a url and a title and will open any GLB the app has. The item is now an optional third argument, both buttons are hidden without one, and it is cleared on close so neither can act on an item no longer being shown. Removing dead code the sabotage pass found: both openers called modelLightboxHideIcon(), and both call closeModelLightbox() first, which already does it. Cleared on CLOSE only now — the same invariant the canvas wipe is commented with, for the same reason. The test noticed by failing to notice: deleting the call from the opener changed nothing. Three existing tests needed updating and none of them for a real break: two lifted or matched openModelLightbox by its PARAMETER LIST, so an added optional argument blanked the body and failed four assertions about lifecycle that the change never touched, and one element stub had no removeAttribute. The lifts match on the function name now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The guide mentioned item 3D models exactly once, in the API-key list, as the thing a Tripo key is for. Nowhere did it say a DM could make one, let alone how — so there was no place to hang "and the picture it is made from is not the one you think". The 3D column gets a short section of its own first: the still, Generate, the upload/download pair, that it takes minutes, and that clicking the still opens the model in a lightbox. Then the mesh icon. Most of it is invisible by design, which is precisely why it has to be written down: a second icon nobody can look at, made by rules nobody stated, is indistinguishable from the model coming out wrong. The section says the inventory icon is not what gets meshed, that a purpose- painted icon is drawn from the item's picture instead, and why — the generator reads its source as a surface and bakes it into the texture, so a painterly source yields a model surfaced in brushwork. It also says the half that is easy to state wrongly, and which I had stated wrongly in a comment before being corrected: the world is still in there. The style is withheld as an instruction, not as an influence, because the attached picture was painted in it. A DM told only that the world art style is dropped would reasonably expect their world to stop looking like itself, so the guide says an item from a muted, mossy world still meshes muted and mossy, and a test fails if that half goes missing. Two of these are behaviour a DM can SEE and would otherwise read as breakage, so both are called out rather than left to be discovered: pressing Generate can now paint a picture as a side effect, and the button no longer greys out on an item with no inventory icon. The third is the Icon AI fallback, which is the one that fails silently — with anything but Nano Banana selected there is no way to attach the reference, the engine meshes the visible icon as it always did, and a DM watching the new mesh icons fail to appear would have no way to connect that to a provider dropdown two screens away. Also corrected: the settings copy named two of the eight generation slots, and the fallback above sends the reader to one of the six it did not mention. And the 3D AI comment in the app still called the visible icon the input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
A correction to the note the last commit added, not to any behaviour. It said the world's own look is "the wrong answer" for this image and that world.artStyle "must not reach it AT ALL". Both overstate it, and the second is simply false — the world's look does reach the mesh icon, by the front door. The distinction the design actually rests on is between the style as TEXT and the style as a PICTURE. As text, "painterly oil with visible brushwork" is an instruction to render in that style, and the brushwork it produces is what the mesher bakes into the model's surface; that is the half withheld. As a picture, the attached reference portrait was itself painted in the world's style, so its design and its palette are the foundation the render is built on. An item from a muted, mossy world still meshes muted and mossy, because its portrait is. So the mesh icon is a RENDER of the world's own design rather than a re-statement of the world's own brushwork, and calling it style-blind — as the previous note effectively did — describes a different build entirely: the one where the reference is dropped too. That build is now a failing test rather than a plausible reading, asserted as a pair, because either half alone tells the wrong story: the style withheld as text AND carried as a picture is the claim. This also completes the reason the reference is the PORTRAIT specifically. It was recorded as "the fullest statement of what the object looks like", which is true and was not the whole of it: the portrait is also the only place the world's look survives a prompt that deliberately drops the world art style. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
BUG-055 had an alarm but no cause. It has one now, and it was sitting in the middle of the diagnostic ring being read as noise: an inactivity auto-logout fired at 13:03, and the bytes recovered from storage afterwards were not "an hour old" by some accident of timing but exactly that 13:03:07 write, down to the same level, the same 488 XP and the same room. logout() sets player.loggedIn = false, which is the first condition saveGameStateNow() refuses on, so every save for the next hour returned false having written nothing while flushSaveForLogout discarded the boolean and logged logout-save-ok regardless. Play continued past the auto-logout only because the run reached the pipeline through handleSend directly rather than through the login overlay logout() re-shows; a person would have been handed the login screen and stopped. That makes the harness the trigger but not the defect. The defect is that the overlay was being trusted as a guard when it is scenery in front of one, and handleSend already carries the argument against exactly that, three lines from where the fix went: refuse at the one entry point every turn goes through rather than trusting the overlay to catch every route in. So handleSend now refuses a turn from a logged-out session, which is the property stated plainly — a turn may only run when it can be saved, the same condition the save path uses. All five routes that submit an action funnel through it, and the dice roll documents that it goes that way deliberately to inherit these guards. The refusal is breadcrumbed once per logout rather than once per turn: the ring holds forty entries and a caller that kept submitting would push off the very history this was diagnosed from. It writes to localStorage and not gameLog, because a session that cannot save cannot leave a record inside the snapshot, which is why the loss was invisible for an hour in the first place. Three hypotheses died on the way and are recorded in the ledger so they are not re-run. There is no race between the periodic and logout saves: _saveChain serialises writes and _flushGameSave builds the snapshot inside the chained callback, so the later write always carries the newer state. The lost-update rev guard is gated on IS_DETACHED_EDITOR. Quota was 948MB of 11GB. And the byte-identity that looked like one store being copied over the other is the designed outcome of mirrorSaveToLibrary writing the same JSON that saveStateRaw just wrote — nothing overwrote level 7, it was never written. The ledger entry also had to be renumbered. It was filed as BUG-050, which the doors work had already taken the day before; it is BUG-055 now, and the header counts, three revisions stale, are recomputed — fifty-five ids, none of them open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
No behaviour changes here. The previous commit shipped the mesh-icon prompt correctly and described its styling as merely "dictated", flagging the brightness line as an unresolved contradiction with the muted palette beside it. That framing was wrong, and left in place it was an invitation: the obvious tidy-up is to delete one of the two lines, and the comment gave no reason not to. The reason, recorded now where the constant lives. A 3D model needs a REALISTIC source image. The mesher does not take a silhouette and extrude it — it reads the picture as surface and paints the texture it wraps the mesh in from what it sees. A painterly or abstract source therefore does not produce a stylised model, it produces a model whose texture is a picture of brushwork, which is the thing that looks wrong in 3D. In 2D a world's art style can be anything and the icon looks fine; that freedom does not survive the third dimension. So the styling is fixed and realistic, and the world's own art style is excluded deliberately — this is the one place in the app where the world's look is the wrong answer, because the output is a mesh rather than a picture. Which is also why both lines stay. Read as a description of one flat picture, "Bright, saturated, high contrast" and "a muted earthy palette" contradict. Read as what they are — instructions to a mesher about legibility and about surface — they do not: the brightness carries the FORM, which is what gets reconstructed, and the muted palette and low-key lighting keep the TEXTURE from looking like a poster. The test asserts the two together, in a single check rather than separately, because the pair is the claim. Its failure message explains the reasoning rather than naming a missing string, so someone who deletes one line to resolve the apparent inconsistency is told why it was there instead of being told a regex did not match. Both directions of the tidy-up were checked: dropping the brightness line, and letting world.artStyle back in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
BUG-050's write failure is not fixed and the cause is still unknown, but the silence around it is, which was the recorded leaning and is the prerequisite for diagnosing the rest. Two separate holes were letting a loss through unannounced. saveGameStateNow's own contract says it resolves TRUE only when a write actually landed, and that a caller reporting "saved" must not do so on a no-op. flushSaveForLogout ignored the boolean and logged ok either way; a write that never happened now logs logout-save-NOOP. And nothing ever compared what landed against what was meant -- the diagnostic field is named `intended` because intent was all it recorded. verifySaveLanded now reads the snapshot back and diffs player, level, xp and roomId, logging logout-save-MISMATCH with every field that moved and what was actually stored. logout-save-ok is reachable only after both checks pass and records verified: true, so a verified success cannot be mistaken for an unverified one. The intended snapshot gained roomId as well as the display name, since two rooms can share a name and only the id can be compared against storage. A verifier that cannot run returns null rather than a verdict, because conflating "I could not look" with "I looked and it was fine" is the exact shape of this bug and must not be reintroduced by its own fix. The boot announcement now covers NOOP and MISMATCH alongside FAILED. Confirmed live: a real logout records logout-save-ok with verified=true and roomId present. The verifier is driven both ways in a new test, including the exact stale snapshot from the entry, and one existing assertion in test_save_diagnostics.js was rewritten -- it pinned a 400-character span between `level` and `beats` and broke when roomId was added, which is a test failing because the thing it guards improved. BUG-050 stays open for the cause. The next occurrence will announce itself with both halves of the diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
About twelve turns of lore-coverage work are gone. Claude18 earned three being hooks and four item hooks, reached level 7 and logged out from the Chancel; logging back in produced level 6 at the Scaffold Landing with beings 0 and items 0, and the Echo-Calm skill gone with them. Everything reverted together, which is what separates this from BUG-034, where lore flags moved while the rest of the state stood. The save diagnostic recorded logout-save-ok at 14:06:22 with intended lvl7 xp264 Chancel. Reading IndexedDB directly afterwards, both tlr_game_state and tlr_save:Claude18 hold level 6, xp 488, scaffold_landing. So the restore was correct for what was stored and the write is what failed -- silently, because the diagnostic's own field is named `intended`: saveDiagSnapshotOf records what the save meant to persist and nothing ever compares it against what landed. Both records are byte-identical at 534691, which suggests one was written from the other rather than both from live state. A shape that fits: the 14:06 logout wrote level 7 to the named slot while tlr_game_state still held the level-6 snapshot from the 13:03 inactivity logout, the next login preferred the rolling record, and a later save propagated that back over the named slot. That is nearly word for word the mechanism BUG-034 suspected and never demonstrated. It is filed as a hypothesis and explicitly not as proven -- the level-7 bytes are gone, so the ordering cannot be reconstructed. The leaning recorded is to make the diagnostic honest before chasing the cause: read the record back after writing and log a mismatch when the persisted level, xp or room differ from the intended ones. That turns a silent loss into a loud one and would have caught this at 14:06 rather than an hour later. Then settle which record a login prefers and make the newer one win. Filed as BUG-050. One open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The 3D provider turns an image into a solid, and the image it was handed was the item's visible inventory icon. That icon is drawn to a different brief: read at 32px, in a UI slot, as a bold flat silhouette. A picture meant to be reconstructed as an object wants the opposite of most of that, so the mesher now gets its own icon, painted for it and for nothing else. The pipeline is PORTRAIT to MESH ICON to MODEL, and every missing link fills itself in. The mesh icon is painted from the item's own portrait as a design and colour reference, because the portrait is the fullest statement of what the object looks like that the world has — a 32px silhouette's compromises are exactly what should not be inherited here. An item with no portrait gets one painted first. The visible icon is now neither read nor written on this path: an item with none can be meshed, and redrawing one never changes the model. The prompt's wording was dictated and is kept verbatim in named constants, because the wording IS the interface to the image model and a paraphrase is a different prompt. It opens by naming the attached reference, replaces the world art style with a fixed styling line, and restates the background line at the end — the constraint a lit, photo-real subject most often drops, and the one that keeps the mesher from reconstructing the scenery along with the object. Two things that look like details and are not. The background is the literal #0d0b08 rather than the app's --bg: the visible icon reads the theme because it composites onto the running UI, while this one is never shown to anyone, and reading the theme here would hand a light-theme player a near-white field to mesh from — three lines below where the same prompt says "never white". And the standard "use the attached image" prefix is suppressed via a new opt-out, because this prompt names the attachment in its own first six words; both sentences would otherwise sit ahead of the subject, which is the precise failure paintIconForItem was rewritten to avoid. The one fallback is an Icon AI that cannot take an attachment at all. A mesh icon is defined by its reference, so without a transport for one there is nothing to make, and meshing the visible icon the old way beats refusing to mesh — logged, because a silently worse mesh is indistinguishable from the feature never having shipped. That check runs before the portrait is painted: a picture is not commissioned for a path that cannot use it. The card's Generate button loses its "give this item an icon first" gate, which was true when the icon was the input and is now simply wrong — it would leave an item with a portrait and no inventory icon unmeshable for a reason that no longer exists. tests/test_mesh_icon.js pins the dictated strings, the reference being the portrait, the theme independence, the reuse rule and the refusal; checked against seven sabotages. The two existing tests that pinned ICON to MODEL are updated rather than deleted — test_item_popup_model.js drives the real pipeline through stubs, so it gained cases for the portrait leg and for the fallback, and keeps a case proving the old icon-first path is intact behind it. Noted while working: `model3dPreview` is a data URI and appears in neither EVAL_STRIP_FIELDS nor MEDIA_FIELD_NAMES, so it is baked into every save and every evaluation payload. Pre-existing, left alone here; `model3dIcon` is added to both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The registry could never tell an author from someone holding a copy. A world's uid is minted in the browser and travels inside the world data, so possession has never been distinguishable from authorship, and every other mechanism in the design answers only "who may press the button" — an account, an origin proof, a vault login. R14 settled that a keypair minted with the world is what answers "whose world is this", and the minting half shipped last commit. This is the half that decides: server/registry-keys.js verifies a claim, and signWorldClaim makes one. The module is deliberately pure — no HTTP, no storage, no clock of its own — which is the only reason this half could be finished ahead of the service it belongs to. Everything that varies is passed in, so the branch table can be exercised without standing anything up. The order of those branches is the design rather than an implementation detail. A record that is already keyed demands a valid signature against the BOUND key and never the offered one, because verifying the offered key would let a claimant bring their own key and their own signature and satisfy the check with both. An unkeyed record with no key offered behaves exactly as first-come always did and is marked unproven, which is what makes this additive rather than a migration: worlds have been minted since long before keys existed, and refusing to list them would make the registry useless on the day it opened. A key being offered binds only against a valid signature, since anyone holding the pack holds the public half and could otherwise bind a key they cannot sign with, locking the real author out for ever. The claim string is the most breakable thing here and it is not the cryptography. The signer is a browser and the verifier is a Node server, and two spellings of "nonce, uid, vault, expiry" produce a protocol that fails closed for every honest author while telling neither end why — a signature that does not verify looks exactly like the wrong key. So it is written once at each end, joined with a NUL because that is the one byte no uid, nonce, https URL or ISO timestamp can contain, and tests/test_registry_key_binding.js signs in the real engine with real Web Crypto, verifies with the server module, and compares the bytes. All four parts are load-bearing and each is checked by changing it and watching the same signature stop verifying: the nonce stops a signature being made before the challenge exists, the uid stops one world's signature publishing another's, the vault stops a captured signature being replayed to repoint a uid at somebody else's server, and the expiry is only a real bound because it is inside the signature. The expiry is checked before the cryptography, and it names expiry rather than a bad signature, so an honest author asks for a new challenge instead of hunting for a lost key. signWorldClaim signs from the browser's holding pen or from the key file the author kept. The pen is losable by design — localStorage is cleared and browsers are changed — so a flow that could only sign from it would make a cleared browser a world that can never be claimed again. Its refusals are sentences rather than failures: a world with no key is told where one comes from, and a world whose private half is elsewhere is told that, and not that it has no key, because an author who believed that would mint a second key for a uid the registry has already bound to the first. keyProblem is asked by worldDescriptorProblems at publish time, so an unusable key is refused while the author is standing at the button. It exists for the failure that never announces itself: a real key labelled with the wrong algorithm parses, publishes, and verifies nothing for ever, and the first claim it makes is refused with the same sentence a stolen key produces. Both suites are green — 669 in the app, the vault's own included. Every assertion in both new files was checked by breaking the implementation under it: thirteen sabotages against the binding test and ten against the vault-side one, each caught by a distinct named assertion. Two survived the first pass and both were the test's fault rather than the code's. A squatter offering their own key and their own signature was being refused by the "a rekey is not a publish" guard rather than by the signature check, so an assertion reading only ok:false passed against a build that verified the offered key; it now pins which refusal, and a squatter who declares no key at all — nothing for a rekey guard to object to — is checked beside it. And a loose match on the word "mint" passed against a build with the unkeyed guard removed, because the message it fell through to mentions having minted a key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Both of this session's changes are visible to a player and neither was written down, which for one of them is the difference between a feature and a bug report. A chill that will not tick down looks like a stuck status from the player's side. Nothing on screen says otherwise: the status chip deliberately shows no countdown, because no number of minutes is an honest answer to how long it stays cold, and a chip with no countdown is exactly what a broken timer would look like too. So the guide now carries a note beside the attribute drift paragraph: what holds a condition open, that its chip shows no countdown on purpose, and the three things that end one — shelter, the weather turning, the temperature lifting. It also says the condition eases rather than vanishing at the doorway, since a player who steps inside and watches the chill persist for another stretch would otherwise reasonably conclude the fix had not worked. That paragraph also carried a claim which is no longer true as stated — that status effects "expire against the game clock" — so it is qualified rather than left to be contradicted by the note under it. A test asserts the bare sentence is gone, which is the assertion that catches a future edit quietly restoring it. The Detach menus needed a home rather than a correction: the guide documented the Editor's detach and nothing else, so a rule about only one menu being open at a time would have read as a rule about one menu. The player-facing screen section now covers detaching a tab properly — which tabs offer it, that four of them hand their view over to the popup while the Guide opens a second copy, one menu at a time, and a right-click anywhere else dismissing it. The DM section points there instead of repeating it. Guide content is pinned from the two feature tests, per the convention test_detach_editor_header.js already uses: the copy is the ground truth, so it is the copy that must not go stale. Both sets were checked by reverting the guide and confirming the assertions fail. Left undone deliberately: the guide still has no weather section at all, and the Guide-tab and viewer-tab detach features are only documented as far as this change required. Both are wider gaps than this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
The companion to the one-at-a-time rule, and the half it could not reach: right-clicking somewhere that is not a tab at all — the story pane, the sidebar — left the open detach menu standing underneath the browser's own context menu, until some later left-click swept it up. Same root cause as the duplicate menus: every menu registers a `click` dismissal listener, and a right-click fires mousedown, mouseup and contextmenu but never a click, so nothing in the wiring hears the gesture at all. A single document-level contextmenu listener now closes them, which covers every right-click regardless of where it lands, rather than trying to enumerate the places a stray right-click might go. It is registered in the CAPTURE phase, and that argument is load-bearing rather than incidental. Listeners on document run before the target's own in the capture phase and after them in the bubble phase. Registered to bubble, this one would fire immediately after the tab handler that had just opened a menu and close it again — right-clicking a tab would flash a menu and leave nothing behind, turning a cosmetic bug into a broken feature. Running first, it clears the field and lets the tab handler, if the right-click was on a tab at all, reopen from a clean slate. That makes the per-handler close calls added with the one-at-a-time rule redundant in the app, and they stay anyway: the capture listener catches the GESTURE, while the per-handler call keeps the invariant true for any caller that opens a menu directly rather than by dispatching an event. Closing an already closed menu costs a display assignment, and an invariant that does not depend on how the handler was reached is worth more than saving it. The comment says so, so the next reader does not "tidy" one away. The test harness grew the ability to record listeners and dispatch contextmenu in true phase order (document capture, then target, then document bubble), because phase is exactly what is at stake and a direct handler call cannot see it. The bubble-phase mistake is now caught by five behavioural assertions naming the tabs whose menus vanish, rather than only by a regex on the third argument — though that regex is kept too, since it names the cause when the behaviour breaks. Checked against three sabotages: bubble instead of capture, no listener at all, and a listener bound to `click`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
R14's minting half, built. The public key rides in the world data with every copy; the private half is handed to the author at the moment it is minted and is in no export, which is the whole difference between a proof and a claim - an imported pack carries the half that verifies and never the half that signs. Minted in claimWorldUid, not the World constructor. The constructor runs on every load and is synchronous while key generation is not, so it carries what it was given, exactly as it already carries the uid. claimWorldUid is async, is called from all five paths that store or stage a world, and is the one place that decides that a world's identity is this. THE FORK BRANCH IS THE LINE THIS TURNS ON. claimWorldUid mints a fresh uid when the one a world arrived with belongs to a different world, and clones its dungeon maps across so a copy starts as a true copy. Maps should follow a fork; a key must not - a fork keeping its parent's key would claim a uid its key does not match, and the parent's author could sign for a world that is no longer theirs. Nothing looks wrong until two people publish. The fork is re-keyed, and only when the parent had a key at all: a fork of an unkeyed world stays unkeyed, because minting there would be the silent retrofit section 05 refuses, arriving by the one route that looks like a natural place to mint. Three carriers, because serializeWorld is an explicit allowlist: it, rebuildWorldFromSnapshot and the constructor. The export envelope inherits from serializeWorld rather than being a fourth, which the recommendation had guessed wrong. A field nobody names in that literal is authored, saved perfectly, and gone on the next reload. Ed25519 where crypto.subtle offers it, ECDSA P-256 where it does not, and the algorithm stored beside the key - with a fallback, both kinds exist in the wild from the first day, and a key whose algorithm has to be inferred is a key nobody can verify in five years. Minting fails SOFTLY where there is no Web Crypto, which is an http:// LAN vault: unkeyed is recoverable, a world-creation flow that threw would be a broken app on somebody's home network, and a fabricated key would be worse than either. Custody ships with minting rather than after it, because a world whose private half was never kept can never be proved - worse than one never keyed, which can still be claimed honestly later. The key file is the home and opens with a sentence a person will read; localStorage is a holding pen and is described as one. Retrofit is an author's deliberate act behind a confirm that says what cannot be undone, never a migration run over every world on disk. Twelve sabotages, each caught by its own assertion. The one that earned its place was the twelfth: an unkeyed world silently retrofitted on fork passed every other assertion in the file, because the legacy case only exercised the path that returns before reaching the fork branch. The signature test is the other half - a claim signed over nonce, uid, vault and expiry verifies, the same signature does not verify for a different vault, and it does not verify against another world's key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
"Mint a keypair with the world" hides three decisions and two of them are traps, so section 05 now says where, what has to carry it, and what must ship alongside. WHERE is claimWorldUid, not the World constructor. The constructor runs on every load and is synchronous while key generation is not; claimWorldUid is already async, is called from all five paths that store or stage a world, and is the one place that decides that a world's identity is this. The constructor's job is to carry what it was given, the way it already carries the uid. THE TRAP is that claimWorldUid does two things. When the uid a world arrives with belongs to a different world it mints a fresh one and calls cloneWorldDungeonBuilds to bring that world's maps across, so a fork starts as a true copy. Maps should follow a fork; a key must not. Mint the key in the same branch that mints the fresh uid, and mint it NEW - a fork keeping its parent's public key would claim a uid its key does not match, and the parent's author could sign for a world that is no longer theirs. This is the one line where a plausible implementation is wrong, and it is wrong silently: everything works until two people publish. WHAT CARRIES IT is four places, because serializeWorld is an explicit allowlist. publicKey has to be named there, in rebuildWorldFromSnapshot, in the constructor and in the export envelope, or it is authored, saved perfectly, and gone on the next reload - the failure the doors record already names in its own words. test_world_uid_roundtrip pins the uid through every trip, one assertion per trip, so the keypair's test is that file with a second field, and writing it first is the cheapest way to find the trip somebody forgot. Ed25519 where crypto.subtle offers it and ECDSA P-256 otherwise - both Web Crypto, no dependency, and this app already uses crypto.subtle for AES-GCM and SHA-256. The algorithm is stored BESIDE the key, because a key whose algorithm has to be inferred is a key nobody can verify in five years, and the fallback guarantees both kinds exist in the wild from the first day. Minting fails softly where crypto.subtle is absent - an http:// LAN vault, which this app already has to say out loud elsewhere - so a world authored there is unkeyed rather than unbuildable. And custody ships WITH minting rather than after it. A world whose private half was never kept can never be proved, which is worse than one that was never keyed, because the second can still be claimed honestly the day custody exists. The smallest custody that always exists is a file the author downloads at the moment of minting; the vault's key store is better and is not always there. Retrofit is deliberate and never a migration: a key added to a world that already exists proves only that whoever added it had a copy, so it belongs at the moment an author publishes, with that said plainly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The unplaced-item finding assumed placement was the only legitimate way for an item to be reachable, and it is not. A DM may deliberately author a pool: real catalogue entries with real values and lore that the Game Master brings into play when a scene calls for one, rather than sitting in a room. That is a good pattern for an improvising GM rather than a lapse, and it is arguably the answer to BUG-045 -- a pool BOUNDS the improvisation, so the model reaches for an authored object with authored stats instead of minting a plausible near-miss of its own. Running the pass over the live Verengrad draft reported four unplaced items and all four were reachable. Two were rebinding targets: a psalter upgrade is bought as a SERVICE, the rebinder cutting the boards off the book you already carry, so the upgrade exists in no room by design. The other two the Game Master had improvised into an NPC's hands, which is the pool pattern working as intended -- I was handed the Sable Undertow out of Ys's invented stock during the run. So the item sheet gains a "GM pool" checkbox beside Magic, mirroring it at all four touchpoints, because missing any one of them is silent: the flag would read back blank, save as false, or survive a fresh sheet from the previous item. Rebinding targets need no checkbox -- an item with rebindsFrom is by definition the output of an upgrade, and the data already says so, so the pass infers those. The root of a chain is still reported, because that one genuinely does need placing. A pass that reports reachable content as missing teaches its reader to skim it, and this is the finding that once nearly got a world repriced under BUG-018. The evaluator half is server-side and the vault requires build-walkthrough.js at boot, so it will not take effect until the server is restarted -- verified with a probe: a pooled item is still reported by the running process while the CLI exempts it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
A Public checkbox per world on the admin Worlds tab. Ticking it builds that world's descriptor and hands it to the registry seam; unticking it withdraws. The registry does not exist, so this is Realm Registry's P0 - the descriptor and its validation, with the service still stubbed. The stub is loud, like the server listing beside it: every result carries stub: true, ok is FALSE because nothing was sent, and the detail says so in a sentence the page prints verbatim. What IS real is the validation - a vault URL a stranger cannot reach is refused now rather than on the day the registry lands, and so is a world with no name or no valid uid. THE DECISION IS STORED WHATEVER THE REGISTRY SAYS, and that ordering is the whole design. A publish cannot succeed today, so a vault that recorded the flag only on success would record it never, and an admin who ticked the box would come back to one that had unticked itself - told the feature is broken when what is true is that the service is not built. So the flag is written first, the honest answer is reported second, and the day the registry lands every world already marked public is already the set to publish. The checkbox does not revert on that answer either; it reverts only when the request never reached the vault, which really did fail. The flag lives in the world INDEX, not in the world file. Whether a realm is offered is a fact about this vault's decision to offer it, not about the world - so a world exported from a vault that listed it, and published on somebody else's, arrives unlisted, which is their decision to make. That also means the two paths which rebuild an index record from scratch have to carry it: re-publishing from the World Editor, and a git pull that changes a world's file under VAULT_WORLDS_DIR. Either dropping it would silently withdraw a realm whose only symptom is that nobody can find it any more. A descriptor is a POINTER. Rooms and regions are COUNTED, never listed - a registry that enumerated rooms would hand any reader a walkthrough of every published realm, which section 07 refuses as a spoiler boundary before it is a privacy one. The summary is the author's own brief, not a sentence this server invented about somebody's realm. On the keypair phase: descriptors carry keyed: false. No world has a keypair, because worlds have been minted since long before R14 settled that a world proves its own name by signing a challenge - so a listing today proves nothing, and the first claim on an unkeyed uid is first-come exactly as it always was. The flag says that in the data rather than leaving a reader to infer it from a missing field, and there is deliberately no empty publicKey, which would read as a key that failed to load rather than one that was never minted. Twelve sabotages, each caught by its own assertion. One existing check needed re-measuring rather than bumping: the worlds table was pinned at six columns, browser-measured against a 600px panel - and the panel has been 1040px since Tuesday, so the number was pinning a constraint that had stopped existing. Measured again headless at 1200/900/760/640 with seven columns and with six: the document overflows at neither 1200, 900 nor 760, and at 640 it overflows with SIX as well, so that is a property of the table and not of this column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Three tabs carry a right-click "Detach" menu — the Editor, the Guide, and the shared one behind
Character / Maps / Journal / Compendium. Each show handler opened its own and left any other one
standing, so right-clicking the Guide tab while the Editor menu was still up put both on screen at
once, side by side, each offering to detach a different tab.
Why it lasted is the part worth recording, because the wiring looks like it already handles this.
Every menu registers document.addEventListener('click', hide...), which reads as a dismissal that
would clear the others. It is not: a right-click fires mousedown, mouseup and contextmenu, and never a
click, so none of those listeners hear the gesture that opens the next menu. Only a subsequent
left-click swept up the stragglers, which is why the duplicate menus looked intermittent rather than
constant. A test asserting that the click listener is registered would have passed throughout.
Each show handler now closes the others before drawing its own. The close is routed through a small
registry of the menus and their own closers rather than blanking every .ctx-menu by class, because the
closer is where per-menu cleanup belongs — hideViewerContextMenu is already the single place that
knows about the shared menu, and a class sweep would silently bypass anything added to it later. The
registry follows the pairs-of-id-and-closer pattern the outside-click dismissal list already uses.
The call sits ahead of each handler's early-return guards on purpose. Right-clicking a tab that will
not offer a menu — no game running, or the DM-only Editor tab — should still clear whatever was open,
rather than leaving it stranded underneath the browser's own context menu. So the rule is simply: a
right-click on a tab clears the field before deciding what, if anything, to draw.
tests/test_detach_menu_exclusive.js drives the handlers and reads what is displayed, rather than
asserting the wiring, for the reason above. It covers all six ordered pairs rather than one
representative case, since the handlers are three separate functions and a fix applied to two of them
would pass a spot check; it pins that re-opening the same menu does not blank the menu being asked
for, which a close placed after the show would do; and it asserts the registry covers every .ctx-menu
in the markup, so a fourth detach menu cannot be added without inheriting the rule. Checked against
five sabotaged implementations, including one that leaves a single handler unfixed.
Not addressed, and worth knowing: right-clicking somewhere that is not a tab still leaves an open
detach menu up until the next left-click, for the same missing-contextmenu-listener reason. That is a
wider change than this one and is left alone deliberately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS# Conflicts: # Evaluations/BUGS.html
The GM catalogued something it had only narrated, and the card came up with no lore, no Prompt section at all, and a portrait that could not be generated. A compendium card is a record OF something. Almost every discovery has a thing behind it - a being in a room, an item in the catalogue, a room that was walked into - and compendiumTypeContext resolves the card back to that thing by name, taking the lore, the image prompt and the picture from it. The entry itself stored only id, name, description, type, imageUrl, location and discoveredAt. But the GM also catalogues subjects that exist only in what it has just narrated: someone spoken of and never met, a city named in a song, a phenomenon described. Nothing answers to those names, so every branch of the resolver came back with obj: null. compendiumPromptSectionHTML returns '' on exactly that, which is why the Prompt section was not empty but absent, and why the portrait button had nothing to send. The contract had nowhere to put what the GM knew, and the entry had nowhere to keep it if it had asked. So an entry can now carry its own lore and its own prompt; the contract asks for both, and says to OMIT them when the subject already exists in the world, because a turn-time sentence written over an authored being's own lore is worse than the empty card this fixes. The resolver falls back to the entry when nothing live answers to the name, and a lore edit made on such a card is written to the entry rather than routed to a setter that would find nothing - the same failure the animals split already caused once, reached from a different direction. The fallback is computed LAST in every branch, so the live thing always wins. A card whose subject exists is untouched, and one invented today and instantiated tomorrow starts resolving to the real thing with nothing to migrate. The property this could most easily have broken is that an UNDISCOVERED room still resolves to the room - most rooms in a real save are never discovered, and the room lore editor exists because of it - so that is asserted directly rather than left to follow. Seven sabotages, each caught by its own assertion, including the one that matters most: making the entry win over a live being, which is the version of this fix that would quietly shadow authored content. test_room_lore_editor needed repairing and was pinning distance again - it looked for the places routing within 300 characters of the setter's name, so adding a guard clause ABOVE that routing failed an assertion about code that had not moved. Fourth of these this week; it now slices the function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
# Conflicts: # Evaluations/BUGS.html
Found in play (BUG-053): the GM applied a "chilled" affliction with a durationMinutes, the in-world clock ran the duration out, and the engine printed "Recovered: chilled" while the player was still outdoors under the same sky, in the same cold, having done nothing whatever to get warm. Every timed status in the engine carries one implicit claim: this ends by itself once enough time passes. That is true of the conditions rule 15a was written around — being winded, a venom running its course, a potion's buff — and it is simply false for a condition whose cause is a continuing state of the surroundings. The cold is not a process running down inside the character; it is a fact outside them, and it lasts exactly as long as they stand in it. Counting minutes against it answers a question nobody asked. Nothing in the engine disagreed, because a status was a label, some stat deltas and two timestamps, with no notion of a cause at all; sweepExpiredStatuses compared one timestamp against the clock and could not have consulted the weather if it had wanted to. The rule comes first. Rule 15a now names ongoing causes: a condition the player's surroundings keep causing — cold, heat, rain, smoke, thin air — is authored with no durationMinutes at all and ended under rule 15 when the surroundings actually change. The rule also says what a duration is for, so it does not read as an instruction to stop using them. That is the fix; the engine change below is the net under it, for the turns where a timer gets set anyway. Timed afflictions gained a third state between running and gone. When one expires, the engine asks whether the sky it owns is still inflicting it — the player outdoors, weather enabled, the band or wetness still at a matching extreme — and if so the condition is held open: its expiry dropped entirely rather than extended, because no number of minutes is the honest answer to how long it stays cold. What releases it is the weather, the temperature, or where the player is. When one of those changes the clock restarts for the span the condition was originally granted, so it fades rather than vanishing the instant the player is under a roof, which is what rule 15 already asked the GM to narrate. The granted span is recorded on the status at apply time because after a hold the two timestamps no longer describe it, and renewing off their difference would compound the span. Deliberately narrow. The engine judges only what it can actually see, so the hold covers three axes read straight off the WeatherState — cold, heat, wet — matched against the condition's own label, which is the only statement of cause a status carries. It knows nothing of the fur cloak, the frostkin heritage or the lean-to in the rocks, so indoors, below ground, on a mild day, or in a world with weather switched off it holds no opinion and every status expires as before. A boon is never held: a ward against frost is not frostbite. The GM keeps the last word — the Active-conditions line reports a held condition as held rather than as its own untimed kind, which would have invited exactly the wrong removal, and playerStatusChanges.remove still ends it outright. The rule text goes in the stable half of the prompt, where it belongs: it is rulebook, identical on every turn. Only the per-condition "HELD OPEN by..." wording is live, and it was already. tests/test_exposure_persistence.js covers each transition and each way the rule could over-reach, and was run against ten sabotaged implementations to confirm every property fails on its own named assertion. Two of those found real holes in the test rather than the code: the boon fixture was labelled "warmed", which names no axis and so never reached the grade gate, and passed happily with that gate deleted; and the cold-rain case was fixtured as a mild rain, which the engine is right not to hold a chill open for. BUG-053 is filed fixed-unverified, not fixed: the engine half is settled by the suite, but the rule-15a half is an instruction to a model and only a run can say whether the GM stops timing environmental conditions. The entry says what to watch for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9TMviYPi5iN5XZXoYCaRS
A room built through the DM console came back with NPCs that had no aggression, no inventory and no race. The beings were real and placed; they were empty where the engine reads them. Five separate GM calls author beings - the DM's room build, the DM's being build, world expansion, region build, and new-world generation - and each spelled the schema out by hand. They had drifted exactly as far as that predicts. aggression appeared in ONE of the five. race in two. inventory in two. The room pipeline asked for eight fields where the being pipeline standing beside it asked for twenty-two. Neither was wrong on its own; there was no single answer to what a being is made of. The lesson was already learned on the other half of the world and never carried across. The item side interpolates its equipment-slot roster into its prompts and never types it, with a comment beside it saying why: two prompts once spelled it by hand and went stale the moment a slot was added. That is the house rule about rosters, and the being schema had never had it applied. So: one BEING_AUTHORED_FIELDS list, two renderings. A full one for a call whose whole job is a being, and a brief one for a room build where a being is one part of a chunk sharing its budget with rooms, items and flora. A field cannot be present in one rendering and absent from the other, because both are built from the array - and that is what the test asserts, rather than asserting that some prompt mentions the word aggression, which says nothing about the sixth prompt somebody adds. The compact rendering carries the reason as well as the field, because the reason is what makes it stick: an aggression the engine does not recognise is discarded and reads as not-passive everywhere it is asked, so the four legal values are spelled out rather than left to be invented; and a note says not to omit these because a being is minor, since "it is only a villager" is exactly the thought that drops the field. The suite found a second bug of the same family while this was being fixed, and it is worth the paragraph. Rewiring the room pipeline made its item schema line interpolate for the first time, which brought it inside test_equipment_slot's heuristic - and the line failed, because that prompt mints ITEMS and had never named equipmentSlots. A sword built by "// add a room" arrived silently unequippable: the exact failure that test was written for, in a pipeline it had not been reaching. Two test slices needed repairing and both were anchored on their neighbours rather than on themselves. test_room_flora_authoring bounded its guide by "the next thing in the file happens to be requestWorldExpansion", so inserting a shared guide between them left it matching nothing and seven assertions failed about a guide nobody had touched. It now ends at the next top-level declaration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The Claude18 run rebuilt the exact precondition -- all three race hooks earned in one session, which is what Claude13 had done when the reversion was seen -- and then walked every mechanism this entry ever suspected: four level-ups through the stat-allocation modal, a full sleep, saveGameState, describeRoom, a logout with login and restore, and finally a complete world swap to another world and back, which happened by accident and is harsher than anything that was planned. The census did not move once. Zero regressions across the run, with the watchdog live and watching forty-one earned entries rather than the five flags it began with. Closed as cannot-reproduce and explicitly not as fixed. The Claude13 observation was real and cleanly recorded, no mechanism was ever found, and nothing has been changed that would explain it -- so nothing may be credited for the silence. What exists is a watchdog that will catch the next one within a second of it happening. The entry says what to capture if that day comes: the census either side, the last directive applied, and whether a save or a world load sat between them. That leaves no open bugs. Two entries remain fixed-unverified, BUG-009 and BUG-013, which are changes only a run can confirm and no run yet has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
R14 settled the mechanism; this puts it in the build order. P2.5 is the registry half: a publicKey stored beside the uid on first claim, a nonce endpoint, and verification of the signature over nonce, uid, vault and expiry on every write to a keyed record. Its own phase rather than part of P2, for two reasons. It answers a different question - P2 proves the host is the host, this proves the world is the author's - and P2 has to keep working without it, since every world minted before keys existed has none. A record with no key falls back to P2 exactly as before, which is what makes this additive rather than a migration. Fractional numbering rather than a renumber, following the doors document, which already carries a P2.5 and a P3.5. The other half is not in this repository and is the only piece here with a deadline. A keypair minted where the uid is minted, the public part written into the world data, is a change to the game with no dependency on the registry existing at all - and it gets worse every day it is not made, because a world authored today is a world that can never be proved. There is no retrofit: a key added to an existing world proves only that whoever added it had a copy. The window closes world by world and the count of unkeyed worlds only goes up. So the order is mint-the-key, then P0, then the rest as written - stated as a dependency in a callout rather than as a phase, because a phase list for a service should not contain a task in another codebase. One correction on the way: the phase count in current-status was edited by matching "5 phases", which is a string most rows in that table share, so the first hit was native-app-login's row rather than the registry's. Reverted and redone by locating the registry's own row first. The same lesson as the section-locating bug earlier this week - a string that identifies nothing cannot be an anchor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
BUG-011 said that taking an item off an NPC duplicates it and mints a counterfeit. Both halves were checked directly rather than inferred. The full item catalogue was snapshotted before the acquisition -- 38 refs, exactly one sable_undertow, none in the pack -- and Ys the Broker's Sable Undertow was then asked for and given. Afterwards the player holds one copy under the authored ref, Ys's inventory is empty so it moved rather than copied, the catalogue is still 38 refs with no new keys at all, and the DM log shows Transfer -- Sable Undertow x 1 from Ys the Broker, so it went through transferItem and not a parallel grant. The catalogue diff is the same check that caught BUG-045; against this path it stays silent. Moved from fixed-unverified to fixed. BUG-034 got a harder test than the one planned. The browser was left overnight on a different world and character, so recovering the session meant switching the world select back to Verengrad, reloading its classes and catalogue, and restoring Claude18 from the saved-game picker -- a full world-swap-and-back rather than yesterday's plain logout and login. All three race hooks came back unlocked, census races 3 places 2 compendium 41, regressions still zero. A stale-snapshot restore was one of the two mechanisms this entry originally suspected, and nothing has come closer to provoking one on purpose. The Last Descent then completed 6/6 with the census unchanged, and giveItem moved the reliquary into Mira's hands on the terminal beat -- the third clean handover since BUG-049 was fixed. Two fixed-unverified remain, BUG-009 and BUG-013, and one open, BUG-034. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The trade is accepted, so R14 is settled the other way and section 05 carries the shape. One correction to the instrument first, because the obvious one is wrong. A callback to the vault cannot prove a world's ownership however it is challenged: a callback proves the host answering is the host named, which is R4's origin proof and stays as it is. If the VAULT holds the signing key then the key is on a machine, and the moment it travels in a Vault Pack the import attack works again - while if it does not travel, a vault legitimately hosting somebody else's world cannot answer either. The challenge is right. What has to answer it is the author, not the machine. So: a keypair minted with the world, at the moment the uid is. The public half rides in the world data with every copy, which is what makes it verifiable anywhere. The private half is kept by the author and is in no pack, ever, which is what makes it a proof. A claim on a uid is a signature over nonce, uid, vault and expiry together - all four, because a signature over the nonce alone can be captured and replayed to repoint the same uid at a different vault, which is the attack this exists to stop wearing a different hat. The return is the whole reason to accept key management. Section 05 has said since Rev. 2 that squatting cannot be closed and that a uid claimed before its real author arrives needs an operator with a mailbox. It does not: first claim stops meaning first-come and starts meaning first to PROVE, and a squatter holding the pack holds the half that verifies signatures and produces none. They cannot take the name at all, rather than being adjudicated off it afterwards. Rev. 2's reasoning had the way out in its own sentence - unavoidable in any scheme that does not ISSUE identity - and wrote it off too quickly, because a registry does not have to issue identity in order to verify it. Where the private half lives is what stays open, with a leaning: the vault's existing encrypted key store, which is AES-256-GCM under a master key and already the pattern for provider keys, plus an export, since a key you cannot take with you is a world you cannot move. That also puts the signer on a server, which is where a challenge-response wants to happen and where the callback instinct was pointing all along. The cost is named: hosting your world on a friend's vault then means giving them the key or signing separately. The browser is the weakest home and unavailable where it is most needed, since crypto.subtle is absent on an http:// LAN vault - something this app already has to say out loud elsewhere. Two things are stated rather than solved. Every world that already exists has a uid and no key, so the first claim on an unkeyed uid is first-come exactly as before and binds a key from then on; refusing to list unkeyed worlds would make the registry useless on the day it opened. And a lost key is a lost world unless something can vouch - which is where the account earns its place a second time, as the recovery path for a rare case rather than the ordinary route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The natural proposal - worlds are published only from the owner's own vault, by someone signed in to it - is worth answering carefully because it is already how the seam is built. POST /api/server/public sits behind requireAdmin and publishToRegistry runs in server/registry.js on the vault itself. Nothing publishes from a browser today and nothing is meant to. So the current code IS the proposal, which makes it a good place to see exactly what the rule buys. It establishes that the publisher is an admin of that vault. That is an authorization fact about the VAULT, and the question is about the WORLD, and two gaps follow from the difference. The registry cannot tell which vault is talking. Signing in to your vault authenticates you to your vault; the registry sees an HTTP request carrying JSON, and every field in it is a claim. Nothing in "I am vault X and I host e3k9xa2" is checkable unless the registry checks something itself, which is origin proof or an account. Publishing server-side is not the proof - it is the thing that makes a proof possible, because a server can answer a callback and a browser cannot. The sharper gap is that a vault admin is not a world's author, and this project makes worlds portable on purpose. Download Vault Pack writes an archive that unzips into another vault's data directory, and WorldStore._reconcile adopts any uid-shaped directory holding a matching world.json - that is the feature. So take a published world's pack, drop it into my vault, sign in to my own vault as its legitimate admin, and publish a descriptor for your uid pointing at my server. Every step satisfies the rule. Possession of a copy is not authorship, and a uid minted in the browser and carried inside the data cannot tell the two apart - the same fact that makes a last-known address safe to try in Region Files section 13, working against us here. The rule stays, because it buys three real things: it keeps the browser out of the write path, which is the vault's whole discipline about keys; it gives the registry a server to call back to, without which origin proof has nothing to talk to; and it lets the vault check that it actually holds what it publishes, so worlds[] is recorded rather than accepted exactly as vault already is. R14 records the only thing that would actually close squatting, and why it is not taken up. Every mechanism in this document answers who may press the button; none answers whose world this is. The one that would is the uid being something only the author can prove - a keypair made with the world, the private half kept by the author, so a claim is a signature and an imported pack carries the public half only. It brings key management into a game that has none: a lost key is a world nobody can ever republish, and an author who changed machines would have had to carry something nobody told them mattered. Section 05 already stops at the same wall from the other side, where signing world data is what would give a fetched world provenance. If either is taken up they are one machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Clicking View on an item with no model yet showed the PREVIOUS item's model, sitting under "generating one now" for the several minutes a mesh takes, looking exactly like the thing that had been asked for. Disposing the viewer does not erase the picture. It releases the scene and stops the render loop; the last frame it drew stays on the canvas, because the drawing buffer is not the viewer's to clear on its way out. freshModelCanvas() looks like it would cover this and does not - it returns the SAME element unless the graphics context has been lost, which is deliberate, since replacing a live canvas leaks contexts. So nothing had erased anything for as long as this code has existed, and it was invisible because every open ended in a render a moment later: the stale frame lasted no longer than the load. The item popup's View is the first path that opens the box with nothing to render at all, and it turned a one-frame flicker into minutes of showing the wrong object. The defect is older than the feature that exposed it, which is the more useful way round to record it. The canvas is wiped by reallocating its drawing buffer, which clears it whatever context is attached - or none, on a canvas nothing has drawn to. On CLOSE rather than in each opener, so it is an invariant rather than a step every caller has to remember: both openers close first, which covers every way in including ones not written yet. Three sabotages. The one worth having is the third: the wipe moved to AFTER the overlay is displayed, which still shows the old model for a frame and would pass any check that only asked whether the canvas was cleared during the open. The test records a timeline - the drawing buffer being reallocated, the overlay being displayed - and asserts the order between them rather than the presence of a call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Inline, the status sat to the right of the sparkle and pushed the Model field along the row for the length of a generation, then let it settle back when the line cleared. Under the icon it says the same thing and the row stays still. The Model field's own status moved with it, since the two cells sit side by side and one wrapping while the other did not would have looked like a mistake rather than a difference. The first attempt was `flex-basis: 100%` on a wrapping flex cell, which is the shape the Items-editor card already uses for its icon row, and it changed nothing at all. The cell is shrink-to-fit inside a content-sized field inside a flex row, so it has no definite width, and the 100% resolved against a width that had itself been measured from everything laid out on one line - there was never a point at which anything needed to wrap. It rendered identically and would have passed a check for "the rule is there". What found it was screenshotting the row in all four states against the file's own CSS. What works needs no width at all: a block cell with inline children, and the status a block after them. Dropping display:flex drops `gap` with it, so the sparkle gets an explicit margin rather than silently sitting against the icon. The five assertions are about the mechanism rather than about a property name, for the reason above - a rule that is present and inert is the failure mode this had. Each was sabotaged: the cell back to inline-flex, the status back to inline, the dead flex-basis reinstated beside the working rule, the button's gap removed, and the margin that holds the line off the icon removed. One thing left as it is: a long failure message is wider than the icon and its button, so it still nudges the Model field a little when it appears. The common case - "Generating…" - is narrower than the row above it and moves nothing, and bounding the other case would mean either a magic max-width or absolute positioning that can overlap the fields beneath. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A mesh takes minutes and a sentence that never changes reads as a hang. The status overlay now carries a bar under its own line, and the line says which phase it belongs to - painting an icon, meshing, rendering the still, downloading a model that already exists. One control with two states rather than a spinner swapped for a bar. Indeterminate is a short fill sliding the length of the track, and that state carries most of the wait rather than being the exception: in Vault mode the mesh is a single request that returns when it is done, so no percentage ever arrives and there is nothing to switch to. When a provider does report progress the same bar fills, which means the moment the first number lands is a bar changing behaviour rather than one element being replaced by another. The bar is visible exactly when there is a message that is not an error, so "is something still happening" is one fact rather than two that can disagree - a bar still sliding under the word "failed" is the worst state this could be left in. A new message is a new phase and resets it to indeterminate, because otherwise "Rendering a preview…" inherits the 100% the meshing left behind and sits full while the work it names has not started. The percentage is passed beside the sentence rather than only inside it. One caller draws a bar with it and the other writes it into a line of text, and parsing a number back out of "Meshing… 40%" would make that wording load-bearing - a reworded sentence would silently stop the bar. Reduced motion gets a dim full-width fill that breathes instead of travelling, since the preference is about things moving across the screen rather than about knowing whether work is happening. Eight sabotages, each caught by its own assertion. Writing them found a real bug: the "this phase has no number" path used Number(pct), and Number(null) is 0 rather than NaN, so an explicit absence of progress arrived as a solid 0% - which says no progress where the truth is unknown progress, and is the single reading that function exists to avoid. It is the same trap loreHookXp already carries a comment about. The status setter also had to start writing into a span rather than onto the overlay, because the overlay is now the bar's parent and textContent on it would have deleted the bar the first time a status changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
A Model field beside Icon, with a View button that opens the 3D lightbox. The two sit together because they are one pipeline: the mesh is made FROM the icon, so a reader who has just looked at the glyph is standing in the right place to ask what it looks like in the round. The field shows the still the generator rendered off the mesh, or a lozenge when there is a model but the still failed, which happens and is not a failed generation. The button reads View in both states. A button that said Generate until a model existed would make the common case - look at this thing - the one you have to know about, and the state it would be reporting is one the lightbox is better placed to explain. THE LIGHTBOX OPENS ON THE FIRST CLICK, WITH OR WITHOUT A MODEL, and that is the design rather than an implementation detail. Meshing takes minutes. A button that disabled itself while nothing visible happened is indistinguishable from a broken one, so the box is the progress display: it says it is generating one, then carries the provider's own percentage, then shows the model in the same box when it arrives. Close it mid-generation and the mesh is still made and still stored - the provider has already been paid for it - but it does not spring back open, because a lightbox that reopens itself after being dismissed is the app arguing with the person using it. The icon half of the pipeline is no longer the reader's job. The card used to refuse an item with no icon and say "generate an icon first", which is a true sentence that leaves a person doing by hand a step the engine can do itself. A missing icon is now painted first and kept - shared with the item type and every live copy, like any other icon - and only a failure to paint one is refused, naming which half failed so the reader is not sent to look at a 3D provider that was never involved. One generator, two surfaces. The card's Generate and the popup's View differ only in where the progress line is written, so that is the parameter and nothing else is; `reuse` separates their intent, since Re-Generate means what it says. The provider call carries the vault-versus-direct rule, and a second copy of it is a second place a key can escape to the browser - so the card holds none, and both 3D test files now read the shared generator and check the card for exactly that. Nine sabotages, each caught by its own assertion. Two things surfaced worth recording. The first markup borrowed the icon cell's class names, which put a second element wearing .item-icon-glyph in the same popup - two existing tests that ask "is the glyph gone now that there is an icon image" started answering about the model's lozenge instead, correctly and about the wrong element. And test_item_size_display sliced the popup builder by a fixed 9000-character window, so ADDING a field made it fail while the code it inspects had not moved: it now slices to the function's own closing brace, which is the third of these found this week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The first fix for this cleared hidesExit when a door opened, on the reasoning that knowledge is monotonic: you watched the boulder move, so you know what is behind it. It made the reported case work and it was wrong about whose field this is. Clearing is permanent. A portcullis dropped again, a boulder pushed back, a slab re-seated could never hide its way a second time - the author wrote a standing property of the door and the engine spent it on the first open. The rule, as the owner states it in the editor's own words: "hides the way" means the exit is neither listed nor traversable UNTIL the door or barrier is open. Some obstacles want narrative activation first - searching, clearing debris, a check against the slabs - and once they are open, the way is shown. That is a condition on state, not a secret to be consumed, so exitIsVisible now reads !hidden && (!hidesExit || state === "open") and nothing mutates the flag at all. hidden is the flag that really is monotonic and still is. It says whether the player has found the obstacle; opening one counts as finding it, and nothing sets it back. The two flags answer different questions and only one of them belongs to the player. revealDoor had the same error the first fix introduced - it cleared hidesExit on discovery - so a secret door that hid the way hid it exactly once. It now clears hidden alone. The objection that shape has to answer is whether a found door with its way still hidden is a dead end, since the door's own button is drawn inside the exit chip and there is no chip. It is not: doorMatchingPhrase and the GM's door dossier both gate on hidden alone, so once the bookcase is found the player can name it - "open the bookcase" resolves - and the GM is told it is there and can operate it. What is withheld is only the direction, because the direction is not walkable yet, and listing a way the doors gate will refuse is the mismatch the whole system exists to prevent. The test that used to assert the old rule now asserts that pair of facts, which is what makes withholding safe. Two things came with it that no amount of fixing the flag would have shown. The Exits line is printed only as part of a room scene - on entry, or when the hour turns the prose over - so opening a door while standing in the room printed nothing at all and the newly available direction stayed invisible until the player left and came back. The engine now says "the way north is open to you now" at the moment it happens. And the GM is told on both edges, so it stops describing the slabs as blocking a way that is open, and starts again when they are back. The editor's hint went with it, because the checkbox label stops one word short of the rule: "the direction is not listed" is true and does not say WHILE IT IS SHUT, which is the half an author is relying on when they tick it for a barrier meant to be cleared. Eight sabotages, each caught by its own assertion. One of them needed a case that did not exist: the conservative rule that hidden outranks a hidesExit of false had nothing isolating it, because the secret-bookcase fixture carries both flags, so deleting the hidden guard entirely was still caught by the other one. Section 07's fourth row is now a fixture of its own. Writing the tests also found that setDoorState reads player.currentRoomId with no guard, which the door editor and world load can both reach with no player at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Regenerated from a full (non-shallow) checkout so the day-by-day tally reflects every commit through 2026-08-21, not a partial slice of history. Co-Authored-By: Claude <noreply@anthropic.com>
A cave whose mouth was sealed with stone slabs, authored so the exit beyond stayed hidden until the stones were moved by a skill check. That is exactly the second row of section 07's table, the great boulder against the wall: hidden false because the slabs are plainly there, hidesExit true because what is behind them is not. The check passed, the door went to open, the editor showed it open in both rooms - and the direction never appeared in either room's Exits line. The door was open and there was no way through it. The two views were reading different fields. The editor reads state, which had moved. The exits line reads exitIsVisible, which is !hidden && !hidesExit and had not, because nothing in the engine cleared hidesExit except revealDoor - the deliberate found-it path, used when a door is searched out, told about or rolled for. Opening one never went through it. setDoorState wrote state to every record of the pair and touched nothing else, so the slabs stood open and kept their secret from both sides. The design doc is half the reason this was possible. Section 07 defines what each row of that matrix LOOKS LIKE and never said how a row is left, so the one row whose entire purpose is to be removed had no exit written for it. Rev. 6 adds that, because a matrix of states with no transitions out of them is an invitation to exactly this. The fix: opening a door is a way of finding it. The flag-clearing half of revealDoor is now its own function and setDoorState calls it when a door goes to open, so both records lose hidden and hidesExit together, the sidebar repaints rather than waiting for the next turn, and the GM is told the way is listed now and should be described as open ground rather than as something still concealed. Row two collapses to row one, and so does row three, since a bookcase standing open is a bookcase you have noticed. Deliberately one-way. Push the slabs back and the direction stays listed: what was learned is not "there is a hole in this wall right now" but "this wall has a way through it", and the shut door blocks it again exactly as section 08's gate already does. An exit blinking out of the list would be the engine forgetting something the player watched happen. Seven sabotages, each caught by its own assertion, run through the real functions against a two-room world rather than against the source - the property is what the Exits line CONTAINS after a sequence of moves, and no arrangement of the code proves that. Writing them found one mistake in the test rather than the engine: the fixture wrote the door's name as `label`, which the schema does not have, so every message called it "the way", the barrier fallback from doorLabel. test_doors_art needed repairing and was pinning the wrong thing. It sliced setDoorState out of the file by a fixed 1600-character window, so growing the comment above the code it inspected made two assertions fail while the code they name had not moved or changed. Both now slice the function by its own closing brace, and the sabotage list includes growing a comment and confirming that no longer fails anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
"/usage" did nothing, and the reason is that it did everything correctly. openUsageDialog found the overlay, set its display to flex, read the ledger and rendered the charts into it. The element was genuinely open. It was also inside #setup-overlay, the login screen, which startGame sets to display:none — and a hidden ancestor cannot be overridden from a descendant. Nothing threw, nothing logged, and there was no symptom to see beyond the absence of a dialog. The timing is what made it total rather than intermittent. The ledger is per-playthrough, so the only moments it has anything to show are moments the login screen is down. The dialog worked exactly when it had nothing to say and failed exactly when it did. This is a known trap in this file, which is the part worth recording: three other dialogs already carry a comment saying they live "OUTSIDE #setup-overlay so it renders in-game", each presumably written after the same afternoon. The two still inside it - Prologue and API Keys - belong there, since both are opened only from the login screen and nothing else calls them. So the fix is to move one element, and the test is about the place rather than about that element. Writing that test took two attempts and the first one is the interesting failure. It tried to derive the rule: find each nested modal's opener, look at its call sites, decide from the surrounding text whether it was reachable during play. It passed against the bug it was written for and against nothing else - moving class-progression-modal, which opens from the character sheet mid-game, inside the overlay failed not a single assertion, because the keywords it searched for described how /usage is reached and not how anything else is. A rule that only recognises the bug it was written for is worse than a named list, because it reads as general and is trusted like one. What replaced it is mechanical. Every modal inside #setup-overlay must appear in a two-entry allow-list, and every entry in that list must still be inside. The judgement - is this dialog only ever opened from the login screen? - is made by a person at the moment they add an entry, and the failure message tells them that is what they are doing and what the alternative is. Sabotaged by nesting three different in-play dialogs, each named individually, plus emptying the overlay entirely so the loop cannot go vacuous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Typing "/ usage" looked the word up in the Field Guide instead of opening the spend dialog, which is the one outcome that route exists to prevent - its own comment says so, that asking what the game has cost must not itself cost a request. The cause was two places parsing one prefix and disagreeing about a space. handleFieldGuideCommand strips the leading slashes and TRIMS before matching, so "/ guide", "/ handbook", "/ hint" and "/ roll 2d6" have always worked. The /usage check sits above that route and tested the raw line with /^\/usage\b/, which cannot match a line with a space in it. So one command out of six was strict about something none of the others were, and it was strict silently. Both readers now go through one named slashCommandBody, which is the actual fix: the divergence, not the space. Fixing it that way immediately introduced a second bug, and the interesting part is how it was found. The old pattern carried the leading-slash requirement inside itself; normalizing the line takes the slash off first, so /^usage\b/ alone also matches a bare "usage" - and "usage of the rope", since the word boundary matches there too - which would have swallowed a typed game action as a command. Every source-shape assertion still passed. What caught it was running the predicate against a list of real lines and printing what each one routed to, which is now part of the test: the branch is extracted from the file and executed, so a weakened predicate fails by behaviour rather than by shape. The other half of the report was discoverability, and that is a defect in its own right. Typing "/" lists the player commands, and it is the ONLY way into this feature - the dialog has no button in any menu, and the Tools menu is explicitly DM maintenance rather than a place for it. /usage was missing from that list, so it could only be found by reading the source, which is how it came up. It is in the list now, described by what it answers rather than by its own name, and the test asserts that every command the list offers is actually routed - excluding the deliberate "/how do I save?" row, which exists to show that an unrecognised slash line is answered rather than refused. Two existing checks had to change and both were pinning shape rather than property. test_usage_ledger located the branch by the literal text of its condition, so it failed the moment that condition was corrected - a true failure report about an entirely right change. It now locates the branch by the call it guards. The new test made the same mistake in its own ordering check, anchoring on the comment above the branch: a sabotage that moved the branch below the catch-all left the comment behind and the check passed while the property it names was broken. Both offsets are code now. A comment is not evidence of where a branch runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
This entry had never had a fair test. The runs since the reversion were short and earned almost no lore, so their silence meant little. Claude18 rebuilt the state Claude13 was in when it happened: all three race hooks earned in one session, each against its authored condition -- Driftkin from Mira in confidence, Verengradi from Ket Drybound once two rebindings had bought real trust, Tidekin from Ys the Broker after the tide-cult owed a favour for a verse given freely rather than stolen. Census went races 0 to 3, places 0 to 2, compendium 17 to 40, over about thirty-five turns and four level-ups. Then every path the entry names as a suspect was walked deliberately, with the census read after each: four level-ups through the stat-allocation modal, a full sleep, saveGameState, describeRoom, and a logout, login and restore. All unchanged. Zero regressions logged for the whole run, with the watchdog live throughout and now counting the compendium as well, so it was watching forty earned entries rather than five flags. That is the first run that exercises this entry rather than merely failing to contradict it, and it still is not a mechanism -- the cause was never found and nothing explains what was different about Claude13. What remains is a threshold, not an investigation: one further long lore-earning run staying silent would justify closing it as cannot-reproduce, the status BUG-041 carries, rather than as fixed, because nothing was fixed and nothing may be credited. BUG-048's hide-on-logout half is also confirmed live now. Logging out with the banner raised left server-stale-logout at display:none, so the control does not outlive the session that gave it a purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The running totals answer what this vault has spent and cannot answer when, which is the question a chart is for. So every recorded call is now also folded into an hourly bucket, and the Usage tab draws those buckets above the table. Hourly rather than daily, because a vault that gets played for an evening is one point on a daily chart and one point is not a shape. Buckets are sparse - an hour with nothing in it is never written - so the finer grain is paid for only by the hours that had something in them. Three decisions carry the feature and each is the kind that looks arbitrary until it is broken. The history is its own file. `_save` rewrites the whole tally on every single provider call, so a history living inside it would re-serialize every bucket ever recorded for the sake of one incremented counter, which at the retention cap is most of a megabyte written per GM turn. This is the same reasoning the game's own /usage ledger was split out of the save snapshot for. The OPEN bucket does ride along in the hot file, because it is one object and it has to survive a restart; closed buckets are appended to the history on rollover, at most once an hour. The bucket and the running total are fed by one arithmetic. Both record methods now compute a delta once and apply it to the entry and to the bucket, rather than incrementing two sets of fields written to look alike. A chart whose bars do not add up to the table beneath it is worse than no chart, and the way to guarantee they agree is not to have two copies of what a call cost. Cost is derived at read time, exactly as _tokenCost already derives it for the table. A bucket stores counts; the rate table stores rates; they meet in history(). That keeps the property the store was built around - a rate landing in pricing.js today prices tokens already on disk - and extends it to the history, where banking the dollar figure at write time would have frozen each bar at whatever the rates were that hour and left a visible seam in the chart the day a rate was corrected. Resetting the meter clears the history too. The alternative is a chart that outlives the table above it, putting two windows of one meter on one page, and no labelling rescues that. The chart is bars, not the line the game's /usage dialog draws. That one plots a running total across a sequence of calls, where a line is right because the value between two points means something. This plots discrete intervals, where the value between two hours does not exist - so a copy of the game's chart would have been the wrong mark as well as a copy that could drift. The X axis is active hours in order rather than a continuous timeline, because a real timeline is a field of whitespace with three bars in it; a skipped stretch is drawn as a wider gap and both ends are labelled with a real timestamp so the reader can see the middle is not evenly spaced. An hour with requests but no published rate gets a baseline mark rather than a zero-height bar, because a zero-height bar claims the hour was free and that is a different sentence. A cost too small to round to a pixel is floored at a visible sliver, or an hour that cost a hundredth of a cent looks exactly like an idle one. The history gets its own route rather than a field on the snapshot, so the table - the thing the tab is for - does not wait on or grow with the chart, and a failed chart empties its own box instead of reporting into the page. It gets its own VAULT_USAGE_HISTORY_FILE for the reason every other file here has one: an operator moving the vault's data somewhere backed up should not discover this one by finding it left behind. Sixteen sabotages across the two test files, and the ones worth recording are the four where the TEST was wrong rather than the code. A route-gating regex that required a named first handler meant deleting requireAdmin removed the route from the list entirely, so the failure reported was "the history has no route" - a true sentence about the wrong problem. A check that the reset refreshes both views matched the boot sequence at the bottom of the file instead of the reset handler, and passed with the call deleted. A tooltip check counted `<title>` anywhere in the function and passed with the tooltip stripped from one of the two kinds of bar. And a bar-height check filtered to `h > 0` before asserting a floor, which dropped the very bar the sabotage shrank to 0.0 and then passed over the tall one that was never in question. The two properties those greps were reaching for - gap widening and the unpriced mark - are now asserted by extracting the chart function from the page and running it against real buckets, because the source of a chart says much less about it than its output does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
640 pixels was a reading measure, and the right one when this page was a column of provider cards. It stopped being right once the page grew tables. The Usage table carries eight columns - provider, requests, input tokens, cached read, cache write, output tokens, credits, cost - and at 640 it scrolled sideways inside its own .usage-scroll box, which put the Cost column off the right edge. The scroll box is doing what it was built to do, and its comment says why it exists: a narrow window must not be able to clip the column an operator came to read. It was never meant to be load-bearing at the default width. Widening the page is the fix rather than narrowing the table, because every column in that table is a number somebody needs, and the two cache columns are the newest and the ones most likely to be the reason the tab is open at all. 1040 leaves the eight columns about a hundred pixels each with room over; the numeric cells are grouped integers in 13px mono with twelve pixels of padding a side, so that is a fit rather than a guess. The other half is prose. A page this wide sets its ledes at around a hundred and twenty characters a line, which is unreadable in a different way than a clipped table is, so anything that is a sentence - the ledes, the section ledes, the notes, the usage note, the whoami line - is capped to 74ch. In ch rather than px, so the cap tracks the font instead of the window. And the provider key field, which is flex:1 in a flex row, would otherwise have become a nine-hundred-pixel input for a value that is forty to a hundred characters long, so it gets a cap of its own. Five assertions in test_admin_page, each sabotaged and each caught by a distinct one: the page back at 640, the prose cap removed entirely, the usage note dropped from the cap's selector list, the cap rewritten in px, and the key input's cap removed. The width is asserted as a floor rather than an exact value, because widening further is fine and narrowing back to a reading measure is the regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The Claude18 run returned the ledger to Hesk in a single turn -- climb up through the narthex, east to the market, put it in his hands. The beat unlocked, Hesk paid his twenty gold, fame rose, and the ledger stayed in the pack with his inventory untouched. The DM log said the GM named a being this room does not contain, quoting "The Saltmonger Hesk", which reads exactly like BUG-013 and is not: matchEntityInRoom already strips a leading the/a/an, and the driven test confirms the same call succeeds with the article intact. The name was fine and the room was wrong. applyStateChanges binds const room = currentRoom() on its second line, moveToRoom is applied around line 117, and the being-targeting directives run at line 540 still reading that first binding -- so the player had moved and the room had not, and Hesk was being looked for in the Flooded Cloister two levels below. That also explains why BUG-046 looked inconsistent. Claude16 typed the same handover while already standing in the market and it worked; Claude18 travelled and gave in one turn and it did not. Same directive, same model, a different question asked of the engine. BUG-046's entry now points here rather than leaving the next reader to think its fix regressed. giveItem and transferItem now resolve against hereNow, currentRoom() taken after the move: to receive a thing, or be robbed of one, a being has to be where the player now is. Scoped to those two deliberately. Rebinding room for the whole function is one line and would silently change every directive above it, several of which legitimately mean the room the turn started in -- the comment says so, because the next reader will see the one-line version available and should know it was refused rather than missed. Three existing tests failed on the rename and each was right to: two asserted the literal call, and test_transfer_item.js slices the function body from that exact string, which its own comment already warns is a fragile anchor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Section 13 rejects a URL as an address, and that stands - a vault that moves must not break every exit pointed at it. But the beyond block already carries hints of exactly this kind: region, which must degrade to fetching whatever holds the room rather than fail, and name, which is what to call a place before anything is loaded. An optional vault belongs beside them, and calling it a last-known address rather than a fallback is most of the design, because the name says it is stale by nature and never the identity. What makes an address nobody vetted safe to try is that the uid travels inside the world data rather than only in the registry's index. WorldStore._reconcile already refuses a directory whose world disagrees with its own name on exactly that basis, and test_world_uid_roundtrip pins the uid through every save, export and reload. So the game fetches, reads the uid out of what came back, and refuses anything that is not the world it asked for. An unvetted address can send a player to nothing; it cannot send them to a different world. Four things follow, and the fourth is the one worth having now. A cold crossing works with the registry down, which is the case a kept region cannot reach. An outage stops being binary and costs only the crossings whose destination has moved, which makes the registry an error-recovery service rather than a dependency of the steady state. The offline single-file case gets far roads at all, where Realm Registry section 13 says plainly it can have none. And the entire crossing - fetch, verify, merge, arrive, refuse with a reason - becomes buildable and playable between two vaults before the registry exists, so the registry stops being the thing everything waits on. The costs are stated rather than implied. It is useless for a world that has moved, which is the case the uid indirection was chosen to survive, so it demotes the registry and does not replace it. It carries no provenance: section 08 says a listing asserts that an origin proved control of a uid, and an address written into an exit was vetted by nobody, so the arrival check proves the payload claims that uid and not that it is authentic. Closing that means signing world data, which is a machine this project should not grow for this. And two sources of truth get one rule rather than a precedence table, on R9's reasoning that nobody can debug a resolution order from inside a game: the registry is asked first and wins whenever it answers. Q13.10 records the part the subsection does not settle - that an author typing a URL will type one that is already stale, so the field is better written by the game on a successful crossing, which makes it a fact refreshed every time it is proved right rather than a claim, and makes it save data more than authored data. The failure story is now written down too, because four causes arrived where Q13.8 had one. A dead registry, a dead vault, a stale address and a refused arrival are one beat to the player and four lines in the log. Three rules make that work. The GM is told the refusal as a fact for the turn rather than left to infer it from a turn where nothing happened, because a model told nothing will helpfully narrate the journey into a world that never loaded. The beat it narrates edits nothing: a GM describing a pass collapsing has made a permanent change to a world it does not own, and requestWorldExpansion and mergeWorldChunk mean this engine can make such things stick. And no failure is cached, because the most ordinary cause of an unreachable far side is an owner rebooting their server - if the engine remembers the failure, a reboot has become a ban, and the player is told the road is shut long after it reopened. The wording carries the same weight: impassable at the moment says not now, the bridge is destroyed says not ever, and a player told the second stops trying a road that will work tomorrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Keeping a fetched foreign region locally - in the player's own vault where there is one, in browser storage for the save where there is not - is a larger idea than the descriptor cache Q13.3 already argues for, and it is worth separating what it fixes from what it appears to fix. Realm Registry section 08 says plainly that a listing does not assert the server will still be there when you knock. A kept copy is the only answer this design has to that: the author's vault goes dark and the player still walks home through a region they have been to. The descriptor cache keeps an address current and still needs somebody answering at it, so keeping the data is strictly stronger. It does not answer R6, and the reason is that R6 is about the case a cache is empty for. A first crossing, by any player who has not made it, needs an address from somewhere. Keeping makes a registry outage retrospectively survivable and prospectively no different at all - every road authored today, and every player who has not walked one, still waits on somebody answering. So R6 now records the mitigation and stays open for exactly the prospective case, and names the other line of attack if it is ever pressed harder: an exit carrying a fallback address beside its uid, which makes the registry the indirection one uses when the fallback is stale, at the cost that an authored address is one no origin proof has ever checked. The trap this walks into is specific to this codebase and is verified rather than suspected. WorldStore._reconcile adopts any uid-shaped directory holding a world.json whose uid agrees with it, marks the record adopted, and lists it among this vault's worlds. That behaviour is deliberate - it is what lets VAULT_WORLDS_DIR be a git checkout - which is exactly why a cache written under the worlds directory becomes one of the vault's own worlds on the next boot with nobody deciding it. Today the consequence stops at the Worlds tab, because buildListing carries a server's name and URL and no worlds at all; but the registry's own stated first step is to teach it to carry world descriptors, and on that day a vault would advertise a stranger's realm as something it hosts, having proved an origin for it. So cached foreign content lives somewhere reconcile does not look. Two consequences follow that are better decided than discovered. A copy kept forever means an author's unpublish reaches nobody who already crossed, which may well be the right answer - the web works this way - but it is an answer rather than a thing to arrive at by the cache never expiring. And the two halves are not equally capable: a vault keeps a foreign region well because its media store is content-addressed, so the art comes along at the price of any other file, while a browser cannot - region JSON expands when refs become instances, and the media is referenced by URL on the vault that may be the very thing that went away. Direct mode's version is smaller by necessity, and saying so beats promising parity and meeting the quota in a playtest. Q13.9 records the shape: kept for the save that fetched it and served to nobody else, because a player's vault holding a foreign region is a cache and never a mirror. The genuinely open part is the author's side - whether an unpublish should expire copies already taken, which is a promise this design can make and cannot keep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
BUG-048 closes on two changes, and the first is the actual repair. The banner holds no interactive content of its own, so it is now pointer-events: none and everything beneath it is reachable again. Confirmed live with the banner raised and a session active: document.elementFromPoint at #logout-btn's own centre returns logout-btn rather than the banner, which is the measurement that diagnosed the defect, inverted. The banner also gains its own logout, as asked, because the message it displays -- go and restart the server -- is exactly the moment a player wants to leave cleanly first. It calls the same logout(), takes pointer-events auto back from its parent, and appears only when the banner is up and someone is signed in. Clicked for real in the browser: it ended the session and returned to the title screen. Not answered with a dismiss control, deliberately. Dismissing would let a player hide the warning in order to reach the button behind it, and the overlap was the defect rather than the warning. Testing the fix caught what reading it did not. The first version wired reflectServerStale into the two places that reveal the header button and nowhere else; the banner survives a logout, so its button then sat on the login screen offering to log out of nothing. logout() refreshes it too now -- a control that mirrors another has to mirror it in both directions. That half is pinned by test and has not been watched live, because the browser session became unreliable straight afterwards, with clicks landing and focusing but not activating across several elements. Flagged in the entry for the next run rather than claimed. One open: BUG-034. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Section 05 asked one question - what stops a vault publishing a descriptor for a world it does not host - and offered three answers to it, of which origin proof was the leaning. Putting a real account system on the registry, on its own permanent domain with its own Auth0 tenant, exposes the framing as the mistake. There are two questions wearing one coat. Who may edit this record is about a person and lasts for years. Whether the host named in the record is actually theirs is about a machine and is true or false this afternoon. Nothing was ever going to answer both well, which is why the table read as a forced choice between things that were not alternatives. So the leaning is now both, doing different jobs. The account owns the uid, and the argument that had counted against accounts - that they make the operator a gatekeeper who must issue and manage credentials - is largely paid off by a tenant, leaving the parts of that cost which are real: the registry becomes an identity system, and it has to not go away. What the account buys is the larger of the two cases Rev. 1 sent to a human. A world moving to a new vault stops being an appeal and becomes an edit, because ownership was never a fact about the old machine. The same reasoning improves section 06: a lapsed uid is recoverable by the account that held it, which survives a host that changed address or a vault that is gone for good, where recovery by origin does not. The account does not fix squatting, and the reason is worth recording because it is a fact about this engine rather than about registries. A world's uid is minted in the browser and is stable across every save, export and reload that test_world_uid_roundtrip pins, so a world arrives at the registry already named. The registry cannot issue identity it does not mint, which means first claim still binds. What changes is that the claimant is a person with an audit trail and a mailbox, so the one remaining appeal is one an operator can actually adjudicate. Origin proof survives, and gets stronger rather than weaker, for a case that is not hijacking. A signed-in publisher who honestly owns a uid can point it at somebody else's server, and the registry will then aim every arriving player's fetch at a third party who agreed to nothing. Uid ownership cannot see that, because nothing about the record is a lie except the address. So R4 narrows from proving a uid to proving an origin, and a failed re-proof can suspend an address without touching ownership - which also drains the urgency from that decision's open half. Two invariants come with accounts and are written down before they can be assumed. A registry token is never a vault token: the tenants are separate, signing in to publish must never resemble signing in to play, and the crossing's auth in Region Files section 13 remains a relationship between a player and a vault. And publisher identity is not player identity, so section 07's refusal of player identity is untouched - accounts live entirely on the write half, and a reader still asks anonymously. The two sections sit close enough together that a reader will otherwise collide them. R13 records what an account owns, which section 05 deliberately does not settle: many uids per account against the single point of loss that implies, a token rather than a browser session so the heartbeat can run unattended, and what becomes of every record if the operator's own tenant lapses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Closing it needed a status the vocabulary did not have. The three closing states are defined precisely and none of them is true here: withdrawn means investigated and found not to be a defect, which is not what happened -- the Claude14 observation was real, correctly recorded, with the right trigger and questUpdate seen null four times. wontfix means real and deliberately left alone, and nothing is being left alone; the defect cannot be found. needs-repro means the observation was confounded, and this one was not, unlike the Mira case withdrawn yesterday. So cannot-reproduce is added to the legend and BUG-041 takes it: real when observed and cleanly recorded, not reproducible since, no mechanism found, closed pending a recurrence and explicitly NOT closed as fixed -- nothing may be credited for it, because Claude15 fired the beat on the first interaction before any change existed. The status carries the instruction to say what to capture if it happens again, which this entry already does: live trigger text, per-turn questUpdate, whether nearMiss was set. The status earns its place rather than being minted for one entry. BUG-034 is the same shape -- a real reversion seen once, never reproduced, watchdog armed -- and will likely want it once a long run has fairly exercised it. Two open: BUG-034 and BUG-048. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Realm Registry doc's section 13 claimed that a registry summary is the earliest untrusted text to reach the Game Master's prompt, on the reasoning that a borders line names the realm beyond an exit and that name came from a descriptor. That is wrong, and it is wrong against this project's own design rather than merely unproven. Region Files section 13 puts name and world_name inside the exit's own beyond block, authored in the local world, and says why in as many words: the GM must be able to say where a road goes before anything is loaded, and it cannot read a name out of a world it does not have. The borders line is the local author's text. Nothing from the registry reaches it. The claim was self-contradicting too, which is the part worth recording. The same subsection prescribed the mitigation - what goes in the prompt is a name, short and stripped, not a summary - directly under a sentence asserting that a summary reaches the prompt. A warning that argues against its own premise reads as authoritative for as long as nobody checks it, and a design document is exactly the kind of artifact that gets quoted rather than checked. What replaces it is the rule stated positively: a descriptor's name and summary are for the page, and the prompt reads the local world. The second entry in the trust list is now the arrival, which is what a descriptor actually decides - which host the game fetches a world from - and is a more valuable thing to corrupt than a line of prose. The surface table's justification went the same way: resolve happens on a crossing, not on a turn, so a callout now records that nothing here is on the turn path and that a registry outage costs a player the ability to walk a far road but not the sentence describing one. Any later design that puts a lookup on the turn path has given up that property and should say so rather than acquire it by drift. The one case where descriptor text would legitimately enter the prompt is now an open decision rather than a warning: R12, whether a renamed realm's new name should override the name the exit was authored with. The leaning is no, for three reasons that agree - the prompt stays free of foreign text, the borders line survives a registry outage, and a name in an exit is part of the fiction its author wrote. Taking it up later is a deliberate reversal with a cap and a strip, not a convenience. The index and status page repeated the overstatement in their realm-registry rows, so both carry the corrected sentence and the decision count moves from eleven to twelve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The gap left by yesterday's retest is closed. Claude17 tested a bare interaction when Claude14 had failed on substantive ones, so it proved the wrong thing. Claude18 ran the real case: a fresh character taken to the Hush-Choir Gallery with the two earlier beats unlocked first, the trigger read out of world.quests in the running game rather than a save file, and then a substantive opening interaction of exactly the kind that stalled four times in Claude14 -- addressing her as a person rather than a statue, saying who sent the player, asking how long she had held the corridor. The beat unlocked on that turn and paid its 70 XP. Both inputs have now been tested against the live world and neither reproduces. Claude14 stalled four times on substantive interaction; Claude15 fired on the first, before any change existed; Claude17 fired on the first with a bare sentence; Claude18 fired on the first with Claude14's own shape. Three non-reproductions across both inputs, one predating every amendment made since, which is as decisive as a single observation permits and still does not license crediting the fix -- Claude15 rules that out by itself. The recommendation recorded is to close as cannot-reproduce rather than as fixed. The Claude14 observation was real and properly recorded, with the correct trigger and questUpdate seen null four times, and no mechanism has ever been found for it. Nothing further is learnable by re-running it. The entry now says what to capture if it ever recurs: the live trigger text, the per-turn questUpdate, and whether nearMiss was set. BUG-048 reproduced a third and fourth time across this run, on both logout attempts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The registry document described a service. This describes who calls it — two questions and a cache — and almost all of its design turns out to be about what the client refuses to send and what it refuses to trust. The address is not hard-coded. R1's leaning is that a game asks its own vault which registry to use, and the mechanism already exists: buildClientConfig hands the client its configuration at boot, and a registryUrl joins the media-store switch and the model ceilings already arriving that way. The consequence is worth stating rather than discovering — a game with no vault has no registry, so Direct mode and the single-file offline case cannot resolve a foreign exit at all. That must not be a broken game; it is a world whose far roads are closed, described as closed, with the rest of play untouched. Every call goes through one chokepoint, for the reason gmFetch is one: a chokepoint is where a rule can actually be enforced. The timeout, the failure shape, and what may be in the request live in one place rather than at each call site, where the third one added quietly does it differently. The rule that matters is that the registry needs a world uid and must never receive the player's identity, character, save, current world or any vault credential. §07 refuses to hold a profile of who plays where, but that refusal is only real if the client declines to offer, which makes it the one client invariant worth a test of its own — asserted on what the mock recorded, not on the call site. The surface splits resolve from cached, and that split is load-bearing. A foreign exit needs a NAME on every turn it is visible, because Region Files §13 puts a borders line in front of the Game Master, and a network call per turn to render a line of prompt is fine in testing and ruinous in play. The prompt reads the cache and never blocks; only an actual crossing resolves. The part most likely to be missed is where a descriptor ends up. It is written by a stranger and reaches two places: the page, where it is escaped like any foreign string, and the GM's prompt, where a borders line names the realm beyond an exit. That makes a registry summary THE EARLIEST UNTRUSTED TEXT TO REACH THE PROMPT — earlier than any world data, because it happens before a crossing rather than after one, on a turn where nothing has been fetched and the player has gone nowhere. The mitigation is deliberately dull: what goes in the prompt is a name, short and stripped; the summary is for the page. Instructions written into a realm's name have very little room to work in. Failing is specified three ways: bounded, because the game is one page and a fetch with no timeout is a turn that never ends; closed rather than absent, because "the exit does not exist" is indistinguishable to a player from the world having changed under them; and said once in the log, which is Q13.8's rule one hop earlier. Two more open decisions with leanings — whether the game browses at all, and what to do when a descriptor's version has moved since the exit was authored, whose answer is that entries[] makes it checkable BEFORE the crossing so a vanished room is a closed road rather than a failed arrival.
The addendum written earlier today reports the Anchor Saint beat firing on a single bare interaction and treats that as testing the surviving instance. It is not the same input. The Claude14 report says the player interacted "four times, with rising substance" before questUpdate stopped coming back null, so the failing case was substantive and escalating while the retest was one flat sentence. What was shown is that the bare case works. The Claude14 evidence itself holds up, which is worth stating because the Mira case did not. Its trigger text matches the live world exactly, checked again today, and questUpdate was null is an observed field rather than something reconstructed afterwards. The result also inverts the hypothesis this entry has been carrying. A Game Master applying a standard of sufficiency would fire the beat on more substance and stall on less. The opposite happened: four rich exchanges stalled and one flat sentence fired. So "a fuzzy predicate invites a judgement of sufficiency" does not explain the data, it predicts the reverse, and it should stop being repeated as though it did. The close condition is now specific: take a fresh character into the Hush-Choir Gallery and interact with the Anchor Saint the way Claude14 did, across several substantive turns, inside a long run rather than a short mechanical one -- and read the trigger out of the running game rather than a save file, which is the mistake that already cost two runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
§13 of Region Files leans on a central broker without saying what one is. This describes it: what it stores, who may publish to it, how a vault proves it owns a world uid, and what a listing does and does not vouch for. It is a separate page rather than a fourteenth section because it is a separate thing — something an operator runs, with its own address, storage and uptime — and the folder's convention is one document per system. The sentence the whole design falls out of is that the registry brokers descriptors and never touches world data. That single constraint is why the service is small, why an outage costs new crossings rather than saved games, why it is not a CDN with a bandwidth bill attached to somebody's hobby, and why what it can leak is bounded by what a vault chose to publish. Three refusals are stated as refusals rather than mitigations. No world data. No room lists — only the entries[] an author published as doors, because a registry enumerating every room would hand any reader a walkthrough of every published realm, which is a spoiler boundary before it is a privacy one. And no player identity: the read API takes no login and should want no cookie, since a registry that knew which player asked about which realm would be building a profile of everyone's play out of a lookup table. The read and write sides are split because they share almost nothing — different callers, different trust, different failure modes. Read is open, unauthenticated and called cross-origin by every game there is; that is the requirement rather than laxity, because a game must resolve a destination before the player has any relationship with the vault holding it. Write is a vault, server-side, authenticated, and its volume is a heartbeat. §05 is the hard part and says so: what stops a vault publishing a descriptor for a world it does not host. The leaning is origin proof with first claim binding — the registry fetches a well-known path on the claimed vault and checks it names the challenge it issued, so control of the origin IS the credential, checked rather than asserted. It needs no accounts in the common case, and the two cases that do need a human — a world legitimately moving, and a uid squatted before its author arrived — are named rather than papered over. Almost none of this is invented. server/registry.js already carries the publish/withdraw seam for a vault announcing itself, already validates against the rules a registry will impose, already refuses a loopback or private-range listing through publicUrlProblem, and is already deliberately loud about being a stub. The extension is from server listings to world descriptors in the same discipline. The admin route even saves the Public Server intent when the publish cannot succeed, so whatever ships first inherits a population of vaults that already said yes. Five phases, ordered so the shape settles before anything depends on it, and P1 before P2 is the point worth defending: a read API over a hand-edited backing file is completely sufficient for the game side of §13 to be built and tested end to end. Publishing is what makes it scale, not what makes it work. Nine open decisions with leanings, including the one most likely to be regretted if deferred — who runs the service and what happens when they stop, whose answer is that it must be trivially self-hostable and its data trivially exportable, so a registry going away is an inconvenience rather than the end of every crossing ever authored. The status index and README carry the new row, and the index's counts were re-reconciled against the folder: 38 documents, 7 proposed, 18 built in part, 10 shipped, 3 finished, which is what is on disk.
With the Mira case withdrawn yesterday, this entry rested on a single observation: the Claude14 run spending four turns on "Reach the Hush-Choir Gallery and interact with the Anchor Saint". Claude17 re-ran it as bare as the trigger allows -- walk into the Gallery on one turn, then type nothing but "I speak to the Anchor Saint", with no naming her and no reasoning about what she is that could satisfy a comprehension standard by accident. The beat unlocked on that turn and paid its 70 XP. The tempting conclusion is that rule 10's amendment and the reminder beside the beat list fixed it, and that conclusion is not available. The Claude15 run also fired this beat on the first interaction, and Claude15 predates both changes. So a single observation has now failed to reproduce three times, once before any fix existed, which supports no causal claim at all. Left open rather than closed. A real observation was made once and nothing here explains what was different about that run, so this is recorded as a non-reproducing single instance: close it if a further run walks a fuzzy-predicate trigger cleanly, and reopen -- with the live trigger quoted from the running world rather than from a save file, which is the mistake that cost two runs -- if it ever recurs. The authoring guidance in the design doc stands on its own merits either way. A trigger ending on something observable is easier to honour than one turning on whether an exchange was enough, and it costs nothing to write them that way. BUG-048 reproduced again on the way out: the logout button was covered by the stale-server banner a second time, and the run had to be ended by focusing the button and pressing Return. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two questions about crossing into a restricted realm, and the design doc now carries both answers. CAN A POPUP LOGIN DO IT? Mechanically yes — it is an ordinary first-party login at the remote vault. But this vault authenticates with express-openid-connect and no explicit session config, so the session is an HttpOnly cookie on that vault's own origin, and using it from the player's game means sending a third-party cookie. That is precisely what browsers are removing. Underneath it sits a harder rule than a trend: a cross-origin request carrying credentials may not be answered with Access-Control-Allow-Origin: *. The combination is forbidden, so a cookie-based crossing forces the hosting vault to know and echo every visiting origin — an allow-list of every player's game, which is unworkable for exactly the population this feature is for. CAN THE EXISTING TOKEN LOG IN SILENTLY? Only when both vaults name the SAME Auth0 issuer. Then the player already has a session at that tenant and prompt=none completes with no interaction. When the issuers differ there is no shared session to be silent about and the answer comes back login_required. Nothing in the app bridges that: the two operators either share a tenant or they do not. It is a federation question wearing a code question's clothes. The leaning is therefore that the popup returns a TOKEN rather than leaving a session behind. The login is a full-page navigation inside a popup, which is first-party and untouched by the cookie rules; what comes back to the opener is a short-lived bearer token, handed over by postMessage and sent as an Authorization header thereafter. The vault already has a bearer gate in requireToken so the shape is not new, a bearer request needs no Allow-Credentials so the published subset keeps the permissive CORS it needs, and a token can be scoped to one world for a while where a session cookie is scoped to a whole vault for as long as it lasts. Silent login survives as an optimisation on that footing rather than as the mechanism: same tenant, prompt=none, and the popup opens only when that comes back empty. Worth stating plainly because it shapes everything above: most crossings should need no login at all. accepts and entries[] are a published, deliberate offer, so the enterable subset of a world is public by its author's choice. Login is only for the realm that admits some players and not others. Three more open decisions, all with leanings: session or token, whether a descriptor advertises its issuer, and what a refused arrival looks like in the fiction — which shares a shape with the far-side-is-down case, since a player cannot tell "you are not welcome" from "the road is shut".
§06 stops at the region boundary and says so outright — what happens at the moment of crossing is "named and left open". §13 takes it the step further that the requirement actually asks for: an exit whose destination is a room in a different world, hosted on a vault somebody else runs. The design turns on one choice: AN EXIT ADDRESSES AN IDENTITY, NOT A LOCATION. An exit carries a `beyond` block naming a world uid, a room id, and optionally the region that room belongs to; a central registry resolves the uid to whichever vault is serving that world today. A world uid is already the vault's key for a world, already validated, and already means "this world, distinct from a fork of it". Addressing by uid rather than URL means a vault that moves does not break every exit pointed at it, a world published to a second vault is still the same destination, and — the part that matters most — the world data never names a host. The registry is a BROKER, NOT A PIPE. It answers where a world lives and what may be arrived at; the game then fetches the world data directly from the hosting vault. That topology deletes the SSRF question rather than fencing it: no vault ever makes a server-side fetch to an address a client supplied, the browser's origin policy is the fence, and the remote operator's CORS headers are their consent. It costs three things the section records as decisions rather than details — the hosting vault must answer a stranger's browser, so the enterable subset has to be genuinely public; CORS is part of the contract, on the published subset only; and mixed content will bite before anything else does, which is a second reason to require https of anything the registry hands back. What a vault publishes is a DESCRIPTOR AND NEVER THE DATA, and it publishes doors rather than maps: identity, a bounded summary, its regions, and an `entries[]` list of the rooms that may be arrived at. Nothing enumerates a world's rooms, beings, quests or lore. That is a spoiler boundary before it is a privacy one — a registry listing every room would hand any player a walkthrough of every published realm — and it makes "may I arrive here" a fact about what was published rather than a permission check at the door. None of this is invented from nothing. server/registry.js already exists as a deliberately loud stub for a vault announcing ITSELF, with the validation the registry will impose already written; §13's extension is from server listings to world descriptors, in the same one-builder, bounded-fields, control-characters-stripped discipline buildListing already sets. Two models are separated rather than merged: portal, where a region is fetched and merged and the player never leaves, and passage, where the character is carried into the destination world. The leaning is portal within a world and passage between worlds, because a neighbouring region IS this world while another author's world is not, and because a character is already a portable document. Six things that will break quietly are named, and the first is the one to write first: normalizeExits rebuilds every exit from a fixed pair of fields on every load, and its own comment records a door being "authored, saved perfectly, and gone on the next reload, with nothing to see". A beyond block not named there dies the same way. The others are room-id collisions that mergeWorldChunk hides by skipping, vault-relative media that 404s once merged, foreign prose reaching the GM's context as the engine's first untrusted text that is not the player's own typing, a far side that can be down, and a return trip neither side can author alone. Five open decisions carry their leanings. Nothing is built, and the section says what to build first: not the network and not the registry, but the beyond block surviving a reload, a borders line for a foreign exit, and a crossing that refuses with a reason.
The Calling was reported failing under the new rule, on a trigger reading "Speak with Mira the Anchorite at Scaffold Landing about the changed rhythm of the bells". That is not this world's trigger. It was read out of a July save, Saves/alicia5-...json, and carried into two runs without ever being checked against the live world. The real trigger asks the player to offer help AFTER Mira explains the Ninth Canticle, and ends on her naming them Cantor-Thief and pressing a Salt-Verse Shard into their hand. Asking about the bells' rhythm never satisfied that, so the Game Master was correct to withhold on both runs and correct not to set nearMiss, because the letter of the condition had not been met. The Claude17 run settled it in one turn: given the actual condition, the beat fired immediately and Mira pressed the shard into the player's hand exactly as authored. Claude16 was therefore four of four correct, not three of four, and the "two contract breaches in one turn" recorded against it never happened. BUG-041 stays open on its ORIGINAL evidence alone -- the Claude14 Anchor Saint case, whose trigger "Reach the Hush-Choir Gallery and interact with the Anchor Saint" is live and unchanged and took four turns. It is now a single-instance finding rather than a pattern, and the hypothesis narrows with it: a trigger needing a judgement of sufficiency is the shape that fails, while one ending on something observable fires on the turn. The live Mira trigger is a model of the latter, which is why it never failed. Everything built on the bad example is corrected rather than quietly edited. The prompt reminder beside the beat list now cites the Anchor Saint case; the design-doc guidance swaps its failing example and keeps the withdrawn one visible as a note, because that trigger is in fact an illustration of the advice; the test carries the correction at the top of the file and asserts the withdrawn wording appears nowhere in the prompt, since teaching the model from a non-defect is worse than teaching it nothing. Also filed BUG-048, found trying to log out at the end of the run: #server-stale-banner is fixed at top 0, 30px tall, z-index 9000, with no dismiss control, and #logout-btn spans 16-36, so elementFromPoint at the button's own centre returns the banner and clicking does nothing. A sweep of the header found exactly one control blocked and it is that one. It matters more than its size because the standing rule here is to log out whenever play stops, since the clock does not -- and the banner appears precisely when the repo has been edited, which in a development session is most of the time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Region Files argues its build order from a measurement, and the measurement was taken before the GM prompt was split for caching. Re-running it against the split changes where the saving is, so §03 now carries a callout saying so, and the phase is marked parked rather than outstanding. The §02 figures still hold — a 143,034-character fixed contract against a 1,095-character World Map at 14 rooms, and about 78 characters a room. What is different is that the prompt now has a cached half carrying a cache_control breakpoint and a live half re-read every turn, and the split does not fall where this document assumed. The World Map is in the CACHED half. Scoping it therefore saves tokens billed at a tenth of the input rate: 0.8% of that half at 14 rooms, 3% at 100, 22% at 500. Whatever motivates P1, it is not that. The live half is another matter, and this is the part worth carrying forward. It measures 17,622 characters, of which 5,960 is Quest Threads and 1,112 is Lore Hooks — world-scale content that grows with the world and is billed at full price on every single turn. That is where scoping would actually pay, and it is the opposite end of the prompt from the one §03 proposes to scope. It also bears on Q5, which weighs keeping quests global at "cheap: 7 K characters today" — cheap measured against the wrong half. And half of P1 turns out to be already done, by a tighter rule than it asked for: "filter the being dossiers to the player's region" has nothing left to do, because there is no being roster in the cached half at all and what the live half carries is the current ROOM's occupants. None of this touches the case for modular regions, which never rested on token economics — authoring at scale, hand-off, review, a world larger than one author holds in their head, content that exists without being in the GM's view. Only the claim that scoping the prompt is the cheapest route to the stated goal has moved. The README row and the status index carry the same correction, and both now also record what a reader of this document would otherwise assume from P2 and P3 being shipped: there is no UNLOAD at all. A region file goes in through mergeWorldChunk and becomes part of the world permanently; the exported borders are ignored on the way in, there is no requires gate, and nothing anywhere removes a region's rooms from a live world.
BUG-041's second attempt, and both halves follow the shape that actually worked for BUG-045: put the instruction where the data is read. Rule 10a's "grant by ref verbatim" achieved nothing until the dossier showed the ref, and rule 10's unlock imperative sits roughly thirty thousand tokens above the beats it governs -- further away since the quest dossier moved to the end of the live half yesterday. A short reminder now runs immediately above the beat list, carrying both halves: unlock a literally satisfied trigger this turn, and if you withhold one anyway you MUST set nearMiss. It says why it duplicates rule 10, so a later reader does not delete it as redundant, and it keeps the lore-hook distinction so it cannot be read as "stop being careful" and undo BUG-028. The second half is the one that matters and the ordering is now explicit in the text: the unlock timing is tolerable latitude, silent withholding is not. A player who repeats an action they have already completed, with nothing on screen saying a reward is pending, has no way to learn otherwise. The design doc gains what the runs actually showed, because this is as much an authoring property as a prompt one. Every trigger whose completion was objectively checkable fired on the turn it was met -- arrival, a stated search, or an NPC clause the Game Master writes and can see it has written, which is what Hesk's trigger has by accident. The one that failed was the one needing a judgement of sufficiency: "Speak with Mira about the changed rhythm of the bells", asked and answered exactly, unlocking nothing and flagging nothing. Claude14 failed identically on "interact with the Anchor Saint" for four turns. So the guidance is to end a trigger on something the narration must contain for it to be true, with a table of three rewrites, and it is marked as guidance rather than a constraint the engine enforces. No engine heuristic, deliberately: detecting this means judging whether a conversation was sufficiently about something, which puts two authorities over one judgement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Developing the built-in world in the app — the GM request box, the editors — had no way home. The edits
lived in a browser save and WORLD_DATA went on saying what it always said. tools/adopt-world.js takes a
Tools › Export World file, or a vault pack zip, and writes it back into text_adventure.html.
The gates exist because Export World is not a clean room, which the smoke test proved rather than
assumed. serializeWorld emits `s.rooms` — the LIVE Room objects — and play mutates them in place. Pick
one item up, export, and it is simply not in the file: measured on the built-in world, 2 of 14 rooms
came back marked visited and a room came back with one fewer item, with nothing anywhere saying so. The
REMOVAL gate diffs the incoming world against the one in the app and refuses when anything has gone;
additions and edits pass unremarked. The MEDIA gate refuses what the deployed site could not serve.
Four things the round-trip taught, each of which changed the tool:
The media policy I wrote first was wrong. It refused provider URLs, and the shipped built-in world
carries 22 of them — so it refused the world that is live right now. data: URIs and /vault/media/ paths
are refused, because one balloons a 127 KB literal and the other resolves only against a vault; provider
URLs are counted and their hosts named, with a NEW host called out, because a CDN link is the one
reference here that rots with nothing changing.
Placements are refs, and the export expands them. A room is authored as {"ref":"coin_purse"} — 25 bytes
that follow the catalogue — and comes back as the whole item. On a clean export 11 of 24 placements
expanded and the world grew 2.3x. The tool folds an instance back to a ref when it still matches its
template, and leaves it inline when it differs, which is an edit worth keeping. It never folds a name two
templates share: the built-in world has town_guard_1 and town_guard_2 both displaying "Town Guard", and
picking one would move a guard between templates.
`stats` and `inventory` are derived, not authored. An instantiated being has NO stats — the engine
flattens the block onto it — and its inventory comes back normalised. Compared literally, all eleven
beings looked edited and nothing folded at all.
Key order matters more than it sounds. A faithful adoption still rewrote every object in the literal,
because the engine returns keys in its own order, and five tests that pin the literal's TEXT went red on
a round-trip that had changed no data. Emitting in the order the file already uses cut that to three and
keeps the diff to what actually changed, which is the only way a retrofit is reviewable.
The 41 comment lines threaded through WORLD_DATA are kept: each block is captured with the key it
introduces and put back in front of it, and a block whose key has gone is reported rather than dropped.
Seventeen sabotages, all caught by name. tests/test_adopt_world.js also carries a standing guard — the
world that ships must keep passing the media gate — so no future retrofit can put art in the file that
the deployed site cannot serve.
NOT DONE, and worth knowing before anyone runs this in anger: an adopted world is about three times the
lines, because serializeWorld writes every optional field where the authored literal is sparse, and
three tests (test_class_equip_slots, test_item_magic_flag, test_item_subtypes_directives) pin the
literal's source text across distance windows that the growth breaks. Their data is identical either
way. Both are jobs for the first real adoption, not for this commit.The label had grown to carry the whole answer — "Upload a world file or vault pack" — with a sentence beside it carrying the rest, because there are three formats to explain and the third is the one an operator gets wrong. That is more than a label can hold without becoming the paragraph it is sitting next to. The button is a verb now, and the explanation is there when it is asked for. The tooltip is CSS only, in the page's own palette, and it opens on :focus-within as well as :hover so it is reachable from a keyboard and not only from a mouse. aria-describedby ties it to the button for anyone who hears the page rather than sees it, and there is deliberately no title attribute beside it: the browser would draw its own tooltip over this one, so the page would answer twice and disagree once. pointer-events:none, because it hangs over the worlds table and must not swallow a click meant for a row. Ten sabotages, all caught by name, and one of them found an assertion of mine that was measuring the wrong thing. "The tooltip names the vault pack" matched the SIGNPOST — the tooltip cites Tools › Download Vault Pack as where to get one — so it passed with the format itself deleted from the list of what is accepted. The menu path is removed before the question is asked now, which is the same move as blanking comments before counting a name in source.
A vault pack was a filesystem drop-in and nothing else: you unzipped it into the vault's data directory. An admin holding only the web UI could download one from Tools › Download Vault Pack and had nowhere to put it. The Worlds tab's Upload button now takes either a world JSON file, as before, or a vault pack. WHAT MAKES THE ROUTE SMALL is the property the pack was built around. Every media reference in a pack's world.json is already /vault/media/<shard>/<sha><ext>, and MediaStore.put() names what it stores by the sha256 of the bytes — so re-ingesting the pack's files mints exactly the URLs the world already carries. The world is stored verbatim, nothing is rewritten, and a file this vault already holds dedupes to nothing. That is asserted per file rather than assumed: the store's answer is compared against the path the pack claimed, and a file whose name disagrees with its own bytes is refused with the hash it really has. Without that check the bytes land correctly and the world's reference — which names the other hash — points at nothing, so the upload says "ok" and every picture 404s. A MEDIA PACK IS REFUSED, by name, because it is the likeliest wrong file to hand this button. Its art is named by a hash of the URL rather than of the content and its world.json points at media/…, a path a vault does not serve; accepting it would mean re-hashing every file and rewriting every reference, which is undoing the thing that pack exists to do. The refusal says which pack it got and which one to use. server/zip-read.js is the counterpart to the writer in the app: zero dependencies, since zlib supplies both inflateRawSync and crc32. It reads the CENTRAL DIRECTORY rather than scanning local headers, which is the tempting shortcut and is wrong on any archive with a data descriptor, where the local header's sizes are zero. It inflates as well as stores, because an operator who opens a pack, edits it and lets their OS re-zip it gets deflate. And it CRC-checks every entry: this is an upload endpoint, and bytes that do not match their own checksum must be refused here rather than stored under a content hash that then disagrees with the world referencing them. The route mounts its raw parser after requireAdmin, for the same reason the JSON route mounts its own: a pack is the largest thing anyone uploads, and a stranger's rejected attempt should not cost the transfer. server/test/test_world_pack_upload.js drives the real HTTP server and judges the result with the real MediaStore — does the world appear, and does every reference in it resolve to a file that hashes to its own name. Its fixtures are built by a zip writer of the test's own, not by the app's, because a reader checked against the writer that produced its input proves only that the two agree. Fifteen sabotages across the route, the reader and the page, all caught by name. Four of them found real gaps rather than confirming coverage: nothing exercised a mislabelled media entry, nothing distinguished "ignored" from "refused" for the README and name card that ride along in every pack, the non-zip refusal was asserted loosely enough that removing the magic-number guard still passed, and the client's PK-sniff routing was not checked at all. One earlier sabotage had already found a real bug — the stored URL was compared against '/' + name when the pack's entries are relative to the data directory and the store's URLs are not, so every correctly stored file was counted as refused while its bytes were already safely on disk. test_admin_page's file-input assertion pinned the exact accept string. It asserts the SET now — a world JSON file or a vault pack zip, and nothing wider — which is what it was always about.
Regenerated from a full (non-shallow) checkout so the day-by-day tally reflects every commit through 2026-08-20, not a partial slice of history.
BUG-044, BUG-045 and BUG-046 are confirmed fixed from inside the game rather than from a harness, all three in one sequence around the Saltmonger's ledger. The arch-verse beat was walked deliberately -- scrape the rime, read the Cantos-script, follow it down to the settling-pool -- and the ledger was in the pack afterwards, with the branch sibling correctly foreclosed and reportUndeliveredBeatRewards silent because there was nothing to report. That is BUG-044. The item delivered was salt_broker_ledger, the declared ref, matching the catalogue exactly with loreXp 22 carried through and no duplicate minted beside it; against the original failure where the twin carried no loreXp and paid 12 where 50 was owed. That is BUG-045, and the dossier fix is what made it possible -- the model had an identifier to quote instead of a name to slugify, which rule 10a's wording could never have achieved alone. Handing the ledger to Hesk, the Game Master reached for giveItem unprompted, applyGiveItem logged the gift, the ledger left the pack and arrived in Hesk's inventory, and he paid the full twenty gold. That is BUG-046, and it was never the model forgetting a directive -- there was no directive, and supplying one was the fix. BUG-041 stays open on a measured result. Four beats were approached by doing the bare literal thing the trigger names and nothing more, which the Claude15 run could not test because it wrapped every action in enough reasoning to satisfy a comprehension standard by accident. Three fired on the turn their trigger was met. The Calling did not: the trigger asks the player to speak with Mira about the changed rhythm of the bells, the player asked exactly that and Mira answered exactly that, and questUpdate came back null. Three of four beats the rule change, against the five turns this was filed over, so it is doing something. It is not doing what it says, and the second breach on that turn is worse than the first -- rule 10 now says a GM that withholds a satisfied trigger MUST set nearMiss, and none was set, so the player paid a blind toll with the one mechanism built to prevent that sitting unused. Two open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two findings from asking where the quest dossier sits in the restructured prompt, and the second explains BUG-045 completely. questSummary rendered a beat's reward as its display name alone: it took the declared ref, looked the item up, printed def.name and dropped the ref. So a beat declaring coral_reliquary showed the Game Master "Coral-and-Bone Reliquary" and no identifier whatever, and when it granted the reward it built a ref out of the only string it had been given. Minting coral_and_bone_reliquary was not carelessness, it was the only move available -- and it meant the rule added this morning, saying to put THAT reference verbatim in addItem, was asking the model to quote something it had never been shown. The line now reads "Salt-Broker's Ledger (ref: salt_broker_ledger) -- Grant the moment the beat unlocks.", carries the verbatim-ref instruction beside the data rather than only in a rule thirty thousand tokens away, and marks a ref that resolves to nothing as NOT IN THE ITEM CATALOG instead of printing it as if it were a name. Sixth appearance of name-as-identity here, after BUG-011, 012, 013, 016 and 018; the transferItem spec has always said to quote the ref exactly as the dossier gave it, which only ever meant anything if the dossier gave it. The dossier also sat in the CACHED half while carrying every beat's [LOCKED] / [UNLOCKED at <in-world date>] status, so each unlock rewrote the ~36k-token prefix and every one of those turns billed 1.25x instead of 0.1x. It now lives at the end of the live half, which fixes the invalidation and puts the quest data closest to the player's action rather than deepest in the prefix. Rule 10 no longer says the section is "above", because it is not. test_prompt_cache.js failed on the previous commit and was pushed red deliberately to record the finding; this closes it. Its mutation battery had no quest case at all, which is why a leak the file exists to catch went unseen -- nothing in it had ever unlocked a beat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The draft settles BUG-044 against the entry that describes it. The beat that recovers the ledger, beat_read_the_arch_verse, declares its reward properly and always did: ref salt_broker_ledger, quantity 1, and a GM-facing note reading "Grant the moment the beat unlocks." So "nothing in the quest, the beat, or the item tells it to" is false, and "placed nowhere" was the wrong question -- a declared beat reward does not need to be placed anywhere, which is what declaring it is for. The Game Master had a ref, a quantity and an instruction, and narrated the handover twice with empty stateChanges regardless. That moves the entry out of authoring and into the contract, beside BUG-045 and BUG-046, as the same failure: the model holds the directive, has been told which one, and writes prose instead. No world edit is wanted, and specifically the ledger must NOT also be given to a being, because a declared reward plus a carried copy invites addItem and transferItem in one turn and hands the player two. The remedy it needs already shipped today for its siblings: its trigger has the player recover the ledger from the settling-pool, which is rule 10a's new third case verbatim, its reward is declared by ref, which 10a now says is granted verbatim, and reportUndeliveredBeatRewards matches that ref so a repeat is logged rather than silent. Separately, and found while checking where the quest dossier sits in the restructured prompt: world.questSummary() renders every beat as [LOCKED] or [UNLOCKED at <in-world date>] and the whole block is interpolated into the CACHED half of the system prompt. Every beat unlock therefore rewrites the ~36k-token prefix. test_prompt_cache.js is built to catch exactly this and its mutation battery had no quest case at all, so nothing ever unlocked a beat and the leak went unseen. The battery now has one, and it fails as it should, naming the first byte that moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The addendum written earlier today said salt_broker_ledger exists in the catalogue and is placed nowhere, in no room and no being's inventory, and is not listed as a reward on the beat that recovers it. That was asserted without checking, and the only world file readable from disk contradicts the placement half: in server/data/worlds/e14pf53/world.json the ledger is carried by Ys the Broker in tide_cult_shrine, value 15. An item in a placed being's hands is reachable, since transferItem can move it. The same file also shows why neither version of the claim can stand as written. It is the published library copy, dated 2026-08-08, and it carries quests: 1 -- it predates the Saltmonger thread and does not contain it at all. The working draft lives client-side and is not readable from here, so the state of the ledger in the world actually being played is simply unknown from this vantage point. The entry now says that, and says what is genuinely established instead: the thread ran to completion in the Claude14 run and the object never reached the pack. Whether the remedy is a placement, a beat reward, or neither is an authoring question to be answered against the live draft. Written as a withdrawal rather than a quiet edit because a ledger entry is read as evidence, and this is the third time in this session a conclusion has been drawn from a subset of the data locations. The pattern is worth leaving visible where the next reader will meet it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Two evaluator defects, and each turned out to be wider than its entry said.
BUG-018 was filed as a placement matched by the wrong key. It was not matched at all: the walk opened with
`if (it.name) out.push(it)`, so an entry authored as { ref, quantity } was dropped before the matching it
blamed was ever reached, on room floors and inside containers exactly as in NPC inventories. It now resolves
through the item catalogue with the same expression the class-kit walk twelve lines below was already using.
Resolution makes a copy rather than writing to the world, since this pass grades data it must not edit, and
that introduced the one trap worth recording: the call sites decide whether an item is nested by object
identity against the top-level entry, so a naive copy makes a top-level ref-only item compare unequal to
itself and get recorded as sitting inside itself. The copy carries __src and isTopOf reads it.
BUG-027 asked for the level-ceiling gate to be traded for a category-uniformity trigger, and that is done --
reported per GROUP rather than per kind, because flora are items and a kind-level test averages them away
among items and says nothing. But its second example could never have been reported however the gate was
set. addHooks was called for items, beings and rooms only, so a race was not a lore hook as far as this pass
was concerned, while rule 13d names place, being, faction and race and they pay lore XP in play. Races and
factions are counted now, and an unpriced Verengrad save reports four uniformity warnings including "every
race lore hook in this world is unpriced -- all 3 of them", which is the example this entry has been
pointing at unheard.
The uniformity fixture is deliberately sized so the OLD ceiling gate stays shut. A smaller world trips the
gate, the assertion passes on the wrong finding, and the case that was actually reported goes untested. One
existing assertion in test_xp_budget.js also began measuring the wrong finding the moment a second xp-map
kind existed, and now selects by severity.
Five open, down from ten at the start of the session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvBUG-034 cannot be fixed by looking at it. The cause is still unknown, both earlier hypotheses were eliminated on evidence rather than argument, and two more full runs have passed without a recurrence. The entry has always said it closes on a clean stretch of play, and that stands. What was worth doing was confirming the watchdog survived a long run of merges, and then asking what it does not cover. All five of its counts were world-side loreUnlocked flags, which is where the reversion was seen. The compendium -- the player's own record of what they have discovered -- had no watch whatever, so the same class of loss landing there would have been exactly as silent, with nothing on screen to say anything had gone. It is now counted as one total across categories, because the question is never which category a discovery filed under, only whether the count falls. Quest beats were considered and left out, and the reason is recorded in the code so the gap cannot read as an oversight and be helpfully closed later. Beat unlocked is monotonic in play, since foreclosing a branch sets foreclosed and never clears unlocked, but beats are rebuilt through their constructor on a world restore, which defaults unlocked to false before the restore reapplies it. A tick inside that window would report a loss that never happened, and a watchdog that cries wolf gets ignored, which costs more than the coverage would gain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-041 read as a beat that would not fire and it was really BUG-028 one level up. The Anchor Saint's trigger asks for presence and interaction -- reach the Hush-Choir Gallery, interact with her -- and the Claude14 run did exactly that four turns running while questUpdate came back null every time, with the Game Master awarding a world-lore entry off the very same exchange it declined the beat for. It fired on the fifth, on a turn whose stated action was picking up a plant, because the prose happened to arrive at a moment of understanding. The authored condition was met on turn one; an unstated one, comprehension, fired the instant it was met. Rule 10 said to unlock "only when its trigger is genuinely satisfied", and genuinely is the crack a richer standard climbs through. It now says a literally satisfied trigger is unlocked, and draws the distinction the conflation was hiding: a hook rewards comprehension and withholding one is right, which is what 13b and 13d ask for, while a beat is the quest's own bookkeeping and its trigger is all of its condition. A GM that still declines must set nearMiss, so declining stops being free. A contract line rather than an engine check, deliberately. Grading the model's judgement from the engine would put two authorities on one decision and have them fight each other, which is the reasoning that settled BUG-028 and it holds here unchanged. The entry also claimed neither remedy had been built. One had: 13e's nearMiss already takes kind "beat" and names a quest beat's trigger outright, so the visible half has existed since BUG-028 was answered. That correction is recorded in the entry rather than quietly dropped. It stays open until a run under this rule confirms it -- and the Claude15 run, where the same beat fired on the first interaction, is noted as a data point explicitly NOT credited to the fix, since it predates the rule and the nudge never fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Both were confirmed from inside the game rather than from a harness, so both are marked fixed and the evidence is written into the entries rather than left in a report nobody will open next time. BUG-017's title is that the roll is thrown away, and it no longer is. The Game Master asked for a roll in prose with no request set, the relay sent the 17 through the ordinary input path, and that number resolved the check at 22 and ended the Bell-Warden encounter for 560 XP. The half-filled shape found in the same fight is BUG-047 and was fixed separately. The underlying contract break is deliberately still counted -- the relay writes an error line saying the roll was rescued and that this still means the contract was not honoured -- so that a mitigation cannot quietly become the permanent state. BUG-028's remedy was never to have the engine grade the hook; that would put a heuristic over the prose and have the two systems fighting each other, and the Game Master's stricter standard is better play than paying out for a mechanical act. The silence was the defect. The run walked the exact Flooded Transept case the entry is written from, the GM withheld and said why, the nudge followed, and the player articulated the meaning and collected the hook plus a hidden second one. Twice across the run, once per subject, no repeats. BUG-044 stays open and its entry now separates the two halves, because the delivery half is confirmed and the half that remains is not code. salt_broker_ledger is in the catalogue and placed nowhere -- no room, no being -- and is not listed as a reward on the beat that recovers it, so the acquisition still rests on the model choosing a directive unprompted. That is an authoring decision and it needs the world edited, not the engine. Seven open, down from ten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
BUG-046 looked like the Game Master forgetting a directive and it was not. The directive list held transferItem for being to player, dropItem for pack to floor, and sellItem for a priced trade, and nothing at all for a gift, a tribute, a thing handed back or entrusted. So a narration reading "Mira takes it in both hands" was not a lapse; there was no field that could express it, and the model did the only thing left and said the act happened. That is this repo's founding failure in its purest form, except that here not even the refusal existed. It cost the terminal beat of The Last Descent: quest complete, 180 XP paid, the recipient moved to devoted, the reliquary still in the pack and her hands empty, and no later turn in which to notice. giveItem is the exact inverse of transferItem, deliberately down to the matching rules -- ref first because two objects can share a display name, an unambiguous name as the fallback, whole-stack moves that pass the real object so a container keeps its contents, split copies otherwise, and stacking that refuses to merge containers. A miss is loud and lists what the player actually holds, because the narration has already claimed the handover and a contradiction on screen is worse than a discrepancy the DM can grep for. The field spec rules out the two wrong answers explicitly, since dropItem leaves a gift on the floor and sellItem invents a price. The leaning first recorded for this bug was a check mirroring BUG-044's, and it was wrong. That check works because a beat DECLARES its reward; this beat declares no handover, and keeping the reliquary was a legitimate ending, so state alone cannot separate a defect from a valid choice. Only the prose disagreed, and detecting that would mean an engine heuristic laid over the narration with the two systems fighting each other. The correction is left visible in the ledger rather than quietly replaced. Rule 10a also now says a declared beat reward is granted by its catalog reference verbatim, and forbids building a ref by slugifying a display name -- the move that produced BUG-045's decoy. Both bugs stay OPEN. The capability and the rule are in and tested, but which directive the model reaches for is prompt-side, and a refusal that only lives in the prompt is a request. They close when a run confirms them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
setCombatAwaiting defaults mode to 'action' whenever the Game Master omits it, so a request can arrive with a die set, a prompt asking the player in plain words to roll, and a mode saying the opposite. That is what the Bell-Warden turn hit: the bar read "Awaiting your Verse-Singing roll.", the player rolled, and because combat.awaiting was truthy the die went to remindCombatInput, which read the mode and answered that the Game Master was waiting for a typed move. Two lines of one interface, disagreeing, with the d20 lost between them. awaitingAsksForRoll now names that shape and reportOrphanRoll treats it the way it already treats nothing-pending, so the number reaches the Game Master instead of vanishing. setCombatAwaiting opens the dice bag for such a request and logs it as malformed where it is constructed, because a player who reads the bar and types instead never reaches the relay and nothing else would ever say the contract was broken. The record is deliberately not rewritten -- correcting the mode in silence would change what was asked for rather than report that it was asked for badly -- and the DM log and the GM note both say the mode was the mistake instead of repeating BUG-017's "you set no request", which is false here and teaches the wrong lesson. The narrowness is the part worth keeping. Only 'action' records are considered, so a well-formed request still reaches the engine's own handler and the relay cannot swallow a roll that was about to resolve properly; and the match is on shapes that ask for a die rather than on the bare word, so an action prompt can still say "you could roll aside from the blast" without being reinterpreted. Both directions are sabotaged in the new test. Two existing tests failed against this and both were right to. test_orphan_roll.js lifts reportOrphanRoll into a sandbox and needed the new helper lifted beside it. test_dice_narrow.js pinned the literal source `mode !== 'action') openDicePopup()`, which legitimately changed shape; its claim -- a request wanting a die surfaces the bag -- is unchanged and is now also driven rather than regexed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Last Descent completed 6/6 with all twelve rooms seen, the first run to reach every one of them. The report is filed beside the others and indexed in the evaluations README. The reason this run was worth playing through the interface rather than the harness is what it settled. Four repairs made since Claude14 were exercised where a player would meet them, and three held. The near-miss nudge fired on the Flooded Transept silt, which is the worked example BUG-028 was written from, with the Game Master correctly refusing the unearned hook and saying plainly that something was still unsaid. The BUG-017 relay rescued the Bell-Warden fight outright, sending a stranded 17 that resolved the check and paid 560 XP where the encounter would otherwise have sat unresolvable. The bell-bronze psalter was the active field book for the whole run, and rule 10a's third case delivered an object the player pulled out of a niche herself. The fourth had a gap, and the same fight found it: a request carrying die 20 and a prompt asking for a roll while mode said 'action', which the relay declines to touch because something is technically awaiting. That is BUG-047. Two more came out of the ending -- BUG-045, a reward delivered as a duplicate ref sharing the authored item's display name, and BUG-046, the quest completing on a handover that left the object in the pack. Both are already filed; this commit only records the run they came from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Bell-Warden fight produced the case the BUG-017 relay does not reach. The Game Master set a combat
request with die 20 and the prompt "Awaiting your Verse-Singing roll." and left mode at 'action'. The status
bar asked for the roll, the player rolled, and reportOrphanRoll saw combat.awaiting was truthy, deferred to
remindCombatInput, which read the mode and answered that the Game Master was waiting for an action. Two lines
of the same interface told the player to roll and not to roll, and the d20 went nowhere.
Recorded as BUG-047. The relay is guarded by `if (combat.awaiting) { remindCombatInput(); return; }`, which
is correct for what it was written against -- the GM asking in prose with nothing set at all -- and leaves a
request that exists but is malformed falling through the very gap it was meant to close. The same fight
showed both halves cleanly: two rounds later the GM asked in prose with nothing set, the relay fired as
designed, sent the 17, and resolved the encounter without a blow being struck. Null is covered, half-filled
is not.
The leaning is that a record carrying die 20 and a prompt asking for a roll is not an action request whatever
mode claims, and the engine holds both fields already. Relaying the die there costs nothing on a
well-formed record, and the reminder should never contradict the prompt displayed beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9QvClaude15 finished the Verengrad run. The terminal beat asks the player to bring the reliquary to the Scaffold Landing and decide its fate; the player gave it to Mira the Anchorite, the Game Master answered "Mira takes it in both hands", and the beat unlocked, paid 180 XP, moved her reputation to devoted and printed QUEST COMPLETE. The reliquary is still in the player's inventory at quantity one and Mira's inventory is empty. No transferItem was ever issued. Recorded as BUG-046. It is the same narrated-but-not-applied failure as BUG-021, BUG-017, the sell side and BUG-044, and the reason it earns its own entry is where it sits rather than what it is. The earlier ones cost something mid-run, where a later turn can still put it right. This one is terminal: the quest closes, the reward pays, and no subsequent turn exists in which the state could be repaired. The player walks away from a finished story holding the object they were told they handed over, and it is the BUG-045 duplicate they are holding, so the authored coral_reliquary was never granted, never transferred, and never reached the character the beat names. The leaning recorded is the counterpart of the check that caught BUG-044. That one asks whether a declared reward arrived; the missing one asks whether a beat reading as a handover left the item where it was. Report it rather than moving the object, for the same reason the unlock site gives for not granting rewards directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
Checking the item catalogue rather than trusting the first reading changed what BUG-045 is. The claim as first written was that the Game Master minted a ref existing nowhere in the world. It did not. The world holds coral_reliquary named "Coral-and-Bone Reliquary", and the following beat refers to it in prose as "the coral-and-bone reliquary"; the model slugified that name into coral_and_bone_reliquary and granted the duplicate. Two refs, one display name. That makes the defect worse rather than milder, and it is the reason it survived a whole chancel sequence unnoticed. A substitution under a different name would show itself the moment the player opened their pack. This one cannot: the inventory line, the narration and the beat text all render identically whichever object is held. The cost is only visible in the numbers, where the authored entry carries loreXp 50 and the duplicate carries none, so the run was paid twelve where it was owed fifty, and the final beat asks for the reliquary to be carried to the Scaffold Landing while the player holds something else. Corrected in place rather than appended, because a ledger entry is read as evidence and the wrong mechanism would send the next reader looking for an invented object instead of a duplicated name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The Claude15 run reached the Chancel and lifted the Ninth Canticle out of its niche. The beat unlocked, an item landed in the pack, and everything on screen was correct. The beat declares one reward and declares it by reference alone -- coral_reliquary -- and what arrived was coral_and_bone_reliquary, a ref that exists nowhere in the world. The Game Master minted a new object with a convincing name rather than granting the authored one, so the player holds a reliquary but not the catalogue entry, and none of its value, lore or loreXp came with it. This is recorded as BUG-045 rather than folded into BUG-044 because the two fail at different points and conflating them would bury one. BUG-044 was a hole where an object should have been; a hole is at least visible. A substitution is not: nothing in the narration, the beat, or the inventory looks wrong, and the only reason this was caught at all is that reportUndeliveredBeatRewards matches on the declared ref and said so while a reliquary sat plainly in the pack. That check was written for BUG-044 and found a different defect on its first live run, which is the case for having written it. The same turn confirmed the other half. Rule 10a's third case -- the player recovers the reward themselves, so it goes to the pack and not into the room -- did what it was added to do, and the GM reached for addItem unprompted where the Claude14 run narrated the handover and applied nothing. The delivery path works. What is still wrong is the identity of the thing delivered, and the leaning recorded is a prompt rule that a declared reward is granted by its ref verbatim, since the engine granting it directly would undo the split the unlock site argues for deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pcLe6V8BVg2evkrEvk9Qv
The DM pointed out something no report had recorded: the game clock advances while a session is logged in, and fatigue accrues with it. A run that pauses to read code, file a bug or argue about a fix is still burning in-world hours, and the character quietly picks up an exhausted status and the CON penalty that comes with it. Nothing on screen connects the two — the next check just rolls worse. That is not a small distortion and it has already touched this run. Claude15 was reading CON 6 and 7 against a base of 8 during the Gill-Wretch fight and the climb out of the nave, and some of that gap is time spent with the tab open while nothing was being played. Any endurance figure from a run that did not log out is suspect, and a report quoting one without saying so is quoting the harness rather than the game. Written into the run guidance beside the fingerprint step, since it applies to every run and not just this one. It also flushes the save, which is the other reason to do it: logout is the one path that leaves live and stored state agreeing.
Answering "which beat do I need to add the ledger to": none. Both ledger_recovery branches already carry salt_broker_ledger, with their own notes — the arch-verse route recovers it intact and legible, the dredging route drags it up water-ruined. The world data was complete all along. I claimed otherwise in BUG-044, having inferred it from the object being placed nowhere without ever looking at beat.rewards. That is the third time this session I have concluded something from two of three data locations, and the correction is in the entry rather than only here. The real cause came from a control sitting in the same quest. Two unlocked beats each owed an item. The driftwood ward, which Hesk unhooks from his stall-post and presses into her hand, arrived — quantity 2 on top of the one in her starting kit, so addItem was set. The ledger, which she lifted out of a settling-pool herself, did not. Same quest, same turn shape, same model, opposite outcomes. Rule 10a offered exactly two roads: an NPC hands it over, so addItem; or it is found in a chest or on a body, so roomUpdates.addItem. A thing the player pulls out of the silt is neither, and the room remedy contradicts a narration that has just said she is holding it. The GM fell between the two cases and did nothing. That is a gap in the rule rather than a lapse in the model, and a fetch quest is this shape almost every time. The rule now names the third case: when the player recovers it themselves, it goes straight to inventory, explicitly not into the room. Kept open until a run confirms it — the pairing is deliberate, with the rule making delivery happen and reportUndeliveredBeatRewards there for when it still does not. 648 passing.
You remembered right: the rebinding transaction was built, and it works. It walks one rung, and Verengrad's chain has three. spellbookTeaches already walks rebindsFrom to the root, and its comment says why — a book's contents must be "independent of HOW it was acquired". consumeRebindSource looked one rung up and stopped, so the two halves of the same contract disagreed about what a chain is. That is invisible for exactly as long as every player climbs the ladder a step at a time, and Claude14 did. Claude15 did not: Ket sells the bell-bronze binding to anyone holding the base psalter and says so plainly, so a level-1 book was rebound straight into a level-3 one, the consume went looking for the whalehide rung that was never bought, and took nothing. spellbookRebindChain is the same walk the teaching side uses, cycle guard and all. The consume takes the nearest ancestor held, since the finer book is what a rebinder would cut apart, and logs a skipped rung as a skip rather than as an ordinary consume. The second half is activeFieldSpellbook, which took the first field book in the pack. That was fine while a player could only hold one and stopped being fine the moment they could hold two: twenty gold bought a level-3 psalter with five slots and she went on casting from the level-1 one with three. It takes the highest level now; an explicit activeBook flag still wins, and a tie keeps the earlier book so a spare copy cannot shuffle a loadout underneath anyone. For BUG-044 the engine deliberately still does not deliver items. The unlock site argues that split and argues it well — only the narration knows whether a thing is pressed into a hand or left in a chest — and that reasoning is about where a reward goes, not whether it arrives. reportUndeliveredBeatRewards closes only the silence: at unlock, after the state changes have landed, it names anything owed and absent, and tells the GM that narrating a handover does not move an object. It would NOT have caught the case that found it, which is the honest half — that beat lists no reward at all, so the world edit is still owed. Both sabotages in the rebinding test initially proved nothing. bookLevel is written on one line, my extractor sliced to the next brace at column 0, and it swallowed a second copy of activeFieldSpellbook whose unmodified definition won at runtime — so the assertions were passing against code they were not touching. The extractor handles one-liners now, which is worth more than either fix. 648 passing. Seven open.
BUG-044 answered by playing it out. Claude15 carried the ledger she did not have back to Hesk and handed it over; he opened it, saw dry ink, counted out the full twenty gold, pressed a driftwood ward on her as a gift, and the thread completed for 120 XP. The object was never in a room, a pack or a directive at any point. So it is not a soft-lock, and the player loses nothing — which is exactly why it would be easy to miss. What the world loses is its item model: a thing was recovered, carried, kept dry and delivered entirely in prose. Anything that reads inventory rather than narration is looking at a world where none of it happened. The quest is fine and the bookkeeping under it is fiction. Downgraded in severity and kept open on principle: the same shape with a beat that CHECKS for the item instead of trusting the GM to remember would be a hard lock, and nothing in the authoring prevents that next time. Also confirmed on the way: the branch design works and pays what it promises. The clever route — Read Fragment on the cloister arch, which is why that skill point was spent there — produced a ledger with the pages dry, and dry pages are worth the FULL twenty gold. Claude14 took the brute-force STR route, brought up a ruined ledger and was paid ten. Two runs, two branches, exactly the authored difference.
Opening a second menu left the first one on screen, overlapping it. The mechanism that should have prevented this was already present and could never fire: the document click-away handler closes any menu whose wrap does not contain the click, and a click on the Tools button is plainly outside the Library wrap — but every toggle begins with ev.stopPropagation(), so that handler never saw the click at all. The menus were mutually exclusive in theory and independent in practice, and nothing between the two said so. The fix is not sixteen calls. Four toggles closing three siblings each is twelve call sites that all have to be found again when a fifth menu appears, and there were already three hand-kept lists of these same menus: the click-away handler knew nine of them, Escape knew nine plus three popups, and the toggles knew none. They had drifted once already — logs-filter and items-facet were in two of the three. So there is one roster now, ONE_AT_A_TIME_MENUS, and three readers: each toggle sweeps it before opening, the click-away handler walks it instead of naming nine wraps by hand, and Escape sweeps it with no exception. A new drop-down is one row. story-book-control is deliberately NOT in it, and the comment beside the roster says why: everything in the list is a plain show/hide, which is what makes sweeping it free, while the story-book menu attaches a document listener on open and detaches it on close. Adding it made eight unrelated suites throw, because closing it is a lifecycle operation rather than a style change — a bigger change than it looks, and one to make deliberately rather than in passing. tests/test_menu_exclusive.js reproduces the report and then checks all twelve ordered pairs, because "closes the others" is not the same claim as "closes the last one". Its final section is the guard that matters: it reads the MARKUP for anything shaped like one of these drop-downs — a -wrap id holding a button with aria-haspopup and a role="menu" — and fails when the roster has no row for it, so a fifth menu cannot reintroduce the overlap silently. It found six. Eleven sabotages, all caught by name, and one of them found a bad assertion of mine: "closeOtherMenus leaves the named wrap alone" swept and then opened, so it passed with the skip deleted outright. It opens and then sweeps now. Six existing tests changed, all of the same kind. Five pinned the hand-written `if (wrap && !wrap.contains(e.target)) closeX()` line verbatim, or the exact pair of calls Escape made; they assert roster membership and the two loops now, which is the property they were always about. The sixth was a character-distance pin — toggleExportMenu's open was matched within 320 characters of its name, and the line closing the siblings pushed it past the bound.
Claude15 recovered the Saltmonger's tally-ledger with the clever branch — Read Fragment on the cloister arch, 40 XP, and the pages dry where Claude14's brute-force route ruined them. The narration said she lifted it from the silt. It was not in her pack. Asked again in plain words, the GM said she wrapped it in oilcloth and stowed it deep, and stateChanges came back empty both times. The reason it cannot work is the interesting part. salt_broker_ledger exists in the item catalogue and is placed nowhere at all — no room, no being's inventory — so there is nothing for pickupItem or transferItem to move, and the only route into the pack is the GM minting it with addItems unprompted. Nothing in the quest, the beat or the item asks it to. The acquisition rests entirely on the model reaching for a directive on its own, and across two deliberate attempts it reached for prose. This is the narrated-but-not-applied family again, but with a difference worth recording: BUG-021 and BUG-017 were capabilities the GM lacked or forgot, while here the capability exists and the world gives it no reason to use it. That makes it as much an authoring defect as a contract one. Severity is left open on purpose. Whether the thread can still finish depends on whether the GM will narrate handing back a ledger the player does not hold. If it does, the quest pays out on fiction with no object ever existing; if it refuses, the thread is soft-locked at a twenty-gold bounty in a world where the entire book ladder costs forty. The next visit to Hesk decides it and the entry gets the answer. Recorded fix either way: a beat should be able to grant its own object — the quest schema already has rewards with an item ref and this beat carries none — or the ledger should be placed in the settling-pool so there is something real to lift. Eight open. 646 passing.
Claude15 did the thing this entry has always waited for. Started at level 1 with the authored 25-gold purse, foraged and sold three plants at their repriced values, and bought the Psalter Bound in Bell-Bronze for twenty gold — charged exactly, 26g 5s 5c down to 6g 5s 5c — at level 3, six rooms in, two beats done, no deaths. The entry said it closes on a fresh run reaching book three and not on the edits, so it closes. What made it possible was finding the stale placement underneath the whole bug rather than any of the dials. Ket's copy of the psalter carried 3200 against a catalogue 2000, so the ladder really cost 52 gold all along, exactly as first filed, while every re-measurement read the catalogue and reported 40. The proof that the Game Master was innocent is worth keeping. With 3200 in the dossier it said "thirty-two gold", twice, in two runs. With 2000 in the dossier it said "Twenty gold" — and reconciled the change in character without being asked: "I quoted you high before, thinking of a commission I've since lost. Twenty is the work." I had called that invention in three separate places. BUG-043 filed from the same purchase. The rebinding left the OLD psalter in the pack, and activeFieldSpellbook returns the first field book carried, so she was casting from the level-1 book with three slots while holding the level-3 one with five that she had just paid a third of the world's realisable wealth for. Nothing said so, and nothing can switch them — the flag exists and the comment beside it says switching is "a later nicety", which was true while a player could only hold one. Recoverable only because I happened to sell the old book, which promoted the new one instantly. Cheapest fix is one line: pick the highest-level field book rather than the first. Declared in the entry: the running save carried its own world snapshot and was stale by exactly one field after the publish, so the harness re-synced that single value. Measured rather than assumed — a full diff showed three differences, two of them my own play shifting item indices. Seven open. 646 passing.
Found in play, on Claude15's first visit to the market. Ket Drybound's copy of the Psalter Bound in Bell-Bronze carried its own value of 3200 against a catalogue 2000 — the same stale-placement mechanism as the flora, sitting on the one purchase this bug has always been about. The ladder has therefore cost 8 + 12 + 32 = 52 gold all along, which is exactly the figure BUG-035 was filed with. Every re-measurement since, mine included, read the catalogue and confidently reported 40. The part I have to own is the correction. I wrote in BUG-042 that prices quoted in dialogue are invented, citing Ket asking "thirty-two gold" for a book worth 2000 copper, and I repeated that in the Claude14 report and in two commit messages. She was quoting what the engine told her: the room dossier hands the GM "worth 32g" because the instance overrode the catalogue. The GM read its dossier correctly, in two separate runs, and I called it invention both times. I had even checked the world for the string "32" and concluded it appeared nowhere — I searched the catalogue and never thought to search the placements, which is the same blind spot that hid the flora prices. What survives is narrower and still worth keeping. The 2200 actually charged matched neither the 3200 quoted nor the 2000 authored, so those two disagreed with each other as well. And the buy-side anchor I added prices from the CATALOGUE while the sell path prices from the INSTANCE — so the cap would have moved a 3200 charge to 2500, not to 2000. That asymmetry is a real design question rather than an error, and it is now the only part of that note still standing. Draft fixed: Ket's psalter to 2000, and a Wooden chest from 20c to a catalogue 0c. Verified by re-reading from storage rather than from the edited object — there are now zero instance/catalogue divergences left in the world. Publishing remains the DM's.
The page disagreed with itself in three places at once: its chip claimed 34 documents reviewed while its own footer said 33, and the folder held 37. Reconciling the arithmetic alone would have been the wrong fix, because the numbers were not wrong — they were counting a set that had three documents missing from it. Re-running rev. 5's own three-set comparison found them: currency, doors-and-barriers and world-packs were all in README.md and none of them on this page. The omission was hiding work rather than proposals, which is the part worth knowing. doors-and-barriers has P1, P2, P2.5, P3.5 and the picking half of P3 shipped, with one phase of six left. world-packs is shipped outright, both pack tools built and tested. And currency has P1 through P3 — a persisted model, the Editor tab with three test files of its own, and the payment gate that withholds the goods on a refusal — while its own chip still read "Proposed — not built" and its README cell still read "Proposed". That is the nineteenth marker in §06, found the same way the other eighteen were, and it is fixed here in both places rather than merely recorded. Every figure the page states is now reproducible from the page: six proposed, eighteen built in part, ten shipped with growth ideas only, three finished — thirty-seven, which is the number of .html files beside it. The chips, the section lead-ins, the §06 row count and the footer all say the same thing, and the check that says so counts the rows rather than trusting any of them. One note on how this went wrong once while being fixed: the first insert located the sections by searching for their titles as text, and "Built in part" appears in prose above the Proposed table, so all three rows landed in the wrong bucket while the total came out correct. A total that reconciles is not evidence the parts do. The rows are placed by <h2> boundary now, with an assertion that the tbody being written to actually falls inside the section it was found for.
Both edits are to the DRAFT. Publishing to the library is the DM's own action, and a new character is built from the library rather than the draft, so nothing here reaches a run until they publish. Seven placed plants re-synced to their catalogue values: Widow's-Thread Vine 35c to 400c, Verger's Bloodmoss 30 to 260, Drowned Saltbloom 40 to 180, Gasping Kelp 60 to 130, Bellwort Rime 6 to 75, Bell-Choked Weepmoss 6 to 45, and Hush-Moss the other way, 25 down to 8. The last one matters: this was a repricing pass that never reached the placements, not a deliberate discount, so the correction runs in both directions. My first pass found only five of the seven. I had written my own flora predicate — type 'flora' or a subtype matching /flora|plant|herb/ — and every plant in this world is type 'plant' with subtypes like ["fungus","lichen"] or ["moss"], so Bellwort Rime and Bell-Choked Weepmoss fell through while Hush-Moss was caught only because "silencing-flora" happens to contain the word. Redone with the engine's own isPlantType(), which is what the house rule about never hand-copying a roster is for, and it found all seven. The lesson is not that my regex was sloppy; it is that I wrote a second definition of flora at all. TideCantrix.startingPurse is now 2500 copper. It had no field, so it was falling back to the hardcoded 1500 — the field existed and this class did not use it. The world now holds 111 gold placed, about 55.5 realisable at the 0.5 rate, 80.5 with the purse against the 40 the ladder costs. Which is where BUG-035 stops being a question about whether the world CAN pay: it plainly can, twice over. What closes it is unchanged and is the only thing that ever could — a level-1 Cantrix reaching book three in play.
Asked whether the economics had already been rebalanced so book three was reachable, I checked instead of answering from memory, and the answer is yes on both counts with one thing underneath that neither of us had seen. The psalter is 2000 copper and the ladder is 40 gold: that landed. Foraged flora are sellable: that works too, and Hesk buys them without hesitation. The only correction to the entry is that Ket Drybound is authored as a rebinder of drowned books rather than a herbalist, so the flora-buying role sits with Hesk — which is fine, and is a correction to what I wrote rather than a defect. What did not land is that the repricing reached the CATALOGUE and not the PLACEMENTS. Nine placed items carry their own value, which overrides the catalogue's and is what the engine actually sells at, and every placed flora is overridden downward — Widow's-Thread Vine at 35c against a catalogue 400c, Bloodmoss 30c against 260c, Bellwort Rime 6c against 75c. The engine was faultless throughout. Claude14 sold the saltbloom for 20c and the kelp for 30c, exactly half of 40 and 60, the sell rate applied perfectly to the numbers it was handed. Which means my own earlier line in this entry — that the flora roster now runs 8 to 400 copper — describes the catalogue and overstates what a player can earn by roughly four times. Foraging the six placed plants is worth about eight silver, not the seven gold the catalogue implies. Hush-Moss is overridden upward, so this is placements never re-synced after a repricing pass rather than a deliberate discount. The ladder is still fundable and the arithmetic does not reopen: at the values the engine really uses the 34 placed items come to 102 gold, 51 realisable, 66 with the starting purse against 40 needed. The salvage carries it and the flora contribute almost nothing, which is the opposite of what the fix intended. Two things recorded while there. TideCantrix has no authored startingPurse and falls back to the hardcoded 15 gold. And no evaluator check reports a placed instance whose value diverges from its catalogue entry — which is exactly how a repricing pass can look complete and not be.
Brings the branch up to main at 99e8868: the two pack tools and their shared name card, the world-packs design doc, region-files' corrected status, and Step 0 of region-files — a room now belongs to a region by id rather than by name. text_adventure.html merged without conflict, which is not on its own evidence that it merged correctly: the branch's whole subject is that buildSystemPrompt is split into a cached `stable` half and a per-turn `live` half, and content arriving from main can land in the wrong one without anything failing to apply. test_prompt_cache is the guard for exactly that, and it still passes — including the assertion that the one thing rewriting the cached half is the conditional damage carve-out and "not something new that crept into the cached half". Step 0's changes to the prompt are label reads (roomRegionName in place of a raw field), so they add no per-turn line to either half. The one conflict was Designs/current-status.html, where both sides amended the same chip row for unrelated reasons: the branch added its own prompt-economics document to the reviewed count, and main moved region-files out of "proposed, nothing built" into "built in part" after finding P2 shipped and P3 in part. Neither supersedes the other, so both are kept. 646 app tests, the vault suite, and the item-taxonomy --check all pass on the merge.
Asked which bugs are open, I read the ledger and found every entry listed twice, BUG-039 missing
entirely, and BUG-035 claiming two different statuses. All of it was mine, from three separate careless
edits, and none of it was caught because I trusted my own scripts' print statements instead of reading
the file back.
The first: filing BUG-039, I built the entry from a template with %s placeholders and never applied the
format arguments, so it went in as id="%s" with <span class="n">%s</span> — an entry with no number, in a
document keyed by number. The script printed "filed BUG-039" from a separate line and I believed it.
The second: marking that entry fixed, s.find('id="bug-039"') returned -1 because of the first mistake,
and the unguarded arithmetic that followed evaluated to s[:-1] + tag + s[37:] — which concatenated the
entire document to itself. Every ledger edit for the rest of the session then ran against a file
containing two copies, editing the first and leaving the second stale and contradicting it.
The third is the one worth remembering, because it is a trap the file itself sets: `<div class="bug` also
matches `<div class="bug-id"`. Slicing an entry on that prefix finds the header div, not the entry, so
BUG-040's escalation was inserted before its own title instead of inside it — the entry opened with a
section written hours later and had no header at all.
Repaired by keeping the first copy (every post-duplication edit landed there, verified rather than
assumed), giving BUG-039 its number and fixed status, and reassembling BUG-040 with its sections in
order. There is now an audit in place of a guess: 42 entries, no duplicate ids, none missing between 001
and 042, div tags balanced 186/186, one document, no placeholders left, and every entry's class, visible
tag and number agreeing.
BUG-035 goes back to OPEN, which is the substantive correction rather than the cosmetic one. The
re-measurement showed the world can fund the book ladder and that got recorded as fixed, while the
entry's own closure rule says it closes on a fresh level-1 run reaching book three. The Claude14 run did
not do that: it reached 20g 5c legitimately, but the psalter it ended up holding arrived free through
BUG-042 in the very turn the payment was refused, and at the 22 gold actually asked that purse was short.
Seven open: 017, 018, 027, 028, 034, 035, 041.Room membership was a stored copy of the region's NAME. Renaming a region therefore orphaned every room in it unless one particular sweep in setRegionName remembered to rewrite them all, and the damage surfaced three fields away from the rename in three unrelated-looking places: the weather stream fell back to the world default, the editor's region filter came up empty, and a region file exported nothing. None of them said a rename had done it. Designs/region-files.html §09.1 calls this the thing to fix before anything else, because the region-scoped prompt it proposes filters the world map by exactly this field — and it had quietly got worse, because applyRegionFile retags every imported room, so shipping region files put the name-keyed path into routine use. A room now stores the region's id. normalizeRoomRegions migrates names on load, from the World constructor and again from rebuildWorldFromSnapshot, which bypasses it — the same trap races, sounds and weather each fell into on that path before. Both shapes are still tolerated on READ, and that is not indecision: a name that resolves to no region has to be kept rather than discarded, or opening a dropdown or importing a region file cut from another world would be the thing that erases an authored membership. Every comparison goes through roomRegionKey and every label through roomRegionName, so the two shapes are never compared by hand, and a rename now shows up everywhere at once because the name is resolved rather than copied. The change nearly shipped a name collision. The helper was written as setRoomRegion, which is already the Regions editor's dropdown handler — wired by name from an inline onchange, like ~780 other controls in this file. The later declaration wins, so the dropdown began calling the helper with a room object where it expected a room id: it loads fine, looks fine, and does nothing. The tests caught it; a browser would not have. It is assignRoomRegion now, and test_room_region_id asserts each of these eight names is declared exactly once, because that is the failure this file is built to make invisible. tests/test_room_region_id.js separates the two properties, because only one of them the old code could have claimed: membership SURVIVES a rename, which a sweep can manage, and membership survives a rename WITHOUT BEING REWRITTEN, which only an identity can. Twenty-two sabotages across six suites, all caught by name. Two of them were bad and were rewritten rather than counted, and one found a real gap: an unresolvable membership must not compare equal to no membership at all, or the "No Region" filter lists rooms that plainly name one. Four existing assertions had to change, and it is worth being clear which kind each was. Two in test_regions_editor and one in test_region_builder asserted the OLD STORAGE SHAPE and now assert the new one plus the property it buys. The fourth was neither: test_doors_p1 pinned `ensureDoorPairs(w);` as the line immediately above `return w;`, so adding any second pre-return step broke it while door pairing was untouched. It now slices rebuildWorldFromSnapshot and checks order rather than adjacency, and was re-verified against the removal it actually guards.
Selling has been pinned to `value` for a long time, for the reason its own comment gives: without it a world's prices are set with care and then re-decided, differently, at every till. Buying was left to the Game Master entirely and the same drift arrived by the same road — a rebinder quoting thirty-two gold for a book the world values at 2000 copper, having been shown the 2000 in its own dossier line by the fix that was meant to prevent exactly this. Showing the number is not enough; there has to be a rule anchoring the price to it, and the sell side has had one all along. It also stopped being untidy and became blocking. While a refused charge still handed the goods over, an invented markup cost nothing. Since a refused payment withholds them, a number from nowhere can wall a player off from the one purchase their class advances by — and did, at 22 gold against a purse holding 20g 5c in a world rebalanced so that purse was reachable. A band, not a price, mirroring the sell band exactly: BUY_RATE_DEFAULT 1.0 because a seller asks what a thing is worth, BUY_BAND 0.25 because scarcity and haggling are real and worth keeping. The GM picks inside it and the engine moves only a figure that falls outside. Worth checking against the real numbers, because it changes what this fix claims: the 22 gold actually charged is INSIDE the band and is left alone — a 10% markup is a haggle, not a defect — while the thirty-two quoted in dialogue is outside it and would be moved. So this does not retroactively unblock that purchase, and it should not; what it stops is the invented figure. Unsure means no cap. A purchase containing anything the engine cannot price yields no cap at all, rather than capping against the priceable half and pricing a two-item sale at one item — that is the sabotage in the test, because it is the plausible-looking shortcut. Three tests broke and all three for the same reason, which is worth recording: they anchored their source slices on `for (const t of directiveList(changes.transferItem))`, and that stopped being unique the moment the price anchor started reading the same directive to work out what the goods are worth. They failed against code that was entirely correct. All three now anchor on something unique to the site they mean. 644 passing.
Thirty-one commits of main, two conflicts, both trivial unions. LOG_CATEGORIES wanted the branch's
`tokens` beside main's `combat`/`sound`/`warn`, and test_log_filter wanted main's derived census rather
than either hand-kept list — the census covers `tokens` for free, which is the argument for deriving it.
The merge also brought in a real defect, which is what this branch's battery exists to catch. Main's
memorizeSpells field note asks a knowsSkill('speed_reading') question three times, and that note sits in
the CACHED half. Speed Reading is learnable mid-run, so the first caster to learn it rewrites the cached
prefix and every turn after it silently pays a cache write — indistinguishable, from the outside, from a
cache that is working.
The existing structural guard could not see it: it checks a hand-written list of six dossiers, which is
the roster-in-a-prompt hazard this repo already knows about. So the fix is a mutation instead of another
name on a list. "learns a skill" now joins the battery, it failed on arrival and named the exact byte,
and it will catch the next one whatever it is called.
The rate itself moved rather than being deleted. The field note states the rule generically, says both
figures, and sends the GM to the live state; the live Spellcasting entry carries this character's actual
rate on its own PREPARING COSTS line. The property is unchanged — the GM is told the figure the engine
will really charge — so the two tests that pinned it follow the sentence to where it went rather than
being loosened.
644 passing. player.name is still interpolated into the cached half and that is fine: it cannot change
during a run, so it costs one cache entry per character and nothing per turn.The Region Builder shipped on 16 August — seven commits ending 20aaba2 — and with it P2 whole and P3 in part: a region's own brief, region files at REGION_FILE_VERSION 1, export and import, the import really going through mergeWorldChunk. Three places went on calling the system unbuilt for four days: the badge at the top of region-files.html, its README.md cell, and the row on current-status.html. All three now say what is true. The correction is per PHASE and not only in the header, because a header chip is what went stale in the first place and there is no reason to expect the next one to be moved either. P2 is marked shipped and names what the envelope carries; P3 is marked partial and names what is missing rather than implying it is done — the exported borders are ignored on load, there is no requires gate, and export writes an empty lore array against an importer that reads it. P0, P1, P4 and P5 are marked outstanding. P1 is the one worth being blunt about, because it is the phase the document itself says delivers the whole goal, and shipping the file format first can easily read as progress toward it. It is untouched. buildSystemPrompt runs to 145,000 characters and names a region six times — the weather dossier, a room's "in <region>" label, the prologue backdrop — every one of them a label rather than a filter. The entire world still reaches the model on every turn, which is the thing region files were proposed to stop. test_region_builder pins the complement deliberately: a region's brief never reaches the per-turn prompt. current-status.html is handled as an AMENDMENT rather than a correction, because unlike the other two it was not wrong. Rev. 5 was read against main at 492c961, 10:06 on 16 August; the Region Builder landed at 19:02 the same day. The page was accurate for nine hours. So its row moves and its counts follow — six proposed and sixteen built in part, both recounted from the rows rather than adjusted by one — and §06 gains a row plus a note saying why this one is a third kind, next to the eighteen markers that were genuine contradictions. Its opening sentence said "the seventeen rows below" and there are now eighteen, which is the same self-contradiction that section exists to catch, so it is reconciled too. Nothing else on that page was re-read, and it says so: every other row still stands against the commit named at the top, and world-packs, added on the 20th, is not on it at all. Both are what the next revision's set comparison is for.
Designs/world-packs.html, with a row in the index. Both tools are shipped, so this is a write-up rather than a proposal — but the thing most worth writing down is invisible from the client and cost the first implementation a wrong layout: /vault/media/… is a ROUTE, matched before static hosting, so an archive that is correct for a plain file server unzips into a vault and is shadowed by that route. Silently. That single fact is why there are two tools rather than one with a mode switch, and it is the first section. The rest follows from the taxonomy of how a world names its media — four ways, of which two are either tool's business — and from the two pieces of the vault that make a zip a viable delivery mechanism at all: WorldStore._reconcile adopts a world.json its index has never seen, and MediaStore.resolve reads straight off the disk with no index lookup. Neither was built for this. Without them the feature would need an upload endpoint and an import UI, and the document says so, because the next person to touch either store should know something else is standing on it. Five decisions are recorded as settled so they are not re-litigated, including the one asked for and refused: the vault pack keeps the filename world.json because _fileFor opens that and nothing else, and the test proves it against the real store rather than arguing it. Five are recorded as open with their leanings. The strongest is D1 — the media pack's world.json sits at the archive root and a second pack overwrites it, and since nothing in that archive resolves against it by path, renaming it to world-<uid>.json would end the collision at the cost of one shipped filename. Two claims were corrected against the source while writing, which is the usual yield of checking rather than remembering. The size ceiling is not "far above any world" in the abstract: the largest thing measured in this repo is a 441 MB portable export with its art inline, named in world-store.js, and a pack is smaller than that because it carries the bytes once rather than base64-expanded. And the 4 GB ZIP64 limit is not merely a limit — zipEntries throws a named error at both boundaries, so an oversized world is a bug report rather than a truncated archive. Every line reference in the document was read before it was cited.
BUG-028, and BUG-041 which is the same defect one level up. A key phrased as a physical action reads to the Game Master as the prerequisite rather than the trigger, so it withholds the hook until the player reasons about what the thing means. The Anchor Saint beat was satisfied on turn one, four times over, and paid on the fifth — on a turn whose stated action was picking up a plant. Of the two options the entry offered, the DM chose to keep that behaviour: holding out for understanding is better play than rewarding a mechanical act. What was wrong with it was the silence, and that is what this fixes. The engine does not guess at it. Deciding whether an action came close to a hidden condition is a reading of the fiction, and an engine heuristic laid over the GM's own judgement would give two authorities that disagree — which is the failure this codebase keeps paying for. So the GM declares a nearMiss and the engine renders it, the same division of labour needsAlive uses. The player gets one line saying the doing is done and the meaning is not. It never names the subject and uses none of the words that would admit a quest system exists, but it does say what kind of thing closes these, which is the half they can act on. Once per subject, matched without case, and suppressed entirely on a turn where something did unlock, because a nudge beside a reward reads as the engine hedging about the reward it just gave. The DM log carries the subject and the GM's own reason, so the rate stays measurable — that is what will say whether the nudge is enough or the behaviour is still costing rewards. The sabotage in this test passed for the wrong reason at first: two sandboxes built with new Function share globalThis, so the second rebound the first's addMsg and every message landed in the wrong collector. The stubs come in as parameters now, which are function-scoped, and there is an assertion that the sabotage pattern was actually found before anything is concluded from its absence. 641 passing.
BUG-017's deferred third half, scoped by what the Claude14 run actually showed. The reason it was deferred was that out of combat an idle d20 is indistinguishable from a meaningful one, so relaying everything would submit turns the player never meant. That reasoning was right and the run made it moot: the GM used the structured request every time outside combat and prose three times out of three inside it. So the relay is combat-only, and the test asserts the asymmetry rather than assuming it. A d20 rolled mid-fight with nothing pending is now submitted as "I rolled a 14." through handleSend — the ordinary input path, deliberately, so it inherits every guard a typed move already has and appears in the story in the player's own voice rather than as a turn from nowhere. The GM gets a note in the same call explaining why it is receiving a bare number, telling it to use the number for the check it called for, and naming the field it should have set. Three guards keep it narrow and each is exercised by a driven test rather than a regex, because the ordering of the guards IS the behaviour: a fight already awaiting something falls through to the existing reminder, a roll thrown mid-turn is refused rather than submitted into a turn in flight, and a stray d6 is reported but never relayed. Every called-for roll in this game is a d20; a d6 from the dice bag is far likelier to be someone playing with it. The greppable line survives with one addition — that the roll was relayed, and that it still counts. A rescued turn that stops being counted is how the contract failure underneath would become permanent, so the entry stays open on its original terms: it closes on runs with fights that produce none of these lines, not on the rescue existing. The older test keyed on the one-argument signature. Its assertions still hold because they pass no value, which is exactly what keeps them on the reporting side of the fork — now said in the file rather than left as a coincidence for the next reader to rediscover. 639 passing.
The media pack now carries the same note the vault pack got: a small file named for the world, holding the name, the uid, when it was packed and how much came with it. It matters more here than it does there. A vault pack's world sits in a uid directory, which at least identifies it; a media pack's world.json sits at the archive ROOT with nothing around it, so once the archive is unzipped beside a server there is nothing at all saying which world the art belongs to. The card also owns up to something the media pack's layout cannot avoid. Two media packs unzipped into one directory leave one world.json — the second overwrites the first — and until now that happened in silence. The note says so and points at the uid as the way to tell which pack won. That is not a fix and is not written as one; it is the difference between a surprise and a documented constraint. packFileSafe and packNameCard are shared rather than copied, which meant hoisting them out of the vault pack's region to sit above both, and giving packNameCard the differing paragraph as an argument — what an operator must not do with the file beside the card is not the same in the two layouts. The vault card warns that world.json is the fixed name the store opens; the media card explains that its world.json is a rewritten copy. Their contract moved with them, to tests/test_pack_name_card.js. Leaving the unit assertions inside a file named for one pack meant the other pack's guarantee lived somewhere it had no reason to look, and copying them into both is the duplication this repo has paid for with rosters twice. The pack tests keep what is theirs: where the card lands, and what its note has to say. Two gaps turned up while sabotaging this, both real. The count in the media pack's success modal was `entries.length - 2` — a subtraction over zip MEMBERS that was right only until a third metadata entry existed, and the card would have made it silently one too many. It now reports what was gathered, and a test pins the figure. And the media pack test computed the card's expected filename by calling packFileSafe, so it tracked any change to the sanitiser and could never catch a regression in it; the world in that fixture is now named "Ash: The Fall" and the filename is asserted as the literal "Ash - The Fall.txt". Twelve sabotages cover the media half and the shared helpers, twenty-nine the vault half. All forty-one are caught by name, and a break in a shared helper is required to be caught by the file that owns it.
BUG-039, both halves. The contract now states beside the escaping rule that values are finished JSON
literals and never expressions, that JSON is data and has no operators, and it quotes the exact thing
that came back twice byte-identical so it is recognisable rather than abstract. It ends with the fallback
to reach for: if you are unsure what a field should hold, write null, never compute it.
parseGmJson extracts, parses, and on failure runs one bounded repair before giving up — rethrowing the
ORIGINAL error when the repair does not help, so a genuinely broken response still reports the fault it
actually has rather than a confusing second one from repaired text. All 64 GM parse sites route through
it, editors included, because a lost world-edit is as expensive as a lost turn.
The restraint is the whole design. Only operators that cannot occur in valid JSON outside a string can
trigger a repair. ? and : are deliberately excluded: a bare colon is how every key/value pair in the
language is written, and acting on it would cut healthy objects in half. The walk is string-aware so
narration that merely mentions an operator is left alone, and the test sabotages that specifically,
because editing prose is the failure this must never become. On the response actually received it
recovers the narration, the speaker and the reputation change, and leaves statusChanges as the
{ "0": null } the model meant — the intended value is always the term before the operator.
Every salvage logs a warning naming the operator and what was dropped, and says in the line itself that a
run of these means the contract wording is not working and the repair is masking it.
Two tests broke and both breaks were real: one detected JSON callers by the literal extract-then-parse
pair, the other asserted that exact call in requestLoreEdit. Both follow the call to where it went.
I also broke the whole suite briefly by putting backticks inside a template literal in the prompt text,
which is worth remembering: re-run the parse check after every edit to a prompt string, not just after
the code ones. 638 passing.Browsing a vault's worlds directory shows a wall of uids, which is a poor way to find a world. The vault pack now carries a note named for the world — worlds/<uid>/Ashen Vale.txt — holding the name, the uid, when it was packed, and how many rooms and files came with it. The world FILE could not be the thing renamed, which is what was asked for first and is worth recording because it is not visible from the client. WorldStore._fileFor() hardcodes "world.json", and _reconcile stats exactly that path; a world file called anything else is adopted by nothing. The archive would unzip perfectly and the world would simply never appear in the Worlds tab — the silent failure this whole feature was built to avoid, arriving through the door marked convenience. So the name goes on a sibling, and the card says out loud that world.json is fixed, because the obvious thing to do on finding two files is to tidy them into one. That constraint is a fact about the server rather than about this packer, so the test now proves it against the real WorldStore instead of asserting it in a comment: a world file named for its world lists nothing, the same file called world.json lists the world. If the store ever learns to find a world file by another name, that assertion is what will say so, and the packer can be changed on purpose. The filename sanitiser replaces the characters Windows refuses rather than dropping them, so "Ash: The Fall" reads as "Ash - The Fall" and not "Ash The Fall"; it trims leading dots and spaces, which would otherwise produce a hidden file or a name Windows silently truncates, and caps the length well under the 255-byte entry ceiling. Its fallback tests for a letter or digit rather than for emptiness — a name made entirely of slashes survives the replacement as " - " and trims to "-", which is neither empty nor a name, and a file called "-.txt" tells an operator less than the uid already did. That case was found by the test rather than by reading the code. Nine sabotages cover the card and the sanitiser, including renaming the world file to the world, which is the one this commit exists to prevent. All thirty-three are caught by name.
BUG-042. The charge and the delivery were two unrelated state changes that happened to arrive in the same object: costCopper checked the purse, failed, and printed the engine's own refusal, while transferItem never asked whether it had. Carrying 20g 5c against a 22g charge, the refusal printed and the level-3 psalter went into the pack for nothing. The turn keeps the result of applyCoinChanges now instead of calling it for its side effects and throwing the answer away, which is why nothing could gate on it before. A refused charge withholds transferItem and addItem — the two fields that hand the player a thing — and nothing else. Two pieces of restraint are load-bearing and both are pinned by the test. The refusal is derived from ok === false explicitly rather than truthiness, because null means no money moved and reading that as a refusal would withhold every gift and quest reward in the game. And exactly two directives are gated: the obvious way to wreck this later is to keep widening it until turns that never involved a purchase start being voided, so the count is asserted rather than the spirit. The withholding speaks to the DM log and to the Game Master, so the next turn does not narrate the player admiring a purchase they never received — and deliberately not to the player, who has already been told "Nothing changes hands" by the code that declined the charge. That sentence is finally true. Left open and recorded rather than quietly folded in: prices quoted in dialogue are still invented, 32 gold spoken and 2200 copper charged against an authored 2000. The sell side derives from value; the buy side has no equivalent, and whether merchants mark up is a decision, not a defect. 637 passing.
Tools grew a second archive beside Download Media Pack: "Download Vault Pack…", shown only when the session is playing against a Server Vault. It writes the world and everything it references laid out the way a vault stores them ON DISK, so unzipping it into another vault's data directory makes that world one of the server's worlds — no import step, no restart. The two packs are not the same archive with different paths, and the reason is the thing that motivated this. "/vault/media/…" is a ROUTE, matched before static hosting and resolved by the media store into its own directory; a file unzipped under the static root at vault/media/… is shadowed by that route and never served at all. The media pack's layout is correct for a plain file server and inert in front of a vault. So this one writes worlds/<uid>/world.json and media/<shard>/<sha><ext> instead, which are the two places a vault actually reads. That it works as a drop-in at all is owed to two pieces of the vault that already existed for other reasons. WorldStore._reconcile() adopts a world.json it finds on disk that its index does not mention — built so VAULT_WORLDS_DIR could be a git checkout, where a world can appear because someone pulled it — and MediaStore.resolve() goes straight to the disk with no index lookup. Both rebuild from the filesystem, which is what makes a zip a viable delivery mechanism: neither index has to be merged. Neither is IN the archive, and that is a rule rather than an omission — worlds-index.json and media-index.json describe the destination vault's entire contents, so shipping either would erase its record of every other world and file the moment someone unpacked this over a running server. Everything is content-addressed, and hashed here rather than trusted from the URL it came from. The store validates the name it is given and caches the file as immutable, so a file installed under a hash it does not match is wrong forever and nothing will ever re-check it; re-hashing is also the only thing that catches a truncated read. A vault reference whose bytes hash to what it already claimed therefore rewrites to itself and is left alone, while a provider URL is rewritten to the vault path its bytes now live at. The extension comes from the response's declared type first and the URL only as a fallback, because a provider URL routinely has no extension at all while its Content-Type is exact — and a type the store will not serve is refused rather than guessed, since a wrong extension is served under a wrong mime forever. A file that could not be fetched keeps the reference it had, so the archive never names a file it does not contain. Three refusals name their own reason: Direct mode points at the media pack, a world whose uid falls outside the store's grammar is refused before the archive is built rather than producing one the destination quietly declines, and an insecure context says why crypto.subtle is missing and what would fix it. Unlike the media pack, a world with no hosted media still packs — the world is the payload here and the media travels with it, so refusing would block the drop-in for the smallest worlds. tests/test_vault_pack.js does not read strings out of the archive. It extracts it and hands the result to the REAL WorldStore and MediaStore, then asks the operator's question: does the world appear in the listing, and does every reference in it resolve to a file that is present and hashes to its own name. The extension roster is a copy of the server's and is pinned against the export, since a roster that has to be duplicated has already cost this repo twice. All 24 sabotages of the implementation are caught by name. test_publish_world.js counted raw occurrences of publishWorldToVault to assert there is exactly one Publish trigger, and the note here saying this builds the same envelope a publish sends read as a third. Comments are blanked before counting now, the same way test_art_review_popup_refresh and test_css_tokens_defined already do it — the assertion is about what CALLS the function, and prose that names it is documentation.
BUG-040, both halves. The Game Master answers a plural question with an array, and every field documented as taking one name read it wrong in its own way: pickupItem threw on .toLowerCase() and killed the turn, sellItem passed a typeof 'object' guard an array satisfies and then read undefined off it, skillLearned was dropped by a typeof 'string' guard. Meanwhile revealItem, identifyItem, senseItem and dropItem each carried their own inline copy of the right answer. The fix is one named helper used by all nine, and the test asserts no field does the check inline any more, because four private spellings are precisely how the right answer failed to reach the other five. A tenth site surfaced while fixing it, and only because the test names the fields individually instead of counting them. revealExit has a second reader for parties below ground that did String(changes.revealExit), so two faces became the direction "north,east", the crawler claimed neither, and the field was nulled wholesale — suppressing the room path that might have handled one of them. Each face is offered on its own now and only the claimed ones are removed. The half-applied state was the worse half and is fixed separately from the shapes that caused it. applyStateChanges runs inside a try, so a throw is logged with its stack and the turn still reaches the room description, the visit tally and the save. What it cannot recover is the directives after the failing one, and the log says that in those words rather than implying otherwise. Four existing tests broke and every break was real rather than incidental: two keyed on the typeof guard that had to go, one on the wholesale null this replaces, and one ran a slice of applyStateChanges without the helper it now depends on. All four were repointed at the new behaviour instead of loosened, and the slice test takes the helper from the app source rather than a local imitation. 634 passing.
A prior generation ran against a shallow checkout and only saw a handful of recent commits. Re-running tools/gen-progress-report.js against the complete history (2,647 commits across 52 days, 2026-06-30 to 2026-08-20) brings the report's stats and day-by-day log back in sync with what actually shipped.
Designs/doors-and-barriers.html and Designs/currency.html both shipped real, playable mechanics since the guides were last touched, and none of the three manuals said a word about either: an exit can now carry a door with a real state, a lock, and a hidden flag that the engine itself enforces against moveToRoom, not just narration; and a world can mint currencies of its own, with a being that genuinely refuses coin it wasn't set up to accept. Added a player-facing "Doors & barriers" and "Coin & trade" passage to both guide.html and the Player's Handbook, a "Doors on exits" and "World › Currency" section to the Dungeon Master's Guide covering the Door Editor and the Currency tab, the openDoor/closeDoor/lockDoor/unlockDoor/revealDoor directive family in the Field Guide's Appendix A, and glossary/cheat-sheet entries across all three. Two things are flagged rather than documented as real: P4 (barrier conditions, buttons, engine-rolled search) isn't built per doors-and-barriers.html, and a player's non-coin wealth has no display yet even though the purse already tracks it — both noted as open rather than glossed over. Designs/emergent-quests.html is proposed only, nothing built, so it gets no guide changes yet.
The Tools menu grew a "Download Media Pack…" item. It walks the live world for every media reference that does not ship with the app, fetches each one, and writes a zip the player can unpack into the directory the app is served from — after which the world's art loads with no network at all. Two kinds of reference are involved and they are packed differently, which is the whole of the design. A vault reference (/vault/media/<shard>/<sha>.<ext>) is already a path, and it is already content-addressed, so the pack stores it at exactly that path minus the leading slash and rewrites nothing: unzipped at the server root it resolves by itself. A provider URL is not a path — it names a CDN — so the fetched bytes go to media/<8hex>-<tail> and the reference has to be rewritten to point there. The hash is FNV-1a over the whole URL, which is what keeps two files with the same basename, or the same path on two hosts, from landing on top of each other; the readable tail is kept only so the folder can be told apart by eye, and an extension is forced on when the URL carries none, since a browser will refuse an extensionless image on some hosts. The rewrite happens on a COPY. The zip carries a world.json with the provider URLs replaced, and the world being played is left alone — a player who packs and then keeps playing is still pointed at the CDN, which is the only behaviour that is safe if the pack is never unzipped. A provider file that could not be fetched keeps its original URL in that copy rather than being rewritten to a file that is not in the archive; the README lists every such miss, because the usual reason is CORS and a picture that renders fine in an img tag but cannot be read back through fetch looks, from the Tools menu, exactly like a bug. The zip writer is hand-rolled and stored-only: no compression, since the payload is already-compressed image, audio and model data, and store-only means the local header, the central directory and the EOCD are the only structures to get right. It flags names UTF-8 (bit 11) so a non-ASCII room name survives, and it is not ZIP64, so the 4 GB ceiling is real but far above any world. The CRC-32 table is checked against the standard check value in the test rather than trusted. tests/test_media_pack.js covers the writer against the system unzip (-t and extraction asserted as separate questions, because -t tolerates a wrong central-directory offset that extraction does not), the collector, the naming, the rewrite-the-copy-not-the-live-world rule, and both refusal paths. All twenty sabotages of the implementation are caught by name.
The first run against the rebalanced world, and the first to complete both quest threads. Level 6, 11 of 12 rooms, every beat paying its authored figure exactly, ending with the Ninth Canticle held unfinished in the Cantrix's own throat. The headline is BUG-042. The engine printed "You cannot cover that — nothing changes hands" and handed over the level-3 psalter anyway, purse untouched, because a charge and a delivery are two unrelated state changes rather than one transaction. Two turns later the GM tried to take it back in prose and that changed nothing either, so the save and the story now disagree in both directions about the single most valuable object in the class's progression. BUG-040 turned out to be one root cause behind three separate failures: act on two things at once and the singular directives break, pickupItem throwing and killing the turn, sellItem refusing in silence while the narration promises the coin. It also leaves a room permanently marked unvisited, which is why the report says 11 of 12 and the truth is 12. BUG-017 reproduced three times and the diagnosis sharpened: outside combat the GM used the structured roll request every single time, inside combat it used prose three times out of three. Its player-facing notice was read by a human for the first time and reads well. Confirmed fixed in play: BUG-021's memorize directive, charging its 18 hours correctly. The Cantrix is not weaponless. The Gill-Wretch resolves without killing it, lore intact, which answers BUG-019 in the world's own voice. The economy reaches the third book by five copper, and only by selling the artifact the quest was about — so the ladder is fundable and the margin is a rounding error. What funds it is the ledger bounty, not the flora trade: foraging returned five silver across two plants. Ket Drybound is a rebinder, so the herbal-buyer half of BUG-035's fix is not actually in the world. The report declares its own harness distortions and records the two diagnoses I got wrong on the way — the sale that looked like a phrasing problem and was plurality, and the beat I filed as a lock that was a four-turn toll. 633 passing.
Following BUG-042 two turns on. Unprompted, the narration corrected the engine: "Ket keeps the bronze-bound psalter back — your purse falls short, and she says so flatly. You leave it on her counter." The right instinct, and it changed nothing. The bell-bronze psalter is still in the pack, still the active field spellbook at level 3 with five slots, and the purse is still full. So the save and the story now disagree in both directions across three turns. The engine said nothing changed hands while handing the book over; then the GM said the book was left behind while the player kept it. A player following the fiction thinks they are carrying the whalehide binding and four slots; the engine will happily let them prepare five. Nothing on screen can be trusted about this object. The general point is worth more than the incident. The GM has no directive for repairing state it can see is wrong, so prose is the only tool it has, and prose does nothing. That is the same shape as BUG-017 and BUG-021 — acting where no directive exists — except here it is acting to fix something, which makes the missing capability more sympathetic and exactly as silent.
Two findings from the back half of the Claude14 run, and the second one dissolves a wrong diagnosis I made earlier in it. BUG-042. Carrying 20g 5c, I paid Ket for the bell-bronze binding. The engine said "You cannot cover that — it comes to 22g and you carry only 20g 5c. Nothing changes hands." Then it handed over the book. The level-3 psalter, five slots, the top of the class progression, is in the pack; the whalehide one is consumed; the purse is untouched. A twenty-gold item acquired for nothing in the same turn the payment was refused. The cause is that there is no transaction. The GM returned costCopper and transferItem as two independent directives and the engine applied them independently — the charge checked the purse and failed, the transfer never asked whether it had. That is the exact inverse of the sell-side hole: there, goods are narrated as sold and no coin arrives; here coin is refused and the goods arrive anyway. Same missing idea, both directions. Worst of it is that "Nothing changes hands" is printed by the code that declined the charge, so the one line a player would trust is false, and a DM reading the log later sees a refused purchase while the save says otherwise. BUG-040 turns out not to be about pickupItem. Selling two things in one sentence returns sellItem as an array of two well-formed sale objects; applySellItem reads .name off the array, gets undefined, and logs a refusal to the DM console while the narration says the coin was counted out. One item at a time works. So plurality is the variable, and it is one root cause behind three failures in this run — including the first failed sale, which I had put down to offer-shaped phrasing. That was wrong and the entry now says so. pickupItem throws and kills the turn, sellItem refuses in silence; both want the same repair, and it should be one helper rather than two patches. Also recorded: the price charged was 2200 copper against an authored 2000, and the same book was quoted at "thirty-two gold" in dialogue earlier. Neither figure appears anywhere in the world data. 633 passing. Ten open.
Correcting BUG-041 as filed earlier today. I wrote that a run which never guesses the unstated standard cannot finish the quest. It can: the beat unlocked on the fifth exchange, paid its full 70 XP and took Claude14 to level 4, so the thread is not blocked and the entry no longer claims it is. What unlocked it is the actual finding. Not a fifth question — taking the hush-moss, in a turn whose stated action was picking up a plant, where the narration arrived at "something settles in you about what her endless verse has been buying". The authored trigger asks only to reach the room and interact with her; that was true on turn one and paid nothing. Comprehension was the real condition, and it paid the moment it was met. So this is BUG-028's second instance rather than a defect of its own, and the entry now says so. The cost is a silent toll rather than a lock: four turns on a trigger already satisfied, with nothing on screen to indicate a reward was pending. That is the half worth fixing, and it is the half BUG-028 already proposes two options for, neither of them built.
Following BUG-040 through. The TypeError does not merely lose the pickup: it fires partway through applying the turn, so the movement that ran before it stands while the bookkeeping after it does not. I am standing in the Flooded Transept and world.rooms.flooded_transept.visited is false. A second full successful turn in the room did not repair it, and nothing else will. One malformed field therefore mislabels a room as never-entered for the life of the save. That matters beyond cosmetics because visited is exactly what the walkthrough pass reads for its `already` flags: the compiled plan will list a room the player has explored as outstanding, and any coverage fraction quoted from that save is wrong by one room while looking perfectly confident. This report would have said 8 of 12 rooms when the truth was 9. A turn that dies mid-apply should either roll back or finish the bookkeeping. Half-applied state is worse than either, because it is the one outcome nothing downstream can detect.
BUG-040. Asked to take two items in one action, the Game Master returned pickupItem as an array of two names, which is the obvious answer to the question. The engine calls .toLowerCase() on that field unguarded, so the turn died on a TypeError, both items were lost, and the movement in the same turn had already applied — the player walked into the next room having picked up nothing, with an engine error where the narration should have been. The schema does say the field is a string, which is not much of a defence for throwing: a plural answer to a plural question is the most predictable near-miss there is, addItems already takes a list, and every sibling directive degrades gracefully instead. sellItem logs and returns. transferItem logs and returns. This one raises. BUG-041. The Anchor Saint beat is worth 70 XP and its trigger reads "Reach the Hush-Choir Gallery and interact with the Anchor Saint". I did both, four times, with escalating substance — naming her as Ilsabet, telling her the chorister had been calling for her, working out aloud that she is not haunting the corridor but is the corridor. Her reputation went 0 to 19 and the fourth exchange paid a 20 XP lore entry. questUpdate was null every time. The beat was eligible, in authored order, with nothing foreclosing it, and the GM was plainly attending to the scene since it paid lore off the same exchange. That is BUG-028 one level up: there the GM withheld a lore hook until the player articulated a meaning the key never asked for, here it withholds a beat past a trigger asking only for presence and interaction. Both times it substitutes its own richer standard for the authored one, silently. The cost is measurable rather than theoretical — every later beat in the thread sits behind this one in authored order, so a run that never guesses the unstated standard cannot finish the quest. 633 passing. Eight open.
Filed from the Claude14 run, four rooms in. Two consecutive turns on the rope-bridge died with "Failed
to parse game master response", and both responses were malformed on the same line, byte for byte:
"statusChanges": { "0": null } === null ? null : null,
A JavaScript conditional where a JSON value belongs. Not a truncation — braces balance six to six, the
text opens and closes correctly, and the parse error lands mid-object at position 1551 the first time
and 1521 the second. The model wrote code instead of a value and did it identically twice.
What it costs is the whole turn, and the narration in both was good: Joss answering that they had been
classmates, that everyone is going down for the Ninth, that they read faster. A reputation change and a
CHA training entry with its own reason were in the same object. All discarded for one property near the
end. The player sees an error and the rope they picked up is not in their pack.
It does not soft-lock, which is what will make it easy to under-rate — the next turn works fine. Twice
in five rooms is a rate worth measuring.
Recorded with the fix in two halves: say in the schema that every value must be a JSON literal, since
nothing currently does; and give extractJsonObject one salvage pass before the turn is thrown away,
logging each repair so the rate stays visible. The reasoning is the door-directive lift's — a contract
the model must satisfy exactly or lose the whole turn will lose turns.
Whether this is specific to that NPC, to a statusChanges shape, or general is explicitly left open; the
run continues and will say. Six open.Verified against the restarted vault, so the pass being exercised is today's rather than the cached pre-commit module the previous sweep hit. Both halves of the day's work land. The race prices the DM set survive — 18, 30, 14, a spread rather than a default — and they come back intact through normalizeRaces(), the rebuild that was erasing them before this morning; checked in the live app, not only in the test. And the two beat-unanchored findings are gone, because every one of the twelve beats carries a Location and the pass reads it now. No beat-location-unknown alongside them, so none of those locations is a typo. Eight findings, all info, down from ten. The part worth recording is what that does to BUG-027. No category in this world is uniformly unpriced any more: flora 6/6, places 12/12, races 3/3, beings 9/10, items 19/24, and the five stragglers are four containers and a primer. So the pass saying nothing about pricing is now the correct answer rather than the silence the entry was filed about — and the entry has lost the only world that could demonstrate it. The gate is unchanged. That is the argument for fixing it rather than against: the next world to leave a whole category at the default will be met by a silence nobody can tell apart from approval. 633 passing.
Reported from the Races tab: asked to set the lore XP for the races, the GM answered that race entries have no lore XP field. That was true of the code and false of the world. Races carry lore and a loreKey exactly as rooms, beings and items do, the Lore tab already lists Races as one of its ten groups, and loreHookXp reads loreXp off whatever it is handed. Only the race path had no way to set one. Four layers dropped it, and fixing any three would have left the feature broken. normalizeRace returned a fresh object literal without loreXp, so every rebuild erased it — that is the one that made the rest futile, because the Lore tab could already write a race's price and the next reload took it back, which is the worst shape a bug can take: it works until it doesn't. raceFieldPatch, the GM-edit whitelist, picked lore and loreKey and let the price fall through. The SCOPE line did not name it, so the refusal was correct. And the lore bullet mandates a secret on every race it creates while never asking what one is worth, against the doctrine already written out for flora — if you give a plant lore, give it a loreXp as well. The roster now reads each race's stored price back to the GM, by the rule the item and being rosters already follow: a field the GM may write has to be one it can read, or "raise the cheap ones" is an instruction it has to guess at. This is the authoring half of BUG-027 and it does not close it. The hole that produced a uniformly unpriced category is shut; the check that should have REPORTED one is still gated on the level ceiling and still says nothing. Verengrad's three races can be priced now — but had the ledger not recorded them, nobody would have known they needed to be, which is the entry's whole argument. The test pins each layer with a sabotage that breaks only its own, plus a guard that the prompt slice was located at all — the first version searched for a delimiter that also appears in the factions editor 274,000 characters earlier, ran the slice backwards, and failed three assertions against text that was correct. 633 passing.
A sweep of the six open ledger entries against the live Verengrad draft, prompted by the observation that the flora lore XP had been priced since BUG-027 was filed. BUG-020 is fixed here, and it had grown. It was filed naming combat and sound as written-but-never- registered; a census of every gameLog call site found warn had since joined them with twelve call sites, more than either of the two originally named. All three are registered now. xp turned out to have been registered all along without a colour rule, so it got one too. The test is the more useful half. It compared a hand-written set against LOG_CATEGORIES, which is two hand-kept lists agreeing with each other — it could not see any of the three and would not have seen the fourth. It now derives the used set from the source and fails on any category written but not registered, plus the reverse, since a registered category nothing writes is a dead checkbox. A third assertion checks the census matched anything at all: without it a regex that found nothing would make both real assertions pass vacuously, which it did on the first run, because this test keeps the extracted source in a local rather than on T. Sabotage-checked by unregistering warn. BUG-027 is re-confirmed rather than closed, and the distinction is the point of the entry. The flora half is genuinely gone — six hooks, priced 15/6/12/20/14/10, a spread rather than a default. But this is an evaluator defect, and the gate it names is untouched: all three races in that same draft carry lore and a loreKey and all three are unpriced, and running the pass returns ten findings, none about pricing. The world got better and the safety net did not, which is what makes fixing the data dangerous rather than sufficient. BUG-018 is re-confirmed with a narrower mechanism than the entry describes. A ref-shaped placement is not matched by the wrong key; withContents opens with `if (it.name) out.push(it)`, so it is never recorded at all — dropped before the name-matching gets a say, on floors and in containers as much as in NPC inventories. Latent in Verengrad now that nothing there is placed by ref alone, which is exactly why it stays open. 017, 028 and 034 are noted as undecidable from data and are unchanged. Five open. 632 passing.
I pushed the previous commit with a red suite. The chain that ran the tests piped them through tail, so the pipeline's exit status was tail's and the commit went ahead regardless — the run was on screen saying 631/632 and I did not read it before the push. Recorded rather than quietly fixed, because the next person to write `node tests/run.js | tail && git commit` deserves to know it does not gate. The failure itself was the test doing its job. It matched the lift's array whole — exactly the five door directives, in order — so adding memorizeSpells to that array made it red. A list that is allowed to grow should not be pinned as a fixed set: what has to hold is that no door directive falls OUT of it, so each is now asserted by name and an addition passes. Pinning the exact five would have taught the next author to edit the assertion rather than think about it, which is how a test stops being evidence. Two more assertions failed for a reason unrelated to what they check: the window was 1600 characters measured from the top of the unpacker, and the paragraph of comment explaining why memorizeSpells joined the list pushed the loop past the end of it. Anchored on the loop itself now, so a comment above it cannot move it out of view again. 632 passing, verified before this push rather than after it.
The directive itself already exists — memorizeSpells has been declared, noted and applied since 7c4a9ca, and it honours every gate BUG-021 named by delegating to the same canMemorize the Spellbook tab uses rather than re-implementing them. So the ledger entry was stale, not the code, and it is marked fixed here with the evidence rather than reopened. What was missing is the placement guard the door directives paid for. unlockDoor was read off stateChanges while the only documentation of it lived in a dossier that never said where to put it, so the GM returned it at the top level and the engine ignored a correct directive sitting in the response the whole time. memorizeSpells has no such excuse — it is in the skeleton — but it fails the same silent way if it lands one level out, and it is the field where silence costs most. BUG-021's own note is the argument: a preparation narrated and never applied is quieter than a soft-lock and worse for it, because the player walks into the next encounter believing they are armed. The lift is a list precisely so a second family could join it, and this is that second family. The test pins three things rather than one, because a lift that fires unconditionally would be its own bug: the field is carried, it is carried only when stateChanges does not already hold one, and the misplacement is logged instead of silently accepted. The asserted window is the loop itself and contains exactly one occurrence of the name, so the assertion cannot be satisfied by the comment above it. The note added to the evaluations index earlier today called BUG-021 open and is corrected in the same commit. Six bugs remain open. 632 passing.
A report is never edited — that is the rule in this README and it is a good one — so the fact that the world has moved under the 2026-08-10 row goes into the index instead, where a reader meets it before they trust a verdict that is now nine days and one rebalance stale. Two of that run's five lock verdicts no longer describe the world. Cost read "clear, and biting" with the top psalter at 32 gold and out of reach all run; it is 20 now, and the re-measurement recorded against BUG-035 puts roughly 64.6 gold within reach against the 40 the book ladder costs. Build read "clear, narrowly — a weaponless caster completed the world, but only because Echo-Calm was chosen at creation". She is not weaponless. The Drowned Psalter she starts holding teaches three level-1 verses, one of them The Salt Answers at 7 MP for 12 damage, and the same item is her level-1 field spellbook: three taught verses against exactly the three slots bookSlots(1) gives, which is too neat to be an accident. The owner confirmed it in play — a fresh Cantrix shows all three on the Spellbook tab and can memorize them — so this is not read off the draft alone. That also settles the question that prompted the check. startingSpells being absent is not a gap: the class is meant to choose its own loadout, and the constructor already folds a starting spellbook's "teaches" into the known set, so she knows the three from turn one and picks which to seat. Populating startingSpells would take the choice away. What has not changed is BUG-021, and it is worth reading beside the good news rather than after it. The loadout works from the Spellbook tab; there is still no memorize directive in the GM schema, so a player who asks the GM to commit a verse gets a scene, a rolled check and a plausible success while the book stays empty. A rebalanced starting kit does nothing for a player who never learns they are unarmed.
The plan located a beat one way: read the trigger prose and look for a room name in it. That works because authored triggers in practice say "Reach the Hush-Choir Gallery and listen", and it fails the moment the trigger is written for the fiction instead. "Descend, and do not look back" names no room, so the beat floated, and the report then told the author to reword a sentence that was not the problem. Meanwhile the beat carries a location field, the world-gen schema asks for it, the editor round-trips it and the Journal draws it as a clickable chip. Nothing in the evaluator read it. The precedent is needsAlive, which settled the identical argument for a different question: the engine reads the declared field "rather than reading your trigger, so write the trigger however the fiction wants". A declared field is evidence; a sentence is a guess. So location is read first, resolved the same two ways the app's own questBeatLocationRoom does — room id, then an exact case-insensitive name — and prose anchoring survives underneath it as the fallback for the quests authored before the field existed. Where the two disagree the field wins, because prose mentions a room for many reasons and the field means only one thing. Reading a field means having to say when it is wrong, which is the new beat-location-unknown warning. A location naming no room is worse than none: it degrades silently to prose-scanning, and the author gets told their trigger is vague when what is actually wrong is a field they filled in and misspelled. The fallback still runs underneath the warning, so a typo cannot cost an anchor the trigger could have supplied. Absent stays info and wrong is a warn — one is a choice, the other a mistake. The beat-unanchored card changes its remedy accordingly. It used to ask the author to reword the trigger until a scanner could read a room out of it; it now asks for the field to be set and says to leave the prose alone. That prompt goes to the GM, which is the only road there is — quest beats have no hand-editable inputs in the editor, so the card's prompt is how a location gets set at all. The test sabotages four things and confirms each break is caught by its own assertion: ignoring the field, dropping the name fallback, reversing the precedence, and removing the unknown-location guard. 632 passing.
Read out of the live draft in the browser rather than the exports on disk, which are all stale — the newest file copy is the vault's from 2026-08-12 and even that predates the rebalance. Every number this entry turned on has moved. The Bell-Bronze psalter came down from 32 gold to 20, so her three books cost 40 rather than 52. Scavengeable worth went from 59.49 gold across 22 items to 99.2 across 34, which is 49.6 realisable at the default sell rate — 64.6 with the starting purse, against 40 needed. And the concentration that made the old figure a lie is gone: Bladeward's Plate was 81% of all the wealth in the world, a suit of armour a frail singer cannot wear and would have to walk past a Gill-Wretch to reach; it is 24% now. The fix is the one this entry proposed. Ket Drybound trades in the Bell-Tower Market beside Saltmonger Hesk, the flora roster has grown from six to eight, and foraging is renewable income rather than a one-off strip of the map. Recorded as unsettled rather than glossed: the sell rate is still unset and running on the 0.5 default, so the arithmetic moves if one is ever authored — and no run has played the rebalanced economy. Every figure here is read from the draft, not earned in a session. Seven bugs remain open.
The DM read the entry against the right test — is the player paying for something they did, or is there no action available to them at all? — and it splits in two under it. Pacifying a creature forecloses its lore unwarned. That is a choice with a consequence, exactly as killing it before studying it is, and a world that lets you lose lore by acting is working rather than failing. Not a defect. The Tide Cantrix being unable to reach the hook is real, but it is BUG-035 seen from the other end, and the original entry hid that behind a misreading of its own. It said her only attack spell is "level 3", which reads as a character level she would reach by playing on. It is a SPELL TIER behind a 32-gold rebinding — and the run that raised this finished at character level 6 with the book still unaffordable. No amount of levelling produces the money. Fix the economy and the capability lock goes with it. Withdrawn rather than deleted, so the next reader does not re-derive the "wait for level 3" reading. One question is recorded as still unanswered: the encounter was SPENT when it was pacified, so if such encounters do not return, even a solvent Cantrix gets one attempt — a hook gated on a single meeting, which is neither of these and would want its own id. Eight bugs remain open.
Two conflicts, both the same shape — each side added something adjacent to the other's addition, and both sides were kept. The branch's /usage modal now sits beside main's newly backdrop-dismissible Prologue, and the status index takes both increments: main added emergent-quests where the branch had added prompt-economics, so 32 becomes 34 rather than either side's 33. The thing this merge is usually for did not arise this time. Main added no new GM call sites, so nothing had to be moved from the string system prompt onto the blocks form; all 52 cache-marked sites are intact and test_prompt_cache passes. The three gmFetch calls that do not use buildSystemPromptBlocks() were each checked and each is legitimately different — one holds the blocks in a variable, one is the DM console's own system prompt, and one is the novelizer's. What did arise is more interesting, because both failures came from main's tests being right about behaviour and wrong about where to look — and the branch is what moved the ground under them. test_door_locks located the door section by searching the assembled prompt for "Doors here". That phrase also appears in the field notes, in the sentence telling the GM to use the exact id from the "Doors here" list. Splitting the prompt into a cached half and a per-turn half put those notes AHEAD of the section, so the search now landed on the mention and the first bullet after it belonged to statusChanges — eight assertions about pickable locks were being made against a paragraph about status grades. The content was never touched. Both lookups now anchor on the section header, which is unambiguous and survives the halves being reordered again. test_escape_closes_modal awarded each dismissal three microtasks and called anything slower a refusal. closeApiKeysDialog persists the keys before it hides, and on this branch that path is one await deeper, so a dialog that closes perfectly well was reported as one that ignores the key. That is a test measuring its own guess about implementation depth, and the fix is not a bigger number: it now polls for the dialog to actually go, with a bound, so only never-closing fails. Its message improved as a side effect — "dismissed, but never went away" says something a timeout never did. Both repairs were re-checked against the behaviour actually removed and both still fail on it. One thing worth noting for the branch's own sake: the /usage modal it adds inherits Escape-to-dismiss and close-on-logout for free, because main built both by reading .modal-overlay off the DOM rather than from a list of ids. Neither feature knows the modal exists and neither needed changing. App suite 634/634, vault clean, taxonomy doc current.
The document posed three ways to handle the fact that a rumour is narrated before the quest behind it exists — narrate and unlock quietly, narrate and announce the arrival, or author first and make the player wait. All three accept a premise that turns out to be false: that the conversation which SEEDS a quest must also be the thing that DELIVERS it. Separate those two moments and the trade-off does not need pricing, because it is gone. The quest is authored silently — no narration, no second message, nothing owed to the player, because nothing was promised. What the chunk carries with it is its own hook: a stranger sitting at the end of the bar the next time the player walks into the inn, a woman weeping in the village square who will not say why unless asked. The first beat unlocks when the player engages, which is an ordinary beat trigger satisfied by something the player actually did. No latency, because nothing waits on a turn. No visible failure, because a hook that was never authored is a stranger who never walks in. No unearned message, because the arrival IS content rather than a notification about content. And it reads better than any of the three: a rumour told across a bar is weaker than a grieving mother you chose to approach. It is also the cheapest of the four to build, which was the surprise. A hook of exactly that shape is an ENCOUNTER, and encounters have been in this engine all along — `where` naming rooms, `when` giving each a per-time-of-day probability, `entities` refs for who arrives, and their own `ambient` acts including `oncePerPresence`, which fires a behaviour once per co-presence rather than on a loop. `spawnEncounter` already refuses to stack a second copy. `requestWorldExpansion` already authors `encounters` in the same chunk as the quest and the rooms, and `mergeWorldChunk` already installs them. The weeping woman is nine lines of data the world model already accepts. One gap remains and the document now leads with it: an encounter cannot RETIRE. `despawn` is stamped on the spawn, not on the encounter, so it removes the woman from the square and the encounter puts her back tomorrow — after her son is found, and after the quest has ended. This is the only part of the design that is not already built, and it is the part most likely to ship without, because the feature works right up until the second morning. It is worth building as a general capability rather than a quest special case: a hand-authoring DM has the same need for a one-time arrival and today has no way to say so. It becomes P0a, ahead of anything else here, and it is independently useful even if the rest is never built. Two costs are recorded rather than waved past. A hook may never fire if the player never returns to the square, so the live-thread cap should count UNDELIVERED threads, which is the number that actually bounds the growth. And the seam moves rather than disappearing — the hook's ambient prompt and the beat's trigger have to agree, which they do because they are written together in one call. D1 is settled. The geography section is stronger for it: an encounter's `where` is a room that already exists, so the delivery end of every seeded quest is anchored in known geography by construction, and the only open question is whether its destination needs new rooms at all. Every new claim was checked against the source — eleven of them, including the absence of any retirement mechanism, which is the one the design now depends on being true.
The previous run of tools/gen-progress-report.js had been generated from a shallow clone that was missing most of the repo's commits, so the report undercounted every stat it shows (total commits, days active, busiest day, lines of code). Re-run after unshallowing so the numbers match what's actually on main.
A design document for quests seeded by narrative interaction — a player asks the innkeeper about rumours
and walks out with a thread that did not exist ten seconds earlier: the quest, its beats, the moor it
leads to and the thing waiting there.
The finding worth the document is that the engine is already most of the way there, which is not what it
looked like from the outside. requestWorldExpansion authors rooms, entities, items, lore, encounters AND
quests with beats, triggers and XP as one chunk; mergeWorldChunk installs it with dangling-exit repair,
{ref} rewiring, name dedupe, reachability findings, door pairing and an unplaced-catalog net. That is what
"// expand the world" runs today. requestQuestEdit goes further for quests alone — branching DAGs, coin and
item rewards, needsAlive, per-beat XP. The turn already unlocks beats, delivers their rewards and pays
their coin within a band, and world.questSummary() is interpolated on every prompt build, so a merged quest
is visible to the Game Master on the next turn with no plumbing at all.
What is missing is one word: initiator. Every authoring path starts with a human at a console, and the
turn's questUpdate is unlock-only, keyed to "exact id from Quest Threads". So the work is a new field, a
post-turn dispatch, and the governance a player-triggered pipeline needs where a DM-triggered one does not.
Three questions carry the design, and each is genuinely open rather than a formality. WHEN the quest
appears — the rumour is narrated now and the quest does not exist yet, so the first beat cannot unlock in
the turn that produced it; three options are set out and the leaning is to narrate, build behind, and
announce the arrival, because the alternative that hides the failure is hiding it in a system that writes
to the save. WHERE its geography attaches — expansion stitches onto an anchor's free direction, so anchoring
at the player opens a tavern door onto the moors; the leaning is to prefer the map already there, since a
rumour about the moors is better when the moors are already out there. HOW OFTEN a world may grow — asking
about rumours is a free, repeatable action with a 16k-token call behind it, so per-NPC memory, a cooldown,
a live-thread cap and an off-by-default setting, each with a precedent already in the codebase rather than
invented for this.
Every claim in it was checked against the source rather than remembered, and two were wrong on the first
pass. The accessibility guide already reaches BOTH authoring directives, so a seeded quest inherits it —
but the needsAlive guidance reaches only requestQuestEdit, which is a real hazard the document now names.
And the per-call token ledger it wanted to lean on for cost attribution does not exist on main; it is on
claude/prompt-economics, so the governance section says what main actually has (the gm log category) and
what waits on that branch.
Indexed in README.md and on the status page. Running the status page's own set comparison — the check rev.
5 introduced, comparing files on disk against README rows against documents named on the page — turned up
two more docs added since it was written, currency and doors-and-barriers, with no row on that page. They
are recorded there as a finding rather than filed onto the status axis, because placing a document there
means reading it and this note came from the directory listing alone. That is the check earning its keep a
second time.On Character › Skills › Learned the cards are grid items, so a row stretches them all to the tallest one. The xp bar was simply the last thing in the stack, which meant it landed wherever the description happened to end: a two-line description put it near the middle of a card sized for a five-line one, and a row showed its bars at three different heights with dead space beneath each. `margin-top: auto` on the xp block. The card is already a flex column and already fills its cell, so the auto margin absorbs whatever space is left over and the bar comes to rest on the bottom edge. Not `justify-content: space-between` on the card, which was the other one-line option: that spreads EVERY gap, so the description would drift away from the meta chips it belongs with. This moves one element and leaves the rest of the stack tight at the top, which is what the card looked like before whenever the description happened to be long enough to fill it. Applied to the shared component rather than scoped to the one tab. buildSkillCard has exactly two callers — this grid and the character-creation picker — and both put the card in a stretched cell, by different mechanisms: on the Learned tab the card IS the grid item, and in the picker a wrapper is the grid item and the card is given height:100%. The picker had the same misalignment for the same reason. Where a card is ever placed somewhere unstretched there is no free space, an auto margin resolves to zero, and nothing moves — which is what makes the unscoped rule safe rather than merely convenient. tests/test_skill_card_xp_pinned.js asserts the property and, separately, each of the four things it rests on, because any of them silently un-fixes it: the card being a flex column, the card filling its cell in both grids, the bar being the LAST child (an auto margin on a middle child pushes everything after it to the bottom instead — read off buildSkillCard's real output, including for a skill with no description, since that child is conditional), and the bar being the only auto margin in the card. That last one is the subtle one: two auto margins SPLIT the free space, so the bar would come to rest halfway down, which reads as the original bug rather than as a new one. Ten sabotages were each caught by a distinct named assertion.
"Ambient on Enter" in Settings › Story: walking into a room picks one of its beats for the current hour at random and fires it at once, instead of offering each authored beat its own chance. This is not a new idea in the code — it is the behaviour entry USED to have, and rollRoomAmbientOnEntry carries the paragraph explaining why it was taken out. A guaranteed beat is ambience that announces your arrival rather than ambience that happens to be going on, and a beat authored at 20% stopped reading as 20% the moment you walked through a door. Both readings are defensible tastes, so this comes back as a switch rather than as a reversal, and OFF is the default for a reason that is not caution: defaulting it on would silently return every existing save to the behaviour somebody deliberately removed. What the switch buys past is PACING, and only that. The chance roll and the ambience cooldown are what it overrides. A turn in flight, a fight, a beat already showing, and the player being somewhere else are not pacing — they are correctness, and no setting should be able to buy past them: two narrations landing together is a bug however it was configured. The cooldown is on the other side of that line for the reason the entry path already gives, that a new scene is stale by definition. A beat authored at chance 0 sits with correctness rather than with pacing. Chance 0 is the author saying "never", not a rate, so the random pick draws from the beats that could ever fire rather than from all of them. That matters twice: it keeps a pacing setting from overruling an authoring decision, and it avoids the failure where a forced chance-0 beat lands on tryRoomAmbient's own chance gate and produces nothing — a DM who switched this on would have seen a room that still greeted them with silence and no reason given. A room whose every beat is chance 0 says so in the log instead. The forced beat still goes through tryRoomAmbient rather than around it, so the same gates apply and it reports itself in the same place. It logs as forced rather than as a roll that landed, because a setting that cannot be told apart from luck is one nobody can confirm is working. Two things the sabotage pass turned up rather than the writing. The direct-call assertions were missing: every "the switch may not buy past this" check went through rollRoomAmbientOnEntry, which has guards of its own for a busy beat, the player being elsewhere and the cooldown — so tryRoomAmbient's force flag was never actually exercised, and four sabotages of it survived. Those now drive the gate head-on, with an unforced call beside the cooldown one so the assertion cannot pass against a cooldown that does nothing. And an off-by-one in the pick threw rather than failing, on room entry, which would abandon the rest of a move; the index cannot be out of range as written, but it is guarded now so a later edit fails quietly instead of breaking walking. tests/test_ambient_on_enter.js covers both halves — that OFF is unchanged, which is what makes it a setting rather than a behaviour change for everybody, and that ON is a certainty. Eighteen sabotages were each caught by a distinct named assertion.
A door that opens and keeps showing a shut door is a picture contradicting the sentence beside it. So a door now carries two STATE pictures — open and shut — derived from the one the DM painted, and when it changes state in front of the player the new picture and its status go into the story. THE BASE IS NEVER ONE OF THE TWO. `image` is what the DM painted or uploaded and is the only thing ever submitted for an edit; the slots are outputs. Editing a prior result would compound the quality loss every time the door was opened and shut, which is the rule the weather banners already follow — they re-paint from the stored base "so repeated changes never compound quality loss", and this is that applied to a smaller picture. Re-painting the base clears both slots, because a variant derived from a picture that no longer exists is worse than none: none falls back to what the DM just painted, a stale one contradicts it. Locked and closed share a slot. A lock is not a thing you can see from outside — the same rule doorPlayerFacts follows for the words — so a separate locked picture would be one generation spent on an image identical to the shut one. Off by default, behind Settings › Show Door State Art, exactly as Show Weather Imagery is and for the same reason: it needs a Nano Banana key and spends an image generation every time a door swings. Slots are cached, so a door opened twice costs one generation and a door nobody opens costs nothing. The edit prompt names only the door and asks for everything else to be left alone — the same materials, frame, wear, lighting and angle — because a prompt that describes the whole scene again invites the model to repaint the whole scene. Shown only where it can be WATCHED: in the room the door is in, and never for a door the player has not found, where the picture would be the admission the hiding exists to withhold. The painting is deliberately not awaited — every caller of setDoorState is mid-turn, and blocking one on an image generation stalls the whole turn. Three sabotages caught nothing at first, all for one reason: the assertions read the RETURN of ensureDoorStateImage, which is an empty string whether it refused or tried and failed. They watch a spy on the painter now, so "did not attempt" is distinguishable from "attempted and got nothing". A fourth turned out to be caught as a parse error rather than a named failure — `await` inside setDoorState is a syntax error, which is a stronger guarantee than the assertion.
Reported from play: the story chip had lost its right border while the sidebar badge looked correct. Three faults, and the third is why only one surface was wrong. THE PAIRING RULES CARRIED THE SAME SPECIFICITY AS THE RULES THEY MODIFY AND SAT BETWEEN THEM. Declared after .exit-chip and before .exit-badge, they beat the first and lost to the second — so the pairing took effect on the story chip and silently did nothing to the sidebar badge. What looked correct in the sidebar was the untouched original, not a working pair. They are declared after both now, with a comment saying why, and a test asserts the ordering rather than the effect: the effect is invisible on whichever surface the rule happens to lose on. The chip then kept the half of the change that applied — losing its right border while the door button drew a left one of its own in var(--border), a different colour from the gold both halves use. So the divider changed colour halfway across the group and the left edge of it read as the chip being open ended. One divider now: the left half keeps its right border and the button drops its left, in the same gold. And the two halves were different heights in the story, 23 against 25, because the chip carries `margin: 1px 2px` where the badge carries none and is spaced by its container's gap. The button matches that margin and drops it inside the exits list. Measured after: equal heights and a zero gap on both surfaces. Five sabotages. The ordering one caught nothing at first and the sabotage was at fault: it sliced from the group comment to the lightbox comment, which the move itself had put ABOVE the group — a negative range, so it relocated nothing and reported the test as blind. Bounded properly it fails two assertions by name.
The exit chip and a mark for the door standing in it are now a single group — one border, two halves — on both surfaces the player sees: the chips in the story and the badges in the sidebar. Both build the mark through one function, so the two cannot drift into offering different things. The wrapper is there even when there is no door, so an exit does not shift sideways the moment one is added to it. Clicking the mark opens a lightbox rather than the sidebar popup, because this is reached from the story and a card appearing in a panel off to the side is a card the player has to go and find. AND THE CARD TELLS THEM ONLY WHAT THEY HAVE LEARNED. `hidden` was whether they know the door EXISTS; this is a second question — whether they have looked closely enough to know how it is fastened. Open or shut is visible from across the room. LOCKED is not: a door looks the same fastened or merely closed, and saying so on a card they can open from the exits bar hands over the puzzle for free. So an unexamined locked door reads as "shut", which is true and is everything they can see, with a line saying the card is short — silently incomplete is indistinguishable from a door with nothing more to know. Two ways to learn it, and both are the same act from the player's side. Examining it teaches it, marked BEFORE the card is built or the first look would always report less than the second. Being STOPPED at it teaches it too, because the refusal has just said out loud that it is locked, and anything less prints that and then has the door's own card deny knowing it. Learned on one face is learned on both: the lock belongs to the door, not to a side of it. Two tests broke on this and neither was about doors. One matched the chip by its position after "Exits: ", which a wrapper moves. The other read a rule's declarations by matching the first occurrence of its class name — and the descendant selector I first wrote put that name earlier in the sheet than the rule itself. I replaced it with a dedicated class, which is better CSS anyway, and then broke the same test a second time with the COMMENT explaining why, because the comment named the selector and the test flattens newlines before matching. Ten sabotages. One caught nothing: the assertion that a hidden door draws no mark was carried entirely by the exit filter upstream, so the guard inside the builder was unreachable from that path and could be deleted with nothing noticing. It is asserted on the builder directly now.
Doors P2. Two flags, not one, and §07 is the reason: `hidden` is whether the player knows the OBSTACLE is there, `hidesExit` whether they know the WAY is. They come apart — a boulder is not hidden at all, you can see it perfectly well, but the way it blocks is not listed. Folding them into "how hidden is it" loses that case. THE PROPERTY EVERYTHING ELSE SERVES: asking about a bookcase that has not been searched must be indistinguishable from asking about any other bookcase. A door that answered "you cannot see that" would confirm it exists, and the ABSENCE of a refusal becomes the tell. So the rule is enforced by WITHHOLDING at the matcher rather than by refusing at each caller — doorMatchingPhrase simply does not know a hidden door is there, and every route through it inherits that, including P2.5's examine intent, which is the debt that phase shipped with. Player-facing surfaces read visibleRoomExitEntries now — the exits bar, the story chips, the map. The GM's dossier still reads the unfiltered list, because it is the one that decides when a secret is found and cannot reveal what it was never told; hidden doors reach it in a [GM-ONLY] block carrying how each is found and its tell, with instructions not to read the tell out as written. Listed in both blocks the GM would have no way to tell which doors the player can see, so a hidden door is absent from the ordinary one — asserted, because that is the leak that would look like the feature working. §07's fourth row — hidden with the way still listed — is an authoring mistake and is reported as a sentence rather than corrected, since the author wrote two things that disagree and only they know which they meant. The visibility rule is CONSERVATIVE about it regardless: hidden wins, because showing a way into a room the player has no idea exists is the failure that cannot be taken back. revealDoor is its own directive rather than a side effect of opening one. Being found and being open are different facts — a secret door behind a bookcase is still shut and often still locked — and every road to "found" ends at one function, so there is one place that decides what found means. It clears hidesExit with hidden, because a door you have found whose direction stays unlisted is one you can neither walk through nor ask about: §07's fourth row arrived at by another road. Thirteen sabotages, thirteen named failures. Browser-verified end to end: zero visible exits against one real one, the examine finding nothing and saying nothing, the popup refusing by id, the GM told with the tell, and the ordinary list clean. The editor's discovery-skill picker keeps a skill this world does not have, selected and marked — the Key picker's case exactly, found by authoring a door against a skill the built-in catalog does not carry. Not built: finding one by SEARCHING. `search` is deliberately still the GM's verb — the engine does not intercept it — so a hidden door is found today by the GM returning revealDoor. The engine-side search route is the remainder of P2 and wants P3's button and trap work beside it.
Reported from play: the cellar door unlocked, then opened, and then moving down did nothing.
THE DOOR DIRECTIVES WERE APPLIED AFTER THE MOVE GATE. A response that both opens a door and moves the
player is one response — "open it and go down" produces exactly that — and the gate tested a door that
had not been opened yet. It refused, said the door was shut, and the same response was in the act of
opening it. The block moves above the gate. The refusal itself is untouched and still fires for a door
this response does not open, including one whose openDoor the engine declines: applying the directives
first must not become a way through a door the engine has just refused to open, and there is a test for
each of those.
A DESTINATION THAT DOES NOT RESOLVE IS NOW SAID. Both move blocks are guarded by
world.rooms[moveToRoom], so a moveToRoom naming anything that is not a room id fell through both and the
player simply did not move, with nothing said to anyone — the same silent-decline family the door gate
exists to repair, one step earlier. A room named by its DISPLAY NAME resolves to its id, since every
dossier prints both and returning the name is the natural slip; anything else is refused out loud and
the GM is told, so the next turn does not continue as if they had arrived.
And the door section states the affirmative case. It was five clauses of "BLOCKS the way", "the engine
will refuse the move", "narration alone changes nothing", with nothing anywhere saying what to do when
the door is open. It now opens with it: an open door is not an obstacle, walk the player through it and
set moveToRoom as normal.
One sabotage caught nothing and the sabotage was at fault for the second time this week, in the same
way: `if (byName) {` occurs twice in the file and the patch hit the first, in another function entirely.
Aimed inside the move guard it fails two assertions by name. Worth remembering that a substring is not
an address.Reported from play twice, and the second report carried the GM's actual reply — which contained a
perfectly correct `"unlockDoor": "door_inn_common_room_down"` sitting at the TOP LEVEL of the response,
beside skillChecks and roomUpdates, while the engine read the field off `stateChanges` and found nothing.
The door stayed locked, the narration said otherwise, and examining it reported the engine's truth.
The GM was not wrong. The four door directives were never listed in the stateChanges field spec at all —
grep finds no `- "unlockDoor"` line anywhere. They appeared only in the Doors dossier, as "return
unlockDoor with its id", which says what to send and nothing about where to put it. The model guessed,
reasonably, and guessed the other way.
Two repairs, and both are needed. The field spec documents all four and says plainly that they are
stateChanges fields. AND the engine lifts a top-level one into stateChanges, because a schema that must
be got exactly right before anything happens at all is a schema that will silently do nothing — a
directive at the top level is unambiguous about intent whatever it says about placement. The documented
placement still wins when both are present, and the lift is logged rather than fixed in silence, since
quietly accepting the wrong shape is how a schema and a model drift apart for good.
MY OWN TEST HID THIS. test_door_locks exercises the directives through
`applyGmChanges: (c) => applyStateChanges(c, {})` — it hands the object straight to the applier and never
goes near `result.stateChanges`, so it proved the unlock logic and could not see that nothing upstream
would ever deliver it. The new assertions read the real unpacking site. It is the same shape as the
`esc` stub in the sell test: a fixture standing in for the part that was broken.
One existing assertion needed tightening rather than fixing — "a room with no doors gets no door
section" matched the bare phrase "Doors here", which the new field-spec line now contains in every
prompt. It matches the section header.Two halves of the same thing the game logout just gained. logoutWorldEditor hands the screen back to the same login overlay, so it had the same hole: the Builder's own dialogs — Generate Brief, the Region Builder, the Door and Type editors — sit at z-index 200 and above over its 100. The only difference from the timeout case is that this one is a deliberate click, so it happened every time rather than occasionally, which means it would have been noticed sooner and not that it was a different bug. Escape now dismisses the topmost dialog by SYNTHESISING ITS BACKDROP CLICK: the same event a click on the dark surround makes, on the same element, running the same inline handler the markup already declares. Two things fall out of that, and together they are the reason it is not a table of ids and close functions. The dialog's own closer runs. Thirty of the thirty-seven declare onclick="if(event.target===this)closeX()", so Escape calls closeX — which means the two that park a pending promise settle it (appConfirmCancel resolves false, closeCharImportModal resolves null) and the editors do whatever cleanup they do. A list here would have had to name thirty functions and would have drifted from the markup the first time one was renamed. And a dialog that must be ANSWERED is left alone for free. The seven with no backdrop dismissal are exactly the ones where waving the dialog away is the wrong outcome: the character-creation steps, where Escape would strand the player with creationPending set and no dialog to finish it in, and the confirms for overwriting a class progression or discarding quest work. Not having a backdrop dismissal is the author's existing statement that this dialog wants a decision, and Escape reads that statement rather than a second list that could disagree with it. That is a deliberate departure from "close the modal whenever Escape is pressed", and the test names the excluded dialogs so the choice is visible rather than buried. The Prologue was the one informational dialog with no backdrop dismissal, which also put it out of Escape's reach. It has one now — fixing the inconsistency at its source rather than special-casing it. Topmost, not all: a delete confirm at 210 opens over an editor at 200, and Escape hands the player back to the editor. The stack is ordered by z-index and then by document order, which is the actual CSS painting rule for a tie rather than a guess at it. When the top dialog is one that must be answered, Escape reports nothing dismissed and the handler falls through to its older list — which is what still closes the big-prompt editor and the drop-downs. Whether a dialog is dismissible is read from the ATTRIBUTE before the click, never from whether it went away afterwards. That was the first implementation and the test caught it: closeApiKeysDialog is async, it awaits saveApiKeys before hiding, so the element is still on screen when click() returns even though the dismissal is under way. Judging by the result called that a refusal and fell through to the older list, tearing down the Settings panel and the drop-downs behind a dialog that was closing perfectly well. Five unrelated tests went red on the handler being reformatted across lines and on a comment added inside logoutWorldEditor — three pinning the Escape handler as a one-line literal, two measuring character distance into that function. Both are the recurring shape this repo has been bitten by, so all five were repaired rather than renumbered: each now slices the handler or the function body and looks for the call inside it, which cannot be broken by prose growing or by a line wrapping. Each was re-checked against the call actually removed, and each still fails on it. tests/test_escape_closes_modal.js covers the key; it wires every mock backdrop to the app's OWN closer, resolved by the name in the markup, so what runs under test is what runs in the browser. Sixteen sabotages were each caught by a distinct named assertion.
Reported from play: a door was unlocked with its key in the fiction and stayed locked in the engine. The next examine reported "shut, and locked" — correctly, and that read as the engine being wrong. The engine was right. unlockDoor works end to end: locked → closed → the examine says "It is shut." What happened is that the GM narrated the unlocking and never returned the directive, so the prose and the state diverged, and P2.5 made the divergence visible for the first time by giving the player a way to ask the engine directly. Making the engine guess would be the wrong repair — that is heuristics over prose, and the two systems end up fighting. The actual gap was in what the GM is shown. The dossier said what the lock NEEDED and never that the player was HOLDING it, so nothing in front of the model connected the moment it was narrating to a directive it should be emitting. It now says so plainly, with the exact doorId to return and the consequence of not returning it. That is engine fact — doorOpenerHeld already answers it for the gate — rather than anything inferred from the narration. The section header states the consequence once for every door: the player can SEE the difference, because examining a door reports the state the engine holds. Only for locks the engine can check. A sealed door never claims the player can open it, because nobody can, and a cue that lies is worse than no cue. Four sabotages. One caught nothing: the id assertion passed on the line's OTHER mention of it — the dossier already prints "(id: …)" further along — so a cue saying "return unlockDoor" without naming which door would have gone unnoticed. Scoped to the cue itself now.
Reported as intermittent — a modal still showing after an inactivity logout, but not every time — and the
intermittence is the misleading part. It is not a race. logout() never closed anything, and the stacking
makes the consequence unconditional: #setup-overlay is z-index 100 and every .modal-overlay is 200 or
above, several of them higher still (210 for the nested delete confirms, 250 for app-confirm, 300+ for the
lightboxes). A dialog open when the session ends is therefore not merely still in the DOM behind the login
screen. It is painted over it, and the player is looking at an Item Editor for a game they are no longer in
and cannot save to. Reproduced deterministically: open a modal, run logout, the modal is still at
display:flex on top of a login screen that has just come back.
What actually varies is the precondition. The idle countdown is reset by any mousedown, mousemove,
keydown, touchstart, wheel or scroll anywhere on the document, so the only way to reach the timeout with a
dialog up is to leave one open and not touch the machine — which is exactly the sort of thing that appears
to happen at random.
closeAllModals sweeps by CLASS. There are thirty-seven .modal-overlay elements, spread across the top of
the document, inside #app, and among the lightboxes; a list of ids in the fix would have been right on the
day it was written and would have missed the thirty-eighth, and would very likely have caught the
top-level ones and left the nested ones behind. The test opens every modal it finds in the markup rather
than the handful a person would think to name.
Two of them are not merely visible. #app-confirm-modal and #char-import-modal each park a resolve function
in a module variable, and hiding one without settling it trades a visible bug for an invisible one — the
caller that awaited the answer waits for the life of the page. Those two go through their own cancel
functions first, which resolve as declined and then hide; everything else is a display flip, which is what
the sweep does. That short exception list is guarded rather than trusted: the test counts the module-level
resolvers in the app and fails if a third dialog is added without being named here.
The tempting shortcut was a stylesheet rule hiding modals whenever the login overlay is up, as a second
line of defence. It would also have hidden API Keys and the Prologue, which are opened FROM the login
screen — a fix that breaks the screen it was protecting. The test asserts no such rule exists and says why.
Two unrelated tests went red on the four lines this added, both for the same reason and neither about
sound: test_login_music.js and test_sound_handler.js located playLoginCues() by measuring `[\s\S]{0,3200}`
from the start of logout(), and a comment pushed it past the bound. That is the recurring failure this
repo has been bitten by before, so they were repaired rather than renumbered — each now slices logout's own
body and looks for the call in it, which cannot be broken by prose growing. Both were re-checked against a
logout with the call removed, and both still fail on it.
tests/test_logout_closes_modals.js covers the fix; thirteen sabotages were each caught by a distinct named
assertion, the first being the pre-fix implementation. The z-index premise is asserted as a fixture, so if
the login overlay is ever raised above the modal layer the file says the problem has moved rather than
quietly pinning a fix for something that no longer bites.Editor › Items › Catalog. One click puts one of the item on the floor of the room the character is standing in — the shortest path from "this thing should be here" to it being here, without a GM turn or a hand-edited room. WHAT LANDS IS AN INSTANCE, NOT THE ENTRY. The catalog is the definition; the floor gets a makeItem copy carrying a `ref` back to it. Pushing the catalog object itself would put the definition in the room, so every later edit to the entry would rewrite the thing lying on the floor and picking it up would carry the definition into the pack. The test scribbles on the dropped one and checks the entry is unmarked, because that is the failure that would take weeks to notice. Drop sits at the LEFT of the footer row, away from Edit and Remove. It is the one action there that changes the WORLD rather than the catalog, and grouping it with them would put a world-changing click beside a catalog-editing one. It is left-aligned by pushing the others away rather than by changing the row's justification, which would have moved Edit and Remove out from under the cursor of anyone used to where they are. It reports on the button itself — Dropped, Unknown, No room — because a card that changes nothing visible and says nothing reads as a dead control. "No room" is the ordinary case rather than an error: the editor opens from the login screen with nobody standing anywhere, and that has to be distinguishable from a broken button. The story gets a line too, in the engine's voice rather than the GM's, since the GM did not do this and should not be made to sound as if it did — and the GM is told next turn, or its following paragraph describes a room that no longer matches what is in it. The button joins the SHARED theming rule rather than getting one beside it. A control left out of that selector renders as the browser's own white button on a dark card, which is how three separate controls have already gone wrong in this file this week; a test asserts membership of the rule rather than the look of this one button. Nine sabotages. One initially caught nothing and the sabotage was at fault, not the test: the guard it cut appears five times in the file and the patch hit the first, in another function entirely. Aimed inside dropCatalogItemHere it throws, which is the point — a room that has never held anything has no items array. Browser-verified: an Iron Sword on the floor of The Village Square, with its ref, as an instance.
Reported from play, and it was one bug wearing two hats: unticking "Same door on both sides" and saving came back ticked, and repainting the picture changed the far side anyway. The checkbox was DERIVED on every open — from whether the faces matched and whether the far side was still an untouched placeholder — and saving recorded nothing about the author's answer. So the guess ran again the next time the dialog opened and re-ticked itself: an explicit choice undone by the dialog that had asked for it. The answer is a field now, three-state on purpose. Null is "the author has not said", which is every door older than the field and is exactly what the smart default exists for; once they tick or untick, the answer is written to BOTH records, because whether two faces are one door is a fact about the pair and two copies of it that could disagree would be a door linked from one room and not from the other. The picture was the visible half. setDoorImage wrote to every record sharing the id and its own comment said the sharing was "kindness" — which is fine as a default and wrong as a rule, because a bookcase on one side and a wooden wall on the other is the case §10-H exists for. It takes the room being painted from now on and honours the answer: linked, the picture crosses; unlinked, it stays on this face. A caller that names no face still reaches both, since guessing which side it meant is worse than painting the door. STATE is deliberately untouched by any of this and has a test saying so. A door standing open is open from both rooms and there is no coherent world in which it is not; unlinking says the two faces LOOK different, not that one can be open while the other is shut. Three sabotages initially caught nothing worth noting, and all three were the same fixture fault: the new assertions exercised the helpers underneath the dialog rather than the dialog, and the helpers were never the broken part — saveDoorEditor simply never called the one that records the answer. The tests drive openDoorEditor and saveDoorEditor end to end now, and the DOM mock reflects a select's options into its value, without which the save read whatever a previous block had left on it. The last of the three needed an assertion on a field the two faces actually DIFFER on, because everything else the copy would have written identically.
Doors P2.5. The record carries imagePrompt, image and ignoreArtStyle — named for the record rather than for a new noun, since this file already spells the same pair three ways and a fourth would be a fourth thing to look up. The editor grows a Picture block with Generate, Upload, Remove and the standing Override World Art Style checkbox, matching every other paintable object. The prompt stays subject-only and the world's style is combined in at generation time, so restyling a world never needs a regeneration. SHAPE FOLLOWS KIND, and this is the one place `kind` earns anything mechanical: a door is taller than it is wide, a barrier — a boulder, a rockfall — is more often square. The design insists everywhere else that kind is pure flavour, which is exactly why it is worth a line and a test. Two routes to the picture and only two, both deliberate acts by the player. EXAMINING it is the primary one and the one that had to work first, because it is the only route that works when the GM never names the door in a way anything can match. It is answered before the turn reaches the model, like a bare direction below ground and the container-open intent: the engine holds the description, the state and the picture, so a round trip would spend a turn being told what it already has and might be told differently. No turn is spent, and the GM is told NEXT turn instead, so its narration can acknowledge the look rather than being surprised by a player who already knows what the door looks like. The matcher is deliberately loose where the linkifier is deliberately strict. "the iron-banded door", "iron banded door" and a bare "the door" all find it — but the bare noun only when the room holds exactly one of that kind, because "the door" in a room with two names neither. That looseness is safe because the player TYPED it: a generous match costs a wrong picture at worst, while a generous linkifier would turn every occurrence of the word door in every paragraph into a link. `search` is left alone on purpose — it is the discovery verb P2 and P3 need back, and taking it here would have to be undone. The second route is the exits bar, where the ENGINE wrote the text and therefore knows exactly which door it is naming: the link is by id and needs no matching at all. It sits beside the badge rather than replacing its click, because the player who wants to LOOK and the player who wants to GO are asking different things and one control cannot answer both. GM-narration linking is not built, and the phase always called it last and optional. State reads as prose — "shut", "standing open", "shut, and locked fast, with no keyhole to work at" — because a player shown `state: locked` is reading the engine rather than the world. The method, the key and the pick DC are DM-only, exactly as a container's are. Ten sabotages, ten named failures, after two of them missed at first for the same reason: one anchored on a regex the shell had eaten, and the other cut a fallback that the call site never reaches because it passes the room explicitly. Browser-verified: the editor block, the examine card in the story, and a painted door reaching both faces of the pair.
The doc still read "Proposed — not built" three commits after P1 shipped, and the README row said the same. A design doc that describes a system as unbuilt is worse than one that is out of date on a detail: the next reader plans work that already exists, or looks for a field the doc implies is coming and finds it. Each phase now carries where it stands, including the two that are half-done — picking works and the rest of P3's mechanisms do not, and `kind: "barrier"` is accepted as a noun while carrying none of the mechanics P4 would give it.
Race, Aggression and Currencies sat in a 1fr grid cell at full width, so a one-word race and a three-option select stretched the whole width of the card and read as far more important than the facts printed beside them. All three are 50% now, and sized alike — they are the same kind of thing and were already the only editable rows in that block. The min-width is a floor rather than decoration: below it the Currencies multi-select clips its own option names, and it is the one of the three that cannot shrink to nothing gracefully. Browser-measured at 442 of an 883px column, which also showed something worth fixing beside it. A multiple select draws a scrollbar gutter whether or not anything overflows, so it sat next to two single-line selects looking like the OS had got in. It is styled to the app's own 4px bar rather than hidden — with five currencies and a size capped at four there IS something to scroll, and a hidden scrollbar would then be a list with no sign that it continues.
"Accepts" read as a verb waiting for an object, which on a card of nouns — Race, Aggression, Reputation — was the odd one out. The data key, the setter and the `acceptRow` variable keep their names: they are identifiers, not labels, and renaming them would churn the file without changing anything a DM reads.
Currency P3, and with it the feature does something a player can run into. An NPC card grows an "Accepts" row, the payment step asks what the being deals in, the GM is told before it narrates a sale, and a merchant who deals in gems pays in gems. The row is drawn ONLY when the world has more than one currency. In an ordinary world there is nothing to choose, and a line on every card in the game reading "accepts coin" is noise that teaches a DM to skim the block it sits in. Selecting nothing means the base currency, exactly as an unset field does, so a DM cannot author a merchant who accepts nothing by clearing a box. The setter re-reads the selection in the WORLD's currency order rather than the order options were clicked, because that order is the preference and preference decides the payout — a payout that depended on click order would be invisible to the person who set it. payTo is the chokepoint and returns a sentence, not a boolean. Which currency to spend is the engine's choice, not the player's: the first accepted currency that can COVER the price, falling through to the next when it cannot, so a merchant taking coin and gems is paid in coin while there is coin and nobody spends a gem on bread by accident. The refusal distinguishes the two failures, because they want different things from the player — "he will not take coin" sends them to find gems, "you are 2 gems short" sends them to earn some, and reading one as the other makes a solvable obstacle look like a broken shop. costCopper gains an optional "paidTo". Optional is the whole design: most costs are tolls, fines and upkeep with no person behind them, and making the payee required would have turned every one of them into a refusal. A payee who is not present falls back to coin rather than refusing, so a name the GM got slightly wrong does not close the shop. The dossier flags a being who will not take the base currency, in caps, with the field name to use — the same reason the door dossier names the door: a GM told a merchant is here but not what he takes will narrate a sale the engine then refuses, in front of the player. AND IT SURFACED A PRE-EXISTING BUG IN SELLING. applySellItem called a free `esc`, which is a local in the dozen functions that define one and a parameter in buildRoomCard, and does not exist at top level at all. So every sale threw a ReferenceError — after the goods had left the pack and the coin had been credited, leaving the sale half-done and the player told nothing. It survived because the sell test stubbed a global `esc` into its sandbox: a fixture standing in for exactly the thing that was missing. The stub is gone, applySellItem calls escapeXml, and a new guard scans every top-level function for an esc() it was neither given as a parameter nor defined for itself, because the next function to reach for it will not be this one. Nine sabotages. One caught nothing and was right to: the pre-check inside the currency loop is redundant with the one spendCurrency already performs, so removing it changes no behaviour. Cutting the actual fallthrough fails by name.
Currency P3, first half: the model and the chokepoint. The NPC card, the dossier line and the GM schema
follow; this is the part everything else will call.
Decisions D and E were resolved with the owner before any of it was written, and are recorded in the
design doc. A merchant pays out in the FIRST currency he accepts, so the order on his card is the
preference and there is no second field to keep in sync. Prices display in the base currency wherever an
item is inspected and in the merchant's currency only in the transaction line — re-pricing every list in
his presence was rejected because with nothing on screen in a common unit a player cannot tell whether
they are being fleeced.
An entity's `acceptedCurrencies` is a list of currency ids. EMPTY MEANS THE BASE CURRENCY, never
"nothing": the field is unset on every entity in every world that already exists, and reading that as
"accepts no money" would close every shop in the game the moment this shipped. Ids naming a currency the
world no longer defines are dropped when the list is read rather than at the point of sale, because a
lookup returning null mid-transaction is how a shop closes silently.
payTo() is the chokepoint and it returns { ok, currency, spent, short, why } where `why` is a SENTENCE
whenever ok is false — the same discipline the door gate follows, for the same reason: the answer and
the reason are one value, so a caller cannot take one without the other. The player's purse is mixed, so
WHICH currency to spend is the engine's choice, not theirs — the first accepted currency that can cover
the price, in preference order, so a merchant who takes both is paid in coin while there is coin and
nobody spends a gem on bread by accident. The refusal distinguishes the two failures that want different
things from the player: not enough of what they take, versus the wrong kind of money entirely.
Underneath, the money primitives take a currency. spendCurrency / creditCurrency / currencyPurse do the
work and spendCopper / creditCopper / coinPurse are one-line wrappers on the base, because roughly every
caller is charging coin and should not have to say so. That is what makes a gem price spendable at all;
P1 had generalised the DENOMINATIONS but every path still assumed the coin family.
One test fixture broke on that: creditCopper is a one-liner now, and the helper that lifts a function by
slicing to the next line starting with a closing brace ran straight past it and swallowed a later
constant, which then read as a duplicate declaration. It lifts the real function and restates the
wrapper.Regenerated from an unshallowed clone: the working checkout for this session had been cloned shallow, and the previous report was built from whatever partial history that left, undercounting commits, days active, and lines of code by a wide margin. Ran node tools/gen-progress-report.js after fetching the rest of the history so the stats and day-by-day log match git reality.
The 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 (08a0c0f, 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.101 commits of main, one textual conflict, and one silent hazard the conflict markers could not show. THE CONFLICT was a genuine collision of intents rather than two edits to the same line. Main had lifted generateItemDescription's body into a shared requestItemDescriptionText helper; this branch had changed that same body to send buildSystemPromptBlocks() instead of a prompt string. Resolved in favour of main's helper — the deduplication is the better shape and the branch's edit was to code that no longer needs to exist — with the helper itself switched to the blocks, so both intents survive. THE HAZARD is the reason this merge needed reading rather than resolving. Main added five more GM call sites since the last merge, every one of them written against the string API that predates this branch: requestItemDescriptionText, requestItemIconGlyph, suggestSkillPrompt, requestItemCompletion and requestEntityCompletion. None of them conflicts with anything. They merge cleanly, the prompt they send is correct, the game plays, and each one quietly bills at 1.25x instead of 0.1x because its prefix no longer matches the cached block. All five now send buildSystemPromptBlocks(); the count of cache-marked sites goes from 47 to 52. tests/test_prompt_cache.js is what turned that from a discovery into a checklist item — it asserts that NO call site passes system: buildSystemPrompt(), which is precisely the assertion a merge like this one exists to fail. It failed, and then it passed. The guard was added on this branch after the previous merge taught the same lesson; it earned its keep on the first merge since. CLAUDE.md merged the way it should: main's placeholder line about this branch gave way to the branch's own expanded note, which is where the stable/live rule is supposed to live once this lands. One consequence worth naming rather than leaving. Main's status index was rewritten to rev. 5 while this branch was away, and its first act is a set comparison — the files on disk against the rows in README.md against the documents the page names — added because five documents had never appeared on it. Merged here, that check immediately reports a sixth: prompt-economics.html, which exists on this branch and not on main, so rev. 5 could not have listed it. It has a row now, in §04 with its two open decisions, flagged as branch-only. It will conflict trivially when this lands, which is a better outcome than a branch whose own index cannot see its own document. App suite 617/617, vault suite clean, Electron suite clean, item-taxonomy doc still generated-current.
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 492c961 — 354 commits after rev. 4 read it at ffecd44 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 c573959 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
Thirty-three commits of main, taken before the playtest rather than during it, so anything the test
turns up is attributable to the prompt structure and not to a month of drift.
text_adventure.html merged clean, which is exactly the merge worth distrusting: a textual auto-merge
of a split prompt is the likeliest way a live value lands in the cached half, and it would break
nothing visible. The five conflicts were all in CLAUDE.md and all predicted — main's wording won on
branch policy (this branch predates knowing the deploy target is a test site), the branch's won on the
prompt-cache convention, which is what main's own placeholder said to do when this landed.
The mutation battery could not have caught what the merge brought in, and saying why is the useful
part: the test character is a Warrior, so every spell dossier renders as an empty string and no
mutation of one can move a byte. Main added the loadout-lapse dossier while this branch was away, and
a fixture with no spellbook would have called it cached and meant nothing by it. So placement is now
asserted against the source for all six per-turn dossiers, plus the converse for askRule, which is a
setting and belongs in the cached half. A first pass matched only `${name}` and reported a correctly
placed dossier as missing — some are calls.
Re-measured: 138,757 / 11,664 chars, 92.2% cached, 83.0% saving per call. The rulebook grew by ~6,400
characters and the dossier did not move at all, which is the shape to expect — prompt work lands in
the half that is cached. Doc updated to the new figures.
583/583, which is main's suite plus this branch's three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGLdropItem 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 b4a5347 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 b4a53473f2a997a15dd8137ab8d4453f445baea3.
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
rev. 3. §10-E answers "is the cache working right now" and scrolls away; §13 is the other half — what this playthrough has cost, which is the question a player actually has and which a log cannot hold. Records the decisions worth not re-litigating: why the ledger sits beside the save rather than in the world or the snapshot, why an unpriced model costs null rather than zero, why money and tokens are never on one plot, and why the per-call chart is emphasis rather than two equal series. Also records the two things that only came out of looking at the thing rendered. The palette failed validation on the obvious choice — the app's own gold is outside the dark lightness band and sits at ΔE 11.3 against a warm grey in light mode, below the floor where full-colour readers can separate two lines. And three geometry bugs no assertion would have caught: two CSS rules losing to the generic .modal-box and .modal-btn at equal specificity, and axis ticks like $0.0625. The validator checks colour; nothing but eyes checks geometry, and that is worth writing down where the next chart will be built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
One row per Claude call, kept in its own key rather than in the world or the save snapshot. A world gets exported and shared and what a session cost is nobody's business but the player's; the snapshot is rewritten on a debounce after almost every action and would re-serialize the whole ledger for the sake of one appended row. It is keyed by the same character+world pair the saved game is, so two playthroughs in one world keep separate books, and deleteSavedGame takes the ledger with it — a ledger outliving its save would bill the next character of that name for the last one's game. Cost is derived only from the usage block Anthropic returns, and a model with no published rate prices as NULL rather than zero. A session that looks free is the one way this could actively mislead, so the totals carry a `priced` flag and the dialog shows "$5.88+" with a note rather than presenting a partial sum as a total. The rate table mirrors server/pricing.js because in Vault mode the server prices the same calls — two tables that disagree give one session two bills with no way to tell which is wrong — and the test fails on drift in either direction. The dialog leads with stat tiles, because a single current value is a tile's job and a one-bar chart is the classic way to get that wrong. Then cumulative spend, then cost per call. Money and tokens are never plotted together: two measures on one plot needs two y-scales whose alignment is arbitrary, so it invents a correlation that is not in the data — token counts live in the tiles and the table where they read exactly. Cumulative spend is one series and gets no legend, since a legend with a single swatch restates the title; its endpoint is labelled directly because that number is what the reader came for. Per-call cost is drawn as emphasis rather than two equal series — the raw line is context in a receding grey, the trailing average is the point in the accent. Everything a tooltip shows is also in the table view, so no value is reachable by hover alone. The palette was validated per mode against this dialog's own surface rather than eyeballed, and the obvious choice failed: the app's own gold sits at OKLCH L 0.74, outside the dark band, and against a warm grey in light mode it lands at ΔE 11.3 — below the floor where full-colour readers can separate two lines. Hence a darker gold on dark, a more chromatic one on light, and a cool grey for context in both, clearing the normal-vision floor at 15.9 and 15.2. Rendering it and looking at it caught three things no assertion would have: two CSS rules that lost to the generic .modal-box and .modal-btn on equal specificity (so the dialog opened at 460px with a full-size button), and axis ticks like $0.0625 from rounding the top and dividing by four instead of picking a nice step and letting the top follow. Twelve sabotages. Two were not caught first time and both were the test's fault: nothing checked that the plot ceiling sits a step ABOVE the data rather than on it, and the escaping check was vacuous because the table only renders when opened, so the hostile string was absent rather than escaped. It now asserts the value arrives escaped before asserting it does not arrive raw. Writing the test also found a live crash — usageRows mapped before filtering, so a null row threw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
Every claim in it was checked against the source: the deploy workflow read rather than assumed, the onclick attributes counted (782, matching build-min's "780-odd"), the bug ledger opened to confirm what the BUG-0NN ids in commit messages point at. Where something could not be substantiated it was softened rather than asserted — Notes/ is described by what it holds, not by how well it is maintained, and the test counts are approximate because an exact one is wrong the first time anyone adds a file. It leads with the five things most likely to bite, in the order they would. Merging to main IS a deploy — the FTP workflow fires on a push touching text_adventure.html, guide.html, support.js, Handbook, Images or Web, with no staging step and no approval — and nothing in CI runs the tests, so the suite is a local discipline or it is nothing. Those two together are the ones worth knowing before the first edit rather than after. `npm test` in server/ running a single file is the kind of thing that reads as a green suite when it is one file. And the checkout reverting under this session, four times now, has its own line, because the recovery is only obvious once you have lost work to it. Then the conventions that have each cost real debugging time: the live/stable split and the question that decides it, why no build may rename globals (782 controls resolve by name at click time, so a mangled build loads fine and does nothing), why a roster is never hand-copied into a prompt (the equipment slots went stale twice and gear was silently unequippable), and why a new top-level directory is a decision the denylist cannot make for you. The style section describes what the history actually does rather than prescribing something new — declarative subjects about behaviour, prose bodies that explain the failure behind the change. The testing note says what a test is expected to survive here, since "assert it, then try to break it and confirm each break is caught" is the practice that has repeatedly caught assertions matching the wrong text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
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
Answering "is there a note telling other sessions where a new dossier line goes": there was, and it was in the wrong place. buildSystemPromptParts's header explained why there are two halves, but the two templates sit 630 and 662 lines below it, and the cached one runs another 237 — so an editor adding a section is 700+ lines from the only thing that would have told them. A note nobody scrolls to is not a note. So the rule now sits at all three points. The header states it as the question an editor actually has — "can its value differ between two consecutive turns?" — rather than describing the design, and warns that topic is not the test: a new per-turn combat line belongs in `live`, not next to the combat contract in `stable`, however much better that reads. That is the mistake most likely to be made in good faith. Each template then carries its own marker: `live` says put per-turn state here, `stable` says nothing turn-varying below this line, for the next 240 of them, and both say what happens if you get it wrong — nothing, visibly, while the hit rate goes to zero and calls bill 1.25x. The comments are one line above each template rather than inside it; a `//` one line further down would have become prompt text the GM reads as instruction, so a scratch check confirmed no marker reaches the built prompt. test_prompt_cache pins PLACEMENT, not prose: each marker must sit within a dozen lines of its template. Prose that is correct but far away is the failure being fixed, so a test that only checked the words existed would pass in exactly the state this commit repairs. Four sabotages, four catches. Designs §12 gains the probe direction it was missing. Everything it listed checks the GM still KNOWS things, and the dossier moved to the end of the prompt, where models use context most reliably — so that half likely improved. The trade runs the other way: the prompt used to end with the response contract and now ends with the dossier, pushing the JSON schema back ~3,152 tokens. Small, and re-anchored by each call's directive, but if anything degrades it is malformed JSON rather than a forgotten room. Watch ERROR alongside TOKENS, and note it is a rate rather than an event — worth a short session on main first if that judgement is to mean anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
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
rev. 2. §10-E moves from proposed to shipped with what it actually says, and §12 is new — the in-play test procedure, so "does this hold up" has an answer in the document rather than only in a chat log. §12 splits the question the way it actually splits: whether the cache is hitting (now a readout, with a table mapping each of the four things you can see to what it means) and whether the GM still plays right with the dossier moved. The second is the real risk and the section says so — the prompt bytes are unchanged but the order is not, so it lists the seven probes that target what MOVED rather than suggesting a general play session. Equipment truth is called out as the sharpest single probe, since "never narrate the player striking with unequipped gear" lives in the moved block. Both open decisions in §11 were waiting on evidence §10-E now collects, so their leans say how to collect it rather than restating that it is missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The cache split has an unusual failure mode: it degrades silently and correctly. A live value leaking into the cached half leaves the prompt right, the game playing, and every content test passing, while each call quietly costs 1.25x instead of 0.1x. Nothing announced it, because nothing read `usage` — the client never has, and the vault recorded the cache counts but rendered only Input and Output. Client: gmUsageLine() turns a usage block into one line, logged from gmTextFromResponse — the single extraction all 71 GM call sites route through, so this cannot be missing from the seventy-second the way a per-site call would be. It says which of three things happened, in the words you would judge it by: "cached (92%)" is the steady state, "cache write" is the first call in a window, and "UNCACHED" is the regression, shouty on purpose. A run of writes where you expected hits is that same regression wearing a quieter coat. Raw API field names sit in the collapsed detail, because a percentage is a summary and not evidence. It is its own log type rather than more GM lines, which is what makes it acceptable to log every call instead of only player turns: one line per GM call is a lot over a session, and TOKENS gets its own filter checkbox to switch off once you have stopped watching. Server: the Usage table gains Cached-read and Cache-write columns beside Input. usage.js has recorded both since before any of this existed and pricing.js already billed them at 0.1x and 1.25x — only the render was missing, which left an operator comparing Input across two windows reading a figure that fell while the bill did not. Ten sabotages, ten distinct catches: a write reported as a hit, UNCACHED softened to "0% cached", the readout unwired, the percentage over the wrong denominator, an absent usage block painting an empty row, the detail dropped, the line spilled open, the category unregistered, and both halves of the admin table reading the wrong fields. test_log_filter's roster pin needed updating, which is the pin working — a new log type should be a deliberate edit, the way `xp` was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
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
The analysis behind the cache split, written down so the next person to look at the GM's bill starts from the measurements rather than re-deriving them. Records the things that were surprising enough to be worth keeping. That caching is a per-request field and not something to go looking for on the API key. That the prompt was uncacheable because of where the dossier SAT (9.5%, with 83% of the prompt behind it), not because anything was too long — a section's cost is set by its position relative to the first varying byte. That the split made the prompt better as well as cheaper, since the current state now reads last, next to the player's message. And that the vault needed no change at all, because proxyGM forwards verbatim and pricing.js was already billing cache reads at 0.1x. §08 corrects the record on cadence, which I got wrong first time: the 60-second encounter timer never calls the GM — tryEncounter rolls locally and spawns — and the ambient timers gate down to ≤0.27 calls/min in the built-in world, zero when the player is alone. Far from being the cost problem, at that rate they keep the 5-minute window warm for free. §09 answers why an ambient beat carries the whole rulebook anyway: voice grounding it genuinely needs, plus "your usual JSON object", which is defined in the response contract. §10 parks six enhancements with their reasoning, including two whose answers moved while writing it. The trimmed ambient prompt was clearly worth doing before the split and is marginal after it, because a second prompt is a second cache entry that stops subsidizing the player's. And caching the conversation history looked easy until conversationHistory turned out to be trimmed with slice(-40) — once at cap, every turn shifts the messages prefix by one, so a breakpoint there would hit for twenty turns and then miss forever, which is worse than not placing it. Trimming in chunks is the fix, and that is the kind of thing worth finding on paper rather than in a bill. Claims checked against the source rather than remembered: call sites counted (71, not the 69 a stale code comment still says), test files counted, the history cap read, the weapon carve-out's 2,101 chars measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
The Messages API caches by PREFIX: the reusable region ends at the first byte that differs. The GM system prompt is ~38.5k tokens and the live dossier — HP, inventory, equipment, the room, who is standing in it — sat at 9.5% of it, with the entire ~35k-token rulebook behind. Nothing behind a value that changes every turn can be cached, so every call re-read the whole thing at full price. buildSystemPrompt() is now a thin joiner over buildSystemPromptParts(), which returns the two halves in cache order: `stable` (world, lore, quests, map, rules, response format) then `live` (the dossier, lifted whole out of the middle). buildSystemPromptBlocks() puts one ephemeral breakpoint on the first, and the 48 call sites plus sendToLLM send that. Measured on the built-in world: 132,309 chars cached, 11,664 live — 91.9% of the prompt, ~35.8k tokens, against ~3.1k re-read per turn. Nothing to configure on the API key; caching has always been a per-request field. The vault needed no change either — proxyGM forwards the body verbatim, its GM logger already joined array-form `system`, and pricing.js already bills cache reads at 0.1x and writes at 1.25x, so the meter reports the saving the day this ships. The dossier moving to the END is also the better prompt: current state is now the last thing the GM reads before the player's message rather than something buried ahead of the rules. One known write outside a turn, pinned rather than hidden: the weapon-damage carve-out is conditional on a statted weapon being equipped and lives in the rulebook, so first equipping one rewrites the cached half. Once per session, not once per turn, and the alternative is a statless world's prompt naming a field it isn't offered. test_prompt_cache.js is the guard. The degradation this change invites is silent — one live value added to `stable` drops the hit rate to zero and fails no test that reads prompt CONTENT — so it tests prompt STABILITY: ten ordinary turn mutations must each leave the cached half byte-identical AND move the live half, the halves must join to exactly what the string builder returns, and no call site may send the joined string (which works, and never hits the cache). Ten sabotages, ten distinct catches. The leak diagnostic reports the first byte that moved, because the commonest leak is the same width before and after. The ten touched tests pinned the old call shape; each now pins the new one, saying what it said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKPYwZ1iew7gqhFNYRHrGL
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 a72f6e7 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 f6139ae: 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 8b0e7e9 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 b582a68; 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. eea45e5 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 eea45e5 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
The Lost Realms · Copyright © 2026 Brave You Worlds, LLC. All rights reserved.
This report is Game Content under LICENSE-CONTENT, not software under LICENSE:
it has no Change Date and does not become open source on any future date, the way the engine does.
Free to read, quote and build against; selling it, redistributing it, or using it in anything you take
revenue from needs a separate licence —
COMMERCIAL-LICENSE.md · licensing@braveyouworlds.com