Opening a finished adventure fetched every action in one response: 589.5 kB on
production's longest, and nothing about that curve bends on its own, because a
story only ever gets longer. The page load now brings the newest 60 actions
and the reader pages up from there. On the harness's 600-action fixture that
is 606.0 kB down to 62.6 kB, and -- the part that matters -- it no longer
depends on how long the story is.
Paged by anchor, not by offset. `before_id` is the oldest action the caller
holds; the server returns what precedes it. An offset counted back from the
newest would shift every older position the moment a turn lands, which is
exactly when someone is likely to be scrolling, and the reader would get one
action twice and never see another. It also keeps working when the story stops
being a flat list: comparing indices to order a branch survives the story tree,
treating them as positions does not.
`has_more` comes from fetching one row past the window rather than from
counting. A deleted anchor -- undo, mid-scroll -- reports the end rather than
guessing and serving a page the reader already has.
GET /{id}/actions and POST /{id}/undo now return {actions, total, has_more}
instead of a bare list. Undo is the action most likely to be repeated several
times running, so having it re-fetch the whole story would have undone the
paging on the worst case.
The adventure payload gets its window through set_committed_value rather than
by assignment: the actions relationship cascades delete-orphan, so assigning a
60-item list to it would delete everything outside the window on the next
flush.
In Play.jsx the prepend is followed by a useLayoutEffect that restores the
scroll position, before paint, so the story does not jump. Loading starts 400px
from the top rather than at it, guarded by a ref because scroll fires far
faster than React re-renders. There is a button as well as the scroll trigger:
on a short viewport the transcript may not be tall enough to scroll at all, and
a reader who cannot scroll must still be able to reach the beginning.
Verified against a running backend and a 220-action adventure: the page load
returns 60 of 220 ending on the newest, and walking back from an anchor returns
exactly the actions before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
The bar ran in prompt order while the legend below it ranked by share, so
reading one against the other meant matching colours back and forth. Sort
the segments the same way the legend already sorts, so the widest slice is
always the leftmost and the two read as one list. The sections further
down the panel keep prompt order — that is the order the model sees.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011BC7Dsg4gSQm3NLcZcG5SK
The panel reported one number for the whole prompt and one flat fill bar,
so "3412 / 4000 tokens" never said which section was spending it. Replace
the fill with a stacked bar — one segment per context section, scaled to
the budget so the leftover width is the remaining headroom — plus a legend
ordered biggest-first with each section's share and token count. Hovering
either the bar or a legend row dims the rest; clicking a row jumps to that
section's text below, and each section header now carries its own share.
Section colours move out of CSS into a single SECTION_COLORS map applied
inline, so the bar, the legend and the section headers cannot drift apart,
and related sections keep their hue family without sharing an exact shade
(two identical colours read as one segment in a stacked bar). The narrow
side panel only fits one legend column, so the long tail folds behind a
"+N smaller sections" toggle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011BC7Dsg4gSQm3NLcZcG5SK
Retry used to delete the last AI action and generate a replacement, so the
discarded narration was simply gone. The row survives now: each attempt is
appended to actions.variants with variant_index naming the live one, and a
ChatGPT-style pager under the message browses them.
Action.text still mirrors the active variant, so the context builder, memory
bank, summarizer and export needed no changes. A variant carries only what
differs between attempts -- the text, the reasoning, and the state it
produced -- never the assembled prompt, which is identical across attempts of
one turn and is the bulk of context_snapshot.
Only the last message can be switched, restoring the script/world state that
attempt produced; earlier turns were written as a continuation of whatever is
active there, so theirs are read-only previews.
Three things that would otherwise bite:
- generate_turn now wraps _generate_turn and watches for a save sentinel. If
the generator ends without it (provider error, empty reply, script stop,
client hangup) it re-applies the previous variant -- otherwise a failed
retry leaves rolled-back stats under un-rolled-back text.
- Retry reuses the turn's own index rather than next_index, or the clock the
world-state cooldowns run on advances on a re-run of the same turn.
- Editing a message rewrites the active variant too, or paging away and back
silently reverts the edit.
Migrations 34/35 verified as an upgrade against a populated database, not
just a fresh schema. test_state_revert's retry test asserted the old delete
behaviour and was rewritten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
NPCs are still part of stat_schema, but editing them inside the World State
panel meant a nested dashed sub-box among four flat stat lists. They now get
a top-level "Cast & NPCs" section: one card per NPC (avatar, name, npc.<id>
address, triggers, description, its own stats) in a responsive grid, saved
through the same schema path via the new NpcEditor/addNpc exports.
The panel's real inconsistency was CSS, not layout: the global field rule
keys off input[type="..."], which never matches the editor's typeless key and
description inputs, so those rendered with browser defaults beside properly
styled number boxes. One scoped base rule now covers the whole editor and
every entry is the same tile with a caption over each control.
Two bugs fell out of that: .se-band-n (0,1,0) always lost to
input[type="number"] (0,1,1), so band bounds rendered full-width; and the
exclusion has to be :not(:where([type="checkbox"])) because a plain :not()
inherits its argument's specificity and swallows every override.
In Play, NPCs group under a single "Cast" heading as compact plates instead
of each becoming another top-level group in the rail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
An adventure copies its scenario's plot text and story cards at creation so
later authoring never disturbs a story in progress. This is the explicit
opt-out, alongside the existing per-script "Sync from library".
GET /adventures/{id}/refresh returns a plan (per-field old/new diff, card
add/update/remove, world-state added/removed paths, and any ${...} answers
still needed); POST applies it under the turn lock so it can't race a
generating turn. The Plot panel shows the plan in a confirm modal first.
Overwrites the plot fields and scenario-derived cards. Deliberately left
alone: the opening `start` action (the story is built on it, and it is baked
into memories and the summary), the adventure's own title and summary,
player-authored story cards, and the live value of every stat the schema
still defines.
Two enablers were needed:
- adventures.placeholders (migration 32). ${...} answers were used once at
creation and discarded, so re-copying scenario text would have re-injected
a literal ${Hero}. Adventures predating the column re-prompt once via the
existing modal, then the answers are saved.
- story_cards.source_ref (migration 33), "card:<id>" / "npc:<key>", NULL for
player-authored. Adventure cards had no link back to their source, so a
rename read as delete-plus-add and player cards would have been clobbered.
Legacy cards name-match once, then adopt the ref.
World state goes through a new worldstate.reconcile(): keep values the schema
still defines, add missing ones at their initial, drop removed ones and their
cooldown bookkeeping. Not instantiate(), which would heal the player to full
and wipe their milestones.
The confirm modal is portalled to <body>: .side-panel's panel-in animation
has fill mode `both`, which makes it the containing block for position:fixed
descendants, so an overlay rendered in place was trapped in the 420px panel
and clipped by its overflow.
14 new tests in backend/tests/test_scenario_refresh.py; 85 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzBCyXH4hBEVHqEaertcq4
Editing a beat opened a fixed 110px box. Player actions are one line, but AI
beats are several paragraphs, so editing one meant working through a keyhole.
New AutoTextarea sizes to its content on mount and on every change; CSS keeps a
110px floor so a short player action doesn't collapse, and a 65vh ceiling that
scrolls internally so Save/Cancel can't be pushed off the screen.
On phones both composers are one flex row, which left almost nothing to type
into: measured at a 390px composer, the Play textarea got 138px (Do/Say/Story
eat ~180px, Send ~75px) and the chat textarea 310px. Both now wrap, with the
buttons on their own line and the textarea taking the full 390px — Play keeps
Do/Say/Story and Send together on the top row, chat drops Send beneath. Play
stays two rows, so nothing gets taller. Desktop is unchanged; every rule is
inside the existing max-width:720px block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
Rework the home page into a single landing surface and give the app a
consistent visual language, chosen as "illuminated tome" over two other
pitched directions because it builds on the existing Cinzel + gold identity
instead of replacing it.
Home is now Continue (up to 4 in-progress stories, each showing where you
left off) over a scenario shelf, each section with a "See all" link. The
full adventure list moves to /adventures.
Scenario cover art has three tiers, in precedence order: an uploaded picture
(downscaled client-side to 400px WebP before storing), an emoji, or gradient
art generated from a hash of the title so no card is ever an empty box.
Adventures inherit their scenario's art. Images live in the row rather than
on disk because Render's free tier has no persistent volume, and it keeps
export bundles self-contained; list responses carry a cacheable
/api/scenarios/{id}/image URL rather than the base64.
Also: ambient drifting motes behind the app, loading skeletons, staggered
card entrance, ornamental scene breaks and a drop cap in the story, a
"Weaving" thinking indicator, and a toast system replacing every alert().
Two fixes found along the way:
- Importing a scenario bundle with no "tags" key returned a 500. Column
defaults are not applied until flush, so the attribute was still None
when the width clamp sliced it.
- Anything meaning "the story's latest narration" was missing action type
"start", which is the only text a freshly created adventure has, so new
adventures looked empty. Collected as NARRATION_TYPES.
Migrations 30 and 31 add scenarios.image and scenarios.icon; both are
additive with a '' default and were verified against a database stamped at
29. vite.config.js now reads AIDND_API_PORT so the recurring port-8000
clash with another local app needs no file edit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
Players/authors can now directly correct the live values of stats the
schema already defines (health, trust, flags, milestones, etc.) without
waiting for the AI to emit a delta. New apply_override() sets values
absolutely rather than adding deltas, and — unlike the AI-facing
apply_delta() — bypasses cooldown/max_delta_per_turn and lets
milestones be un-set, since this is a deliberate correction rather
than a turn to police. Exposed via PUT /adventures/{id}/world-state
and an edit toggle in the World State drawer. Also fixes the
schema-editor stat-kind dropdown and free-text initial-value input
to size consistently with the numeric fields next to them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Numeric stats couldn't represent things like worn armor or held items.
A stat can now be marked type: "text" — the AI reports the new full
value instead of a delta, with no clamping/bands (cooldown still
applies). Updated the scenario editor's stat kind selector, the
World State drawer's stat display, and the emit-rule prompt; added
tests and an outfit stat to the RPG demo scenario.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each AI action now carries a compact world_changes summary (derived from its
stored snapshot), rendered as small chips beneath the message: numeric stats
show a signed delta (green up / red down), flags show on/off, milestones show
a check. Gives an at-a-glance "what changed" without opening Insights.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Store the model's literal reply (before the world-state block is stripped)
in each turn's snapshot, and render it in the Insights panel when inspecting
a past AI message (the 🔍 button). Makes it possible to see exactly what the
model emitted — including whether it sent a state delta and with what paths —
which is the fastest way to debug under-reported or misrouted stat changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single shared `npc` stat template (+ npc_card_types) with an
`npcs` section: each NPC keyed by a stable id, carrying its own name,
description, trigger keys, and its OWN stats block. The AI addresses NPCs
as npc.<id>.<stat> (id shown in context), which also fixes the old
card-id-guessing problem. On adventure creation each NPC auto-creates a
story card (name/keys/desc) for lore + in-scene detection, unless a
same-name card already exists. All NPCs instantiate up front.
- engine: npcs instantiate/apply/render/reference, npc_name/npc_triggers
- builder: _visible_npcs matches each NPC's own keys
- create_adventure: auto-create story cards from npcs
- WorldStateDrawer: render defined NPCs with their own stats + desc tooltip
- demo seed: Gwen (health/trust) + Bandit Leader (health/aggression)
- tests updated (34 pass); no new migration (npcs lives in stat_schema JSON)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Structured world/player/NPC stats, two-way flags, and sticky milestones
per scenario (stat_schema). The AI proposes a per-turn delta; a Python
engine referees it (clamp to min/max, per-turn cap, cooldown, counters).
Band word-labels plus a fixed stat guide (descriptions + full ranges)
keep the model grounded. World State drawer + Insights delta report;
undo/retry roll it back via the Phase 11 snapshot pattern.
- migrations 26-28 (scenarios.stat_schema, adventures.world_state,
actions.world_state_before); all nullable, additive, safe on existing rows
- migration 29 raises the default context budget 4096 -> 16384
(custom values preserved)
- seeded demo scenario 04-rpg-world-state.json (Bandit Camp)
- 19 new tests (33 total pass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swap the single-line action <input> for a <textarea> that grows with its
content (capped at ~4 lines, then scrolls) and shrinks back after send.
Enter still sends; Shift+Enter inserts a newline. Buttons stay bottom-aligned
as it grows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
The State drawer stringified non-string values with JSON.stringify, so
nested objects/arrays showed as raw JSON. Add a recursive StateValue /
StateTree renderer: primitives are typed and coloured, objects/arrays are
collapsible and indented so deep state shows its structure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
Adventure scripts are frozen snapshots taken at creation. Add an opt-in
way to pull the latest library code into a live adventure:
- AdventureScript.source_script_id links a copy to its library Script
(migration 24; set at adventure creation)
- list endpoint flags out_of_date by diffing copy vs library
- POST /adventures/{id}/scripts/{sid}/sync overwrites the copy's code,
preserving enabled/position/script_state
- legacy copies with no link fall back to a name match, then adopt the link
- Play page shows a Sync button only when a copy differs from its library
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
Each script in the panel can now be downloaded as an import-compatible
JSON bundle (matching the /scripts export format), so demo/server-owned
scripts can be forked into a user's own library via the Scripts page's
Import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
The panel only showed a name + on/off toggle, so demo (server-owned)
scripts — which never appear on the personal Scripts page — had no
in-app way to inspect their code. AdventureScriptOut already ships the
hook sources, so add a "View code" expander that renders the non-empty
hooks (library / onInput / onModelContext / onOutput) read-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
Exposes the scripting `state` object (every variable scripts read/write
via state.x, persisted per-adventure) in a collapsible left rail that
refreshes after each turn. New owner-scoped GET
/api/adventures/{id}/script-state endpoint; the drawer only fetches
while open and shows an empty-state hint until a script sets a variable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
Add GET /api/story-cards/export and POST /api/story-cards/import for a
scenario or adventure, using the AI Dungeon world-info array shape
(keys/value/type/title/description/useForCharacterCreation) mapped to our
columns. Import accepts a bare array or {cards|storyCards|worldInfo:[...]},
enforces the per-owner cap (existing + incoming) and import rate limit.
Export/Import buttons added to the scenario editor and the in-play Plot
panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
- Pin the play header (Plot/Memory/Scripts/Insights toggles) just below
the top nav so they stay reachable without scrolling back up to the
first message.
- Fix streaming autoscroll: snap to the real document bottom instantly
instead of smooth-scrolling to storyEndRef. That ref sits above the
sticky composer, so block:'end' stopped short — hiding the latest line
behind the input bar and yanking the reader back up when they scrolled
down. Smooth behavior also never settled at per-token speed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEpWMjnfPqzZ13nPMoGhs5
Backend:
- provider: fall back to parsing a plain JSON body when a server ignores
stream=true (was: silent empty turn); error if response has no text (#5)
- memorybank: clamp cursors after undo/retry shrinks the action list, and
translate summary_cursor (list position) to an Action.index boundary
before comparing with Memory.source_end (#7)
- memorybank: pinned memories now count toward the top_k budget (#8)
- memorybank: cosine() returns 0.0 on dimension mismatch; changing the
embedding model clears stored vectors so they re-embed (#9)
- scripting: MAX_STORY_CARDS cap now counts cards inserted during the
hook, so a script can't add unbounded cards in one turn (#10)
- settings: /test tolerates non-dict JSON from /models (#13)
- scenarios: import accepts worldInformation as a story-card source (#14)
Frontend:
- per-key debounce timers in PlotPanel and ScenarioEditor — editing two
things within 600ms no longer drops the first save (#15, #16)
- Continue button no longer discards typed input (#17)
- failed retry resyncs actions from the server instead of leaving the
removed action missing (#18)
- Settings save/test surface errors instead of hanging on Testing… (#19)
- InsightsPanel ignores stale responses from superseded requests (#20)
- placeholder scan includes story-card trigger keys (#21)
addStoryCard returning the 0-based index (falsy for the first card) matches
real AI Dungeon per the scripting guidebook — kept, documented (#11).
Statuses updated in CODE_REVIEW_FINDINGS.md; stale entries for previously
fixed items (#1-4, #6, #12) corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg