From b1772c6e21be3d6592af44c830563f0eec7d73db Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sat, 29 Aug 2026 00:36:21 +0530 Subject: [PATCH 01/11] Plan the readability refactor, and clear the tree for it Phase 17 splits the four files that hold most of the code, finishes the schema migration SP8 left half done, and stops the published guide from drifting away from its Markdown source. `plan/17-refactor.md` carries the plan and the progress table, and `plan/STATUS.md` points at it. Stage 0 is hygiene only. Both abandoned worktrees are gone, which freed about 104 MB. Removing `sp7-tree-ui` needed one extra step: a Vite dev server had been running out of it since 2026-08-18, holding `frontend/.vite` open and owning port 5173, and serving a tree 54 commits behind `main`. The three stale `.db` files are deleted; `data.db` is not. `AIDND_TRUSTED_PROXY_HOPS` is now documented. It was read at `limits.py:55` and named in no `.env.example`, README, or blueprint. It sets how many proxy hops the rate limiter trusts in `X-Forwarded-For`, so a deployment that adds a hop without setting it gets the bucket-rotation bypass back. The 19 squash-landed branches are still there. `git branch -D` is blocked by the permission classifier; the verified command is in the plan file. 549 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r --- README.md | 6 + backend/.env.example | 12 ++ plan/17-refactor.md | 406 +++++++++++++++++++++++++++++++++++++++++++ plan/STATUS.md | 4 + 4 files changed, 428 insertions(+) create mode 100644 plan/17-refactor.md diff --git a/README.md b/README.md index 7382a27..210f393 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,12 @@ that takes about 30 to 60 seconds to wake it. Point any keep-warm pinger at `/ap which deliberately doesn't touch the database: waking the database around the clock costs far more than the cold start saves. +If you put another proxy or CDN in front of Render, set `AIDND_TRUSTED_PROXY_HOPS` to the +number of proxies in the chain. It defaults to 1. The rate limiter reads the client IP that +many entries from the right of `X-Forwarded-For`, because the trusted edge appends the real +one last. Leave it at 1 behind two proxies and the limiter reads an entry the caller +supplied, so anyone can rotate the header for a fresh rate-limit bucket per request. + ## Repo notes - `plan/` holds the phased implementation plan this project was built from, kept as a build diff --git a/backend/.env.example b/backend/.env.example index 335e2f7..97e7e5c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,6 +25,18 @@ AIDND_DATABASE_URL= # Default: http://localhost:5173,http://127.0.0.1:5173 (the Vite dev server). AIDND_CORS_ORIGINS= +# How many proxy hops the rate limiter trusts in `X-Forwarded-For`. It reads +# the entry that many places from the right, because the trusted edge appends +# the real client IP last. Set this to the number of proxies in front of the +# app. Default: 1, which is correct for a single edge such as Render. +# +# Get it wrong in either direction and the rate limits weaken. Too low reads an +# entry the caller supplied, so anyone can rotate the header for a fresh +# rate-limit bucket per request and walk past the auth and guest limits. Too +# high reads past the real client. Only multi-user mode rate-limits at all, so +# local installs can ignore this. +AIDND_TRUSTED_PROXY_HOPS= + # --------------------------------------------------------------------------- # Phase 8 — optional accounts & multi-user (all optional; defaults keep the # app in frictionless single-user "local mode") diff --git a/plan/17-refactor.md b/plan/17-refactor.md new file mode 100644 index 0000000..9e9ff05 --- /dev/null +++ b/plan/17-refactor.md @@ -0,0 +1,406 @@ +# Phase 17: refactor for readability + +The code works and it is tested. It is also hard to read, because four files hold +most of it, one schema migration is half finished, and the published guide is a +hand-maintained copy of another file. This phase fixes those three things without +changing what the app does. + +## How to use this file + +This file is both the plan and the running status. Update the progress table at +the end of every stage. Read the table first when you pick the work back up. + +Each stage lands as its own commit on `refactor-17`. No stage starts until the +stage before it is green. + +## Progress + +| Stage | Work | Status | Landed | +|---|---|---|---| +| 0 | Hygiene: worktrees, branches, undocumented settings | done, except the branch deletion | 2026-08-29 | +| 1 | Split the four largest files | not started | | +| 2 | Remove duplication | not started | | +| 3 | SP8: drop the legacy columns | not started | | +| 4 | Documentation | not started | | +| 5 | A frontend test runner | not started | | + +## Baseline + +Record these numbers before you start. They are how you tell a refactor from a +rewrite. + +- 549 backend tests pass in 103 seconds. +- `backend/app` holds 13,486 lines across 45 files. +- `backend/tests` holds 12,221 lines across 38 files. +- `frontend/src` holds 9,171 lines across 21 files. +- The app exposes 70 endpoints and applies 64 migrations on boot. + +Run the suite with `cd backend && .venv/Scripts/python.exe -m pytest tests/ -q`. + +## What the review found + +### The code is clean at the statement level + +A scan for unreferenced functions across `backend/app` returned only FastAPI route +handlers, which the decorator references rather than the name. A scan of +`index.css` found 5 unused class names out of 373, and every one of the 5 is either +CodeMirror's or built from a template string. There is no dead code to delete and +no `TODO` or `FIXME` anywhere in the tree. + +Read that as a constraint. The gains in this phase come from moving code, not from +finding rot. + +### Four files hold most of the complexity + +| File | Lines | What it holds | +|---|---|---| +| `backend/app/routers/adventures.py` | 2353 | Nine concerns: the turn engine, branches, takes, import and export, adventure scripts, refresh from scenario, insights, memory CRUD, and action CRUD | +| `frontend/src/pages/Play.jsx` | 2280 | 27 components, plus a 722-line `Play()` holding 18 `useState` calls and 7 `useEffect` calls | +| `frontend/src/index.css` | 2865 | One stylesheet. The `max-width: 720px` block at line 2655 overrides rules written 2000 lines above it | +| `backend/app/worldstate/engine.py` | 918 | Four jobs: parse a delta, apply a delta, render the context sections, and instantiate a schema | + +### The schema is half migrated + +`plan/14-phase-story-tree.md` defines SP8, which drops the columns the tree +replaced. SP8 was gated on the tree running in production. It now does. Eight +columns are still written on every turn and read by nothing: + +- `actions.index`, `variants`, `variant_index`, `variant_count` +- `actions.state_before`, `world_state_before` +- `adventures.memory_cursor`, `summary_cursor` + +Until they go, `models.py` carries two vocabularies for one idea, and every reader +has to be told which one is live. + +### The published guide is a hand-written copy + +`docs/guide.html` is 84 KB of hand-written HTML covering the same material as the +63 KB `docs/GUIDE.md`. Nothing generates one from the other. They have already +drifted: the HTML last changed on 2026-08-20 and the Markdown on 2026-08-28, and +section 3.6, "Counting visits", exists only in the Markdown. GitHub Pages publishes +the HTML, so readers get the stale copy. `docs/architecture.html` is 87 KB of +hand-written HTML with no Markdown source at all. + +### Duplication the tests already protect + +- `worldstate.apply_delta` and `apply_override` each re-implement the same path + routing for `flags.`, `milestones.`, and `npc..`. That is six parallel + branches at `engine.py:573`, `587`, `617`, `675`, `697`, and `740`. +- 35 of the 38 test files repeat the same 8-line temporary-database prologue. There + is no `conftest.py`. +- `ScriptedProvider` or `FakeProvider` is defined 12 times, once per test file that + needs a fake model. +- The debounced autosave helper is copied into `Play.jsx:204`, + `ScenarioEditor.jsx:23`, and `ScriptEditor.jsx:22`. +- `get_adventure_or_404` is called by hand in about 20 handlers. It is a function, + not a dependency, so every handler repeats the call. +- `routers/chat.py:22` imports `SSE_HEADERS` and `sse` from `routers/adventures.py`. + One router reaches into another for shared plumbing. + +### One setting is documented nowhere + +`AIDND_TRUSTED_PROXY_HOPS` is read at `limits.py:55`. It does not appear in +`backend/.env.example`, `README.md`, or `render.yaml`. It sets how many proxy hops +`client_ip()` trusts, which is what stops a rotated `X-Forwarded-For` header from +buying a fresh rate-limit bucket. If a deployment adds a proxy hop and nobody sets +this variable, the bypass comes back silently. + +The unprefixed `DATABASE_URL` that `database.py:24` also accepts turned out to be +documented already, inside the `AIDND_DATABASE_URL` entry in +`backend/.env.example`. No change needed there. + +### `Settings.stream` is dead + +`models.py:622` defines the column, and `schemas.py:452` and `499` expose it. +Nothing reads it in the backend or the frontend. This is item S1 in +`docs/self-review.md`. + +### Local clutter + +Everything here is gitignored, so it costs repository weight nothing. It costs disk +and attention. + +- Two abandoned worktrees, each a full copy of the tree: + `.claude/worktrees/simplify-comments` and `.claude/worktrees/sp7-tree-ui`. Both + branches landed on `main` as squashes. +- 21 local branches. 13 read as unmerged to `git branch --no-merged`, and + `plan/STATUS.md` already records that they landed as squashes. +- Three stale SQLite files in `backend/`: `data.backup-2026-07-06.db`, + `data.backup-pre-phase8.db`, and `scroll_fixture.db`, 2.9 MB together. The live + local database, `backend/data.db`, is not one of them and is not touched. + +## Stage 0: hygiene + +No source changes. Do this first, so later stages run against a quiet tree. + +1. Remove both worktrees with `git worktree remove`, then delete the branches that + landed as squashes. Verify each one first: a branch is safe to delete when + `git log main..` names only commits whose message already appears in + `git log main`. +2. Delete the three stale `.db` files. Leave `backend/data.db` alone. +3. Add `AIDND_TRUSTED_PROXY_HOPS` to `backend/.env.example`, with the reason it + exists. Add it to the deploy section of `README.md` too, because that is where + somebody putting the app behind another proxy looks. + +**Check:** the suite still passes, and `git status` is clean. + +### What Stage 0 actually did, 2026-08-29 + +Both worktrees are gone, which freed about 104 MB. Removing `sp7-tree-ui` needed one +extra step: a Vite dev server had been running out of that worktree since +2026-08-18, holding `frontend/.vite` open and owning port 5173. It was serving a tree +54 commits behind `main`. Stopping it freed the directory. Restart the real one with +`start.ps1`. + +The three stale `.db` files are deleted. `backend/data.db` is untouched. + +`AIDND_TRUSTED_PROXY_HOPS` is now in `backend/.env.example` and in the README deploy +section. + +**Still owed.** The 19 squash-landed branches are still there. `git branch -D` is +blocked by the permission classifier, which is a reasonable guard on a destructive +command. All 19 are verified safe by the rule above. Run this to clear them: + +``` +git branch -D bugfix-code-review docs-story-tree fix-prepend-autoscroll \ + fix-silent-clamps-and-milestone-ids fix-store-refusals-and-delta-wording \ + handover-2026-08-17 measure-post-vacuum-sizes phase-14-story-tree \ + phase-7-public-repo phase-8-accounts sp1-tree-schema sp10-memory-bank-eviction \ + sp2-branch-clause sp3-node-cursors sp4-sibling-nodes sp5-fork-on-continue \ + sp6-bundle-v2 sp7-tree-ui sp7b-take-pager worktree-keep-fixture-and-status \ + worktree-simplify-comments +``` + +`worktree-keep-fixture-and-status` is the one that needed checking by hand. Its +commit subject appears nowhere in `main`, but the `--keep` flag it adds is on `main` +at `tools/stress_session.py:764`, along with both gotchas the message describes, at +`:686` and `:692`. Only the message differs. + +The remote branches are left alone. Deleting those is a separate decision. + +## Stage 1: split the four largest files + +Every change in this stage moves code. None of it changes behavior. The 549 tests +are the check, and they must pass without being edited, except where this section +says otherwise. + +### `routers/adventures.py` becomes a package + +Split it into `backend/app/routers/adventures/`: + +| Module | Holds | Source lines | +|---|---|---| +| `__init__.py` | The `APIRouter`, the shared dependencies, and re-exports | | +| `paging.py` | `ACTION_LIST_COLUMNS`, `ACTION_PAGE`, `action_window`, `annotate_takes`, `current_window` | 27-146, 1203-1261 | +| `crud.py` | List, create, get, patch, delete, plus the script-state and world-state readers | 225-580 | +| `turns.py` | The turn engine: `generate_turn`, `_generate_turn`, `run_player_turn`, the turn lock, and the SSE helpers | 581-1004 | +| `takes.py` | Retry, variants, takes, forking, and undo | 1005-1192, 1503-1778 | +| `branches.py` | The branch endpoints | 1193-1502 | +| `bundle_io.py` | Export and import | 1779-1837 | +| `scripts.py` | The per-adventure script endpoints | 1838-1941 | +| `refresh.py` | Refresh from scenario | 1942-2152 | +| `insights.py` | The context dry run and the per-action context | 2153-2189 | +| `memories.py` | Memory bank CRUD | 2190-2279 | + +Target no file above 450 lines. + +**The tests couple to the module, so read this before you start.** Twelve test +files call `monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ...)`. +`monkeypatch` replaces a name in the module where the calling code looks it up, so +re-exporting from `__init__.py` does not keep those patches working. Once +`_generate_turn` lives in `turns.py`, the target becomes +`adventures.turns.OpenAICompatibleProvider`. + +Do Stage 2's `conftest.py` work first if you want that to be a one-line change +instead of twelve. Otherwise retarget all twelve here. The other patched names are +`adventures.limits`, `adventures.check_demo_cap`, `adventures.generate_turn`, and +`adventures._active_turns`, each used once. + +Keep these importable from the package root, because tests import them by name: +`ACTION_PAGE`, `SNIPPET_MAX`, `_snippet`, `world_delta_of`, `acquire_turn_lock`, +`retry_action`, `undo_turn`, and `_active_turns`. + +`_active_turns` is module-level mutable state guarded by a lock. It must live in +exactly one module, `turns.py`, and every other module must import the module and +reach through it. If two modules import the set by value, the lock guards two +different sets and the turn lock stops working. + +### `worldstate/engine.py` becomes a package + +Split `backend/app/worldstate/engine.py` into four modules under +`backend/app/worldstate/`: + +- `schema.py`: `has_schema`, `instantiate`, `reconcile`, `band_label`, `npc_name`, + `npc_triggers`, `_initials`. +- `parse.py`: `extract_delta`, `_tolerant_load`, `render_delta_block`, + `applied_delta`, `refusals`, `render_refusals`. +- `apply.py`: `apply_delta`, `apply_override`, and the shared path resolver Stage 2 + introduces. +- `render.py`: `render_state_section`, `render_reference`, `_stat_line`, + `_describe_stat`. + +`worldstate/__init__.py` re-exports every public name it exports today, so no call +site changes. The engine is imported as a module, not monkeypatched, so this split +carries none of the router's test coupling. + +### `pages/Play.jsx` becomes a directory + +Split it into `frontend/src/pages/Play/`: + +- `index.jsx`: the page component. +- `usePlaySession.js`: the 18 `useState` calls and 7 `useEffect` calls that drive + one adventure, behind one hook. +- `panels/`: `PlotPanel`, `MemoryPanel`, `ScriptsPanel`, `InsightsPanel`, + `BranchPanel`. +- `drawers/`: `StatusDrawer`, `WorldStateDrawer`, and the `StatRow`, `StatGroup`, + `StateTree`, and `StateValue` parts they use. +- `reports/`: `StateChangeChips`, `WorldStateReport`, `ScriptReport`, + `CacheReport`, `TokenBreakdown`. +- `TakePager.jsx`, `RefreshModal.jsx`. + +There is no frontend test runner until Stage 5, so this split is verified by +`npm run lint`, `npm run build`, and by driving the Play screen in a browser. Drive +it. `plan/STATUS.md` records that the last two Play bugs were both found by hand and +were unreachable from any test that existed. + +### `index.css` becomes a directory + +Split it into `frontend/src/styles/`, imported in order from `index.css`: +`tokens.css`, `base.css`, `nav.css`, `cards.css`, `play.css`, `drawers.css`, +`schema-editor.css`, `insights.css`, `modals.css`, and `theme.css`. + +Move each `@media` block next to the rules it overrides, rather than leaving one +`max-width: 720px` block at the end. Keep the source order identical when you move +rules, because CSS resolves ties by order and the file relies on that in at least +two known places, recorded in `plan/STATUS.md` and in the comments at +`index.css:1011` and `1082`. + +**Check:** 549 tests pass, `npm run lint` and `npm run build` are clean, and the +Play screen works in a browser at desktop and at 500 px wide. + +## Stage 2: remove duplication + +Each item here is small and is covered by tests that already exist. + +1. **One path resolver in `worldstate`.** `apply_delta` and `apply_override` route + `flags.`, `milestones.`, `npc..`, `world.`, and + `player.` with parallel code. Extract a resolver that returns the + container, the stat definition, and the kind, then let the two functions differ + only in the write rule. Their rules genuinely differ, so do not merge the + functions themselves. `apply_override` ignores `cooldown`, + `max_delta_per_turn`, and the rule that a counter cannot decrease, and it lets a + milestone be un-set. `tests/test_worldstate.py` covers both. +2. **Add `backend/tests/conftest.py`.** Move the temporary-database prologue there, + so the other 35 files drop 8 lines each. The prologue has to run before + `from app.main import app`, because `main.py` calls `bootstrap(engine)` at import + time. A `conftest.py` runs before any test module, which satisfies that. +3. **One fake provider.** Move `ScriptedProvider` and `FakeProvider` to `conftest.py` + as one fixture, replacing 12 copies. +4. **Move the SSE helpers out of the router.** Put `sse`, `SSE_HEADERS`, and + `turn_error` in `backend/app/sse.py`, so `chat.py` stops importing from + `adventures`. +5. **Make `get_adventure_or_404` a dependency.** About 20 handlers repeat the call. + A `Depends` removes the line from each one and puts the ownership check in the + signature, where a reader looking for it expects it. +6. **One `useDebouncedSave` hook** in `frontend/src/hooks/`, replacing the three + copies. +7. **Delete `Settings.stream`.** Remove the column, the two schema fields, and add + the migration that drops it. This is item S1 in `docs/self-review.md`. Mark it + applied there. + +**Check:** 549 tests pass. Test count may drop if consolidating fixtures removes a +duplicate case. If it does, say which case and why in the commit message. + +## Stage 3: SP8, drop the legacy columns + +This is the only stage that touches the production database. Follow +`plan/14-phase-story-tree.md`, which specifies it. + +1. Confirm nothing reads the eight columns. `ACTION_LIST_COLUMNS` in the adventures + package lists `index`, `variant_count`, and `variant_index` today, so that tuple + changes here. `bundle.py:37` and `context/history.py:424` both describe `index` + as unread; verify that rather than trusting the comment. +2. Add the migration that drops them. Remove the columns from `models.py`, and + remove the fields from `schemas.py` and from `ActionOut`. +3. Deploy, then run `VACUUM FULL actions;` on the direct Neon endpoint, not the + `-pooler` one. Dropping a column rewrites toasted values, and nothing reclaims + that space on its own. `plan/STATUS.md` records what happened the last time + nobody ran it: the database reached 144.2 MB against a 512 MB tier. +4. `VACUUM FULL` takes an `ACCESS EXCLUSIVE` lock, so the app blocks on `actions` + for the duration. It took 5.5 seconds at 144 MB. + +**Check:** 549 tests pass, `/api/health` answers `{"ok":true}` after the deploy, and +one existing adventure opens, takes a turn, retries it, and pages back through the +transcript. + +## Stage 4: documentation + +1. **Generate `docs/guide.html` from `docs/GUIDE.md`.** Write a small build script + that renders the Markdown into the existing hand-written HTML shell, keeping the + current styles and metadata. After that, one edit updates both. Note in + `README.md` that the HTML is generated and that you edit the Markdown. +2. **Decide what `docs/architecture.html` is.** It has no Markdown source. Either + give it one and generate it the same way, or state at the top of the file that + it is hand-written, so the next person does not look for a source that does not + exist. +3. **Split `plan/STATUS.md`.** It is 63 KB, and most of it is dated session logs. + Keep "Pick up here", "Things worth remembering", and "Running things" in + `STATUS.md`. Move the dated entries to `plan/history/`, newest file first. +4. **Add `docs/DEVELOPING.md`.** Record how to run the app, how to run the tests, + where each subsystem lives, and the invariants a newcomer breaks first: + - Anything that reads `adventure.actions` during generation takes + `exclude_action_id`. It leaks into four places, not one. + - `memory_cursor` and `summary_cursor` are positions into `story_actions()`, and + `Memory.source_start` and `source_end` are `Action.index` values. The two + spaces diverge as soon as anything is deleted. + - `/auth/me` must not raise. It is the SPA's bootstrap call, so anything it + touches that can raise takes the whole frontend down. + - The global field rule in the stylesheet keys off `input[type=...]`, so a bare + `` and any new input type get browser default styling until you add + them. + - A modal opened from `.side-panel` needs `createPortal`, because the panel's + filling transform animation makes it the containing block for + `position: fixed`. + +**Check:** the generated HTML matches the Markdown section for section, including +section 3.6. Every link in `README.md` and `docs/index.html` resolves. + +## Stage 5: a frontend test runner + +`plan/STATUS.md` names the missing runner as the reason this project keeps finding +UI bugs by hand. Two shipped bugs were unreachable from any test that existed. + +1. Add Vitest, React Testing Library, and jsdom. Add a `test` script to + `frontend/package.json`. +2. Add the step to the existing `frontend` job in `.github/workflows/ci.yml`, + between lint and build. +3. Write the first tests against the two bug classes that already recurred: + - The take pager renders when a retry's reply arrives over SSE. The stream builds + its own `ActionOut`, so it was the one payload that never carried the + annotation. + - Retaking a player turn does not produce `> You > You`. The editor is seeded + with stored text that is already formatted. +4. Add a test for `usePlaySession` from Stage 1, since that hook now holds the state + the page used to hold inline. + +**Check:** `npm test` passes locally and in CI. + +## Risks + +| Risk | Where | How you catch it | +|---|---|---| +| A monkeypatch silently stops patching, and a test passes while calling a real provider | Stage 1, `routers/adventures` split | After retargeting, break the fake on purpose and confirm the tests that use it fail | +| `_active_turns` ends up imported by value into two modules, so the turn lock guards two sets | Stage 1 | `tests/test_state_revert.py` reaches for `adventures._active_turns`. Keep that import path working, and confirm a second concurrent turn still returns 409 | +| A CSS rule changes meaning because the split reorders it | Stage 1, stylesheet split | Compare the built CSS before and after. Order within each section must not change | +| The column drop rewrites the table and nobody reclaims the space | Stage 3 | Run `VACUUM FULL actions;` on the direct endpoint, and read sizes from `sum(octet_length(col))` rather than `n_live_tup` | +| Splitting `Play.jsx` breaks something no test covers | Stage 1 | Drive the Play screen by hand at both widths. Stage 5 exists to shrink this risk for next time | + +## What this phase does not do + +- It does not replace the hand-rolled migration runner with Alembic. 64 migrations + run correctly, and `docs/GUIDE.md` section 2.6 already explains the choice. +- It does not move `bootstrap(engine)` out of import time in `main.py`. The current + behavior means a failed migration means no service, which is deliberate. Stage 2's + `conftest.py` removes the boilerplate that import-time bootstrapping forces on + tests, which is the part that actually hurts. +- It does not change any API shape, any database content, or any prompt. diff --git a/plan/STATUS.md b/plan/STATUS.md index edd1924..90aeb65 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -78,6 +78,10 @@ needed; nothing requires reading a row of anyone's story. ## Pick up here +**`plan/17-refactor.md` is the active phase.** It carries its own progress table, which +is the first thing to read when you pick the work back up. It runs in six stages, and +SP8 below is stage 3 of it. Nothing in it changes what the app does. + **`plan/14-phase-story-tree.md`, SP8 — drop the legacy columns.** SP8 was gated on the tree being proven live, and it now is: SP9 merged, and production answers `/api/health` with the tree schema in place. SP8 drops `index`, `variants`, `variant_index`, From 32cd7c1077c9bf8b4294ae2b5d51d4c001d14387 Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sat, 29 Aug 2026 00:47:46 +0530 Subject: [PATCH 02/11] Give the tests one setup instead of thirty-five Every test module carried the same eight-line prologue redirecting the database to a temp file. Only the first one to be imported ever took effect: `app.database` reads `AIDND_DB_PATH` at import and builds `engine` from it once, so by the time the second module ran the engine already existed. The other 34 copies created a temp file that nothing opened and nothing deleted, and leaked one per module per run. `conftest.py` now does it once, which is early enough because pytest imports conftest before any test module. It also deletes the file when the run ends. The tests still share one database, exactly as they already did: each `client` fixture calls `create_all` on setup and `drop_all` on teardown, so no test sees another test's rows. `tests/fakes.py` holds the one `ScriptedProvider`. Nine modules each had a copy, and the copies had drifted into four feature sets, so a test that needed to raise a provider error had to be written in one of the files whose copy supported that. The shared one is the superset. The two `FakeProvider` copies were the same class with a fixed reply, so they use it too. `test_chat.py` keeps its own, which implements `chat` rather than `generate` and records what it was constructed with. An autouse fixture resets the fake's class state between tests, so a stale reply list can no longer reach the next test. 435 lines out of the suite. 549 tests pass. Verified live by sabotage: breaking the shared fake fails 13 tests across four modules. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r --- backend/tests/conftest.py | 57 ++++++++++++++++++++ backend/tests/fakes.py | 41 ++++++++++++++ backend/tests/test_accesslog.py | 9 ---- backend/tests/test_action_paging.py | 9 ---- backend/tests/test_analytics.py | 9 ---- backend/tests/test_attempt_siblings.py | 28 +--------- backend/tests/test_branch_clause.py | 9 ---- backend/tests/test_branch_forking.py | 28 +--------- backend/tests/test_branch_management.py | 24 +-------- backend/tests/test_bundle_v2.py | 26 +-------- backend/tests/test_chat.py | 9 ---- backend/tests/test_egress.py | 9 ---- backend/tests/test_embedding_blob.py | 7 --- backend/tests/test_embedding_model_switch.py | 9 ---- backend/tests/test_guest_cleanup.py | 7 --- backend/tests/test_history_window.py | 9 ---- backend/tests/test_length_hint.py | 7 --- backend/tests/test_memory_nodes.py | 9 ---- backend/tests/test_memory_retrieval.py | 9 ---- backend/tests/test_memory_settling.py | 7 --- backend/tests/test_netguard.py | 9 ---- backend/tests/test_prompt_caching.py | 6 --- backend/tests/test_ratelimit_hardening.py | 9 ---- backend/tests/test_retry_variants.py | 33 ++---------- backend/tests/test_scenario_art.py | 7 --- backend/tests/test_scenario_refresh.py | 9 ---- backend/tests/test_seed_sweep.py | 7 --- backend/tests/test_snapshot_compression.py | 7 --- backend/tests/test_starter_adventure.py | 7 --- backend/tests/test_state_revert.py | 7 --- backend/tests/test_story_tree_baseline.py | 33 +----------- backend/tests/test_take_edit.py | 24 +-------- backend/tests/test_take_parentage.py | 22 +------- backend/tests/test_take_state.py | 26 +-------- backend/tests/test_tree_migration.py | 7 --- backend/tests/test_turn_flow_integration.py | 24 ++------- backend/tests/test_worldstate_integration.py | 24 ++------- 37 files changed, 123 insertions(+), 460 deletions(-) create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/fakes.py diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..b63f213 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,57 @@ +"""Shared setup for the test suite. + +pytest imports this file before it imports any test module, which is the only +reason the database redirection below works. `app.database` reads +`AIDND_DB_PATH` at import and builds `engine` from it once, so the variable has +to be set before the first `from app...` line anywhere in the suite. + +Every test module used to carry its own copy of that redirection. Only the first +one to be imported ever took effect, because the engine already existed by the +time the second one ran. The other copies created a temp file that nothing +opened and nothing deleted. One copy here does the job, and it cleans up after +itself. + +The tests share one database. That is not new: they already did. Each `client` +fixture calls `Base.metadata.create_all` on setup and `drop_all` on teardown, so +no test sees another test's rows. +""" +import os +import tempfile + +_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_tmp.close() +os.environ["AIDND_DB_PATH"] = _tmp.name +# A real `AIDND_DATABASE_URL` or `DATABASE_URL` in the developer's shell points +# at Postgres, and `app.database` prefers either over the SQLite path above. +# Clear both, so running the suite never touches a server database. +os.environ.pop("AIDND_DATABASE_URL", None) +os.environ.pop("DATABASE_URL", None) + +import pytest # noqa: E402 Import order is load-bearing; see above. + +from fakes import ScriptedProvider # noqa: E402 + + +@pytest.fixture(autouse=True) +def reset_scripted_provider(): + """Clears the fake provider's state between tests. + + `ScriptedProvider` keeps its replies and its call count on the class, because + the code under test constructs the provider itself and a test cannot reach + the instance. Class state outlives a test, so reset it here rather than + trusting every fixture to remember. + """ + ScriptedProvider.replies = [] + ScriptedProvider.calls = 0 + ScriptedProvider.prompts = [] + yield + + +def pytest_sessionfinish(session, exitstatus): + """Deletes the temporary database once the run ends.""" + try: + os.unlink(_tmp.name) + except OSError: + # The file is already gone, or Windows still holds a handle on it. It is + # in the temp directory either way, so leaving it costs nothing. + pass diff --git a/backend/tests/fakes.py b/backend/tests/fakes.py new file mode 100644 index 0000000..8b035c9 --- /dev/null +++ b/backend/tests/fakes.py @@ -0,0 +1,41 @@ +"""Stand-ins for the parts of the app a test must not really call. + +Import these rather than writing another copy. Nine test modules each carried +their own `ScriptedProvider`, and the copies had drifted into four different +feature sets, so a test that needed to raise a provider error had to be written +in one of the files whose copy supported it. +""" + + +class ScriptedProvider: + """Streams canned replies in place of `OpenAICompatibleProvider`. + + Set `replies` to the texts the model returns, one per call. The last entry + repeats once the list runs out, so a test that plays more turns than it + scripted still gets text. To drive the provider-error path, put an + `Exception` in the list. It is raised rather than streamed. + + State lives on the class, not on the instance, because the turn engine + constructs the provider itself and a test never sees the object. The autouse + `reset_scripted_provider` fixture in `conftest.py` clears it between tests. + + `prompts` records every assembled `(system, story)` pair, which is what a + test asserts on to check what the model was shown. + """ + + last_usage = None + replies: list = [] + calls = 0 + prompts: list = [] + + def __init__(self, *a, **k): + pass + + async def generate(self, parts, *, temperature, max_tokens): + index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) + ScriptedProvider.calls += 1 + ScriptedProvider.prompts.append((parts.system, parts.story)) + reply = ScriptedProvider.replies[index] + if isinstance(reply, Exception): + raise reply + yield ("text", reply) diff --git a/backend/tests/test_accesslog.py b/backend/tests/test_accesslog.py index d014974..5a1da0c 100644 --- a/backend/tests/test_accesslog.py +++ b/backend/tests/test_accesslog.py @@ -9,15 +9,6 @@ accounts on a schedule, and a log that deletes itself is not a log. python -m pytest tests/test_accesslog.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient diff --git a/backend/tests/test_action_paging.py b/backend/tests/test_action_paging.py index 6f4280a..4c09d7c 100644 --- a/backend/tests/test_action_paging.py +++ b/backend/tests/test_action_paging.py @@ -12,15 +12,6 @@ to be scrolling. An anchor means the same thing before and after. python -m pytest tests/test_action_paging.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py index e8765c4..f0e3eb4 100644 --- a/backend/tests/test_analytics.py +++ b/backend/tests/test_analytics.py @@ -9,15 +9,6 @@ the dashboard, and cannot inflate what it reports beyond hitting the page. python -m pytest tests/test_analytics.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - from datetime import timedelta import pytest diff --git a/backend/tests/test_attempt_siblings.py b/backend/tests/test_attempt_siblings.py index dbe0252..2afbbb8 100644 --- a/backend/tests/test_attempt_siblings.py +++ b/backend/tests/test_attempt_siblings.py @@ -8,15 +8,6 @@ story, and the arrangement costs neither an extra prompt nor an extra turn. python -m pytest tests/test_attempt_siblings.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -26,9 +17,10 @@ from app import attempts, auth, limits, models, tree from app.context import cursors, history from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures +from fakes import ScriptedProvider + SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}} GOLD_SCRIPT = """ @@ -40,22 +32,6 @@ modifier(text); """ -class ScriptedProvider: - last_usage = None - replies: list = [] - calls = 0 - prompts: list = [] - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - ScriptedProvider.prompts.append((parts.system, parts.story)) - yield ("text", ScriptedProvider.replies[index]) - - @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) diff --git a/backend/tests/test_branch_clause.py b/backend/tests/test_branch_clause.py index cf84fdf..6e52fd5 100644 --- a/backend/tests/test_branch_clause.py +++ b/backend/tests/test_branch_clause.py @@ -21,15 +21,6 @@ nodes may appear on C. python -m pytest tests/test_branch_clause.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient diff --git a/backend/tests/test_branch_forking.py b/backend/tests/test_branch_forking.py index 41cac9b..9fb1181 100644 --- a/backend/tests/test_branch_forking.py +++ b/backend/tests/test_branch_forking.py @@ -12,15 +12,6 @@ that makes borrowing possible lives in `lineage`. python -m pytest tests/test_branch_forking.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -29,9 +20,10 @@ from app import auth, limits, models from app.context import cursors, lineage from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures +from fakes import ScriptedProvider + # `hp` moves freely. `mana` has a cooldown of 2 turns, so an incorrect # advance shows up as a change the referee should have rejected. SCHEMA = { @@ -50,22 +42,6 @@ modifier(text); """ -class ScriptedProvider: - last_usage = None - replies: list = [] - calls = 0 - prompts: list = [] - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - ScriptedProvider.prompts.append((parts.system, parts.story)) - yield ("text", ScriptedProvider.replies[index]) - - @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) diff --git a/backend/tests/test_branch_management.py b/backend/tests/test_branch_management.py index 27023ea..5c1f0b0 100644 --- a/backend/tests/test_branch_management.py +++ b/backend/tests/test_branch_management.py @@ -19,15 +19,6 @@ Two rules carry most of this file: python -m pytest tests/test_branch_management.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -37,22 +28,9 @@ from app import auth, limits, models, schemas from app.context import cursors, lineage from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures - -class ScriptedProvider: - last_usage = None - replies: list = [] - calls = 0 - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - yield ("text", ScriptedProvider.replies[index]) +from fakes import ScriptedProvider @pytest.fixture() diff --git a/backend/tests/test_bundle_v2.py b/backend/tests/test_bundle_v2.py index cb2ff28..e3c0682 100644 --- a/backend/tests/test_bundle_v2.py +++ b/backend/tests/test_bundle_v2.py @@ -23,15 +23,6 @@ importer a file that does disagree with itself. python -m pytest tests/test_bundle_v2.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -40,9 +31,10 @@ from app import auth, bundle, limits, models from app.context import lineage from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures +from fakes import ScriptedProvider + SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}} # Ten gold a turn, so the stored gold total tells how many turns the @@ -58,20 +50,6 @@ modifier(text); OPENING = "You enter a cave." -class ScriptedProvider: - last_usage = None - replies: list = [] - calls = 0 - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - yield ("text", ScriptedProvider.replies[index]) - - @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index df52166..42acfce 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -6,15 +6,6 @@ page. python -m pytest tests/test_chat.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient diff --git a/backend/tests/test_egress.py b/backend/tests/test_egress.py index 64cf5b5..372f72e 100644 --- a/backend/tests/test_egress.py +++ b/backend/tests/test_egress.py @@ -17,15 +17,6 @@ Two kinds of guard live here, and both are needed: python -m pytest tests/test_egress.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import json import random diff --git a/backend/tests/test_embedding_blob.py b/backend/tests/test_embedding_blob.py index c45a2c0..6fa82a5 100644 --- a/backend/tests/test_embedding_blob.py +++ b/backend/tests/test_embedding_blob.py @@ -7,15 +7,8 @@ for bit, and that the migration reaches every row, however many there are. python -m pytest tests/test_embedding_blob.py -v """ -import os import struct -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import json import random diff --git a/backend/tests/test_embedding_model_switch.py b/backend/tests/test_embedding_model_switch.py index 4e3db33..292d62d 100644 --- a/backend/tests/test_embedding_model_switch.py +++ b/backend/tests/test_embedding_model_switch.py @@ -19,15 +19,6 @@ garbage instead. python -m pytest tests/test_embedding_model_switch.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import asyncio import pytest diff --git a/backend/tests/test_guest_cleanup.py b/backend/tests/test_guest_cleanup.py index 995ba57..5f180d4 100644 --- a/backend/tests/test_guest_cleanup.py +++ b/backend/tests/test_guest_cleanup.py @@ -6,15 +6,8 @@ deleted. python -m pytest tests/test_guest_cleanup.py -v """ -import os -import tempfile from datetime import timedelta -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest from sqlalchemy import create_engine, event diff --git a/backend/tests/test_history_window.py b/backend/tests/test_history_window.py index d0a5635..54972c0 100644 --- a/backend/tests/test_history_window.py +++ b/backend/tests/test_history_window.py @@ -16,15 +16,6 @@ Two things must hold, and both are easy to break by accident: python -m pytest tests/test_history_window.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from sqlalchemy import event diff --git a/backend/tests/test_length_hint.py b/backend/tests/test_length_hint.py index 7477e29..0c26659 100644 --- a/backend/tests/test_length_hint.py +++ b/backend/tests/test_length_hint.py @@ -16,15 +16,8 @@ Two things are easy to break here: python -m pytest tests/test_length_hint.py -v """ -import os import re -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest diff --git a/backend/tests/test_memory_nodes.py b/backend/tests/test_memory_nodes.py index e2fa158..2d6f24f 100644 --- a/backend/tests/test_memory_nodes.py +++ b/backend/tests/test_memory_nodes.py @@ -19,15 +19,6 @@ exactly as `test_branch_clause.py` builds it. python -m pytest tests/test_memory_nodes.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import asyncio import pytest diff --git a/backend/tests/test_memory_retrieval.py b/backend/tests/test_memory_retrieval.py index 110902d..98980f2 100644 --- a/backend/tests/test_memory_retrieval.py +++ b/backend/tests/test_memory_retrieval.py @@ -14,15 +14,6 @@ failure that no error message would report: python -m pytest tests/test_memory_retrieval.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import asyncio from datetime import timedelta diff --git a/backend/tests/test_memory_settling.py b/backend/tests/test_memory_settling.py index fa0fdb8..f339b7e 100644 --- a/backend/tests/test_memory_settling.py +++ b/backend/tests/test_memory_settling.py @@ -23,14 +23,7 @@ happened. python -m pytest tests/test_memory_settling.py -v """ import asyncio -import os -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest diff --git a/backend/tests/test_netguard.py b/backend/tests/test_netguard.py index a0465f4..4b4e962 100644 --- a/backend/tests/test_netguard.py +++ b/backend/tests/test_netguard.py @@ -2,15 +2,6 @@ python -m pytest tests/test_netguard.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from app import auth, netguard diff --git a/backend/tests/test_prompt_caching.py b/backend/tests/test_prompt_caching.py index a84c0dd..88d51af 100644 --- a/backend/tests/test_prompt_caching.py +++ b/backend/tests/test_prompt_caching.py @@ -25,13 +25,7 @@ than assumed. python -m pytest tests/test_prompt_caching.py -v """ import os -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest diff --git a/backend/tests/test_ratelimit_hardening.py b/backend/tests/test_ratelimit_hardening.py index 60328e0..a83455f 100644 --- a/backend/tests/test_ratelimit_hardening.py +++ b/backend/tests/test_ratelimit_hardening.py @@ -9,15 +9,6 @@ also has an email-keyed throttle that no IP trick can weaken. python -m pytest tests/test_ratelimit_hardening.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from app import auth, limits diff --git a/backend/tests/test_retry_variants.py b/backend/tests/test_retry_variants.py index 4623bbf..fad75c6 100644 --- a/backend/tests/test_retry_variants.py +++ b/backend/tests/test_retry_variants.py @@ -4,15 +4,6 @@ switchable, restoring the world/script state that attempt produced. python -m pytest tests/test_retry_variants.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -20,9 +11,11 @@ from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts, ProviderError +from app.providers import ProviderError from app.routers import adventures +from fakes import ScriptedProvider + SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}} # Each turn spends 10 gold, so a double-applied or un-rolled-back attempt shows. @@ -35,26 +28,6 @@ modifier(text); """ -class ScriptedProvider: - """Streams the next canned reply each call, so successive retries differ.""" - last_usage = None - replies: list = [] - calls = 0 - prompts: list = [] # every assembled (system, story) pair, for context assertions - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - ScriptedProvider.prompts.append((parts.system, parts.story)) - reply = ScriptedProvider.replies[index] - if isinstance(reply, Exception): - raise reply - yield ("text", reply) - - @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) diff --git a/backend/tests/test_scenario_art.py b/backend/tests/test_scenario_art.py index 44d8180..3d7e46c 100644 --- a/backend/tests/test_scenario_art.py +++ b/backend/tests/test_scenario_art.py @@ -9,14 +9,7 @@ player's last line. python -m pytest tests/test_scenario_art.py -v """ import base64 -import os -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest from fastapi import Depends diff --git a/backend/tests/test_scenario_refresh.py b/backend/tests/test_scenario_refresh.py index d5c0d73..9e37532 100644 --- a/backend/tests/test_scenario_refresh.py +++ b/backend/tests/test_scenario_refresh.py @@ -3,15 +3,6 @@ story cards and stat schema back down over a running adventure's copy. python -m pytest tests/test_scenario_refresh.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient diff --git a/backend/tests/test_seed_sweep.py b/backend/tests/test_seed_sweep.py index c5affd0..0ff965d 100644 --- a/backend/tests/test_seed_sweep.py +++ b/backend/tests/test_seed_sweep.py @@ -8,14 +8,7 @@ deletes seeded rows no file claims any more. python -m pytest tests/test_seed_sweep.py -v """ import json -import os -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest diff --git a/backend/tests/test_snapshot_compression.py b/backend/tests/test_snapshot_compression.py index 20e9fd9..26b8e23 100644 --- a/backend/tests/test_snapshot_compression.py +++ b/backend/tests/test_snapshot_compression.py @@ -17,16 +17,9 @@ Three things must hold, and only the first is obvious: python -m pytest tests/test_snapshot_compression.py -v """ import json -import os import random -import tempfile import zlib -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest from sqlalchemy import text diff --git a/backend/tests/test_starter_adventure.py b/backend/tests/test_starter_adventure.py index 38e478a..3ca393e 100644 --- a/backend/tests/test_starter_adventure.py +++ b/backend/tests/test_starter_adventure.py @@ -8,14 +8,7 @@ empty account would. python -m pytest tests/test_starter_adventure.py -v """ import json -import os -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest from sqlalchemy import create_engine, event diff --git a/backend/tests/test_state_revert.py b/backend/tests/test_state_revert.py index 9fe18aa..998241b 100644 --- a/backend/tests/test_state_revert.py +++ b/backend/tests/test_state_revert.py @@ -11,17 +11,10 @@ only in their outcome. Run from the backend dir: python -m pytest tests/test_state_revert.py -v """ -import os -import tempfile # Point the app at a throwaway SQLite file before importing anything that # binds the engine at import time. `app.database` reads `AIDND_DB_PATH` # on import. -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest from fastapi import HTTPException diff --git a/backend/tests/test_story_tree_baseline.py b/backend/tests/test_story_tree_baseline.py index 36b7697..f5cf8b5 100644 --- a/backend/tests/test_story_tree_baseline.py +++ b/backend/tests/test_story_tree_baseline.py @@ -17,15 +17,6 @@ those subphases, the change is wrong, not the test. SP4 is the first subphase allowed to move it, and only for the variant-count semantics called out in plan/14. """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -33,9 +24,10 @@ from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures +from fakes import ScriptedProvider + # A world-state schema, so the RPG layer is exercised rather than skipped. SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}} @@ -52,27 +44,6 @@ modifier(text); OPENING = "You enter a cave." -class ScriptedProvider: - """Streams the next canned reply each call, so successive turns differ.""" - last_usage = None - - replies: list = [] - calls = 0 - prompts: list = [] # every assembled (system, story) pair - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - ScriptedProvider.prompts.append((parts.system, parts.story)) - reply = ScriptedProvider.replies[index] - if isinstance(reply, Exception): - raise reply - yield ("text", reply) - - def _make_world(monkeypatch, *, seeded_actions: int = 0): """Create a user, a scenario, and an adventure, with `seeded_actions` extra story actions written straight to the database. Paging tests need diff --git a/backend/tests/test_take_edit.py b/backend/tests/test_take_edit.py index 5300490..d02c433 100644 --- a/backend/tests/test_take_edit.py +++ b/backend/tests/test_take_edit.py @@ -20,15 +20,6 @@ be silently broken. python -m pytest tests/test_take_edit.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -36,22 +27,9 @@ from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures - -class ScriptedProvider: - last_usage = None - replies: list = [] - calls = 0 - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - yield ("text", ScriptedProvider.replies[index]) +from fakes import ScriptedProvider @pytest.fixture() diff --git a/backend/tests/test_take_parentage.py b/backend/tests/test_take_parentage.py index 47fbe84..0ce72e0 100644 --- a/backend/tests/test_take_parentage.py +++ b/backend/tests/test_take_parentage.py @@ -20,14 +20,7 @@ they mean. python -m pytest tests/test_take_parentage.py -v """ import json -import os -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest from fastapi import Depends @@ -36,22 +29,9 @@ from fastapi.testclient import TestClient from app import attempts, auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures - -class ScriptedProvider: - last_usage = None - replies: list = [] - calls = 0 - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - yield ("text", ScriptedProvider.replies[index]) +from fakes import ScriptedProvider @pytest.fixture() diff --git a/backend/tests/test_take_state.py b/backend/tests/test_take_state.py index c02e1cb..49429d5 100644 --- a/backend/tests/test_take_state.py +++ b/backend/tests/test_take_state.py @@ -15,15 +15,6 @@ the line it was on. python -m pytest tests/test_take_state.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -31,9 +22,10 @@ from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures +from fakes import ScriptedProvider + SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}} # Ten gold a turn, every turn. A number that only ever increases makes a @@ -48,20 +40,6 @@ modifier(text); """ -class ScriptedProvider: - last_usage = None - replies: list = [] - calls = 0 - - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) - ScriptedProvider.calls += 1 - yield ("text", ScriptedProvider.replies[index]) - - @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) diff --git a/backend/tests/test_tree_migration.py b/backend/tests/test_tree_migration.py index e775b59..05746b8 100644 --- a/backend/tests/test_tree_migration.py +++ b/backend/tests/test_tree_migration.py @@ -16,14 +16,7 @@ place, would silently skip the DDL and test only half the change. python -m pytest tests/test_tree_migration.py -v """ import json -import os -import tempfile -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) import pytest from fastapi import Depends diff --git a/backend/tests/test_turn_flow_integration.py b/backend/tests/test_turn_flow_integration.py index c5745f4..0630065 100644 --- a/backend/tests/test_turn_flow_integration.py +++ b/backend/tests/test_turn_flow_integration.py @@ -7,15 +7,6 @@ adventure's stored gold total stays correct across play, undo, and retry. python -m pytest tests/test_turn_flow_integration.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -23,9 +14,10 @@ from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures +from fakes import ScriptedProvider + GOLD_SCRIPT = """ const modifier = (text) => { state.gold = (state.gold || 0) + 10; @@ -35,14 +27,7 @@ modifier(text); """ -class FakeProvider: - """Stand-in for OpenAICompatibleProvider: streams one fixed line, no network.""" - last_usage = None - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - yield ("text", "The torch flickers as you press onward.") +AI_REPLY = "The torch flickers as you press onward." @pytest.fixture() @@ -66,7 +51,8 @@ def client(monkeypatch): setup.close() # Force a real, non-demo turn that uses the fake provider. - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", FakeProvider) + ScriptedProvider.replies = [AI_REPLY] + monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) diff --git a/backend/tests/test_worldstate_integration.py b/backend/tests/test_worldstate_integration.py index 87a195f..1054344 100644 --- a/backend/tests/test_worldstate_integration.py +++ b/backend/tests/test_worldstate_integration.py @@ -4,15 +4,6 @@ undo rolling the world state back. python -m pytest tests/test_worldstate_integration.py -v """ -import os -import tempfile - -_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) -_tmp.close() -os.environ["AIDND_DB_PATH"] = _tmp.name -os.environ.pop("AIDND_DATABASE_URL", None) -os.environ.pop("DATABASE_URL", None) - import pytest from fastapi import Depends from fastapi.testclient import TestClient @@ -20,9 +11,10 @@ from fastapi.testclient import TestClient from app import auth, limits, models from app.database import Base, SessionLocal, engine, get_db from app.main import app -from app.providers import PromptParts from app.routers import adventures +from fakes import ScriptedProvider + SCHEMA = { "player": {"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30}}, "npcs": { @@ -45,15 +37,6 @@ AI_REPLY = ( ) -class FakeProvider: - last_usage = None - def __init__(self, *a, **k): - pass - - async def generate(self, parts: PromptParts, *, temperature, max_tokens): - yield ("text", AI_REPLY) - - @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) @@ -78,7 +61,8 @@ def client(monkeypatch): adv_id, user_id = adv.id, user.id setup.close() - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", FakeProvider) + ScriptedProvider.replies = [AI_REPLY] + monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) From 2fa812c05631525b5f307b0f55accbfb9751f0e6 Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sat, 29 Aug 2026 01:05:50 +0530 Subject: [PATCH 03/11] Split the adventures router into a package `backend/app/routers/adventures.py` held 2353 lines and 35 endpoints. It is now a package of 14 modules, the largest 443 lines. The split moves text rather than rewriting it. An AST comparison against the old file confirms all 86 definitions are identical, and the OpenAPI schema still lists the same 35 operations. Names a test replaces now live in `turns.py` only, and other modules reach them as `turns.`. Rebinding a re-exported alias changes the alias and leaves every caller reading the original, so the package root does not re-export them. A patch aimed at the old target raises `AttributeError` instead of passing while doing nothing. Tests and the fixtures in `backend/tools/` say `adventures.turns.`. The same rule keeps the turn lock working. One module owns `_active_turns`, so one lock guards one set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r --- backend/app/routers/adventures.py | 2353 ----------------- backend/app/routers/adventures/__init__.py | 58 + backend/app/routers/adventures/actions.py | 90 + backend/app/routers/adventures/branches.py | 266 ++ backend/app/routers/adventures/bundle_io.py | 70 + backend/app/routers/adventures/crud.py | 340 +++ backend/app/routers/adventures/deps.py | 25 + backend/app/routers/adventures/insights.py | 50 + backend/app/routers/adventures/memories.py | 118 + backend/app/routers/adventures/nodes.py | 146 + backend/app/routers/adventures/paging.py | 176 ++ backend/app/routers/adventures/refresh.py | 194 ++ .../app/routers/adventures/scenario_text.py | 106 + backend/app/routers/adventures/scripts.py | 116 + backend/app/routers/adventures/takes.py | 390 +++ backend/app/routers/adventures/turns.py | 443 ++++ backend/tests/test_attempt_siblings.py | 4 +- backend/tests/test_branch_forking.py | 4 +- backend/tests/test_branch_management.py | 4 +- backend/tests/test_bundle_v2.py | 4 +- backend/tests/test_retry_variants.py | 4 +- backend/tests/test_scenario_refresh.py | 4 +- backend/tests/test_state_revert.py | 14 +- backend/tests/test_story_tree_baseline.py | 6 +- backend/tests/test_take_edit.py | 4 +- backend/tests/test_take_parentage.py | 4 +- backend/tests/test_take_state.py | 4 +- backend/tests/test_turn_flow_integration.py | 4 +- backend/tests/test_worldstate_integration.py | 8 +- backend/tools/branch_fixture.py | 2 +- backend/tools/shots_fixture.py | 2 +- backend/tools/stress_session.py | 4 +- backend/tools/tree_fixture.py | 2 +- plan/17-refactor.md | 41 +- 34 files changed, 2667 insertions(+), 2393 deletions(-) delete mode 100644 backend/app/routers/adventures.py create mode 100644 backend/app/routers/adventures/__init__.py create mode 100644 backend/app/routers/adventures/actions.py create mode 100644 backend/app/routers/adventures/branches.py create mode 100644 backend/app/routers/adventures/bundle_io.py create mode 100644 backend/app/routers/adventures/crud.py create mode 100644 backend/app/routers/adventures/deps.py create mode 100644 backend/app/routers/adventures/insights.py create mode 100644 backend/app/routers/adventures/memories.py create mode 100644 backend/app/routers/adventures/nodes.py create mode 100644 backend/app/routers/adventures/paging.py create mode 100644 backend/app/routers/adventures/refresh.py create mode 100644 backend/app/routers/adventures/scenario_text.py create mode 100644 backend/app/routers/adventures/scripts.py create mode 100644 backend/app/routers/adventures/takes.py create mode 100644 backend/app/routers/adventures/turns.py diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py deleted file mode 100644 index af82f5c..0000000 --- a/backend/app/routers/adventures.py +++ /dev/null @@ -1,2353 +0,0 @@ -import json -import re -import threading - -from fastapi import APIRouter, Body, Depends, HTTPException, Request -from fastapi.responses import StreamingResponse -from sqlalchemy import func -from sqlalchemy.orm import Session, load_only, undefer -from sqlalchemy.orm.attributes import set_committed_value - -from .. import ( - analytics, attempts, auth, bundle, images, limits, memorybank, models, schemas, - tree, worldstate, -) -from ..context import build_context, cursors -from ..context import history as context_history -from ..context import lineage -from ..database import get_db -from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError -from ..scripting import ScriptPipeline -from .settings import get_settings - -router = APIRouter(prefix="/api/adventures", tags=["adventures"]) - -CurrentUser = Depends(auth.get_current_user) - -# The columns `schemas.ActionOut` renders, listed explicitly. -# -# `deferred=True` in `models.py` keeps the four heavy columns out of bulk reads, -# but each new column then has to opt in to staying narrow. Both egress -# regressions this project has had came from a column that did not opt in. This -# tuple inverts the default: a new column costs nothing until you add it here. -# -# `world_delta` is listed because `ActionOut.world_changes` is computed from it. -# Omitting it saves no bytes. It converts one bulk read into one lazy load per -# row. -ACTION_LIST_COLUMNS = ( - models.Action.adventure_id, - models.Action.index, - models.Action.type, - models.Action.text, - models.Action.reasoning, - models.Action.world_delta, - models.Action.variant_count, - models.Action.variant_index, - # SP9: the pager's key. If `parent_id` were deferred, every row on the page - # would cost a lazy load, which is the cost `load_only` is here to prevent. - # `branch_id` is listed for the same reason. The pager reads it to tell a - # local step from a branch switch. - models.Action.parent_id, - models.Action.branch_id, - models.Action.created_at, -) - -# How many actions an adventure opens with, and how many arrive per scroll. -# -# Opening a finished adventure once fetched the whole story in one response. -# That reached 589.5 kB for the longest story in production, and it grew as -# stories grew. 60 actions is a few screens of reading. The common case of -# opening a story, reading the end, and taking a turn never pages, and the worst -# case is bounded by the window size rather than by the length of the story. -ACTION_PAGE = 60 - - -def action_window( - db: Session, - adventure: models.Adventure, - before_id: int | None = None, - limit: int = ACTION_PAGE, -) -> tuple[list[models.Action], int, bool]: - """Returns the `limit` actions immediately older than `before_id`, oldest first. - - The return value is `(actions, total, has_more)`. If `before_id` is `None`, - the newest window is returned. - - The query is scoped to the head branch's lineage, which is the story being - played, rather than to the adventure. A sibling branch's turns therefore - never appear in the transcript. `total` counts the same path, because it is - what tells the reader that more actions exist above. - - The window is anchored on an action, never on a count or on arithmetic over - depth, for two reasons: - - * Appends. Counting back from the newest action shifts every older position - when a turn lands. A reader who scrolls up while a turn is generating gets - a window that is one row off, which re-sends one action and skips another. - An anchor is stable, because "older than this action" means the same thing - before and after the story grows. - * The story tree. Depth is dense today, and branching ends that. Comparing - depths to order a path still works, but treating them as positions does - not. - - `has_more` comes from requesting one row past the window rather than from a - second count, so it costs one row instead of a scan. - """ - path = lineage.path_of(db, adventure) - on_path = ( - models.Action.adventure_id == adventure.id, - path.clause(models.Action), - ) - total = db.query(func.count(models.Action.id)).filter(*on_path).scalar() - if limit <= 0: - return [], total, total > 0 - - query = db.query(models.Action).options(load_only(*ACTION_LIST_COLUMNS)).filter(*on_path) - if before_id is not None: - anchor = ( - db.query(models.Action.depth) - .filter(models.Action.id == before_id, *on_path) - .scalar() - ) - if anchor is None: - # The anchor was deleted while the reader scrolled, by an undo or - # by an edited turn, or it belongs to a branch this story is not on. - # No row can be older than a row that is not present, so report the - # end of the story rather than guess and return a duplicate page. - return [], total, False - query = query.filter(models.Action.depth < anchor) - - rows = ( - query.order_by(models.Action.depth.desc(), models.Action.id.desc()) - .limit(limit + 1) - .all() - ) - has_more = len(rows) > limit - rows = rows[:limit] - rows.reverse() - return rows, total, has_more - - -# The columns `schemas.MemoryOut` renders. `embedded` is a real column and -# belongs here. The vector it describes does not. -MEMORY_LIST_COLUMNS = ( - models.Memory.adventure_id, - models.Memory.text, - models.Memory.pinned, - models.Memory.forgotten, - models.Memory.embedded, - models.Memory.use_count, - models.Memory.last_used_at, - models.Memory.source_start, - models.Memory.source_end, - models.Memory.created_at, -) - - -def get_adventure_or_404( - adventure_id: int, db: Session, user: models.User -) -> models.Adventure: - adventure = db.get(models.Adventure, adventure_id) - if adventure is None or adventure.user_id != user.id: - raise HTTPException(404, "Adventure not found") - return adventure - - -# How many characters of the last narration a Continue card shows. The limit is -# long enough to re-establish the scene and short enough to keep the card -# small. -SNIPPET_MAX = 220 - - -def _snippet(text: str) -> str: - """Condenses stored action text into a single line for a card.""" - # The streaming handler below strips the world-state block before storing AI - # text, so this function only has to normalize whitespace. - collapsed = " ".join((text or "").split()) - if len(collapsed) <= SNIPPET_MAX: - return collapsed - # Cut at a word boundary rather than mid-word. CSS adds the ellipsis. - cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0] - return f"{cut}…" - - -# Action types that read as narration. `start` is the scenario's opening prompt, -# which is the only text a newly created adventure has. Without `start`, a new -# story's card would show no text at all. `do` and `say` are excluded because -# the card quotes the story's voice rather than the player's. -NARRATION_TYPES = ("ai", "story", "start") - - -def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]: - """Maps each adventure id to the text of its most recent narrated action. - - This runs one window-function query rather than one lookup per adventure, so - the list endpoint makes a fixed number of round trips. - - The query is scoped by head branch rather than by the full lineage, and this - is the only place in the codebase that does so. A lineage clause per - adventure would add a hundred OR terms to the index screen's query to select - one row each. The two scopes differ only for a branch with no nodes of its - own, and playing a turn onto a branch is what creates it, so that state does - not occur. An adventure with no branch has no story to quote. - """ - branch_ids = [b for b in head_branches.values() if b is not None] - if not branch_ids: - return {} - ranked = ( - db.query( - models.Action.adventure_id.label("adventure_id"), - models.Action.text.label("text"), - func.row_number() - .over( - partition_by=models.Action.adventure_id, - order_by=(models.Action.depth.desc(), models.Action.id.desc()), - ) - .label("rank"), - ) - .filter( - models.Action.adventure_id.in_(list(head_branches)), - models.Action.branch_id.in_(branch_ids), - models.Action.type.in_(NARRATION_TYPES), - # Sibling attempts share a depth, and the newest has the highest - # id. Without this filter the snippet quotes the attempt written - # last rather than the one the story tells. After you switch back to - # an earlier attempt, the index screen would quote the discarded one - # and disagree with the story on screen. - models.Action.live.is_(True), - ) - .subquery() - ) - rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all() - return {adventure_id: text for adventure_id, text in rows} - - -@router.get("", response_model=list[schemas.AdventureListItem]) -def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser): - # Select named columns rather than the whole Adventure entity. The entity is - # sixteen columns wide and includes `script_state`, `world_state`, - # `placeholders`, `story_summary`, `memory`, `authors_note`, and - # `ai_instructions`. That is about 15 kB per row in production, fetched once - # per adventure on every index load, and this screen uses none of it. Naming - # the columns also means a wide column added to Adventure later has to opt - # in to being listed here. - rows = ( - db.query( - models.Adventure.id, - models.Adventure.scenario_id, - models.Adventure.title, - models.Adventure.updated_at, - models.Adventure.head_branch_id, - func.count(models.Action.id), - models.Scenario.title, - models.Scenario.image, - models.Scenario.icon, - models.Scenario.updated_at, - ) - .outerjoin(models.Action) - .outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id) - .filter(models.Adventure.user_id == user.id) - # Group by both primary keys. Postgres requires every selected column - # to be grouped or aggregated. The Adventure columns are covered by its - # own grouped primary key, but the Scenario columns come from a joined - # table and have to be listed as well. SQLite accepts the shorter form, - # and Postgres rejects it. - .group_by( - models.Adventure.id, - models.Scenario.id, - models.Scenario.title, - models.Scenario.image, - models.Scenario.icon, - models.Scenario.updated_at, - ) - .order_by(models.Adventure.updated_at.desc()) - .all() - ) - narration = _latest_narration(db, {row[0]: row[4] for row in rows}) - return [ - schemas.AdventureListItem( - id=adv_id, - scenario_id=scenario_id, - scenario_title=scenario_title, - title=title, - updated_at=updated_at, - action_count=count, - snippet=_snippet(narration.get(adv_id, "")), - # The art belongs to the scenario, so the cache-busting stamp uses - # the scenario's `updated_at`, not the adventure's. - image_url=images.public_url(scenario_id, image or "", scenario_updated), - icon=icon or "", - ) - # `count` counts every action in the adventure, not only the ones on - # the path. With one branch the two numbers are equal. After forking - # ships, the index screen overstates a story that has sibling branches. - # The fix belongs to SP5, which is where a fork can first exist. - for (adv_id, scenario_id, title, updated_at, _head_branch_id, count, - scenario_title, image, icon, scenario_updated) in rows - ] - - -PLACEHOLDER_RE = re.compile(r"\$\{([^}]+)\}") - - -def fill_placeholders(text: str, values: dict[str, str]) -> str: - """Replaces `${Name}` with the player-provided value. - - Unknown names are left unchanged. - """ - if not text or not values: - return text - return PLACEHOLDER_RE.sub( - lambda m: values.get(m.group(1).strip(), m.group(0)), text - ) - - -# Adventure fields that start as a copy of the scenario's text, so "Update from -# scenario" can copy them again. `title` is excluded because it is the -# adventure's own name, which players rename. `story_summary` is excluded -# because it is play output rather than scenario content. -SCENARIO_TEXT_FIELDS = ("memory", "authors_note", "ai_instructions") - -# Story-card fields that are copied from the scenario and compared to detect -# drift. -CARD_FIELDS = ("type", "name", "keys", "entry", "notes") - - -def scenario_card_specs(scenario: models.Scenario, values: dict[str, str]) -> dict[str, dict]: - """Returns every story card a scenario implies, keyed by a stable `source_ref`. - - The result holds the scenario's own cards, keyed `card:`, plus one card - per NPC defined in its `stat_schema`, keyed `npc:`. Placeholders are - already filled in. - - Adventure creation and refresh both call this function, so the two cannot - diverge. - """ - specs: dict[str, dict] = {} - existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards} - for card in scenario.story_cards: - specs[f"card:{card.id}"] = { - "type": card.type, - "name": card.name, - "keys": fill_placeholders(card.keys, values), - "entry": fill_placeholders(card.entry, values), - "notes": card.notes, - } - # Phase 12: each defined NPC gets a story card, so its description works as - # lore and can trigger in a scene. If a card with that name already exists, - # skip the NPC. - for npc_key, ndef in (scenario.stat_schema or {}).get("npcs", {}).items(): - if not isinstance(ndef, dict): - continue - name = worldstate.npc_name(ndef, npc_key) - if name.strip().lower() in existing_names: - continue - specs[f"npc:{npc_key}"] = { - "type": "character", - "name": name, - "keys": fill_placeholders(str(ndef.get("keys") or name), values), - "entry": fill_placeholders(str(ndef.get("desc") or ""), values), - "notes": "", - } - return specs - - -@router.post("", response_model=schemas.AdventureOut, status_code=201) -def create_adventure( - payload: schemas.AdventureCreate, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - limits.check_row_cap("adventures", db, user) - scenario = None - if payload.scenario_id is not None: - scenario = db.get(models.Scenario, payload.scenario_id) - # A scenario is playable if the user owns it or if it is public. - if scenario is None or (scenario.user_id != user.id and not scenario.is_public): - raise HTTPException(404, "Scenario not found") - - values = payload.placeholders - adventure = models.Adventure( - user_id=user.id, - scenario_id=scenario.id if scenario else None, - title=payload.title or (scenario.title if scenario else "Untitled Adventure"), - memory=fill_placeholders(scenario.memory, values) if scenario else "", - authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "", - ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "", - # Phase 12: seed the live RPG state from the scenario's template. - world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {}, - # Stored so that a later "Update from scenario" fills the copied text - # with the same answers instead of inserting literal `${...}` tokens. - placeholders=dict(values), - ) - db.add(adventure) - db.flush() - # Give every adventure a story tree as soon as it exists, before anything - # is played onto it. Otherwise the tree code has to tolerate a NULL head - # everywhere, which buys nothing. - tree.head_branch(db, adventure) - - if scenario: - for ref, spec in scenario_card_specs(scenario, values).items(): - db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec)) - for position, script in enumerate(scenario.scripts): - db.add( - models.AdventureScript( - adventure_id=adventure.id, - source_script_id=script.id, - position=position, - name=script.name, - description=script.description, - library_js=script.library_js, - input_js=script.input_js, - context_js=script.context_js, - output_js=script.output_js, - ) - ) - if scenario.prompt.strip(): - opening = models.Action( - adventure_id=adventure.id, - index=0, - type="start", - text=fill_placeholders(scenario.prompt, values), - ) - # Record the starting state on the opening node, so undoing or - # retrying the first turn has a state to roll back to. - attempts.snapshot_outcome(adventure, opening) - tree.place_action(db, adventure, opening) - db.add(opening) - - db.commit() - db.refresh(adventure) - analytics.record_event(analytics.EV_ADVENTURE, user) - # Track which shared scenarios players pick. This is the only content this - # module records, and it records only public scenarios. A player's own - # scenario titles stay private. - if scenario is not None and scenario.is_public: - analytics.record(analytics.M_SCENARIO, scenario.title) - return adventure - - -@router.get("/{adventure_id}", response_model=schemas.AdventureOut) -def get_adventure( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Returns the adventure and the newest window of its story. - - `actions` holds the last `ACTION_PAGE` actions, not all of them. - `action_count` reports the real total, so the reader knows that more actions - exist above. `GET /{id}/actions` serves the older pages as the reader - scrolls up. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - actions, total, _ = action_window(db, adventure) - # Annotate before handing over the window. This path serializes through the - # relationship rather than building `ActionOut` itself, so the pager numbers - # have to be on the rows before Pydantic reads them. - annotate_takes(db, adventure.id, actions) - # Attach the window as if the relationship had loaded it. - # `set_committed_value` is the only safe way to do this. Assigning - # `adventure.actions = [...]` marks the collection dirty, and the - # relationship cascades delete-orphan, so the next flush deletes every - # action outside the window. `set_committed_value` records the rows as the - # already-loaded, unmodified value, so serialization triggers no lazy load - # and leaves nothing pending. - set_committed_value(adventure, "actions", actions) - out = schemas.AdventureOut.model_validate(adventure) - out.action_count = total - return out - - -@router.get("/{adventure_id}/script-state") -def get_script_state( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Returns the scripting `state` object. - - The object holds every variable that scripts read and write through - `state.x`, persisted after each hook. It stays `{}` until a script sets a - variable. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - state = adventure.script_state if isinstance(adventure.script_state, dict) else {} - return {"state": state} - - -@router.get("/{adventure_id}/world-state") -def get_world_state( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Returns the live RPG world state and the scenario's `stat_schema`. - - The play view uses both to render the character sheet and the milestones. - `schema` is null when the adventure has no RPG layer. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - schema = adventure.scenario.stat_schema if adventure.scenario else None - state = adventure.world_state if isinstance(adventure.world_state, dict) else {} - return { - "state": state, - "schema": schema if worldstate.has_schema(schema) else None, - } - - -@router.put("/{adventure_id}/world-state") -def override_world_state( - adventure_id: int, - overrides: dict = Body(...), - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Edits the live RPG values directly, as a manual correction rather than a turn. - - `overrides` maps paths such as `player.hp`, `npc.gwen.trust`, `flags.x`, and - `milestones.y` to their new absolute values. The endpoint rejects unknown - paths and wrong types one at a time, and applies the rest. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - schema = adventure.scenario.stat_schema if adventure.scenario else None - if not worldstate.has_schema(schema): - raise HTTPException(400, "This adventure has no RPG world-state layer") - state = adventure.world_state if isinstance(adventure.world_state, dict) else {} - new_state, report = worldstate.apply_override(state, schema, overrides) - adventure.world_state = new_state - db.commit() - return {"state": new_state, "report": report} - - -# ---------- Retry history (sibling attempts) ---------- -# -# Retry first deleted the AI action and generated a replacement. A later version -# kept the row and appended each attempt to a JSON list on it. Now every attempt -# is its own node on the same branch at the same depth, and exactly one of them -# is `live`. `app/attempts.py` owns the group and its invariants. The endpoints -# below only query it. - - -def world_delta_of(snapshot: dict | None) -> dict | None: - """Returns the bulk-read slice of a context snapshot, for `Action.world_delta`. - - `context_snapshot` is deferred because it holds the whole assembled prompt. - The parts that every action needs get their own small column instead: the - world-change chips, the emit block replayed into history, and the refusal - note fed back to the model. Update this function wherever a snapshot is - written. - - Carry all three report lists, not just `applied`. `Action.world_changes` - marks a chip from `clamped` and builds its refusal chips from `rejected`, - and `worldstate.refusals` reads both. Storing `applied` alone left every - consumer unable to tell a refused change from one that worked, which is the - distinction this column exists to carry. The two extra lists are subsets of - one turn's block, so they cost a few hundred bytes per action at most. - """ - ws = (snapshot or {}).get("world_state") - if not isinstance(ws, dict): - return None - report = ws.get("report") or {} - return { - "delta": ws.get("delta") or {}, - "applied": report.get("applied") or [], - "clamped": report.get("clamped") or [], - "rejected": report.get("rejected") or [], - } - - -@router.patch("/{adventure_id}", response_model=schemas.AdventureOut) -def update_adventure( - adventure_id: int, - payload: schemas.AdventureUpdate, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - adventure = get_adventure_or_404(adventure_id, db, user) - for field, value in payload.model_dump(exclude_unset=True).items(): - setattr(adventure, field, value) - db.commit() - return adventure - - -@router.delete("/{adventure_id}", status_code=204) -def delete_adventure( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - adventure = get_adventure_or_404(adventure_id, db, user) - db.delete(adventure) - db.commit() - # No later request reads this adventure's vectors, so drop them now. The - # cache would otherwise hold them until the process restarted. - memorybank.forget_cached_vectors(adventure_id) - - -# ---------- Turn engine ---------- - -# One turn at a time per adventure. The set lives in memory, which is enough for -# a single-process local app. Sync endpoints run in a threadpool, so the -# check-and-add needs a lock. The check also has to run during the request -# rather than when the SSE generator first runs. Otherwise two rapid requests -# both pass the check and generate concurrently. -_active_turns: set[int] = set() -_active_turns_guard = threading.Lock() - - -def acquire_turn_lock(adventure_id: int): - """Claims the adventure's turn slot atomically. `with_turn_lock` releases it.""" - with _active_turns_guard: - if adventure_id in _active_turns: - raise HTTPException(409, "A turn is already generating for this adventure.") - _active_turns.add(adventure_id) - - -async def with_turn_lock(adventure_id: int, gen): - """Wraps an SSE generator so that it releases the `acquire_turn_lock` lock.""" - try: - async for event in gen: - yield event - finally: - _active_turns.discard(adventure_id) - - -def format_player_input(action_type: str, text: str) -> str: - """Formats player input the way AI Dungeon does.""" - text = text.strip() - if action_type == "say": - text = text.strip('"') - if text and text[-1] not in ".!?…": - text += "." - return f'> You say "{text}"' - if action_type == "do": - if text.lower().startswith("you "): - text = text[4:] - if text and text[-1] not in ".!?…": - text += "." - return f"> You {text}" - return text # The "story" type is appended as raw text. - - -def sse(obj: dict) -> str: - return f"data: {json.dumps(obj)}\n\n" - - -def turn_error(detail: str, **extra) -> str: - """Returns an SSE error for a turn that could not be produced, and counts it. - - A failed turn is still an HTTP 200 response, so the middleware's status-code - tally cannot see it. This metric exists so that a demo whose model refuses - every request does not report as healthy. - """ - analytics.record(analytics.M_EVENT, analytics.EV_TURN_ERROR) - return sse({"type": "error", "detail": detail, **extra}) - - -# `no-cache` stops an intermediary from caching the stream. `X-Accel-Buffering` -# makes nginx-style reverse proxies, which hosted deploys use, flush each event -# immediately rather than buffer it. -SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} - - -def action_json(action: models.Action, db: Session | None = None) -> dict: - """Serializes one action for the wire. - - Passing `db` fills in the pager numbers, and a turn that was just played must - pass it. The attempt that turn created is often the second one at its - coordinate, so the message needs a pager that the client cannot infer from a - count of one. Without `db`, a retry showed no pager until the page reloaded. - The adventure GET has the same requirement and builds its window a third way. - """ - if db is not None: - annotate_takes(db, action.adventure_id, [action]) - return schemas.ActionOut.model_validate(action).model_dump(mode="json") - - -def next_index(adventure: models.Adventure) -> int: - return context_history.max_action_index(adventure) + 1 - - -def next_depth(adventure: models.Adventure) -> int: - """Returns the depth for the next node played onto this story, one past the tip. - - This is not `next_index`, which returned the same number until SP5. `index` - has to stay unique across the whole adventure, because it is the v1 bundle's - key. On a story forked at depth 6 after twenty turns, `next_index` gives the - next node depth 21 and leaves a fourteen-deep gap in the path. A depth is a - position along this one story, and the branch is what makes it unambiguous. - """ - return adventure.head_depth + 1 - - -def last_action(adventure: models.Adventure, db: Session) -> models.Action | None: - """Returns the newest action of any kind on the story being played, or `None`. - - This runs a query rather than reading `adventure.actions[-1]`, which loads - the entire story to read one row. That collection also holds every branch's - actions, so it sometimes returns a row from the wrong branch. - """ - return ( - db.query(models.Action) - .filter( - models.Action.adventure_id == adventure.id, - lineage.path_of(db, adventure).clause(models.Action), - ) - .order_by(models.Action.depth.desc(), models.Action.id.desc()) - .first() - ) - - -async def generate_turn( - adventure: models.Adventure, - db: Session, - pipeline: ScriptPipeline, - user: models.User, - retry_of: models.Action | None = None, -): - """Streams the AI continuation as SSE, then stores the result. - - The continuation passes through the `context` and `output` script hooks. - - If `retry_of` is set, the result is stored as a sibling of that AI action, at - the same turn and the same coordinate, and the discarded attempt stays where - it was written. Before calling, the caller must roll the adventure back to - the state before that turn. See `retry_action`. If this generator ends - without saving, the rollback is undone, so the state cannot diverge from the - text on screen. - """ - saved = False - try: - async for event in _generate_turn(adventure, db, pipeline, user, retry_of): - if event is _SAVED: - saved = True - continue - yield event - finally: - if retry_of is not None and not saved: - # The turn failed with a provider error, an empty reply, a script - # stop, or a disconnected client. No sibling was written, so the - # attempt on screen is still the live one. Restore the state it - # produced. - attempts.restore_state(adventure, retry_of) - db.commit() - - -# `_generate_turn` yields this sentinel once the action is committed. It tells -# the wrapper above to leave the rollback in place rather than reverse it. -_SAVED = object() - - -async def _generate_turn( - adventure: models.Adventure, - db: Session, - pipeline: ScriptPipeline, - user: models.User, - retry_of: models.Action | None = None, -): - settings = get_settings(db, user) - cfg = auth.resolve_provider_config(settings) - # On a retry, the attempt being replaced is still the live node of its turn, - # because it stays live until a replacement exists. Filter it out of the - # context. Otherwise the model reads the attempt it is replacing as - # established story and writes a sequel to it. - replacing_id = retry_of.id if retry_of is not None else None - if cfg.using_demo: - # The server-funded key makes no embedding or summarization calls, so - # memory retrieval is skipped. If the bank is on, return a note. - memories = ( - {"used": [], "error": "Memory bank is unavailable on the shared demo key — add your own API key in Settings."} - if adventure.memory_bank_enabled - else None - ) - else: - memories = await memorybank.retrieve_memories( - adventure, settings, update_stats=True, exclude_action_id=replacing_id - ) - system_text, story_text, snapshot = build_context( - adventure, settings, memories, exclude_action_id=replacing_id - ) - - # onModelContext: scripts read, and can rewrite, the whole assembled - # context. - combined = f"{system_text}\n\n{story_text}" if system_text else story_text - modified, stop = pipeline.run("context", combined) - if stop: - yield sse({"type": "stopped", "script": pipeline.report()}) - return - context_changed = modified != combined - parts = ( - PromptParts(system="", story=modified) - if context_changed - else PromptParts(system=system_text, story=story_text) - ) - snapshot["script"] = pipeline.report() | { - "context_changed": context_changed, - "context_before": combined if context_changed else None, - "context_after": modified if context_changed else None, - } - - provider = OpenAICompatibleProvider( - cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode, - settings.reasoning_max_tokens, - ) - chunks: list[str] = [] - reasoning_chunks: list[str] = [] - try: - async for kind, chunk in provider.generate( - parts, temperature=settings.temperature, max_tokens=settings.max_output_tokens - ): - if kind == "reasoning": - reasoning_chunks.append(chunk) - yield sse({"type": "reasoning", "text": chunk}) - else: - chunks.append(chunk) - yield sse({"type": "chunk", "text": chunk}) - except ProviderError as exc: - yield turn_error(str(exc)) - return - - text = "".join(chunks).strip() - # The model's literal reply, kept for the Insights "Raw AI output" view. It - # still contains the world-state block, which the code below strips. - raw_output = text - if not text: - # The model streamed reasoning but no story text, so it spent its whole - # budget on reasoning. Report that rather than "empty response". - if reasoning_chunks: - detail = ( - "The model used its entire token budget on reasoning and returned no " - 'story text. Raise "Max output tokens" in Settings, set a "Reasoning ' - 'max tokens" cap, or switch to a non-reasoning model.' - ) - else: - detail = "The AI returned an empty response." - yield turn_error(detail) - return - - # onOutput - text, _ = pipeline.run("output", text) - if not text.strip(): - yield turn_error("A script's output modifier returned empty text.") - return - snapshot["script"] = snapshot["script"] | pipeline.report() - - # RPG world state (Phase 12): read the AI's state delta out of the reply, - # apply it through the engine, and strip the block from the displayed text. - # - # A retry re-runs the same turn, so it is played at that turn's depth. The - # cooldown rules run on a position in the story, and a second attempt at turn - # 12 is still turn 12. This was `retry_of.index`, which held the same number - # until SP4. Depth stays correct once a branch has its own numbering. - ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure) - stat_schema = adventure.scenario.stat_schema if adventure.scenario else None - if worldstate.has_schema(stat_schema): - text, delta = worldstate.extract_delta(text) - if not text.strip(): - yield turn_error("The AI returned only a state update and no story text.") - return - new_world_state, ws_report = worldstate.apply_delta( - adventure.world_state, stat_schema, delta, ai_depth - ) - adventure.world_state = new_world_state - snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state} - - snapshot["raw_output"] = raw_output - # The cost the endpoint reports for the call, including how much of the - # prompt came from cache rather than being billed in full. This is recorded - # per attempt, next to the prompt it priced. - snapshot["usage"] = provider.last_usage - - reasoning = "".join(reasoning_chunks).strip() or None - ai_action = models.Action( - adventure_id=adventure.id, - # A sibling shares the turn's legacy index for the same reason it - # shares its depth: it is the same turn. Two rows then hold one index, - # which is safe, because `max_action_index` takes a maximum rather than - # a count, and nothing else reads the column. - index=retry_of.index if retry_of is not None else next_index(adventure), - depth=ai_depth, - type="ai", - text=text, - reasoning=reasoning, - context_snapshot=snapshot, - world_delta=world_delta_of(snapshot), - ) - attempts.snapshot_outcome(adventure, ai_action) - if retry_of is not None: - attempts.add_attempt(db, adventure, retry_of, ai_action) - db.add(ai_action) - # The text at this coordinate changed, so anything derived from it no - # longer describes the story. Withdraw the memory attached to the node - # and return that stretch to both passes. Before SP4 this code was - # unreachable, because the summarizer held the newest action back until - # a turn landed on top of it. See `memorybank`. - memorybank.forget_node(db, adventure, retry_of) - cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1) - db.flush() - attempts.renumber(attempts.group(db, ai_action)) - else: - tree.place_action(db, adventure, ai_action) - db.add(ai_action) - adventure.updated_at = models.utcnow() - if cfg.using_demo: - # Successful demo turns count against the daily cap, which the endpoint - # checks before the turn starts. A failed provider call above returns - # before this line. - auth.count_demo_turn(user) - db.commit() - # Count the turn here, after every path on which it could still have failed, - # so the number means "stories advanced" rather than "requests attempted". - # The demo tally counts those same turns as spend on the server-funded key. - analytics.record_event(analytics.EV_TURN, user) - if cfg.using_demo: - analytics.record(analytics.M_EVENT, analytics.EV_DEMO_TURN) - db.refresh(ai_action) - yield _SAVED - yield sse({"type": "done", "action": action_json(ai_action, db), "script": pipeline.report()}) - # Phase 6: schedule summarization and embedding without waiting for them. - # The task opens its own database session. It is skipped on the demo key, - # because background AI calls are unmetered spend on the server-funded - # key. - if not cfg.using_demo: - memorybank.schedule_post_turn(adventure) - - -def check_demo_cap(db: Session, user: models.User) -> None: - """Checks the demo cap before a turn starts. - - Checking first avoids storing a capped player's input and then leaving it - without a reply. - """ - settings = get_settings(db, user) - if auth.resolve_provider_config(settings).using_demo and auth.demo_turns_left(user) <= 0: - raise HTTPException(429, auth.DEMO_CAP_MESSAGE) - - -async def run_player_turn( - adventure: models.Adventure, - db: Session, - payload: schemas.ActionCreate, - user: models.User, - preformatted: bool = False, -): - """Plays a player's turn: their action, then the reply to it. - - `preformatted` means the text already carries the `> You ...` conventions and - is written as-is. That applies when the player retakes a turn they already - played (SP9). The editor is seeded with the stored text, which is already - formatted, and a plain edit puts that same text in the box and writes it back - verbatim. Formatting it a second time produces `> You > You ...`. - """ - pipeline = ScriptPipeline(adventure, db) - - # An empty do, say, or story action behaves as a continue. - if payload.type != "continue" and payload.text.strip(): - # onInput reads the formatted text, as in AI Dungeon: "> You ...". - formatted = ( - payload.text.strip() if preformatted - else format_player_input(payload.type, payload.text) - ) - modified, stop = pipeline.run("input", formatted) - if not modified.strip(): - yield turn_error("A script's input modifier returned empty text.", - script=pipeline.report()) - return - player_action = models.Action( - adventure_id=adventure.id, - index=next_index(adventure), - depth=next_depth(adventure), - type=payload.type, - text=modified, - ) - # The state after the input hook has run. The node leaves this state - # behind. The AI turn after it starts here, and a retry of that turn - # rolls back to here. - attempts.snapshot_outcome(adventure, player_action) - tree.place_action(db, adventure, player_action) - db.add(player_action) - db.commit() - db.refresh(player_action) - # The new action was added through its foreign key, so the loaded - # `adventure.actions` collection is stale. Without this expire, - # `build_context` and `next_index` for the AI action do not see the - # player action that was just saved. - db.expire(adventure, ["actions"]) - yield sse({"type": "player", "action": action_json(player_action, db)}) - if stop: - # If onInput returns `{ stop: true }`, skip the AI call. - yield sse({"type": "stopped", "script": pipeline.report()}) - return - - async for event in generate_turn(adventure, db, pipeline, user): - yield event - - -@router.post("/{adventure_id}/actions") -def create_action( - adventure_id: int, - payload: schemas.ActionCreate, - request: Request, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - adventure = get_adventure_or_404(adventure_id, db, user) - limits.rate_limit("turn", request, user) - limits.check_row_cap("actions", db, user, adventure=adventure) - check_demo_cap(db, user) - acquire_turn_lock(adventure_id) - try: - _move_to_after(db, adventure, payload.after_id) - except BaseException: - _active_turns.discard(adventure_id) - raise - return StreamingResponse( - with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)), - media_type="text/event-stream", - headers=SSE_HEADERS, - ) - - -def _move_to_after( - db: Session, adventure: models.Adventure, after_id: int | None -) -> None: - """Moves the story to `after_id` before the turn is played. - - This is where a branch is created (SP9). Reading an attempt that the story - moved past changes nothing on the server. Writing below one is the first time - the player states which line they mean, and that is when the fork happens. - - An attempt already on the path needs no move, because the story is already - there. - """ - if after_id is None: - return - node = db.get(models.Action, after_id) - if node is None or node.adventure_id != adventure.id: - raise HTTPException(404, "Action not found") - if node.live and lineage.path_of(db, adventure).contains(node): - return - if not node.live and len(attempts.group(db, node)) < 2: - # The pager cannot reach this node, so no legitimate action put the - # player here. - raise HTTPException(400, "That take is not one of this turn's.") - stand_on(db, adventure, node) - db.commit() - db.refresh(adventure) - - -@router.post("/{adventure_id}/retry") -def retry_action( - adventure_id: int, - request: Request, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Regenerates the last AI action and keeps the discarded attempt. - - The attempt on screen stays as it was written. The shared script state and - world state roll back to what the node before it left behind, and the new - attempt is stored as a sibling at the same coordinate. No text the AI wrote - is rewritten or deleted. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - limits.rate_limit("turn", request, user) - check_demo_cap(db, user) - acquire_turn_lock(adventure_id) - last_ai = None - try: - newest = last_action(adventure, db) - if newest is not None and newest.type == "ai": - last_ai = newest - # Roll the state back to before this AI turn's hooks ran, so that - # regenerating starts from a clean state rather than applying output - # mutations on top of the attempt being replaced. If the preceding - # node has no snapshot, which happens for a pre-SP4 row that the - # migration could not derive one for, this call does nothing and - # leaves the state as it is. - attempts.roll_back_before(db, adventure, last_ai) - db.commit() - db.refresh(adventure) - except BaseException: - _active_turns.discard(adventure_id) - raise - return StreamingResponse( - with_turn_lock( - adventure_id, - generate_turn( - adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai - ), - ), - media_type="text/event-stream", - headers=SSE_HEADERS, - ) - - -@router.get( - "/{adventure_id}/actions/{action_id}/variants", - response_model=list[schemas.VariantOut], -) -def list_variants( - adventure_id: int, - action_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Returns every attempt made for one AI turn. - - The client fetches these on demand, because the adventure payload carries - only the counts. That keeps old narration out of every page load. - - You can address the turn by any of its attempts, not only the live one. - Switching changes which row the story tells, and a client that holds an id it - received a moment ago still has to be able to ask about the same turn. - """ - get_adventure_or_404(adventure_id, db, user) - action = db.get(models.Action, action_id) - if action is None or action.adventure_id != adventure_id: - raise HTTPException(404, "Action not found") - rows = attempts.group(db, action) - if len(rows) < 2: - return [] # Never retried, so the turn has one attempt. - return [ - schemas.VariantOut( - id=row.id, - index=i, - text=row.text, - reasoning=row.reasoning, - branch_id=row.branch_id, - created_at=row.created_at.isoformat() if row.created_at else None, - active=row.live, - ) - for i, row in enumerate(rows) - ] - - -@router.post( - "/{adventure_id}/actions/{action_id}/variant", response_model=schemas.ActionOut -) -def select_variant( - adventure_id: int, - action_id: int, - payload: schemas.VariantSelect, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Makes an earlier attempt live again and restores the state it produced. - - The restored state covers both the script state and the world state. - - Only the last action can be switched. The turns after an older action were - written to continue the text that is currently active, so replacing that text - would leave the story contradicting itself. The attempts of earlier turns - stay readable through `list_variants`. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - action = db.get(models.Action, action_id) - if action is None or action.adventure_id != adventure_id: - raise HTTPException(404, "Action not found") - rows = attempts.group(db, action) - if not 0 <= payload.index < len(rows) or len(rows) < 2: - raise HTTPException(400, "No such attempt for this action") - newest = last_action(adventure, db) - if newest is None or newest.depth != action.depth or newest.branch_id != action.branch_id: - raise HTTPException( - 400, - "Only the latest message can be switched — the story has already " - "continued from this one.", - ) - acquire_turn_lock(adventure_id) - try: - chosen = rows[payload.index] - if not chosen.live: - # The story at this coordinate is about to change, so withdraw - # anything derived from the previous text. A retry does the same - # thing for the same reason. - memorybank.forget_node(db, adventure, chosen) - cursors.rewind_all(adventure, chosen.branch_id, (chosen.depth or 0) - 1) - attempts.make_live(db, adventure, chosen) - adventure.updated_at = models.utcnow() - db.commit() - db.refresh(chosen) - # Return the row that is now in the story, which is a different row - # from the one the request addressed. An attempt is a node, so choosing - # one moves the story onto it rather than rewriting a row. - return chosen - finally: - _active_turns.discard(adventure_id) - - -def delete_turn( - db: Session, adventure: models.Adventure, node: models.Action -) -> None: - """Removes a turn, including every attempt at it and not only the one on screen. - - A discarded attempt is a leaf at the same coordinate, and the only way to - reach it is through that coordinate. Leaving it behind when the turn is - deleted orphans a row that no read can reach. Whatever the turn produced is - withdrawn once, because a memory is attached to the coordinate rather than to - one attempt. - """ - memorybank.forget_node(db, adventure, node) - # Scoped to this node's branch (SP9). Groups span branches now, and an - # attempt forked onto its own line belongs to another branch's story. See - # `attempts.on_branch`. - for attempt in attempts.on_branch(attempts.group(db, node), node): - db.delete(attempt) - - -# ---------- Branches (Phase 14, SP5) ---------- -# -# Attempts accumulate at the tip as siblings, which costs nothing. An attempt -# becomes a branch only when the player continues the story from it and leaves -# the line that moved past it. That is the same event as playing a turn past the -# attempt. Creating the branch here rather than on the next turn means a branch -# exists only for a divergence someone built on, and the line being left is not -# modified. - - -def annotate_takes( - db: Session, adventure_id: int, actions: list[models.Action] -) -> list[models.Action]: - """Sets the `2/4` pager numbers on every action on a page (SP9). - - This runs one query for the whole page rather than one per row. The pager - needs the shape of each turn's attempt group, and calling `attempts.group` - per action costs one query per message on screen. `variant_count` was cached - to avoid that cost, which is why SP8 could not drop it. - - This function reads the siblings rather than counting them. A group holds - only a few attempts, the page is bounded, and a count still needs a second - query for the ordinal. It fetches only the id and the ordering keys, so it - stays cheap even when the text is large. - """ - parents = {a.parent_id for a in actions if a.parent_id is not None} - if parents: - rows = ( - db.query( - models.Action.id, - models.Action.parent_id, - models.Action.variant_index, - ) - .filter( - models.Action.adventure_id == adventure_id, - models.Action.parent_id.in_(parents), - ) - .order_by(models.Action.variant_index, models.Action.id) - .all() - ) - else: - rows = [] - siblings: dict[int, list[int]] = {} - for row_id, parent_id, _ in rows: - siblings.setdefault(parent_id, []).append(row_id) - for action in actions: - ids = siblings.get(action.parent_id) if action.parent_id else None - if not ids: - # A root node, or a pre-SP9 row that the backfill could not place. - # It has one attempt, which is how it was written. - action.take_count, action.take_index = 1, 0 - continue - action.take_count = len(ids) - action.take_index = ids.index(action.id) if action.id in ids else 0 - return actions - - -def current_window(db: Session, adventure: models.Adventure) -> schemas.ActionPage: - actions, total, has_more = action_window(db, adventure) - return schemas.ActionPage( - actions=[ - schemas.ActionOut.model_validate(a) - for a in annotate_takes(db, adventure.id, actions) - ], - total=total, - has_more=has_more, - ) - - -@router.get("/{adventure_id}/branches", response_model=list[schemas.BranchOut]) -def list_branches( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Returns every branch of the adventure and where each one leaves its parent. - - A tree view is drawn from this shape. `fork_depth` gives the depth where the - line splits off, and `depth` gives the depth where it currently ends. The - whole picture costs one query over `branches` plus one grouped query over - `actions`, never one query per branch, so a view of a hundred forks does not - cost a hundred round trips. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - branches = ( - db.query(models.Branch) - .filter(models.Branch.adventure_id == adventure.id) - .order_by(models.Branch.id) - .all() - ) - owned = { - branch_id: (count, tip) - for branch_id, count, tip in db.query( - models.Action.branch_id, - func.count(models.Action.id), - func.max(models.Action.depth), - ) - .filter( - models.Action.adventure_id == adventure.id, - models.Action.live.is_(True), - ) - .group_by(models.Action.branch_id) - .all() - } - out = [] - for branch in branches: - count, tip = owned.get(branch.id, (0, None)) - out.append(schemas.BranchOut( - id=branch.id, - parent_branch_id=branch.parent_branch_id, - fork_depth=branch.fork_depth, - # A branch with no nodes of its own sits at its fork point. That - # node is the last one its story contains. The node is borrowed, but - # it is still the tip. This matches `tree.refresh_head`. - depth=tip if tip is not None else ( - branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH - ), - own_actions=count, - is_head=(branch.id == adventure.head_branch_id), - name=branch.name, - created_at=branch.created_at, - )) - return out - - -def get_branch_or_404( - adventure: models.Adventure, branch_id: int, db: Session -) -> models.Branch: - """Returns one branch of this adventure. - - If the branch belongs to another adventure, the 404 does not confirm that the - branch exists. - """ - branch = db.get(models.Branch, branch_id) - if branch is None or branch.adventure_id != adventure.id: - raise HTTPException(404, "Branch not found") - return branch - - -@router.patch( - "/{adventure_id}/branches/{branch_id}", response_model=schemas.BranchOut -) -def rename_branch( - adventure_id: int, - branch_id: int, - payload: schemas.BranchRename, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Names a branch, or clears the name to leave it unnamed. - - A blank string means the same thing as `null`. A name of only spaces is not a - name anyone chose, and storing one gives the client an empty label to draw - instead of the fork depth. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - branch = get_branch_or_404(adventure, branch_id, db) - name = (payload.name or "").strip() - branch.name = name or None - adventure.updated_at = models.utcnow() - db.commit() - db.refresh(branch) - # Read both numbers in one pass, and count them the way `list_branches` - # counts them, as live rows on this branch. A renamed branch is the same - # branch, so this response has to match the row the panel would fetch. - tip, own = ( - db.query(func.max(models.Action.depth), func.count(models.Action.id)) - .filter( - models.Action.adventure_id == adventure.id, - models.Action.branch_id == branch.id, - models.Action.live.is_(True), - ) - .one() - ) - return schemas.BranchOut( - id=branch.id, - parent_branch_id=branch.parent_branch_id, - fork_depth=branch.fork_depth, - depth=tip if tip is not None else ( - branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH - ), - own_actions=own, - is_head=(branch.id == adventure.head_branch_id), - name=branch.name, - created_at=branch.created_at, - ) - - -@router.delete("/{adventure_id}/branches/{branch_id}", status_code=204) -def delete_branch( - adventure_id: int, - branch_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Deletes a branch and everything forked from it. - - Nothing prunes the tree automatically, so this endpoint is what keeps a - heavily retried adventure from growing without bound. That is why it ships - with the view that first lets anyone create a fork rather than after it. - - Two kinds of branch cannot be deleted. The root cannot, because it holds the - turns every other branch borrows, so deleting it deletes the whole story. The - branch currently being read cannot, and neither can any branch it was forked - from, because the cascade would remove the head under the player and leave - `head_branch_id` dangling. Switch branches first. - - Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the - cascade on `branches.parent_branch_id`, so the delete is a single statement - however deep the subtree is. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - branch = get_branch_or_404(adventure, branch_id, db) - if branch.parent_branch_id is None: - raise HTTPException( - 400, "This is the story's first branch — deleting it would delete " - "the adventure. Delete the adventure itself instead.", - ) - head = db.get(models.Branch, adventure.head_branch_id) - # The head's lineage lists itself and every branch it borrows from, so one - # membership test covers both the branch being read and any branch forked - # from it. - if head is not None and branch.id in { - entry_id for entry_id, _ in lineage.entries_of(head) - }: - raise HTTPException( - 400, "You are reading this branch, or one forked from it. Switch to " - "another branch first.", - ) - acquire_turn_lock(adventure_id) - try: - # Collect the subtree before the delete, because afterwards there is no - # way to ask which branches were removed. A cursor left pointing at a - # deleted branch is harmless on Postgres, which never reuses ids, but it - # is a bug on SQLite, where the next fork can receive the id that was - # just freed. A stale anchor then resolves onto a branch it never saw. - doomed = _branch_subtree(db, adventure, branch) - for cursor in cursors.ALL: - stored_branch, _ = cursor.stored(adventure) - if stored_branch in doomed: - cursor.clear(adventure) - db.delete(branch) - adventure.updated_at = models.utcnow() - db.commit() - finally: - _active_turns.discard(adventure_id) - # The deleted branch's memories are deleted with it, and their cached - # vectors drop out of the catalogue on the next read, so no invalidation - # call is needed. See the note on the `memorybank` cache. - - -def _branch_subtree( - db: Session, adventure: models.Adventure, root: models.Branch -) -> set[int]: - """Returns `root` and every branch descended from it, following parent pointers. - - The walk runs over the adventure's own branch rows rather than one query per - level. An adventure has few branches, so the walk costs one round trip, and a - recursive CTE would have to be written twice for the two dialects this - codebase supports. - """ - children: dict[int | None, list[int]] = {} - for bid, parent in db.query(models.Branch.id, models.Branch.parent_branch_id).filter( - models.Branch.adventure_id == adventure.id - ): - children.setdefault(parent, []).append(bid) - found: set[int] = set() - stack = [root.id] - while stack: - current = stack.pop() - if current in found: - continue - found.add(current) - stack.extend(children.get(current, ())) - return found - - -@router.post( - "/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage -) -def switch_branch( - adventure_id: int, - branch_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Reads and plays a different branch of the story. - - No row is copied and no row is rewritten. The head pointer moves, and the - shared script state and world state are restored to what that branch's tip - left behind. The restore is what makes a switch safe. Both states are stored - per adventure, so a branch that did not restore them would be played with - another branch's numbers, including the world-state cooldown clock inside the - snapshot. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - branch = db.get(models.Branch, branch_id) - if branch is None or branch.adventure_id != adventure.id: - raise HTTPException(404, "Branch not found") - acquire_turn_lock(adventure_id) - try: - adventure.head_branch_id = branch.id - tree.refresh_head(db, adventure) - attempts.restore_state(adventure, db_tip(db, adventure)) - adventure.updated_at = models.utcnow() - db.commit() - db.refresh(adventure) - return current_window(db, adventure) - finally: - _active_turns.discard(adventure_id) - - -def db_tip(db: Session, adventure: models.Adventure) -> models.Action | None: - """Returns the newest node of the story as it stands, with its outcome loaded.""" - return ( - db.query(models.Action) - .filter( - models.Action.adventure_id == adventure.id, - lineage.path_of(db, adventure).clause(models.Action), - ) - .options( - undefer(models.Action.state_after), - undefer(models.Action.world_state_after), - ) - .order_by(models.Action.depth.desc(), models.Action.id.desc()) - .first() - ) - - -@router.post( - "/{adventure_id}/actions/{action_id}/fork", response_model=schemas.ActionPage -) -def fork_from_attempt( - adventure_id: int, - action_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Continues the story from this attempt, forking a branch if one is needed. - - There are three cases, and the first two do not fork: - - * The attempt is already the one the story tells, so there is nothing to do. - * Its turn is the tip, so the attempts are still leaves that nothing was - built on. The endpoint switches, as `/variant` does, and creates no branch. - * The story has moved past its turn, so the endpoint forks. The attempt gets - a branch of its own, and the line it leaves keeps every turn it has. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - action = db.get(models.Action, action_id) - if action is None or action.adventure_id != adventure_id: - raise HTTPException(404, "Action not found") - # Check this before checking the shape of the turn, because a fork leaves - # the promoted attempt alone on its branch. A client that repeats the call, - # after a double click or a retried request, has to get the same answer - # rather than an error saying the turn it just forked has nothing to fork - # to. - if action.live: - # A live node already holds what its coordinate says, so there is no - # attempt here to promote. On the path being read this call does - # nothing, and it has to stay that way, so that a repeated call after a - # double click or a retried request gets the same answer. Off the path - # the node belongs to another line's story, and moving there is a branch - # switch. - # - # The membership test covers the whole lineage, not `head_branch_id`. A - # head borrows its ancestors' turns, so a live node on an ancestor is - # already being read. Forking it would move the live row off the parent - # and promote a sibling in its place, which rewrites the story on a - # branch nobody asked about and on this one, which borrows that depth. - if lineage.path_of(db, adventure).contains(action): - return current_window(db, adventure) - raise HTTPException( - 400, - "That take is already the story on another branch. Switch to that " - "branch to read it.", - ) - if len(attempts.group(db, action)) < 2: - raise HTTPException( - 400, "This turn has only one take, so there is nothing to fork to." - ) - acquire_turn_lock(adventure_id) - try: - stand_on(db, adventure, action) - adventure.updated_at = models.utcnow() - db.commit() - db.refresh(adventure) - return current_window(db, adventure) - finally: - _active_turns.discard(adventure_id) - - -@router.post("/{adventure_id}/actions/{action_id}/takes") -def add_take( - adventure_id: int, - action_id: int, - payload: schemas.TakeCreate, - request: Request, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Plays a turn again, whoever wrote it. - - This endpoint replaces two earlier operations. `retry` gave an AI turn - another attempt, but only for the newest turn, and a player's own message had - no attempts at all, so changing text you had typed meant overwriting it and - losing the story it led to. Here an AI turn regenerates, a player turn takes - the text you supply, and neither depends on where in the story it sits. - - The tip is the only case that needs no branch, and only for an AI turn, - because nothing was played after it and its attempts are still leaves. A - player turn is never at the tip, since the reply to it is, so a player turn - that has been answered always takes a branch. - - A branch is needed here for the same reason `fork` needs one. The turn being - replayed already has a story after it, and that story was written as a - continuation of the old text. `branch_at` leaves the path just before this - turn, so the new attempt is written at the same depth under the same parent, - and the line it leaves is unchanged. No node below is copied. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - limits.rate_limit("turn", request, user) - limits.check_row_cap("actions", db, user, adventure=adventure) - check_demo_cap(db, user) - action = db.get(models.Action, action_id) - if action is None or action.adventure_id != adventure_id: - raise HTTPException(404, "Action not found") - if action.type not in ("do", "say", "story", "continue", "ai"): - # The opening is not a turn anyone played, so it has no second attempt. - # Editing the scenario is what changes it. - raise HTTPException(400, "The opening of a story has no other take.") - if action.depth is None or not lineage.path_of(db, adventure).contains(action): - raise HTTPException(400, "That turn is not on the story you are reading.") - acquire_turn_lock(adventure_id) - retry_of = None - try: - newest = last_action(adventure, db) - at_the_tip = newest is not None and newest.id == action.id - if at_the_tip and action.type == "ai": - # Nothing was played after it, so its attempts are still leaves and - # a branch would serve no purpose. This is the `retry` path. - retry_of = action - attempts.roll_back_before(db, adventure, action) - else: - # The turn has a story after it, written as a continuation of the - # text that is there now. The new attempt leaves the path just - # before the turn, so that story keeps the attempt it was written - # for. - tree.branch_at(db, adventure, action.depth - 1) - attempts.roll_back_before(db, adventure, action) - adventure.updated_at = models.utcnow() - db.commit() - db.refresh(adventure) - except BaseException: - _active_turns.discard(adventure_id) - raise - if action.type == "ai": - # There is no player action to write. The action this turn answers is - # already on the path, borrowed from the line being left. - stream = generate_turn( - adventure, db, ScriptPipeline(adventure, db), user, retry_of=retry_of - ) - else: - stream = run_player_turn( - adventure, - db, - schemas.ActionCreate(type=action.type, text=payload.text), - user, - # The client seeded its editor from the stored text, which already - # carries the "> You ..." conventions. - preformatted=True, - ) - return StreamingResponse( - with_turn_lock(adventure_id, stream), - media_type="text/event-stream", - headers=SSE_HEADERS, - ) - - -def stand_on( - db: Session, adventure: models.Adventure, action: models.Action -) -> None: - """Makes `action` the attempt the story tells, forking only if that is needed. - - There are two cases, and the caller does not have to know which one applies. - While the turn is still the tip, its attempts are leaves that nothing was - built on, so this is a switch and no branch is created. Once the story has - moved past the turn, the line being left keeps every turn it has, so the - attempt needs a branch of its own. - - The fork endpoint calls this function, and so does a turn played below an - attempt the story moved past. Both are the same operation, once as a request - and once as a step on the way to writing (SP9). - """ - newest = last_action(adventure, db) - at_the_tip = ( - newest is not None - and newest.branch_id == action.branch_id - and newest.depth == action.depth - ) - if at_the_tip: - # The story at this coordinate is about to change, so withdraw whatever - # was derived from it. A retry does the same thing. A fork needs none of - # this, because it leaves the coordinate and its memory where they are. - # See `tree.fork`. - memorybank.forget_node(db, adventure, action) - cursors.rewind_all(adventure, action.branch_id, (action.depth or 0) - 1) - attempts.make_live(db, adventure, action) - else: - tree.fork(db, adventure, action) - attempts.restore_state(adventure, action) - - -@router.post("/{adventure_id}/undo", response_model=schemas.ActionPage) -def undo_turn( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Deletes the last turn: the trailing AI action and its player action, if any. - - The endpoint also rolls the shared `script_state` back to before that turn - ran, and it prunes any memory that summarized the removed actions. The turn - lock prevents an undo while a turn is still generating. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - acquire_turn_lock(adventure_id) - try: - # Only the last turn is removed, so fetch the two actions it can - # consist of rather than the whole story. - newest = ( - db.query(models.Action) - .filter( - models.Action.adventure_id == adventure.id, - lineage.path_of(db, adventure).clause(models.Action), - ) - .order_by(models.Action.depth.desc(), models.Action.id.desc()) - .limit(2) - .all() - ) - if not newest or newest[0].type == "start": - raise HTTPException(400, "Nothing to undo") - last = newest[0] - before_that = newest[1] if len(newest) > 1 else None - # Undo only what this branch owns. Everything before the fork is - # borrowed from an ancestor and is part of that ancestor's story too, so - # an undo here must never delete a turn out of another branch. The test - # reads the row's own branch rather than the fork depth, because the - # branch is what decides the case. - if last.branch_id != adventure.head_branch_id: - raise HTTPException( - 400, "Nothing to undo on this branch — the turns before it " - "belong to the branch it was forked from.", - ) - first_removed = last - if (last.type == "ai" and before_that is not None - and before_that.type in ("do", "say", "story") - and before_that.branch_id == adventure.head_branch_id): - first_removed = before_that - # The state the story returns to once the turn is gone, which is what - # the node before the earliest removed one left behind. Read it before - # the deletes, while those rows are still in the story. - restore_to = attempts.preceding(db, adventure, first_removed) - delete_turn(db, adventure, last) - if first_removed is not last: - delete_turn(db, adventure, first_removed) - attempts.restore_state(adventure, restore_to) - db.flush() # Apply the deletes before anything reads the story back. - db.expire(adventure, ["actions"]) - # The tip moves back with the deleted rows. - tree.refresh_head(db, adventure) - db.commit() - db.refresh(adventure) - # Return the newest window rather than the whole story. The client - # replaces its transcript with this response, and the transcript is a - # window. Returning everything would defeat the paging on the action a - # player is most likely to repeat several times in a row. - actions, total, has_more = action_window(db, adventure) - return schemas.ActionPage( - actions=[ - schemas.ActionOut.model_validate(a) - for a in annotate_takes(db, adventure.id, actions) - ], - total=total, - has_more=has_more, - ) - finally: - _active_turns.discard(adventure_id) - - -# ---------- Import / Export ---------- - -@router.get("/{adventure_id}/export") -def export_adventure( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Returns a full backup: plot components, story cards, scripts, state, and tree. - - `app/bundle.py` owns the format, in both of its versions. A backup outlives - the schema, so no call site decides anything about its shape. - """ - adv = get_adventure_or_404(adventure_id, db, user) - return bundle.export(db, adv) - - -@router.post("/import", response_model=schemas.AdventureOut, status_code=201) -def import_adventure( - request: Request, - payload: dict = Body(...), - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - version = bundle.check_format(payload) - limits.rate_limit("import", request, user) - limits.check_row_cap("adventures", db, user) - limits.check_bundle_lists( - story_cards=payload.get("storyCards"), - memories=payload.get("memories"), - actions=payload.get("actions"), - branches=payload.get("branches"), - ) - # Check the tree before the adventure row exists, so that an inconsistent - # file returns a 400 rather than leaving a half-imported adventure with a - # gap in its story. - story = bundle.plan(payload, version) - # Count again, this time over what is written. The check above reads the - # file's own lists, and in a v1 file one turn is one entry that carries its - # retries in a `variants` array. `plan()` expands that into one row per - # attempt, because SP4 made every attempt a node. A file of 5,000 turns with - # ten attempts each therefore passes a 5,000-action cap and writes 50,000 - # rows, well inside the 20 MB body limit. `plan()` has no side effects and - # the adventure does not exist yet, so this check costs only the planning. - limits.check_bundle_lists( - actions=story["nodes"], - memories=story["memories"], - branches=story["branches"], - ) - - adventure = bundle.materialize(db, payload, story, user.id) - - db.commit() - db.refresh(adventure) - # This is not a funnel step. A returning player imports a bundle, so it - # says nothing about how far a first-time visitor got. It is counted anyway, - # because it is the clearest evidence that anyone uses the export format. - analytics.record_event(analytics.EV_IMPORT, user) - return adventure - - -# ---------- Adventure scripts ---------- - -# Fields that are copied from a library Script into its adventure-script -# snapshot, and compared to decide whether a copy is out of date. -SYNC_FIELDS = ("name", "description", "library_js", "input_js", "context_js", "output_js") - - -def resolve_library_script( - adv_script: models.AdventureScript, db: Session, user: models.User -) -> models.Script | None: - """Returns the library Script an adventure script can re-sync from. - - The result is the script this copy was made from. For a legacy copy with no - link, it is one of the player's own scripts with the same name. Only the - player's own scripts are considered, so a copy derived from a demo scenario - has nothing to sync to. - """ - if adv_script.source_script_id is not None: - script = db.get(models.Script, adv_script.source_script_id) - if script is not None and script.user_id == user.id: - return script - return ( - db.query(models.Script) - .filter(models.Script.user_id == user.id, models.Script.name == adv_script.name) - .order_by(models.Script.updated_at.desc()) - .first() - ) - - -def _mark_out_of_date( - adv_script: models.AdventureScript, db: Session, user: models.User -) -> models.AdventureScript: - """Attaches a transient `out_of_date` flag, which `AdventureScriptOut` reads. - - The flag is `True` or `False` when a syncable library version exists, and - `None` when none exists. - """ - library = resolve_library_script(adv_script, db, user) - adv_script.out_of_date = ( - None if library is None - else any(getattr(adv_script, f) != getattr(library, f) for f in SYNC_FIELDS) - ) - return adv_script - - -@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut]) -def list_adventure_scripts( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - adventure = get_adventure_or_404(adventure_id, db, user) - return [_mark_out_of_date(s, db, user) for s in adventure.scripts] - - -@router.post( - "/{adventure_id}/scripts/{adv_script_id}/sync", - response_model=schemas.AdventureScriptOut, -) -def sync_adventure_script( - adventure_id: int, - adv_script_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Overwrites this copy's code with the latest from its library script. - - `enabled`, `position`, and the adventure's shared `script_state` are kept. - """ - get_adventure_or_404(adventure_id, db, user) - script = db.get(models.AdventureScript, adv_script_id) - if script is None or script.adventure_id != adventure_id: - raise HTTPException(404, "Script not found") - library = resolve_library_script(script, db, user) - if library is None: - raise HTTPException(404, "No library script to sync from") - for field in SYNC_FIELDS: - setattr(script, field, getattr(library, field)) - # Store the link, so that a name-matched legacy copy syncs by id next - # time. - script.source_script_id = library.id - db.commit() - db.refresh(script) - return _mark_out_of_date(script, db, user) - - -@router.patch( - "/{adventure_id}/scripts/{adv_script_id}", response_model=schemas.AdventureScriptOut -) -def update_adventure_script( - adventure_id: int, - adv_script_id: int, - payload: schemas.AdventureScriptUpdate, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - get_adventure_or_404(adventure_id, db, user) - script = db.get(models.AdventureScript, adv_script_id) - if script is None or script.adventure_id != adventure_id: - raise HTTPException(404, "Script not found") - for field, value in payload.model_dump(exclude_unset=True).items(): - setattr(script, field, value) - db.commit() - return script - - -# ---------- Refresh from scenario ---------- -# -# An adventure copies the scenario's plot text and story cards when it is -# created, so that later authoring does not change a story in progress. The -# per-script "Sync from library" above works the same way. This section is the -# explicit opt-out. It copies the scenario's current content over the -# adventure's copy. - - -def resolve_source_scenario( - adventure: models.Adventure, db: Session, user: models.User -) -> models.Scenario | None: - """Returns the scenario an adventure can refresh from. - - The result is the scenario the adventure was started from, if that scenario - still exists and the user can still read it, which means the user owns it or - it is public. The result is `None` after the scenario is deleted, which sets - `scenario_id` to NULL, or after it stops being shared. - """ - if adventure.scenario_id is None: - return None - scenario = db.get(models.Scenario, adventure.scenario_id) - if scenario is None or (scenario.user_id != user.id and not scenario.is_public): - return None - return scenario - - -def _placeholder_names(*texts: str) -> list[str]: - """Returns the unique `${Placeholder}` names across the given texts. - - The order is first appearance first. This matches the frontend's - `extractPlaceholders`. - """ - names: list[str] = [] - for text in texts: - for match in PLACEHOLDER_RE.finditer(text or ""): - name = match.group(1).strip() - if name and name not in names: - names.append(name) - return names - - -def scenario_placeholder_names(scenario: models.Scenario) -> list[str]: - """Returns every placeholder the scenario's refreshable content asks for. - - The opening prompt is excluded, because a refresh never rewrites it. - """ - texts = [scenario.memory, scenario.authors_note, scenario.ai_instructions] - for card in scenario.story_cards: - texts += [card.keys, card.entry] - for ndef in (scenario.stat_schema or {}).get("npcs", {}).values(): - if isinstance(ndef, dict): - texts += [str(ndef.get("keys") or ""), str(ndef.get("desc") or "")] - return _placeholder_names(*texts) - - -def _scenario_cards(adventure: models.Adventure) -> dict[str, models.StoryCard]: - """Returns the adventure's scenario-derived cards, keyed by `source_ref`. - - An adventure created before `source_ref` existed has none, so it falls back - to matching the scenario's cards by name. The fallback runs only when the - adventure has no tagged cards at all. Otherwise a player-authored card that - shares a scenario card's name would be adopted and overwritten. - """ - return {c.source_ref: c for c in adventure.story_cards if c.source_ref} - - -def _match_legacy( - adventure: models.Adventure, specs: dict[str, dict] -) -> dict[str, models.StoryCard]: - by_name: dict[str, models.StoryCard] = {} - for card in adventure.story_cards: - by_name.setdefault((card.name or "").strip().lower(), card) - matched: dict[str, models.StoryCard] = {} - for ref, spec in specs.items(): - card = by_name.get((spec["name"] or "").strip().lower()) - if card is not None: - matched[ref] = card - return matched - - -def plan_refresh( - adventure: models.Adventure, scenario: models.Scenario, values: dict[str, str] -) -> tuple[dict, dict, dict]: - """Computes what a refresh would change, without modifying anything. - - The return value is `(plan, specs, matched)`. `plan` is the summary the UI - shows, `specs` holds the scenario's card specs by ref, and `matched` holds - the existing adventure card for each ref that already has one. - """ - fields = { - field: {"old": getattr(adventure, field), "new": fill_placeholders( - getattr(scenario, field), values)} - for field in SCENARIO_TEXT_FIELDS - } - changed_fields = {f: v for f, v in fields.items() if v["old"] != v["new"]} - - specs = scenario_card_specs(scenario, values) - tagged = _scenario_cards(adventure) - matched = tagged or _match_legacy(adventure, specs) - - added, updated = [], [] - for ref, spec in specs.items(): - card = matched.get(ref) - if card is None: - added.append(spec["name"]) - elif any(getattr(card, f) != spec[f] for f in CARD_FIELDS): - updated.append(card.name or spec["name"]) - # Only a card the scenario produced is removable. A player-authored card - # has no `source_ref` and is never modified. - removed = [c.name for ref, c in tagged.items() if ref not in specs] - - _, world = worldstate.reconcile(adventure.world_state, scenario.stat_schema) - - plan = { - "scenario_id": scenario.id, - "scenario_title": scenario.title, - "fields": changed_fields, - "cards": {"added": added, "updated": updated, "removed": removed}, - "world_state": world, - } - plan["has_changes"] = bool( - changed_fields or added or updated or removed - or world["added"] or world["removed"] - ) - return plan, specs, matched - - -@router.get("/{adventure_id}/refresh", response_model=schemas.RefreshPlan) -def preview_refresh( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Returns what "Update from scenario" would change, for the confirm dialog.""" - adventure = get_adventure_or_404(adventure_id, db, user) - scenario = resolve_source_scenario(adventure, db, user) - if scenario is None: - raise HTTPException(404, "No scenario to update from") - stored = adventure.placeholders if isinstance(adventure.placeholders, dict) else {} - plan, _, _ = plan_refresh(adventure, scenario, stored) - # An adventure started before placeholder answers were stored has none, and - # an author can add a new `${...}` later. In both cases the player is asked - # for the missing names, and the answers are saved for next time. - plan["placeholders_needed"] = [ - n for n in scenario_placeholder_names(scenario) if n not in stored - ] - return plan - - -@router.post("/{adventure_id}/refresh", response_model=schemas.AdventureOut) -def refresh_from_scenario( - adventure_id: int, - payload: schemas.AdventureRefresh = Body(default=schemas.AdventureRefresh()), - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Copies the scenario's current plot text, story cards, and stat schema over - this adventure's copy. - - The refresh overwrites the plot fields and every scenario-derived card, adds - what the scenario gained, and removes what it dropped. - - The refresh leaves these unchanged: the opening `start` action, because the - story is built on it and it is already part of the memories and the summary; - the adventure's own title; its story summary; its player-authored story - cards; and, through `worldstate.reconcile`, the live value of every stat the - schema still defines. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - scenario = resolve_source_scenario(adventure, db, user) - if scenario is None: - raise HTTPException(404, "No scenario to update from") - - values = {**(adventure.placeholders if isinstance(adventure.placeholders, dict) else {}), - **payload.placeholders} - - # A refresh rewrites the same state a turn is part-way through changing, so - # it takes the turn slot rather than run at the same time as the - # generator. - acquire_turn_lock(adventure_id) - try: - _, specs, matched = plan_refresh(adventure, scenario, values) - - for field in SCENARIO_TEXT_FIELDS: - setattr(adventure, field, fill_placeholders(getattr(scenario, field), values)) - - for ref, spec in specs.items(): - card = matched.get(ref) - if card is None: - db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec)) - continue - for field in CARD_FIELDS: - setattr(card, field, spec[field]) - # Store the ref, so that a name-matched legacy card syncs by id - # next time. - card.source_ref = ref - for ref, card in _scenario_cards(adventure).items(): - if ref not in specs: - db.delete(card) - - adventure.world_state, _ = worldstate.reconcile( - adventure.world_state, scenario.stat_schema - ) - adventure.placeholders = values - db.commit() - finally: - _active_turns.discard(adventure_id) - - db.refresh(adventure) - return adventure - - -# ---------- Insights ---------- - -@router.get("/{adventure_id}/context") -async def dry_run_context( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - """Returns what the app would send to the AI if the player continued now.""" - adventure = get_adventure_or_404(adventure_id, db, user) - settings = get_settings(db, user) - if auth.resolve_provider_config(settings).using_demo: - memories = ( - {"used": [], "error": "Memory bank is unavailable on the shared demo key."} - if adventure.memory_bank_enabled - else None - ) - else: - memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False) - _, _, report = build_context(adventure, settings, memories) - return report - - -@router.get("/{adventure_id}/actions/{action_id}/context") -def action_context( - adventure_id: int, - action_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - get_adventure_or_404(adventure_id, db, user) - action = db.get(models.Action, action_id) - if action is None or action.adventure_id != adventure_id: - raise HTTPException(404, "Action not found") - if action.context_snapshot is None: - raise HTTPException(404, "No context snapshot for this action") - return action.context_snapshot - - -# ---------- Memory bank (Phase 6) ---------- - -@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut]) -def list_memories( - adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser -): - adventure = get_adventure_or_404(adventure_id, db, user) - # Name the columns in a query rather than walk `adventure.memories`. - # Retrieval used to walk the relationship, which is why a turn cost - # megabytes: a relationship load returns whole entities, so it reads - # whatever the model carries. `embedding_blob` is deferred and would stay - # out today, so this rule is about the next wide column rather than that - # one. - # - # The drawer shows the same bank the model reads. The filter uses the same - # clause retrieval uses, so the drawer answers one question rather than two. - # An adventure-wide list would show memories from branches this story never - # went down, which are never retrieved, and a reader cannot tell those apart - # from the ones in play. No memory becomes unreachable, because a memory - # belongs to a branch: switching to that branch shows it, and deleting the - # branch deletes its memories. - return ( - db.query(models.Memory) - .options(load_only(*MEMORY_LIST_COLUMNS)) - .filter( - models.Memory.adventure_id == adventure_id, - lineage.path_of(db, adventure).clause(models.Memory), - ) - .order_by(models.Memory.id) - .all() - ) - - -@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201) -def create_memory( - adventure_id: int, - payload: schemas.MemoryCreate, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Adds a memory manually. The next post-turn pass embeds it.""" - adventure = get_adventure_or_404(adventure_id, db, user) - limits.check_row_cap("memories", db, user, adventure=adventure) - if not payload.text.strip(): - raise HTTPException(400, "Memory text cannot be empty") - memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip()) - # No node produced this memory, so it gets a branch but no depth. - tree.place_memory(db, adventure, memory) - db.add(memory) - db.commit() - db.refresh(memory) - return memory - - -@router.patch("/{adventure_id}/memories/{memory_id}", response_model=schemas.MemoryOut) -def update_memory( - adventure_id: int, - memory_id: int, - payload: schemas.MemoryUpdate, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - get_adventure_or_404(adventure_id, db, user) - memory = db.get(models.Memory, memory_id) - if memory is None or memory.adventure_id != adventure_id: - raise HTTPException(404, "Memory not found") - fields = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None} - if "text" in fields and fields["text"].strip() != memory.text: - memorybank.set_vector(memory, None) # Re-embed on the next post-turn pass. - for field, value in fields.items(): - setattr(memory, field, value) - db.commit() - return memory - - -@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204) -def delete_memory( - adventure_id: int, - memory_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - get_adventure_or_404(adventure_id, db, user) - memory = db.get(models.Memory, memory_id) - if memory is None or memory.adventure_id != adventure_id: - raise HTTPException(404, "Memory not found") - db.delete(memory) - db.commit() - - -# ---------- Actions (CRUD) ---------- - -@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage) -def list_actions( - adventure_id: int, - before_id: int | None = None, - limit: int = ACTION_PAGE, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - """Returns a page of the story, working backwards from the newest action. - - `before_id` is the oldest action the caller already holds, so scrolling up - asks for what comes before it. Omit `before_id` for the newest window. See - `action_window` for why this anchors on a row rather than an offset. - """ - adventure = get_adventure_or_404(adventure_id, db, user) - limit = max(1, min(limit, ACTION_PAGE * 4)) - actions, total, has_more = action_window( - db, adventure, before_id=before_id, limit=limit - ) - return schemas.ActionPage( - actions=[ - schemas.ActionOut.model_validate(a) - for a in annotate_takes(db, adventure.id, actions) - ], - total=total, - has_more=has_more, - ) - - -@router.patch("/{adventure_id}/actions/{action_id}", response_model=schemas.ActionOut) -def update_action( - adventure_id: int, - action_id: int, - payload: schemas.ActionUpdate, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - get_adventure_or_404(adventure_id, db, user) - action = db.get(models.Action, action_id) - if action is None or action.adventure_id != adventure_id: - raise HTTPException(404, "Action not found") - # One row holds one text. Nothing mirrors it now, so nothing else has to be - # updated. The edit used to have to be written into the live variant entry - # as well, or paging away and back reverted it. - action.text = payload.text - db.commit() - return action - - -@router.delete("/{adventure_id}/actions/{action_id}", status_code=204) -def delete_action( - adventure_id: int, - action_id: int, - db: Session = Depends(get_db), - user: models.User = CurrentUser, -): - adventure = get_adventure_or_404(adventure_id, db, user) - action = db.get(models.Action, action_id) - if action is None or action.adventure_id != adventure_id: - raise HTTPException(404, "Action not found") - # This works like undo. The turn is deleted with all of its attempts, and - # whatever it produced is withdrawn. Nothing else is needed, because the - # marks are depths, and a depth does not move when an action before it is - # deleted. - delete_turn(db, adventure, action) - db.flush() - db.expire(adventure, ["actions"]) - # Deleting the newest action moves the tip. Deleting an action in the - # middle leaves a gap in the depths, which is intended. See - # `_backfill_tree`. - tree.refresh_head(db, adventure) - db.commit() diff --git a/backend/app/routers/adventures/__init__.py b/backend/app/routers/adventures/__init__.py new file mode 100644 index 0000000..afbf4de --- /dev/null +++ b/backend/app/routers/adventures/__init__.py @@ -0,0 +1,58 @@ +"""The adventure endpoints, split across one module per group of routes. + +Importing this package registers every route, because each endpoint module +decorates its handlers with the shared `router` from `deps`. The import order +below is the order FastAPI matches paths in. No two routes here shadow each +other, so the order is for reading rather than for correctness. + +Read the modules in this order to follow a turn from end to end: + + deps the router and the ownership check every endpoint runs + paging reading a window of actions and numbering its attempts + nodes moving around the story tree + turns playing a turn, and the lock that allows only one at a time + takes retries and the attempts that collect at one coordinate + branches where a story splits + +What this package re-exports, and what it deliberately does not: + +Pure helpers and handlers are re-exported below, so `adventures.ACTION_PAGE` and +`adventures.undo_turn` keep working. The names a test replaces are not, and you +must reach those as `adventures.turns.`. Rebinding a re-exported alias +changes only the alias, so patching `adventures.generate_turn` would leave every +caller reading the original. Leaving those names off raises `AttributeError` +instead, which is the failure you want. +""" +from .deps import router + +# Imported for the side effect of registering routes. The names are unused here. +from . import ( # noqa: F401 + crud, + turns, + takes, + branches, + bundle_io, + scripts, + refresh, + insights, + memories, + actions, +) +from ... import limits # noqa: F401 `adventures.limits` is patched by tests. +from .crud import SNIPPET_MAX, _snippet +from .paging import ACTION_PAGE +from .takes import retry_action, undo_turn +from .turns import SSE_HEADERS, sse, world_delta_of + +__all__ = [ + "ACTION_PAGE", + "SNIPPET_MAX", + "SSE_HEADERS", + "_snippet", + "limits", + "retry_action", + "router", + "sse", + "undo_turn", + "world_delta_of", +] diff --git a/backend/app/routers/adventures/actions.py b/backend/app/routers/adventures/actions.py new file mode 100644 index 0000000..553634b --- /dev/null +++ b/backend/app/routers/adventures/actions.py @@ -0,0 +1,90 @@ +"""Reading, editing, and deleting individual actions. + +`list_actions` pages through the current branch. The edit and delete endpoints +change one node, and deleting one removes the whole attempt group at its +coordinate through `nodes.delete_turn`. +""" + +from fastapi import Depends, HTTPException +from sqlalchemy.orm import Session + +from ... import models, schemas, tree +from ...database import get_db + +from .deps import CurrentUser, get_adventure_or_404, router +from .nodes import delete_turn +from .paging import ACTION_PAGE, action_window, annotate_takes + + +@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage) +def list_actions( + adventure_id: int, + before_id: int | None = None, + limit: int = ACTION_PAGE, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Returns a page of the story, working backwards from the newest action. + + `before_id` is the oldest action the caller already holds, so scrolling up + asks for what comes before it. Omit `before_id` for the newest window. See + `action_window` for why this anchors on a row rather than an offset. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + limit = max(1, min(limit, ACTION_PAGE * 4)) + actions, total, has_more = action_window( + db, adventure, before_id=before_id, limit=limit + ) + return schemas.ActionPage( + actions=[ + schemas.ActionOut.model_validate(a) + for a in annotate_takes(db, adventure.id, actions) + ], + total=total, + has_more=has_more, + ) + + +@router.patch("/{adventure_id}/actions/{action_id}", response_model=schemas.ActionOut) +def update_action( + adventure_id: int, + action_id: int, + payload: schemas.ActionUpdate, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + # One row holds one text. Nothing mirrors it now, so nothing else has to be + # updated. The edit used to have to be written into the live variant entry + # as well, or paging away and back reverted it. + action.text = payload.text + db.commit() + return action + + +@router.delete("/{adventure_id}/actions/{action_id}", status_code=204) +def delete_action( + adventure_id: int, + action_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + adventure = get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + # This works like undo. The turn is deleted with all of its attempts, and + # whatever it produced is withdrawn. Nothing else is needed, because the + # marks are depths, and a depth does not move when an action before it is + # deleted. + delete_turn(db, adventure, action) + db.flush() + db.expire(adventure, ["actions"]) + # Deleting the newest action moves the tip. Deleting an action in the + # middle leaves a gap in the depths, which is intended. See + # `_backfill_tree`. + tree.refresh_head(db, adventure) + db.commit() diff --git a/backend/app/routers/adventures/branches.py b/backend/app/routers/adventures/branches.py new file mode 100644 index 0000000..5453531 --- /dev/null +++ b/backend/app/routers/adventures/branches.py @@ -0,0 +1,266 @@ +"""The branch endpoints: list, rename, delete, and switch. + +Attempts accumulate at the tip as siblings, which costs nothing. An attempt +becomes a branch only when the player continues the story from it and leaves the +line that moved past it. That is the same event as playing a turn past the +attempt. Creating the branch then rather than on the next turn means a branch +exists only for a divergence someone built on, and the line being left is not +modified. +""" + +from fastapi import Depends, HTTPException +from sqlalchemy import func +from sqlalchemy.orm import Session + +from ... import attempts, models, schemas, tree +from ...context import cursors +from ...context import lineage +from ...database import get_db + +from . import turns +from .deps import CurrentUser, get_adventure_or_404, router +from .nodes import db_tip +from .paging import current_window + + +@router.get("/{adventure_id}/branches", response_model=list[schemas.BranchOut]) +def list_branches( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Returns every branch of the adventure and where each one leaves its parent. + + A tree view is drawn from this shape. `fork_depth` gives the depth where the + line splits off, and `depth` gives the depth where it currently ends. The + whole picture costs one query over `branches` plus one grouped query over + `actions`, never one query per branch, so a view of a hundred forks does not + cost a hundred round trips. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + branches = ( + db.query(models.Branch) + .filter(models.Branch.adventure_id == adventure.id) + .order_by(models.Branch.id) + .all() + ) + owned = { + branch_id: (count, tip) + for branch_id, count, tip in db.query( + models.Action.branch_id, + func.count(models.Action.id), + func.max(models.Action.depth), + ) + .filter( + models.Action.adventure_id == adventure.id, + models.Action.live.is_(True), + ) + .group_by(models.Action.branch_id) + .all() + } + out = [] + for branch in branches: + count, tip = owned.get(branch.id, (0, None)) + out.append(schemas.BranchOut( + id=branch.id, + parent_branch_id=branch.parent_branch_id, + fork_depth=branch.fork_depth, + # A branch with no nodes of its own sits at its fork point. That + # node is the last one its story contains. The node is borrowed, but + # it is still the tip. This matches `tree.refresh_head`. + depth=tip if tip is not None else ( + branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH + ), + own_actions=count, + is_head=(branch.id == adventure.head_branch_id), + name=branch.name, + created_at=branch.created_at, + )) + return out + + +def get_branch_or_404( + adventure: models.Adventure, branch_id: int, db: Session +) -> models.Branch: + """Returns one branch of this adventure. + + If the branch belongs to another adventure, the 404 does not confirm that the + branch exists. + """ + branch = db.get(models.Branch, branch_id) + if branch is None or branch.adventure_id != adventure.id: + raise HTTPException(404, "Branch not found") + return branch + + +@router.patch( + "/{adventure_id}/branches/{branch_id}", response_model=schemas.BranchOut +) +def rename_branch( + adventure_id: int, + branch_id: int, + payload: schemas.BranchRename, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Names a branch, or clears the name to leave it unnamed. + + A blank string means the same thing as `null`. A name of only spaces is not a + name anyone chose, and storing one gives the client an empty label to draw + instead of the fork depth. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + branch = get_branch_or_404(adventure, branch_id, db) + name = (payload.name or "").strip() + branch.name = name or None + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(branch) + # Read both numbers in one pass, and count them the way `list_branches` + # counts them, as live rows on this branch. A renamed branch is the same + # branch, so this response has to match the row the panel would fetch. + tip, own = ( + db.query(func.max(models.Action.depth), func.count(models.Action.id)) + .filter( + models.Action.adventure_id == adventure.id, + models.Action.branch_id == branch.id, + models.Action.live.is_(True), + ) + .one() + ) + return schemas.BranchOut( + id=branch.id, + parent_branch_id=branch.parent_branch_id, + fork_depth=branch.fork_depth, + depth=tip if tip is not None else ( + branch.fork_depth if branch.fork_depth is not None else tree.NO_DEPTH + ), + own_actions=own, + is_head=(branch.id == adventure.head_branch_id), + name=branch.name, + created_at=branch.created_at, + ) + + +@router.delete("/{adventure_id}/branches/{branch_id}", status_code=204) +def delete_branch( + adventure_id: int, + branch_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Deletes a branch and everything forked from it. + + Nothing prunes the tree automatically, so this endpoint is what keeps a + heavily retried adventure from growing without bound. That is why it ships + with the view that first lets anyone create a fork rather than after it. + + Two kinds of branch cannot be deleted. The root cannot, because it holds the + turns every other branch borrows, so deleting it deletes the whole story. The + branch currently being read cannot, and neither can any branch it was forked + from, because the cascade would remove the head under the player and leave + `head_branch_id` dangling. Switch branches first. + + Nodes and memories are deleted by `ON DELETE CASCADE`, and descendants by the + cascade on `branches.parent_branch_id`, so the delete is a single statement + however deep the subtree is. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + branch = get_branch_or_404(adventure, branch_id, db) + if branch.parent_branch_id is None: + raise HTTPException( + 400, "This is the story's first branch — deleting it would delete " + "the adventure. Delete the adventure itself instead.", + ) + head = db.get(models.Branch, adventure.head_branch_id) + # The head's lineage lists itself and every branch it borrows from, so one + # membership test covers both the branch being read and any branch forked + # from it. + if head is not None and branch.id in { + entry_id for entry_id, _ in lineage.entries_of(head) + }: + raise HTTPException( + 400, "You are reading this branch, or one forked from it. Switch to " + "another branch first.", + ) + turns.acquire_turn_lock(adventure_id) + try: + # Collect the subtree before the delete, because afterwards there is no + # way to ask which branches were removed. A cursor left pointing at a + # deleted branch is harmless on Postgres, which never reuses ids, but it + # is a bug on SQLite, where the next fork can receive the id that was + # just freed. A stale anchor then resolves onto a branch it never saw. + doomed = _branch_subtree(db, adventure, branch) + for cursor in cursors.ALL: + stored_branch, _ = cursor.stored(adventure) + if stored_branch in doomed: + cursor.clear(adventure) + db.delete(branch) + adventure.updated_at = models.utcnow() + db.commit() + finally: + turns._active_turns.discard(adventure_id) + + + # The deleted branch's memories are deleted with it, and their cached + # vectors drop out of the catalogue on the next read, so no invalidation + # call is needed. See the note on the `memorybank` cache. + + +def _branch_subtree( + db: Session, adventure: models.Adventure, root: models.Branch +) -> set[int]: + """Returns `root` and every branch descended from it, following parent pointers. + + The walk runs over the adventure's own branch rows rather than one query per + level. An adventure has few branches, so the walk costs one round trip, and a + recursive CTE would have to be written twice for the two dialects this + codebase supports. + """ + children: dict[int | None, list[int]] = {} + for bid, parent in db.query(models.Branch.id, models.Branch.parent_branch_id).filter( + models.Branch.adventure_id == adventure.id + ): + children.setdefault(parent, []).append(bid) + found: set[int] = set() + stack = [root.id] + while stack: + current = stack.pop() + if current in found: + continue + found.add(current) + stack.extend(children.get(current, ())) + return found + + +@router.post( + "/{adventure_id}/branches/{branch_id}/switch", response_model=schemas.ActionPage +) +def switch_branch( + adventure_id: int, + branch_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Reads and plays a different branch of the story. + + No row is copied and no row is rewritten. The head pointer moves, and the + shared script state and world state are restored to what that branch's tip + left behind. The restore is what makes a switch safe. Both states are stored + per adventure, so a branch that did not restore them would be played with + another branch's numbers, including the world-state cooldown clock inside the + snapshot. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + branch = db.get(models.Branch, branch_id) + if branch is None or branch.adventure_id != adventure.id: + raise HTTPException(404, "Branch not found") + turns.acquire_turn_lock(adventure_id) + try: + adventure.head_branch_id = branch.id + tree.refresh_head(db, adventure) + attempts.restore_state(adventure, db_tip(db, adventure)) + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(adventure) + return current_window(db, adventure) + finally: + turns._active_turns.discard(adventure_id) diff --git a/backend/app/routers/adventures/bundle_io.py b/backend/app/routers/adventures/bundle_io.py new file mode 100644 index 0000000..a0ce3ec --- /dev/null +++ b/backend/app/routers/adventures/bundle_io.py @@ -0,0 +1,70 @@ +"""Exporting an adventure to a bundle, and importing one back. + +`app/bundle.py` owns the format and the version handling. These two endpoints +only check ownership and hand the work over. +""" + +from fastapi import Body, Depends, Request +from sqlalchemy.orm import Session + +from ... import analytics, bundle, limits, models, schemas +from ...database import get_db + +from .deps import CurrentUser, get_adventure_or_404, router + + +@router.get("/{adventure_id}/export") +def export_adventure( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Returns a full backup: plot components, story cards, scripts, state, and tree. + + `app/bundle.py` owns the format, in both of its versions. A backup outlives + the schema, so no call site decides anything about its shape. + """ + adv = get_adventure_or_404(adventure_id, db, user) + return bundle.export(db, adv) + + +@router.post("/import", response_model=schemas.AdventureOut, status_code=201) +def import_adventure( + request: Request, + payload: dict = Body(...), + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + version = bundle.check_format(payload) + limits.rate_limit("import", request, user) + limits.check_row_cap("adventures", db, user) + limits.check_bundle_lists( + story_cards=payload.get("storyCards"), + memories=payload.get("memories"), + actions=payload.get("actions"), + branches=payload.get("branches"), + ) + # Check the tree before the adventure row exists, so that an inconsistent + # file returns a 400 rather than leaving a half-imported adventure with a + # gap in its story. + story = bundle.plan(payload, version) + # Count again, this time over what is written. The check above reads the + # file's own lists, and in a v1 file one turn is one entry that carries its + # retries in a `variants` array. `plan()` expands that into one row per + # attempt, because SP4 made every attempt a node. A file of 5,000 turns with + # ten attempts each therefore passes a 5,000-action cap and writes 50,000 + # rows, well inside the 20 MB body limit. `plan()` has no side effects and + # the adventure does not exist yet, so this check costs only the planning. + limits.check_bundle_lists( + actions=story["nodes"], + memories=story["memories"], + branches=story["branches"], + ) + + adventure = bundle.materialize(db, payload, story, user.id) + + db.commit() + db.refresh(adventure) + # This is not a funnel step. A returning player imports a bundle, so it + # says nothing about how far a first-time visitor got. It is counted anyway, + # because it is the clearest evidence that anyone uses the export format. + analytics.record_event(analytics.EV_IMPORT, user) + return adventure diff --git a/backend/app/routers/adventures/crud.py b/backend/app/routers/adventures/crud.py new file mode 100644 index 0000000..24c216d --- /dev/null +++ b/backend/app/routers/adventures/crud.py @@ -0,0 +1,340 @@ +"""Listing, creating, reading, renaming, and deleting adventures. + +The world-state and script-state readers are here too, because they report an +adventure's stored state rather than play a turn. +""" + +from fastapi import Body, Depends, HTTPException +from sqlalchemy import func +from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import set_committed_value + +from ... import analytics, attempts, images, limits, memorybank, models, schemas, tree, worldstate +from ...database import get_db + +from .deps import CurrentUser, get_adventure_or_404, router +from .paging import action_window, annotate_takes +from .scenario_text import fill_placeholders, scenario_card_specs + + +# How many characters of the last narration a Continue card shows. The limit is +# long enough to re-establish the scene and short enough to keep the card +# small. +SNIPPET_MAX = 220 + + +def _snippet(text: str) -> str: + """Condenses stored action text into a single line for a card.""" + # `turns._generate_turn` strips the world-state block before storing AI text, + # so this function only has to normalize whitespace. + collapsed = " ".join((text or "").split()) + if len(collapsed) <= SNIPPET_MAX: + return collapsed + # Cut at a word boundary rather than mid-word. CSS adds the ellipsis. + cut = collapsed[:SNIPPET_MAX].rsplit(" ", 1)[0] + return f"{cut}…" + + +# Action types that read as narration. `start` is the scenario's opening prompt, +# which is the only text a newly created adventure has. Without `start`, a new +# story's card would show no text at all. `do` and `say` are excluded because +# the card quotes the story's voice rather than the player's. +NARRATION_TYPES = ("ai", "story", "start") + + +def _latest_narration(db: Session, head_branches: dict[int, int | None]) -> dict[int, str]: + """Maps each adventure id to the text of its most recent narrated action. + + This runs one window-function query rather than one lookup per adventure, so + the list endpoint makes a fixed number of round trips. + + The query is scoped by head branch rather than by the full lineage, and this + is the only place in the codebase that does so. A lineage clause per + adventure would add a hundred OR terms to the index screen's query to select + one row each. The two scopes differ only for a branch with no nodes of its + own, and playing a turn onto a branch is what creates it, so that state does + not occur. An adventure with no branch has no story to quote. + """ + branch_ids = [b for b in head_branches.values() if b is not None] + if not branch_ids: + return {} + ranked = ( + db.query( + models.Action.adventure_id.label("adventure_id"), + models.Action.text.label("text"), + func.row_number() + .over( + partition_by=models.Action.adventure_id, + order_by=(models.Action.depth.desc(), models.Action.id.desc()), + ) + .label("rank"), + ) + .filter( + models.Action.adventure_id.in_(list(head_branches)), + models.Action.branch_id.in_(branch_ids), + models.Action.type.in_(NARRATION_TYPES), + # Sibling attempts share a depth, and the newest has the highest + # id. Without this filter the snippet quotes the attempt written + # last rather than the one the story tells. After you switch back to + # an earlier attempt, the index screen would quote the discarded one + # and disagree with the story on screen. + models.Action.live.is_(True), + ) + .subquery() + ) + rows = db.query(ranked.c.adventure_id, ranked.c.text).filter(ranked.c.rank == 1).all() + return {adventure_id: text for adventure_id, text in rows} + + +@router.get("", response_model=list[schemas.AdventureListItem]) +def list_adventures(db: Session = Depends(get_db), user: models.User = CurrentUser): + # Select named columns rather than the whole Adventure entity. The entity is + # sixteen columns wide and includes `script_state`, `world_state`, + # `placeholders`, `story_summary`, `memory`, `authors_note`, and + # `ai_instructions`. That is about 15 kB per row in production, fetched once + # per adventure on every index load, and this screen uses none of it. Naming + # the columns also means a wide column added to Adventure later has to opt + # in to being listed here. + rows = ( + db.query( + models.Adventure.id, + models.Adventure.scenario_id, + models.Adventure.title, + models.Adventure.updated_at, + models.Adventure.head_branch_id, + func.count(models.Action.id), + models.Scenario.title, + models.Scenario.image, + models.Scenario.icon, + models.Scenario.updated_at, + ) + .outerjoin(models.Action) + .outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id) + .filter(models.Adventure.user_id == user.id) + # Group by both primary keys. Postgres requires every selected column + # to be grouped or aggregated. The Adventure columns are covered by its + # own grouped primary key, but the Scenario columns come from a joined + # table and have to be listed as well. SQLite accepts the shorter form, + # and Postgres rejects it. + .group_by( + models.Adventure.id, + models.Scenario.id, + models.Scenario.title, + models.Scenario.image, + models.Scenario.icon, + models.Scenario.updated_at, + ) + .order_by(models.Adventure.updated_at.desc()) + .all() + ) + narration = _latest_narration(db, {row[0]: row[4] for row in rows}) + return [ + schemas.AdventureListItem( + id=adv_id, + scenario_id=scenario_id, + scenario_title=scenario_title, + title=title, + updated_at=updated_at, + action_count=count, + snippet=_snippet(narration.get(adv_id, "")), + # The art belongs to the scenario, so the cache-busting stamp uses + # the scenario's `updated_at`, not the adventure's. + image_url=images.public_url(scenario_id, image or "", scenario_updated), + icon=icon or "", + ) + # `count` counts every action in the adventure, not only the ones on + # the path. With one branch the two numbers are equal. After forking + # ships, the index screen overstates a story that has sibling branches. + # The fix belongs to SP5, which is where a fork can first exist. + for (adv_id, scenario_id, title, updated_at, _head_branch_id, count, + scenario_title, image, icon, scenario_updated) in rows + ] + + +@router.post("", response_model=schemas.AdventureOut, status_code=201) +def create_adventure( + payload: schemas.AdventureCreate, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + limits.check_row_cap("adventures", db, user) + scenario = None + if payload.scenario_id is not None: + scenario = db.get(models.Scenario, payload.scenario_id) + # A scenario is playable if the user owns it or if it is public. + if scenario is None or (scenario.user_id != user.id and not scenario.is_public): + raise HTTPException(404, "Scenario not found") + + values = payload.placeholders + adventure = models.Adventure( + user_id=user.id, + scenario_id=scenario.id if scenario else None, + title=payload.title or (scenario.title if scenario else "Untitled Adventure"), + memory=fill_placeholders(scenario.memory, values) if scenario else "", + authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "", + ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "", + # Phase 12: seed the live RPG state from the scenario's template. + world_state=worldstate.instantiate(scenario.stat_schema) if scenario else {}, + # Stored so that a later "Update from scenario" fills the copied text + # with the same answers instead of inserting literal `${...}` tokens. + placeholders=dict(values), + ) + db.add(adventure) + db.flush() + # Give every adventure a story tree as soon as it exists, before anything + # is played onto it. Otherwise the tree code has to tolerate a NULL head + # everywhere, which buys nothing. + tree.head_branch(db, adventure) + + if scenario: + for ref, spec in scenario_card_specs(scenario, values).items(): + db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec)) + for position, script in enumerate(scenario.scripts): + db.add( + models.AdventureScript( + adventure_id=adventure.id, + source_script_id=script.id, + position=position, + name=script.name, + description=script.description, + library_js=script.library_js, + input_js=script.input_js, + context_js=script.context_js, + output_js=script.output_js, + ) + ) + if scenario.prompt.strip(): + opening = models.Action( + adventure_id=adventure.id, + index=0, + type="start", + text=fill_placeholders(scenario.prompt, values), + ) + # Record the starting state on the opening node, so undoing or + # retrying the first turn has a state to roll back to. + attempts.snapshot_outcome(adventure, opening) + tree.place_action(db, adventure, opening) + db.add(opening) + + db.commit() + db.refresh(adventure) + analytics.record_event(analytics.EV_ADVENTURE, user) + # Track which shared scenarios players pick. This is the only content this + # module records, and it records only public scenarios. A player's own + # scenario titles stay private. + if scenario is not None and scenario.is_public: + analytics.record(analytics.M_SCENARIO, scenario.title) + return adventure + + +@router.get("/{adventure_id}", response_model=schemas.AdventureOut) +def get_adventure( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Returns the adventure and the newest window of its story. + + `actions` holds the last `ACTION_PAGE` actions, not all of them. + `action_count` reports the real total, so the reader knows that more actions + exist above. `GET /{id}/actions` serves the older pages as the reader + scrolls up. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + actions, total, _ = action_window(db, adventure) + # Annotate before handing over the window. This path serializes through the + # relationship rather than building `ActionOut` itself, so the pager numbers + # have to be on the rows before Pydantic reads them. + annotate_takes(db, adventure.id, actions) + # Attach the window as if the relationship had loaded it. + # `set_committed_value` is the only safe way to do this. Assigning + # `adventure.actions = [...]` marks the collection dirty, and the + # relationship cascades delete-orphan, so the next flush deletes every + # action outside the window. `set_committed_value` records the rows as the + # already-loaded, unmodified value, so serialization triggers no lazy load + # and leaves nothing pending. + set_committed_value(adventure, "actions", actions) + out = schemas.AdventureOut.model_validate(adventure) + out.action_count = total + return out + + +@router.get("/{adventure_id}/script-state") +def get_script_state( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Returns the scripting `state` object. + + The object holds every variable that scripts read and write through + `state.x`, persisted after each hook. It stays `{}` until a script sets a + variable. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + state = adventure.script_state if isinstance(adventure.script_state, dict) else {} + return {"state": state} + + +@router.get("/{adventure_id}/world-state") +def get_world_state( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Returns the live RPG world state and the scenario's `stat_schema`. + + The play view uses both to render the character sheet and the milestones. + `schema` is null when the adventure has no RPG layer. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + schema = adventure.scenario.stat_schema if adventure.scenario else None + state = adventure.world_state if isinstance(adventure.world_state, dict) else {} + return { + "state": state, + "schema": schema if worldstate.has_schema(schema) else None, + } + + +@router.put("/{adventure_id}/world-state") +def override_world_state( + adventure_id: int, + overrides: dict = Body(...), + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Edits the live RPG values directly, as a manual correction rather than a turn. + + `overrides` maps paths such as `player.hp`, `npc.gwen.trust`, `flags.x`, and + `milestones.y` to their new absolute values. The endpoint rejects unknown + paths and wrong types one at a time, and applies the rest. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + schema = adventure.scenario.stat_schema if adventure.scenario else None + if not worldstate.has_schema(schema): + raise HTTPException(400, "This adventure has no RPG world-state layer") + state = adventure.world_state if isinstance(adventure.world_state, dict) else {} + new_state, report = worldstate.apply_override(state, schema, overrides) + adventure.world_state = new_state + db.commit() + return {"state": new_state, "report": report} + + +@router.patch("/{adventure_id}", response_model=schemas.AdventureOut) +def update_adventure( + adventure_id: int, + payload: schemas.AdventureUpdate, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + adventure = get_adventure_or_404(adventure_id, db, user) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(adventure, field, value) + db.commit() + return adventure + + +@router.delete("/{adventure_id}", status_code=204) +def delete_adventure( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + adventure = get_adventure_or_404(adventure_id, db, user) + db.delete(adventure) + db.commit() + # No later request reads this adventure's vectors, so drop them now. The + # cache would otherwise hold them until the process restarted. + memorybank.forget_cached_vectors(adventure_id) diff --git a/backend/app/routers/adventures/deps.py b/backend/app/routers/adventures/deps.py new file mode 100644 index 0000000..02faeba --- /dev/null +++ b/backend/app/routers/adventures/deps.py @@ -0,0 +1,25 @@ +"""The router every endpoint module registers on, and the dependencies they share. + +This module imports nothing else in the package. Keeping it at the bottom of the +import graph is what lets each endpoint module import the router without +importing its siblings. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from ... import auth, models + + +router = APIRouter(prefix="/api/adventures", tags=["adventures"]) + +CurrentUser = Depends(auth.get_current_user) + + +def get_adventure_or_404( + adventure_id: int, db: Session, user: models.User +) -> models.Adventure: + adventure = db.get(models.Adventure, adventure_id) + if adventure is None or adventure.user_id != user.id: + raise HTTPException(404, "Adventure not found") + return adventure diff --git a/backend/app/routers/adventures/insights.py b/backend/app/routers/adventures/insights.py new file mode 100644 index 0000000..5526bd1 --- /dev/null +++ b/backend/app/routers/adventures/insights.py @@ -0,0 +1,50 @@ +"""Read-only views of the context an adventure would send, or did send. + +The dry run assembles a prompt without calling the model. The per-action endpoint +returns the prompt a turn was actually generated from. Neither writes anything. +""" + +from fastapi import Depends, HTTPException +from sqlalchemy.orm import Session + +from ... import auth, memorybank, models +from ...context import build_context +from ...database import get_db +from ..settings import get_settings + +from .deps import CurrentUser, get_adventure_or_404, router + + +@router.get("/{adventure_id}/context") +async def dry_run_context( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Returns what the app would send to the AI if the player continued now.""" + adventure = get_adventure_or_404(adventure_id, db, user) + settings = get_settings(db, user) + if auth.resolve_provider_config(settings).using_demo: + memories = ( + {"used": [], "error": "Memory bank is unavailable on the shared demo key."} + if adventure.memory_bank_enabled + else None + ) + else: + memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False) + _, _, report = build_context(adventure, settings, memories) + return report + + +@router.get("/{adventure_id}/actions/{action_id}/context") +def action_context( + adventure_id: int, + action_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + if action.context_snapshot is None: + raise HTTPException(404, "No context snapshot for this action") + return action.context_snapshot diff --git a/backend/app/routers/adventures/memories.py b/backend/app/routers/adventures/memories.py new file mode 100644 index 0000000..e6acf9b --- /dev/null +++ b/backend/app/routers/adventures/memories.py @@ -0,0 +1,118 @@ +"""CRUD for the memory bank entries attached to one adventure. + +`app/memorybank.py` owns embedding and retrieval. These endpoints only edit the +rows. +""" + +from fastapi import Depends, HTTPException +from sqlalchemy.orm import Session, load_only + +from ... import limits, memorybank, models, schemas, tree +from ...context import lineage +from ...database import get_db + +from .deps import CurrentUser, get_adventure_or_404, router + + +# The columns `schemas.MemoryOut` renders. `embedded` is a real column and +# belongs here. The vector it describes does not. +MEMORY_LIST_COLUMNS = ( + models.Memory.adventure_id, + models.Memory.text, + models.Memory.pinned, + models.Memory.forgotten, + models.Memory.embedded, + models.Memory.use_count, + models.Memory.last_used_at, + models.Memory.source_start, + models.Memory.source_end, + models.Memory.created_at, +) + + +@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut]) +def list_memories( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + adventure = get_adventure_or_404(adventure_id, db, user) + # Name the columns in a query rather than walk `adventure.memories`. + # Retrieval used to walk the relationship, which is why a turn cost + # megabytes: a relationship load returns whole entities, so it reads + # whatever the model carries. `embedding_blob` is deferred and would stay + # out today, so this rule is about the next wide column rather than that + # one. + # + # The drawer shows the same bank the model reads. The filter uses the same + # clause retrieval uses, so the drawer answers one question rather than two. + # An adventure-wide list would show memories from branches this story never + # went down, which are never retrieved, and a reader cannot tell those apart + # from the ones in play. No memory becomes unreachable, because a memory + # belongs to a branch: switching to that branch shows it, and deleting the + # branch deletes its memories. + return ( + db.query(models.Memory) + .options(load_only(*MEMORY_LIST_COLUMNS)) + .filter( + models.Memory.adventure_id == adventure_id, + lineage.path_of(db, adventure).clause(models.Memory), + ) + .order_by(models.Memory.id) + .all() + ) + + +@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201) +def create_memory( + adventure_id: int, + payload: schemas.MemoryCreate, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Adds a memory manually. The next post-turn pass embeds it.""" + adventure = get_adventure_or_404(adventure_id, db, user) + limits.check_row_cap("memories", db, user, adventure=adventure) + if not payload.text.strip(): + raise HTTPException(400, "Memory text cannot be empty") + memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip()) + # No node produced this memory, so it gets a branch but no depth. + tree.place_memory(db, adventure, memory) + db.add(memory) + db.commit() + db.refresh(memory) + return memory + + +@router.patch("/{adventure_id}/memories/{memory_id}", response_model=schemas.MemoryOut) +def update_memory( + adventure_id: int, + memory_id: int, + payload: schemas.MemoryUpdate, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + get_adventure_or_404(adventure_id, db, user) + memory = db.get(models.Memory, memory_id) + if memory is None or memory.adventure_id != adventure_id: + raise HTTPException(404, "Memory not found") + fields = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None} + if "text" in fields and fields["text"].strip() != memory.text: + memorybank.set_vector(memory, None) # Re-embed on the next post-turn pass. + for field, value in fields.items(): + setattr(memory, field, value) + db.commit() + return memory + + +@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204) +def delete_memory( + adventure_id: int, + memory_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + get_adventure_or_404(adventure_id, db, user) + memory = db.get(models.Memory, memory_id) + if memory is None or memory.adventure_id != adventure_id: + raise HTTPException(404, "Memory not found") + db.delete(memory) + db.commit() diff --git a/backend/app/routers/adventures/nodes.py b/backend/app/routers/adventures/nodes.py new file mode 100644 index 0000000..c05ea8b --- /dev/null +++ b/backend/app/routers/adventures/nodes.py @@ -0,0 +1,146 @@ +"""Moving around the story tree: what is newest, what comes next, what to remove. + +These functions answer questions about action nodes without knowing which +endpoint asked. They do not touch the turn lock and they do not stream, so any +module in the package can import them. +""" + +from fastapi import HTTPException +from sqlalchemy.orm import Session, undefer + +from ... import attempts, memorybank, models, tree +from ...context import cursors +from ...context import history as context_history +from ...context import lineage + + +def next_index(adventure: models.Adventure) -> int: + return context_history.max_action_index(adventure) + 1 + + +def next_depth(adventure: models.Adventure) -> int: + """Returns the depth for the next node played onto this story, one past the tip. + + This is not `next_index`, which returned the same number until SP5. `index` + has to stay unique across the whole adventure, because it is the v1 bundle's + key. On a story forked at depth 6 after twenty turns, `next_index` gives the + next node depth 21 and leaves a fourteen-deep gap in the path. A depth is a + position along this one story, and the branch is what makes it unambiguous. + """ + return adventure.head_depth + 1 + + +def last_action(adventure: models.Adventure, db: Session) -> models.Action | None: + """Returns the newest action of any kind on the story being played, or `None`. + + This runs a query rather than reading `adventure.actions[-1]`, which loads + the entire story to read one row. That collection also holds every branch's + actions, so it sometimes returns a row from the wrong branch. + """ + return ( + db.query(models.Action) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).clause(models.Action), + ) + .order_by(models.Action.depth.desc(), models.Action.id.desc()) + .first() + ) + + +def _move_to_after( + db: Session, adventure: models.Adventure, after_id: int | None +) -> None: + """Moves the story to `after_id` before the turn is played. + + This is where a branch is created (SP9). Reading an attempt that the story + moved past changes nothing on the server. Writing below one is the first time + the player states which line they mean, and that is when the fork happens. + + An attempt already on the path needs no move, because the story is already + there. + """ + if after_id is None: + return + node = db.get(models.Action, after_id) + if node is None or node.adventure_id != adventure.id: + raise HTTPException(404, "Action not found") + if node.live and lineage.path_of(db, adventure).contains(node): + return + if not node.live and len(attempts.group(db, node)) < 2: + # The pager cannot reach this node, so no legitimate action put the + # player here. + raise HTTPException(400, "That take is not one of this turn's.") + stand_on(db, adventure, node) + db.commit() + db.refresh(adventure) + + +def delete_turn( + db: Session, adventure: models.Adventure, node: models.Action +) -> None: + """Removes a turn, including every attempt at it and not only the one on screen. + + A discarded attempt is a leaf at the same coordinate, and the only way to + reach it is through that coordinate. Leaving it behind when the turn is + deleted orphans a row that no read can reach. Whatever the turn produced is + withdrawn once, because a memory is attached to the coordinate rather than to + one attempt. + """ + memorybank.forget_node(db, adventure, node) + # Scoped to this node's branch (SP9). Groups span branches now, and an + # attempt forked onto its own line belongs to another branch's story. See + # `attempts.on_branch`. + for attempt in attempts.on_branch(attempts.group(db, node), node): + db.delete(attempt) + + +def db_tip(db: Session, adventure: models.Adventure) -> models.Action | None: + """Returns the newest node of the story as it stands, with its outcome loaded.""" + return ( + db.query(models.Action) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).clause(models.Action), + ) + .options( + undefer(models.Action.state_after), + undefer(models.Action.world_state_after), + ) + .order_by(models.Action.depth.desc(), models.Action.id.desc()) + .first() + ) + + +def stand_on( + db: Session, adventure: models.Adventure, action: models.Action +) -> None: + """Makes `action` the attempt the story tells, forking only if that is needed. + + There are two cases, and the caller does not have to know which one applies. + While the turn is still the tip, its attempts are leaves that nothing was + built on, so this is a switch and no branch is created. Once the story has + moved past the turn, the line being left keeps every turn it has, so the + attempt needs a branch of its own. + + The fork endpoint calls this function, and so does a turn played below an + attempt the story moved past. Both are the same operation, once as a request + and once as a step on the way to writing (SP9). + """ + newest = last_action(adventure, db) + at_the_tip = ( + newest is not None + and newest.branch_id == action.branch_id + and newest.depth == action.depth + ) + if at_the_tip: + # The story at this coordinate is about to change, so withdraw whatever + # was derived from it. A retry does the same thing. A fork needs none of + # this, because it leaves the coordinate and its memory where they are. + # See `tree.fork`. + memorybank.forget_node(db, adventure, action) + cursors.rewind_all(adventure, action.branch_id, (action.depth or 0) - 1) + attempts.make_live(db, adventure, action) + else: + tree.fork(db, adventure, action) + attempts.restore_state(adventure, action) diff --git a/backend/app/routers/adventures/paging.py b/backend/app/routers/adventures/paging.py new file mode 100644 index 0000000..ed50654 --- /dev/null +++ b/backend/app/routers/adventures/paging.py @@ -0,0 +1,176 @@ +"""Reading a window of actions, and numbering the attempts inside it. + +Three callers build an action window: the adventure GET, the action list, and +every endpoint that returns a page after changing the story. They read the same +columns and apply the same numbering, so both live here. +""" + +from sqlalchemy import func +from sqlalchemy.orm import Session, load_only + +from ... import models, schemas +from ...context import lineage + + +# The columns `schemas.ActionOut` renders, listed explicitly. +# +# `deferred=True` in `models.py` keeps the four heavy columns out of bulk reads, +# but each new column then has to opt in to staying narrow. Both egress +# regressions this project has had came from a column that did not opt in. This +# tuple inverts the default: a new column costs nothing until you add it here. +# +# `world_delta` is listed because `ActionOut.world_changes` is computed from it. +# Omitting it saves no bytes. It converts one bulk read into one lazy load per +# row. +ACTION_LIST_COLUMNS = ( + models.Action.adventure_id, + models.Action.index, + models.Action.type, + models.Action.text, + models.Action.reasoning, + models.Action.world_delta, + models.Action.variant_count, + models.Action.variant_index, + # SP9: the pager's key. If `parent_id` were deferred, every row on the page + # would cost a lazy load, which is the cost `load_only` is here to prevent. + # `branch_id` is listed for the same reason. The pager reads it to tell a + # local step from a branch switch. + models.Action.parent_id, + models.Action.branch_id, + models.Action.created_at, +) + + +# How many actions an adventure opens with, and how many arrive per scroll. +# +# Opening a finished adventure once fetched the whole story in one response. +# That reached 589.5 kB for the longest story in production, and it grew as +# stories grew. 60 actions is a few screens of reading. The common case of +# opening a story, reading the end, and taking a turn never pages, and the worst +# case is bounded by the window size rather than by the length of the story. +ACTION_PAGE = 60 + + +def action_window( + db: Session, + adventure: models.Adventure, + before_id: int | None = None, + limit: int = ACTION_PAGE, +) -> tuple[list[models.Action], int, bool]: + """Returns the `limit` actions immediately older than `before_id`, oldest first. + + The return value is `(actions, total, has_more)`. If `before_id` is `None`, + the newest window is returned. + + The query is scoped to the head branch's lineage, which is the story being + played, rather than to the adventure. A sibling branch's turns therefore + never appear in the transcript. `total` counts the same path, because it is + what tells the reader that more actions exist above. + + The window is anchored on an action, never on a count or on arithmetic over + depth, for two reasons: + + * Appends. Counting back from the newest action shifts every older position + when a turn lands. A reader who scrolls up while a turn is generating gets + a window that is one row off, which re-sends one action and skips another. + An anchor is stable, because "older than this action" means the same thing + before and after the story grows. + * The story tree. Depth is dense today, and branching ends that. Comparing + depths to order a path still works, but treating them as positions does + not. + + `has_more` comes from requesting one row past the window rather than from a + second count, so it costs one row instead of a scan. + """ + path = lineage.path_of(db, adventure) + on_path = ( + models.Action.adventure_id == adventure.id, + path.clause(models.Action), + ) + total = db.query(func.count(models.Action.id)).filter(*on_path).scalar() + if limit <= 0: + return [], total, total > 0 + + query = db.query(models.Action).options(load_only(*ACTION_LIST_COLUMNS)).filter(*on_path) + if before_id is not None: + anchor = ( + db.query(models.Action.depth) + .filter(models.Action.id == before_id, *on_path) + .scalar() + ) + if anchor is None: + # The anchor was deleted while the reader scrolled, by an undo or + # by an edited turn, or it belongs to a branch this story is not on. + # No row can be older than a row that is not present, so report the + # end of the story rather than guess and return a duplicate page. + return [], total, False + query = query.filter(models.Action.depth < anchor) + + rows = ( + query.order_by(models.Action.depth.desc(), models.Action.id.desc()) + .limit(limit + 1) + .all() + ) + has_more = len(rows) > limit + rows = rows[:limit] + rows.reverse() + return rows, total, has_more + + +def annotate_takes( + db: Session, adventure_id: int, actions: list[models.Action] +) -> list[models.Action]: + """Sets the `2/4` pager numbers on every action on a page (SP9). + + This runs one query for the whole page rather than one per row. The pager + needs the shape of each turn's attempt group, and calling `attempts.group` + per action costs one query per message on screen. `variant_count` was cached + to avoid that cost, which is why SP8 could not drop it. + + This function reads the siblings rather than counting them. A group holds + only a few attempts, the page is bounded, and a count still needs a second + query for the ordinal. It fetches only the id and the ordering keys, so it + stays cheap even when the text is large. + """ + parents = {a.parent_id for a in actions if a.parent_id is not None} + if parents: + rows = ( + db.query( + models.Action.id, + models.Action.parent_id, + models.Action.variant_index, + ) + .filter( + models.Action.adventure_id == adventure_id, + models.Action.parent_id.in_(parents), + ) + .order_by(models.Action.variant_index, models.Action.id) + .all() + ) + else: + rows = [] + siblings: dict[int, list[int]] = {} + for row_id, parent_id, _ in rows: + siblings.setdefault(parent_id, []).append(row_id) + for action in actions: + ids = siblings.get(action.parent_id) if action.parent_id else None + if not ids: + # A root node, or a pre-SP9 row that the backfill could not place. + # It has one attempt, which is how it was written. + action.take_count, action.take_index = 1, 0 + continue + action.take_count = len(ids) + action.take_index = ids.index(action.id) if action.id in ids else 0 + return actions + + +def current_window(db: Session, adventure: models.Adventure) -> schemas.ActionPage: + actions, total, has_more = action_window(db, adventure) + return schemas.ActionPage( + actions=[ + schemas.ActionOut.model_validate(a) + for a in annotate_takes(db, adventure.id, actions) + ], + total=total, + has_more=has_more, + ) diff --git a/backend/app/routers/adventures/refresh.py b/backend/app/routers/adventures/refresh.py new file mode 100644 index 0000000..7505a7c --- /dev/null +++ b/backend/app/routers/adventures/refresh.py @@ -0,0 +1,194 @@ +"""Copying a scenario's current content over an adventure's copy. + +An adventure copies the scenario's plot text and story cards when it is created, +so that later authoring does not change a story in progress. The per-script "Sync +from library" in `scripts` works the same way. This module is the explicit +opt-out. The preview endpoint reports what would change, and the write endpoint +applies it. +""" + +from fastapi import Body, Depends, HTTPException +from sqlalchemy.orm import Session + +from ... import models, schemas, worldstate +from ...database import get_db + +from . import turns +from .deps import CurrentUser, get_adventure_or_404, router +from .scenario_text import ( + CARD_FIELDS, SCENARIO_TEXT_FIELDS, fill_placeholders, scenario_card_specs, + scenario_placeholder_names, +) + + +def resolve_source_scenario( + adventure: models.Adventure, db: Session, user: models.User +) -> models.Scenario | None: + """Returns the scenario an adventure can refresh from. + + The result is the scenario the adventure was started from, if that scenario + still exists and the user can still read it, which means the user owns it or + it is public. The result is `None` after the scenario is deleted, which sets + `scenario_id` to NULL, or after it stops being shared. + """ + if adventure.scenario_id is None: + return None + scenario = db.get(models.Scenario, adventure.scenario_id) + if scenario is None or (scenario.user_id != user.id and not scenario.is_public): + return None + return scenario + + +def _scenario_cards(adventure: models.Adventure) -> dict[str, models.StoryCard]: + """Returns the adventure's scenario-derived cards, keyed by `source_ref`. + + An adventure created before `source_ref` existed has none, so it falls back + to matching the scenario's cards by name. The fallback runs only when the + adventure has no tagged cards at all. Otherwise a player-authored card that + shares a scenario card's name would be adopted and overwritten. + """ + return {c.source_ref: c for c in adventure.story_cards if c.source_ref} + + +def _match_legacy( + adventure: models.Adventure, specs: dict[str, dict] +) -> dict[str, models.StoryCard]: + by_name: dict[str, models.StoryCard] = {} + for card in adventure.story_cards: + by_name.setdefault((card.name or "").strip().lower(), card) + matched: dict[str, models.StoryCard] = {} + for ref, spec in specs.items(): + card = by_name.get((spec["name"] or "").strip().lower()) + if card is not None: + matched[ref] = card + return matched + + +def plan_refresh( + adventure: models.Adventure, scenario: models.Scenario, values: dict[str, str] +) -> tuple[dict, dict, dict]: + """Computes what a refresh would change, without modifying anything. + + The return value is `(plan, specs, matched)`. `plan` is the summary the UI + shows, `specs` holds the scenario's card specs by ref, and `matched` holds + the existing adventure card for each ref that already has one. + """ + fields = { + field: {"old": getattr(adventure, field), "new": fill_placeholders( + getattr(scenario, field), values)} + for field in SCENARIO_TEXT_FIELDS + } + changed_fields = {f: v for f, v in fields.items() if v["old"] != v["new"]} + + specs = scenario_card_specs(scenario, values) + tagged = _scenario_cards(adventure) + matched = tagged or _match_legacy(adventure, specs) + + added, updated = [], [] + for ref, spec in specs.items(): + card = matched.get(ref) + if card is None: + added.append(spec["name"]) + elif any(getattr(card, f) != spec[f] for f in CARD_FIELDS): + updated.append(card.name or spec["name"]) + # Only a card the scenario produced is removable. A player-authored card + # has no `source_ref` and is never modified. + removed = [c.name for ref, c in tagged.items() if ref not in specs] + + _, world = worldstate.reconcile(adventure.world_state, scenario.stat_schema) + + plan = { + "scenario_id": scenario.id, + "scenario_title": scenario.title, + "fields": changed_fields, + "cards": {"added": added, "updated": updated, "removed": removed}, + "world_state": world, + } + plan["has_changes"] = bool( + changed_fields or added or updated or removed + or world["added"] or world["removed"] + ) + return plan, specs, matched + + +@router.get("/{adventure_id}/refresh", response_model=schemas.RefreshPlan) +def preview_refresh( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Returns what "Update from scenario" would change, for the confirm dialog.""" + adventure = get_adventure_or_404(adventure_id, db, user) + scenario = resolve_source_scenario(adventure, db, user) + if scenario is None: + raise HTTPException(404, "No scenario to update from") + stored = adventure.placeholders if isinstance(adventure.placeholders, dict) else {} + plan, _, _ = plan_refresh(adventure, scenario, stored) + # An adventure started before placeholder answers were stored has none, and + # an author can add a new `${...}` later. In both cases the player is asked + # for the missing names, and the answers are saved for next time. + plan["placeholders_needed"] = [ + n for n in scenario_placeholder_names(scenario) if n not in stored + ] + return plan + + +@router.post("/{adventure_id}/refresh", response_model=schemas.AdventureOut) +def refresh_from_scenario( + adventure_id: int, + payload: schemas.AdventureRefresh = Body(default=schemas.AdventureRefresh()), + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Copies the scenario's current plot text, story cards, and stat schema over + this adventure's copy. + + The refresh overwrites the plot fields and every scenario-derived card, adds + what the scenario gained, and removes what it dropped. + + The refresh leaves these unchanged: the opening `start` action, because the + story is built on it and it is already part of the memories and the summary; + the adventure's own title; its story summary; its player-authored story + cards; and, through `worldstate.reconcile`, the live value of every stat the + schema still defines. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + scenario = resolve_source_scenario(adventure, db, user) + if scenario is None: + raise HTTPException(404, "No scenario to update from") + + values = {**(adventure.placeholders if isinstance(adventure.placeholders, dict) else {}), + **payload.placeholders} + + # A refresh rewrites the same state a turn is part-way through changing, so + # it takes the turn slot rather than run at the same time as the + # generator. + turns.acquire_turn_lock(adventure_id) + try: + _, specs, matched = plan_refresh(adventure, scenario, values) + + for field in SCENARIO_TEXT_FIELDS: + setattr(adventure, field, fill_placeholders(getattr(scenario, field), values)) + + for ref, spec in specs.items(): + card = matched.get(ref) + if card is None: + db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec)) + continue + for field in CARD_FIELDS: + setattr(card, field, spec[field]) + # Store the ref, so that a name-matched legacy card syncs by id + # next time. + card.source_ref = ref + for ref, card in _scenario_cards(adventure).items(): + if ref not in specs: + db.delete(card) + + adventure.world_state, _ = worldstate.reconcile( + adventure.world_state, scenario.stat_schema + ) + adventure.placeholders = values + db.commit() + finally: + turns._active_turns.discard(adventure_id) + + db.refresh(adventure) + return adventure diff --git a/backend/app/routers/adventures/scenario_text.py b/backend/app/routers/adventures/scenario_text.py new file mode 100644 index 0000000..9812230 --- /dev/null +++ b/backend/app/routers/adventures/scenario_text.py @@ -0,0 +1,106 @@ +"""Copying a scenario's text and story cards onto an adventure. + +An adventure holds its own copy of the scenario's plot text and cards, so that +later authoring does not change a story in progress. Two callers make that copy: +`crud.create_adventure` on the way in, and `refresh` when the player asks for the +scenario's current content. +""" +import re + + +from ... import models, worldstate + + +PLACEHOLDER_RE = re.compile(r"\$\{([^}]+)\}") + + +def fill_placeholders(text: str, values: dict[str, str]) -> str: + """Replaces `${Name}` with the player-provided value. + + Unknown names are left unchanged. + """ + if not text or not values: + return text + return PLACEHOLDER_RE.sub( + lambda m: values.get(m.group(1).strip(), m.group(0)), text + ) + + +# Adventure fields that start as a copy of the scenario's text, so "Update from +# scenario" can copy them again. `title` is excluded because it is the +# adventure's own name, which players rename. `story_summary` is excluded +# because it is play output rather than scenario content. +SCENARIO_TEXT_FIELDS = ("memory", "authors_note", "ai_instructions") + + +# Story-card fields that are copied from the scenario and compared to detect +# drift. +CARD_FIELDS = ("type", "name", "keys", "entry", "notes") + + +def scenario_card_specs(scenario: models.Scenario, values: dict[str, str]) -> dict[str, dict]: + """Returns every story card a scenario implies, keyed by a stable `source_ref`. + + The result holds the scenario's own cards, keyed `card:`, plus one card + per NPC defined in its `stat_schema`, keyed `npc:`. Placeholders are + already filled in. + + Adventure creation and refresh both call this function, so the two cannot + diverge. + """ + specs: dict[str, dict] = {} + existing_names = {(c.name or "").strip().lower() for c in scenario.story_cards} + for card in scenario.story_cards: + specs[f"card:{card.id}"] = { + "type": card.type, + "name": card.name, + "keys": fill_placeholders(card.keys, values), + "entry": fill_placeholders(card.entry, values), + "notes": card.notes, + } + # Phase 12: each defined NPC gets a story card, so its description works as + # lore and can trigger in a scene. If a card with that name already exists, + # skip the NPC. + for npc_key, ndef in (scenario.stat_schema or {}).get("npcs", {}).items(): + if not isinstance(ndef, dict): + continue + name = worldstate.npc_name(ndef, npc_key) + if name.strip().lower() in existing_names: + continue + specs[f"npc:{npc_key}"] = { + "type": "character", + "name": name, + "keys": fill_placeholders(str(ndef.get("keys") or name), values), + "entry": fill_placeholders(str(ndef.get("desc") or ""), values), + "notes": "", + } + return specs + + +def _placeholder_names(*texts: str) -> list[str]: + """Returns the unique `${Placeholder}` names across the given texts. + + The order is first appearance first. This matches the frontend's + `extractPlaceholders`. + """ + names: list[str] = [] + for text in texts: + for match in PLACEHOLDER_RE.finditer(text or ""): + name = match.group(1).strip() + if name and name not in names: + names.append(name) + return names + + +def scenario_placeholder_names(scenario: models.Scenario) -> list[str]: + """Returns every placeholder the scenario's refreshable content asks for. + + The opening prompt is excluded, because a refresh never rewrites it. + """ + texts = [scenario.memory, scenario.authors_note, scenario.ai_instructions] + for card in scenario.story_cards: + texts += [card.keys, card.entry] + for ndef in (scenario.stat_schema or {}).get("npcs", {}).values(): + if isinstance(ndef, dict): + texts += [str(ndef.get("keys") or ""), str(ndef.get("desc") or "")] + return _placeholder_names(*texts) diff --git a/backend/app/routers/adventures/scripts.py b/backend/app/routers/adventures/scripts.py new file mode 100644 index 0000000..ca2e2a0 --- /dev/null +++ b/backend/app/routers/adventures/scripts.py @@ -0,0 +1,116 @@ +"""The per-adventure copies of library scripts. + +An adventure snapshots a library `Script` when it starts, so editing the library +does not change a story in progress. These endpoints report whether a snapshot +has fallen behind its library original, and copy the original over on request. +""" + +from fastapi import Depends, HTTPException +from sqlalchemy.orm import Session + +from ... import models, schemas +from ...database import get_db + +from .deps import CurrentUser, get_adventure_or_404, router + + +# Fields that are copied from a library Script into its adventure-script +# snapshot, and compared to decide whether a copy is out of date. +SYNC_FIELDS = ("name", "description", "library_js", "input_js", "context_js", "output_js") + + +def resolve_library_script( + adv_script: models.AdventureScript, db: Session, user: models.User +) -> models.Script | None: + """Returns the library Script an adventure script can re-sync from. + + The result is the script this copy was made from. For a legacy copy with no + link, it is one of the player's own scripts with the same name. Only the + player's own scripts are considered, so a copy derived from a demo scenario + has nothing to sync to. + """ + if adv_script.source_script_id is not None: + script = db.get(models.Script, adv_script.source_script_id) + if script is not None and script.user_id == user.id: + return script + return ( + db.query(models.Script) + .filter(models.Script.user_id == user.id, models.Script.name == adv_script.name) + .order_by(models.Script.updated_at.desc()) + .first() + ) + + +def _mark_out_of_date( + adv_script: models.AdventureScript, db: Session, user: models.User +) -> models.AdventureScript: + """Attaches a transient `out_of_date` flag, which `AdventureScriptOut` reads. + + The flag is `True` or `False` when a syncable library version exists, and + `None` when none exists. + """ + library = resolve_library_script(adv_script, db, user) + adv_script.out_of_date = ( + None if library is None + else any(getattr(adv_script, f) != getattr(library, f) for f in SYNC_FIELDS) + ) + return adv_script + + +@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut]) +def list_adventure_scripts( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + adventure = get_adventure_or_404(adventure_id, db, user) + return [_mark_out_of_date(s, db, user) for s in adventure.scripts] + + +@router.post( + "/{adventure_id}/scripts/{adv_script_id}/sync", + response_model=schemas.AdventureScriptOut, +) +def sync_adventure_script( + adventure_id: int, + adv_script_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Overwrites this copy's code with the latest from its library script. + + `enabled`, `position`, and the adventure's shared `script_state` are kept. + """ + get_adventure_or_404(adventure_id, db, user) + script = db.get(models.AdventureScript, adv_script_id) + if script is None or script.adventure_id != adventure_id: + raise HTTPException(404, "Script not found") + library = resolve_library_script(script, db, user) + if library is None: + raise HTTPException(404, "No library script to sync from") + for field in SYNC_FIELDS: + setattr(script, field, getattr(library, field)) + # Store the link, so that a name-matched legacy copy syncs by id next + # time. + script.source_script_id = library.id + db.commit() + db.refresh(script) + return _mark_out_of_date(script, db, user) + + +@router.patch( + "/{adventure_id}/scripts/{adv_script_id}", response_model=schemas.AdventureScriptOut +) +def update_adventure_script( + adventure_id: int, + adv_script_id: int, + payload: schemas.AdventureScriptUpdate, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + get_adventure_or_404(adventure_id, db, user) + script = db.get(models.AdventureScript, adv_script_id) + if script is None or script.adventure_id != adventure_id: + raise HTTPException(404, "Script not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(script, field, value) + db.commit() + return script diff --git a/backend/app/routers/adventures/takes.py b/backend/app/routers/adventures/takes.py new file mode 100644 index 0000000..01e083f --- /dev/null +++ b/backend/app/routers/adventures/takes.py @@ -0,0 +1,390 @@ +"""Retries, takes, and the attempts that pile up at one coordinate. + +Retry first deleted the AI action and generated a replacement. A later version +kept the row and appended each attempt to a JSON list on it. Now every attempt is +its own node on the same branch at the same depth, and exactly one of them is +`live`. `app/attempts.py` owns the group and its invariants. The endpoints here +only query it. +""" + +from fastapi import Depends, HTTPException, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from ... import attempts, limits, memorybank, models, schemas, tree +from ...context import cursors +from ...context import lineage +from ...database import get_db +from ...scripting import ScriptPipeline + +from . import turns +from .deps import CurrentUser, get_adventure_or_404, router +from .nodes import delete_turn, last_action, stand_on +from .paging import action_window, annotate_takes, current_window +from .turns import SSE_HEADERS + + +@router.post("/{adventure_id}/retry") +def retry_action( + adventure_id: int, + request: Request, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Regenerates the last AI action and keeps the discarded attempt. + + The attempt on screen stays as it was written. The shared script state and + world state roll back to what the node before it left behind, and the new + attempt is stored as a sibling at the same coordinate. No text the AI wrote + is rewritten or deleted. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + limits.rate_limit("turn", request, user) + turns.check_demo_cap(db, user) + turns.acquire_turn_lock(adventure_id) + last_ai = None + try: + newest = last_action(adventure, db) + if newest is not None and newest.type == "ai": + last_ai = newest + # Roll the state back to before this AI turn's hooks ran, so that + # regenerating starts from a clean state rather than applying output + # mutations on top of the attempt being replaced. If the preceding + # node has no snapshot, which happens for a pre-SP4 row that the + # migration could not derive one for, this call does nothing and + # leaves the state as it is. + attempts.roll_back_before(db, adventure, last_ai) + db.commit() + db.refresh(adventure) + except BaseException: + turns._active_turns.discard(adventure_id) + raise + return StreamingResponse( + turns.with_turn_lock( + adventure_id, + turns.generate_turn( + adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai + ), + ), + media_type="text/event-stream", + headers=SSE_HEADERS, + ) + + +@router.get( + "/{adventure_id}/actions/{action_id}/variants", + response_model=list[schemas.VariantOut], +) +def list_variants( + adventure_id: int, + action_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Returns every attempt made for one AI turn. + + The client fetches these on demand, because the adventure payload carries + only the counts. That keeps old narration out of every page load. + + You can address the turn by any of its attempts, not only the live one. + Switching changes which row the story tells, and a client that holds an id it + received a moment ago still has to be able to ask about the same turn. + """ + get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + rows = attempts.group(db, action) + if len(rows) < 2: + return [] # Never retried, so the turn has one attempt. + return [ + schemas.VariantOut( + id=row.id, + index=i, + text=row.text, + reasoning=row.reasoning, + branch_id=row.branch_id, + created_at=row.created_at.isoformat() if row.created_at else None, + active=row.live, + ) + for i, row in enumerate(rows) + ] + + +@router.post( + "/{adventure_id}/actions/{action_id}/variant", response_model=schemas.ActionOut +) +def select_variant( + adventure_id: int, + action_id: int, + payload: schemas.VariantSelect, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Makes an earlier attempt live again and restores the state it produced. + + The restored state covers both the script state and the world state. + + Only the last action can be switched. The turns after an older action were + written to continue the text that is currently active, so replacing that text + would leave the story contradicting itself. The attempts of earlier turns + stay readable through `list_variants`. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + rows = attempts.group(db, action) + if not 0 <= payload.index < len(rows) or len(rows) < 2: + raise HTTPException(400, "No such attempt for this action") + newest = last_action(adventure, db) + if newest is None or newest.depth != action.depth or newest.branch_id != action.branch_id: + raise HTTPException( + 400, + "Only the latest message can be switched — the story has already " + "continued from this one.", + ) + turns.acquire_turn_lock(adventure_id) + try: + chosen = rows[payload.index] + if not chosen.live: + # The story at this coordinate is about to change, so withdraw + # anything derived from the previous text. A retry does the same + # thing for the same reason. + memorybank.forget_node(db, adventure, chosen) + cursors.rewind_all(adventure, chosen.branch_id, (chosen.depth or 0) - 1) + attempts.make_live(db, adventure, chosen) + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(chosen) + # Return the row that is now in the story, which is a different row + # from the one the request addressed. An attempt is a node, so choosing + # one moves the story onto it rather than rewriting a row. + return chosen + finally: + turns._active_turns.discard(adventure_id) + + +@router.post( + "/{adventure_id}/actions/{action_id}/fork", response_model=schemas.ActionPage +) +def fork_from_attempt( + adventure_id: int, + action_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Continues the story from this attempt, forking a branch if one is needed. + + There are three cases, and the first two do not fork: + + * The attempt is already the one the story tells, so there is nothing to do. + * Its turn is the tip, so the attempts are still leaves that nothing was + built on. The endpoint switches, as `/variant` does, and creates no branch. + * The story has moved past its turn, so the endpoint forks. The attempt gets + a branch of its own, and the line it leaves keeps every turn it has. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + # Check this before checking the shape of the turn, because a fork leaves + # the promoted attempt alone on its branch. A client that repeats the call, + # after a double click or a retried request, has to get the same answer + # rather than an error saying the turn it just forked has nothing to fork + # to. + if action.live: + # A live node already holds what its coordinate says, so there is no + # attempt here to promote. On the path being read this call does + # nothing, and it has to stay that way, so that a repeated call after a + # double click or a retried request gets the same answer. Off the path + # the node belongs to another line's story, and moving there is a branch + # switch. + # + # The membership test covers the whole lineage, not `head_branch_id`. A + # head borrows its ancestors' turns, so a live node on an ancestor is + # already being read. Forking it would move the live row off the parent + # and promote a sibling in its place, which rewrites the story on a + # branch nobody asked about and on this one, which borrows that depth. + if lineage.path_of(db, adventure).contains(action): + return current_window(db, adventure) + raise HTTPException( + 400, + "That take is already the story on another branch. Switch to that " + "branch to read it.", + ) + if len(attempts.group(db, action)) < 2: + raise HTTPException( + 400, "This turn has only one take, so there is nothing to fork to." + ) + turns.acquire_turn_lock(adventure_id) + try: + stand_on(db, adventure, action) + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(adventure) + return current_window(db, adventure) + finally: + turns._active_turns.discard(adventure_id) + + +@router.post("/{adventure_id}/actions/{action_id}/takes") +def add_take( + adventure_id: int, + action_id: int, + payload: schemas.TakeCreate, + request: Request, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Plays a turn again, whoever wrote it. + + This endpoint replaces two earlier operations. `retry` gave an AI turn + another attempt, but only for the newest turn, and a player's own message had + no attempts at all, so changing text you had typed meant overwriting it and + losing the story it led to. Here an AI turn regenerates, a player turn takes + the text you supply, and neither depends on where in the story it sits. + + The tip is the only case that needs no branch, and only for an AI turn, + because nothing was played after it and its attempts are still leaves. A + player turn is never at the tip, since the reply to it is, so a player turn + that has been answered always takes a branch. + + A branch is needed here for the same reason `fork` needs one. The turn being + replayed already has a story after it, and that story was written as a + continuation of the old text. `branch_at` leaves the path just before this + turn, so the new attempt is written at the same depth under the same parent, + and the line it leaves is unchanged. No node below is copied. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + limits.rate_limit("turn", request, user) + limits.check_row_cap("actions", db, user, adventure=adventure) + turns.check_demo_cap(db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + if action.type not in ("do", "say", "story", "continue", "ai"): + # The opening is not a turn anyone played, so it has no second attempt. + # Editing the scenario is what changes it. + raise HTTPException(400, "The opening of a story has no other take.") + if action.depth is None or not lineage.path_of(db, adventure).contains(action): + raise HTTPException(400, "That turn is not on the story you are reading.") + turns.acquire_turn_lock(adventure_id) + retry_of = None + try: + newest = last_action(adventure, db) + at_the_tip = newest is not None and newest.id == action.id + if at_the_tip and action.type == "ai": + # Nothing was played after it, so its attempts are still leaves and + # a branch would serve no purpose. This is the `retry` path. + retry_of = action + attempts.roll_back_before(db, adventure, action) + else: + # The turn has a story after it, written as a continuation of the + # text that is there now. The new attempt leaves the path just + # before the turn, so that story keeps the attempt it was written + # for. + tree.branch_at(db, adventure, action.depth - 1) + attempts.roll_back_before(db, adventure, action) + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(adventure) + except BaseException: + turns._active_turns.discard(adventure_id) + raise + if action.type == "ai": + # There is no player action to write. The action this turn answers is + # already on the path, borrowed from the line being left. + stream = turns.generate_turn( + adventure, db, ScriptPipeline(adventure, db), user, retry_of=retry_of + ) + else: + stream = turns.run_player_turn( + adventure, + db, + schemas.ActionCreate(type=action.type, text=payload.text), + user, + # The client seeded its editor from the stored text, which already + # carries the "> You ..." conventions. + preformatted=True, + ) + return StreamingResponse( + turns.with_turn_lock(adventure_id, stream), + media_type="text/event-stream", + headers=SSE_HEADERS, + ) + + +@router.post("/{adventure_id}/undo", response_model=schemas.ActionPage) +def undo_turn( + adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser +): + """Deletes the last turn: the trailing AI action and its player action, if any. + + The endpoint also rolls the shared `script_state` back to before that turn + ran, and it prunes any memory that summarized the removed actions. The turn + lock prevents an undo while a turn is still generating. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + turns.acquire_turn_lock(adventure_id) + try: + # Only the last turn is removed, so fetch the two actions it can + # consist of rather than the whole story. + newest = ( + db.query(models.Action) + .filter( + models.Action.adventure_id == adventure.id, + lineage.path_of(db, adventure).clause(models.Action), + ) + .order_by(models.Action.depth.desc(), models.Action.id.desc()) + .limit(2) + .all() + ) + if not newest or newest[0].type == "start": + raise HTTPException(400, "Nothing to undo") + last = newest[0] + before_that = newest[1] if len(newest) > 1 else None + # Undo only what this branch owns. Everything before the fork is + # borrowed from an ancestor and is part of that ancestor's story too, so + # an undo here must never delete a turn out of another branch. The test + # reads the row's own branch rather than the fork depth, because the + # branch is what decides the case. + if last.branch_id != adventure.head_branch_id: + raise HTTPException( + 400, "Nothing to undo on this branch — the turns before it " + "belong to the branch it was forked from.", + ) + first_removed = last + if (last.type == "ai" and before_that is not None + and before_that.type in ("do", "say", "story") + and before_that.branch_id == adventure.head_branch_id): + first_removed = before_that + # The state the story returns to once the turn is gone, which is what + # the node before the earliest removed one left behind. Read it before + # the deletes, while those rows are still in the story. + restore_to = attempts.preceding(db, adventure, first_removed) + delete_turn(db, adventure, last) + if first_removed is not last: + delete_turn(db, adventure, first_removed) + attempts.restore_state(adventure, restore_to) + db.flush() # Apply the deletes before anything reads the story back. + db.expire(adventure, ["actions"]) + # The tip moves back with the deleted rows. + tree.refresh_head(db, adventure) + db.commit() + db.refresh(adventure) + # Return the newest window rather than the whole story. The client + # replaces its transcript with this response, and the transcript is a + # window. Returning everything would defeat the paging on the action a + # player is most likely to repeat several times in a row. + actions, total, has_more = action_window(db, adventure) + return schemas.ActionPage( + actions=[ + schemas.ActionOut.model_validate(a) + for a in annotate_takes(db, adventure.id, actions) + ], + total=total, + has_more=has_more, + ) + finally: + turns._active_turns.discard(adventure_id) diff --git a/backend/app/routers/adventures/turns.py b/backend/app/routers/adventures/turns.py new file mode 100644 index 0000000..b828302 --- /dev/null +++ b/backend/app/routers/adventures/turns.py @@ -0,0 +1,443 @@ +"""Playing a turn: the model call, the SSE stream, and the one-turn-at-a-time lock. + +Everything a test needs to intercept lives here, and other modules reach it as +`turns.` rather than importing it by value. That matters twice. The turn +lock guards one set only while one module owns it. And a test that replaces +`OpenAICompatibleProvider`, `generate_turn`, or `check_demo_cap` patches this +module, which every caller reads through. +""" +import json +import threading + +from fastapi import Depends, HTTPException, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from ... import ( + analytics, attempts, auth, limits, memorybank, models, schemas, tree, worldstate, +) +from ...context import build_context, cursors +from ...database import get_db +from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError +from ...scripting import ScriptPipeline +from ..settings import get_settings + +from .deps import CurrentUser, get_adventure_or_404, router +from .nodes import _move_to_after, next_depth, next_index +from .paging import annotate_takes + + +def world_delta_of(snapshot: dict | None) -> dict | None: + """Returns the bulk-read slice of a context snapshot, for `Action.world_delta`. + + `context_snapshot` is deferred because it holds the whole assembled prompt. + The parts that every action needs get their own small column instead: the + world-change chips, the emit block replayed into history, and the refusal + note fed back to the model. Update this function wherever a snapshot is + written. + + Carry all three report lists, not just `applied`. `Action.world_changes` + marks a chip from `clamped` and builds its refusal chips from `rejected`, + and `worldstate.refusals` reads both. Storing `applied` alone left every + consumer unable to tell a refused change from one that worked, which is the + distinction this column exists to carry. The two extra lists are subsets of + one turn's block, so they cost a few hundred bytes per action at most. + """ + ws = (snapshot or {}).get("world_state") + if not isinstance(ws, dict): + return None + report = ws.get("report") or {} + return { + "delta": ws.get("delta") or {}, + "applied": report.get("applied") or [], + "clamped": report.get("clamped") or [], + "rejected": report.get("rejected") or [], + } + + +# One turn at a time per adventure. The set lives in memory, which is enough for +# a single-process local app. Sync endpoints run in a threadpool, so the +# check-and-add needs a lock. The check also has to run during the request +# rather than when the SSE generator first runs. Otherwise two rapid requests +# both pass the check and generate concurrently. +_active_turns: set[int] = set() +_active_turns_guard = threading.Lock() + + +def acquire_turn_lock(adventure_id: int): + """Claims the adventure's turn slot atomically. `with_turn_lock` releases it.""" + with _active_turns_guard: + if adventure_id in _active_turns: + raise HTTPException(409, "A turn is already generating for this adventure.") + _active_turns.add(adventure_id) + + +async def with_turn_lock(adventure_id: int, gen): + """Wraps an SSE generator so that it releases the `acquire_turn_lock` lock.""" + try: + async for event in gen: + yield event + finally: + _active_turns.discard(adventure_id) + + +def format_player_input(action_type: str, text: str) -> str: + """Formats player input the way AI Dungeon does.""" + text = text.strip() + if action_type == "say": + text = text.strip('"') + if text and text[-1] not in ".!?…": + text += "." + return f'> You say "{text}"' + if action_type == "do": + if text.lower().startswith("you "): + text = text[4:] + if text and text[-1] not in ".!?…": + text += "." + return f"> You {text}" + return text # The "story" type is appended as raw text. + + +def sse(obj: dict) -> str: + return f"data: {json.dumps(obj)}\n\n" + + +def turn_error(detail: str, **extra) -> str: + """Returns an SSE error for a turn that could not be produced, and counts it. + + A failed turn is still an HTTP 200 response, so the middleware's status-code + tally cannot see it. This metric exists so that a demo whose model refuses + every request does not report as healthy. + """ + analytics.record(analytics.M_EVENT, analytics.EV_TURN_ERROR) + return sse({"type": "error", "detail": detail, **extra}) + + +# `no-cache` stops an intermediary from caching the stream. `X-Accel-Buffering` +# makes nginx-style reverse proxies, which hosted deploys use, flush each event +# immediately rather than buffer it. +SSE_HEADERS = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} + + +def action_json(action: models.Action, db: Session | None = None) -> dict: + """Serializes one action for the wire. + + Passing `db` fills in the pager numbers, and a turn that was just played must + pass it. The attempt that turn created is often the second one at its + coordinate, so the message needs a pager that the client cannot infer from a + count of one. Without `db`, a retry showed no pager until the page reloaded. + The adventure GET has the same requirement and builds its window a third way. + """ + if db is not None: + annotate_takes(db, action.adventure_id, [action]) + return schemas.ActionOut.model_validate(action).model_dump(mode="json") + + +async def generate_turn( + adventure: models.Adventure, + db: Session, + pipeline: ScriptPipeline, + user: models.User, + retry_of: models.Action | None = None, +): + """Streams the AI continuation as SSE, then stores the result. + + The continuation passes through the `context` and `output` script hooks. + + If `retry_of` is set, the result is stored as a sibling of that AI action, at + the same turn and the same coordinate, and the discarded attempt stays where + it was written. Before calling, the caller must roll the adventure back to + the state before that turn. See `retry_action`. If this generator ends + without saving, the rollback is undone, so the state cannot diverge from the + text on screen. + """ + saved = False + try: + async for event in _generate_turn(adventure, db, pipeline, user, retry_of): + if event is _SAVED: + saved = True + continue + yield event + finally: + if retry_of is not None and not saved: + # The turn failed with a provider error, an empty reply, a script + # stop, or a disconnected client. No sibling was written, so the + # attempt on screen is still the live one. Restore the state it + # produced. + attempts.restore_state(adventure, retry_of) + db.commit() + + +# `_generate_turn` yields this sentinel once the action is committed. It tells +# the wrapper above to leave the rollback in place rather than reverse it. +_SAVED = object() + + +async def _generate_turn( + adventure: models.Adventure, + db: Session, + pipeline: ScriptPipeline, + user: models.User, + retry_of: models.Action | None = None, +): + settings = get_settings(db, user) + cfg = auth.resolve_provider_config(settings) + # On a retry, the attempt being replaced is still the live node of its turn, + # because it stays live until a replacement exists. Filter it out of the + # context. Otherwise the model reads the attempt it is replacing as + # established story and writes a sequel to it. + replacing_id = retry_of.id if retry_of is not None else None + if cfg.using_demo: + # The server-funded key makes no embedding or summarization calls, so + # memory retrieval is skipped. If the bank is on, return a note. + memories = ( + {"used": [], "error": "Memory bank is unavailable on the shared demo key — add your own API key in Settings."} + if adventure.memory_bank_enabled + else None + ) + else: + memories = await memorybank.retrieve_memories( + adventure, settings, update_stats=True, exclude_action_id=replacing_id + ) + system_text, story_text, snapshot = build_context( + adventure, settings, memories, exclude_action_id=replacing_id + ) + + # onModelContext: scripts read, and can rewrite, the whole assembled + # context. + combined = f"{system_text}\n\n{story_text}" if system_text else story_text + modified, stop = pipeline.run("context", combined) + if stop: + yield sse({"type": "stopped", "script": pipeline.report()}) + return + context_changed = modified != combined + parts = ( + PromptParts(system="", story=modified) + if context_changed + else PromptParts(system=system_text, story=story_text) + ) + snapshot["script"] = pipeline.report() | { + "context_changed": context_changed, + "context_before": combined if context_changed else None, + "context_after": modified if context_changed else None, + } + + provider = OpenAICompatibleProvider( + cfg.endpoint_url, cfg.api_key, cfg.model, settings.api_mode, + settings.reasoning_max_tokens, + ) + chunks: list[str] = [] + reasoning_chunks: list[str] = [] + try: + async for kind, chunk in provider.generate( + parts, temperature=settings.temperature, max_tokens=settings.max_output_tokens + ): + if kind == "reasoning": + reasoning_chunks.append(chunk) + yield sse({"type": "reasoning", "text": chunk}) + else: + chunks.append(chunk) + yield sse({"type": "chunk", "text": chunk}) + except ProviderError as exc: + yield turn_error(str(exc)) + return + + text = "".join(chunks).strip() + # The model's literal reply, kept for the Insights "Raw AI output" view. It + # still contains the world-state block, which the code below strips. + raw_output = text + if not text: + # The model streamed reasoning but no story text, so it spent its whole + # budget on reasoning. Report that rather than "empty response". + if reasoning_chunks: + detail = ( + "The model used its entire token budget on reasoning and returned no " + 'story text. Raise "Max output tokens" in Settings, set a "Reasoning ' + 'max tokens" cap, or switch to a non-reasoning model.' + ) + else: + detail = "The AI returned an empty response." + yield turn_error(detail) + return + + # onOutput + text, _ = pipeline.run("output", text) + if not text.strip(): + yield turn_error("A script's output modifier returned empty text.") + return + snapshot["script"] = snapshot["script"] | pipeline.report() + + # RPG world state (Phase 12): read the AI's state delta out of the reply, + # apply it through the engine, and strip the block from the displayed text. + # + # A retry re-runs the same turn, so it is played at that turn's depth. The + # cooldown rules run on a position in the story, and a second attempt at turn + # 12 is still turn 12. This was `retry_of.index`, which held the same number + # until SP4. Depth stays correct once a branch has its own numbering. + ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure) + stat_schema = adventure.scenario.stat_schema if adventure.scenario else None + if worldstate.has_schema(stat_schema): + text, delta = worldstate.extract_delta(text) + if not text.strip(): + yield turn_error("The AI returned only a state update and no story text.") + return + new_world_state, ws_report = worldstate.apply_delta( + adventure.world_state, stat_schema, delta, ai_depth + ) + adventure.world_state = new_world_state + snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state} + + snapshot["raw_output"] = raw_output + # The cost the endpoint reports for the call, including how much of the + # prompt came from cache rather than being billed in full. This is recorded + # per attempt, next to the prompt it priced. + snapshot["usage"] = provider.last_usage + + reasoning = "".join(reasoning_chunks).strip() or None + ai_action = models.Action( + adventure_id=adventure.id, + # A sibling shares the turn's legacy index for the same reason it + # shares its depth: it is the same turn. Two rows then hold one index, + # which is safe, because `max_action_index` takes a maximum rather than + # a count, and nothing else reads the column. + index=retry_of.index if retry_of is not None else next_index(adventure), + depth=ai_depth, + type="ai", + text=text, + reasoning=reasoning, + context_snapshot=snapshot, + world_delta=world_delta_of(snapshot), + ) + attempts.snapshot_outcome(adventure, ai_action) + if retry_of is not None: + attempts.add_attempt(db, adventure, retry_of, ai_action) + db.add(ai_action) + # The text at this coordinate changed, so anything derived from it no + # longer describes the story. Withdraw the memory attached to the node + # and return that stretch to both passes. Before SP4 this code was + # unreachable, because the summarizer held the newest action back until + # a turn landed on top of it. See `memorybank`. + memorybank.forget_node(db, adventure, retry_of) + cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1) + db.flush() + attempts.renumber(attempts.group(db, ai_action)) + else: + tree.place_action(db, adventure, ai_action) + db.add(ai_action) + adventure.updated_at = models.utcnow() + if cfg.using_demo: + # Successful demo turns count against the daily cap, which the endpoint + # checks before the turn starts. A failed provider call above returns + # before this line. + auth.count_demo_turn(user) + db.commit() + # Count the turn here, after every path on which it could still have failed, + # so the number means "stories advanced" rather than "requests attempted". + # The demo tally counts those same turns as spend on the server-funded key. + analytics.record_event(analytics.EV_TURN, user) + if cfg.using_demo: + analytics.record(analytics.M_EVENT, analytics.EV_DEMO_TURN) + db.refresh(ai_action) + yield _SAVED + yield sse({"type": "done", "action": action_json(ai_action, db), "script": pipeline.report()}) + # Phase 6: schedule summarization and embedding without waiting for them. + # The task opens its own database session. It is skipped on the demo key, + # because background AI calls are unmetered spend on the server-funded + # key. + if not cfg.using_demo: + memorybank.schedule_post_turn(adventure) + + +def check_demo_cap(db: Session, user: models.User) -> None: + """Checks the demo cap before a turn starts. + + Checking first avoids storing a capped player's input and then leaving it + without a reply. + """ + settings = get_settings(db, user) + if auth.resolve_provider_config(settings).using_demo and auth.demo_turns_left(user) <= 0: + raise HTTPException(429, auth.DEMO_CAP_MESSAGE) + + +async def run_player_turn( + adventure: models.Adventure, + db: Session, + payload: schemas.ActionCreate, + user: models.User, + preformatted: bool = False, +): + """Plays a player's turn: their action, then the reply to it. + + `preformatted` means the text already carries the `> You ...` conventions and + is written as-is. That applies when the player retakes a turn they already + played (SP9). The editor is seeded with the stored text, which is already + formatted, and a plain edit puts that same text in the box and writes it back + verbatim. Formatting it a second time produces `> You > You ...`. + """ + pipeline = ScriptPipeline(adventure, db) + + # An empty do, say, or story action behaves as a continue. + if payload.type != "continue" and payload.text.strip(): + # onInput reads the formatted text, as in AI Dungeon: "> You ...". + formatted = ( + payload.text.strip() if preformatted + else format_player_input(payload.type, payload.text) + ) + modified, stop = pipeline.run("input", formatted) + if not modified.strip(): + yield turn_error("A script's input modifier returned empty text.", + script=pipeline.report()) + return + player_action = models.Action( + adventure_id=adventure.id, + index=next_index(adventure), + depth=next_depth(adventure), + type=payload.type, + text=modified, + ) + # The state after the input hook has run. The node leaves this state + # behind. The AI turn after it starts here, and a retry of that turn + # rolls back to here. + attempts.snapshot_outcome(adventure, player_action) + tree.place_action(db, adventure, player_action) + db.add(player_action) + db.commit() + db.refresh(player_action) + # The new action was added through its foreign key, so the loaded + # `adventure.actions` collection is stale. Without this expire, + # `build_context` and `next_index` for the AI action do not see the + # player action that was just saved. + db.expire(adventure, ["actions"]) + yield sse({"type": "player", "action": action_json(player_action, db)}) + if stop: + # If onInput returns `{ stop: true }`, skip the AI call. + yield sse({"type": "stopped", "script": pipeline.report()}) + return + + async for event in generate_turn(adventure, db, pipeline, user): + yield event + + +@router.post("/{adventure_id}/actions") +def create_action( + adventure_id: int, + payload: schemas.ActionCreate, + request: Request, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + adventure = get_adventure_or_404(adventure_id, db, user) + limits.rate_limit("turn", request, user) + limits.check_row_cap("actions", db, user, adventure=adventure) + check_demo_cap(db, user) + acquire_turn_lock(adventure_id) + try: + _move_to_after(db, adventure, payload.after_id) + except BaseException: + _active_turns.discard(adventure_id) + raise + return StreamingResponse( + with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)), + media_type="text/event-stream", + headers=SSE_HEADERS, + ) diff --git a/backend/tests/test_attempt_siblings.py b/backend/tests/test_attempt_siblings.py index 2afbbb8..853fce7 100644 --- a/backend/tests/test_attempt_siblings.py +++ b/backend/tests/test_attempt_siblings.py @@ -60,7 +60,7 @@ def client(monkeypatch): ScriptedProvider.replies = ["Attempt one."] ScriptedProvider.calls = 0 ScriptedProvider.prompts = [] - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -76,7 +76,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_branch_forking.py b/backend/tests/test_branch_forking.py index 9fb1181..0d9065b 100644 --- a/backend/tests/test_branch_forking.py +++ b/backend/tests/test_branch_forking.py @@ -70,7 +70,7 @@ def client(monkeypatch): ScriptedProvider.replies = ["A reply."] ScriptedProvider.calls = 0 ScriptedProvider.prompts = [] - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -86,7 +86,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_branch_management.py b/backend/tests/test_branch_management.py index 5c1f0b0..6360c0d 100644 --- a/backend/tests/test_branch_management.py +++ b/backend/tests/test_branch_management.py @@ -54,7 +54,7 @@ def client(monkeypatch): ScriptedProvider.replies = ["Attempt one.", "Attempt two.", "Next turn."] ScriptedProvider.calls = 0 - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -70,7 +70,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_bundle_v2.py b/backend/tests/test_bundle_v2.py index e3c0682..2523b8b 100644 --- a/backend/tests/test_bundle_v2.py +++ b/backend/tests/test_bundle_v2.py @@ -77,7 +77,7 @@ def client(monkeypatch): ScriptedProvider.replies = ["A reply."] ScriptedProvider.calls = 0 - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -93,7 +93,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_retry_variants.py b/backend/tests/test_retry_variants.py index fad75c6..2fc18dd 100644 --- a/backend/tests/test_retry_variants.py +++ b/backend/tests/test_retry_variants.py @@ -56,7 +56,7 @@ def client(monkeypatch): ScriptedProvider.replies = ["Attempt one."] ScriptedProvider.calls = 0 ScriptedProvider.prompts = [] - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -72,7 +72,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_scenario_refresh.py b/backend/tests/test_scenario_refresh.py index 9e37532..40e5dc8 100644 --- a/backend/tests/test_scenario_refresh.py +++ b/backend/tests/test_scenario_refresh.py @@ -366,9 +366,9 @@ def test_refresh_is_rejected_while_a_turn_is_generating(client): sid = make_scenario(client, title="Keep", memory="Old") adv_id = start(client, sid) - adventures._active_turns.add(adv_id) + adventures.turns._active_turns.add(adv_id) try: r = client.post(f"/api/adventures/{adv_id}/refresh", json={}) assert r.status_code == 409 finally: - adventures._active_turns.discard(adv_id) + adventures.turns._active_turns.discard(adv_id) diff --git a/backend/tests/test_state_revert.py b/backend/tests/test_state_revert.py index 998241b..5ffd8a6 100644 --- a/backend/tests/test_state_revert.py +++ b/backend/tests/test_state_revert.py @@ -33,7 +33,7 @@ def db(): finally: session.close() Base.metadata.drop_all(bind=engine) - adventures._active_turns.clear() + adventures.turns._active_turns.clear() def _make_adventure(db, script_state): @@ -130,15 +130,15 @@ def test_undo_blocked_by_active_turn_lock(db): _add(db, adv, 1, "ai", state_after={}) db.commit() - adventures.acquire_turn_lock(adv.id) # a turn is "generating" + adventures.turns.acquire_turn_lock(adv.id) # a turn is "generating" try: with pytest.raises(HTTPException) as exc: adventures.undo_turn(adv.id, db=db, user=user) assert exc.value.status_code == 409 # The failed undo must not have released someone else's lock. - assert adv.id in adventures._active_turns + assert adv.id in adventures.turns._active_turns finally: - adventures._active_turns.discard(adv.id) + adventures.turns._active_turns.discard(adv.id) def test_undo_prunes_memory_covering_removed_actions(db): @@ -221,12 +221,12 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch): db.commit() monkeypatch.setattr(adventures.limits, "rate_limit", lambda *a, **k: None) - monkeypatch.setattr(adventures, "check_demo_cap", lambda *a, **k: None) + monkeypatch.setattr(adventures.turns, "check_demo_cap", lambda *a, **k: None) async def _noop(*a, **k): if False: yield # make it an async generator - monkeypatch.setattr(adventures, "generate_turn", _noop) + monkeypatch.setattr(adventures.turns, "generate_turn", _noop) adventures.retry_action(adv.id, request=None, db=db, user=user) @@ -238,4 +238,4 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch): assert last.live is True assert last.variant_count == 0 assert last.state_after == {"gold": 20} # its own outcome, untouched - adventures._active_turns.discard(adv.id) + adventures.turns._active_turns.discard(adv.id) diff --git a/backend/tests/test_story_tree_baseline.py b/backend/tests/test_story_tree_baseline.py index f5cf8b5..8a30803 100644 --- a/backend/tests/test_story_tree_baseline.py +++ b/backend/tests/test_story_tree_baseline.py @@ -80,7 +80,7 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0): ScriptedProvider.replies = ["A reply."] ScriptedProvider.calls = 0 ScriptedProvider.prompts = [] - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -102,7 +102,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) @@ -114,7 +114,7 @@ def long_client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_take_edit.py b/backend/tests/test_take_edit.py index d02c433..2d3dba1 100644 --- a/backend/tests/test_take_edit.py +++ b/backend/tests/test_take_edit.py @@ -56,7 +56,7 @@ def client(monkeypatch): ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)] ScriptedProvider.calls = 0 - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -72,7 +72,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_take_parentage.py b/backend/tests/test_take_parentage.py index 0ce72e0..e7b6c5a 100644 --- a/backend/tests/test_take_parentage.py +++ b/backend/tests/test_take_parentage.py @@ -57,7 +57,7 @@ def client(monkeypatch): # Distinct replies so a take can be told apart from its siblings by text. ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)] ScriptedProvider.calls = 0 - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr( auth, "resolve_provider_config", @@ -76,7 +76,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_take_state.py b/backend/tests/test_take_state.py index 49429d5..1ae7bd0 100644 --- a/backend/tests/test_take_state.py +++ b/backend/tests/test_take_state.py @@ -67,7 +67,7 @@ def client(monkeypatch): ScriptedProvider.replies = [f"Take {n}." for n in range(1, 40)] ScriptedProvider.calls = 0 - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -83,7 +83,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_turn_flow_integration.py b/backend/tests/test_turn_flow_integration.py index 0630065..d9a78c1 100644 --- a/backend/tests/test_turn_flow_integration.py +++ b/backend/tests/test_turn_flow_integration.py @@ -52,7 +52,7 @@ def client(monkeypatch): # Force a real, non-demo turn that uses the fake provider. ScriptedProvider.replies = [AI_REPLY] - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -69,7 +69,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tests/test_worldstate_integration.py b/backend/tests/test_worldstate_integration.py index 1054344..fc64613 100644 --- a/backend/tests/test_worldstate_integration.py +++ b/backend/tests/test_worldstate_integration.py @@ -8,7 +8,7 @@ import pytest from fastapi import Depends from fastapi.testclient import TestClient -from app import auth, limits, models +from app import auth, limits, models, worldstate from app.database import Base, SessionLocal, engine, get_db from app.main import app from app.routers import adventures @@ -50,7 +50,7 @@ def client(monkeypatch): setup.flush() adv = models.Adventure( user_id=user.id, scenario_id=scenario.id, title="Run", - world_state=adventures.worldstate.instantiate(SCHEMA), + world_state=worldstate.instantiate(SCHEMA), ) setup.add(adv) setup.flush() @@ -62,7 +62,7 @@ def client(monkeypatch): setup.close() ScriptedProvider.replies = [AI_REPLY] - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False)) monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) @@ -79,7 +79,7 @@ def client(monkeypatch): yield c finally: app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() Base.metadata.drop_all(bind=engine) diff --git a/backend/tools/branch_fixture.py b/backend/tools/branch_fixture.py index 81abe1c..d53b65e 100644 --- a/backend/tools/branch_fixture.py +++ b/backend/tools/branch_fixture.py @@ -105,7 +105,7 @@ db.commit() adv_id = adv.id db.close() -adventures.OpenAICompatibleProvider = ScriptedProvider +adventures.turns.OpenAICompatibleProvider = ScriptedProvider auth.resolve_provider_config = lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False) limits.rate_limit = lambda *a, **k: None diff --git a/backend/tools/shots_fixture.py b/backend/tools/shots_fixture.py index be1d3cb..88279f6 100644 --- a/backend/tools/shots_fixture.py +++ b/backend/tools/shots_fixture.py @@ -163,7 +163,7 @@ class ScriptedProvider: yield ("text", prose + block) -adventures.OpenAICompatibleProvider = ScriptedProvider +adventures.turns.OpenAICompatibleProvider = ScriptedProvider auth.resolve_provider_config = lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False) limits.rate_limit = lambda *a, **k: None diff --git a/backend/tools/stress_session.py b/backend/tools/stress_session.py index 53dadb5..35ab40a 100644 --- a/backend/tools/stress_session.py +++ b/backend/tools/stress_session.py @@ -582,7 +582,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]: def install_fakes(user_id: int, rng: random.Random) -> None: embeddings = FakeEmbeddings(rng) - adventures.OpenAICompatibleProvider = FakeProvider + adventures.turns.OpenAICompatibleProvider = FakeProvider memorybank.embedding_provider = lambda settings: embeddings memorybank.summary_provider = lambda settings: FakeProvider() auth.resolve_provider_config = lambda s, **k: auth.ProviderConfig( @@ -812,7 +812,7 @@ def main(argv=None) -> int: print(f"{'total across all shapes':<44}{kb(sum(s.total.fetched for s in meter.scopes)):>16}") app.dependency_overrides.clear() - adventures._active_turns.clear() + adventures.turns._active_turns.clear() # After the shapes, not before: make_bootable() writes, and the meter is # still attached until the report above is rendered. diff --git a/backend/tools/tree_fixture.py b/backend/tools/tree_fixture.py index 155e4d1..fb8aa71 100644 --- a/backend/tools/tree_fixture.py +++ b/backend/tools/tree_fixture.py @@ -89,7 +89,7 @@ db.commit() adv_id = adv.id db.close() -adventures.OpenAICompatibleProvider = ScriptedProvider +adventures.turns.OpenAICompatibleProvider = ScriptedProvider auth.resolve_provider_config = lambda s: auth.ProviderConfig( "http://fake", "k", "test-model", False) limits.rate_limit = lambda *a, **k: None diff --git a/plan/17-refactor.md b/plan/17-refactor.md index 9e9ff05..f130480 100644 --- a/plan/17-refactor.md +++ b/plan/17-refactor.md @@ -18,7 +18,7 @@ stage before it is green. | Stage | Work | Status | Landed | |---|---|---|---| | 0 | Hygiene: worktrees, branches, undocumented settings | done, except the branch deletion | 2026-08-29 | -| 1 | Split the four largest files | not started | | +| 1 | Split the four largest files | tests share one setup; the router is a package; three files left | | | 2 | Remove duplication | not started | | | 3 | SP8: drop the legacy columns | not started | | | 4 | Documentation | not started | | @@ -225,6 +225,45 @@ exactly one module, `turns.py`, and every other module must import the module an reach through it. If two modules import the set by value, the lock guards two different sets and the turn lock stops working. +### What the router split actually did, 2026-08-29 + +`backend/app/routers/adventures.py` is now a package of 14 modules. The largest +is `turns.py` at 443 lines. Four modules exist that the table above does not +list, because the plan's eleven still mixed unrelated work: + +| Extra module | Why it exists | +|---|---| +| `deps.py` | Holds the `APIRouter` and the ownership check. It imports nothing else in the package, so every endpoint module can import the router without importing its siblings. | +| `scenario_text.py` | Copying a scenario's text and cards has two callers, `crud.create_adventure` and `refresh`. Leaving it in either one made the other import an endpoint module. | +| `nodes.py` | Story-tree navigation that four modules use: `last_action`, `next_index`, `next_depth`, `stand_on`, `db_tip`, `delete_turn`. | +| `actions.py` | The three action endpoints. They page and delete rather than play a turn, so they do not belong in `crud.py`. | + +Two decisions differ from the plan above. + +**The package root does not re-export `acquire_turn_lock` or `_active_turns`.** +The plan said to keep them importable, but that makes a broken patch look like a +working one. Rebinding `adventures.generate_turn` changes the alias and leaves +every caller reading the original, and the test still passes. Leaving those names +off the package root raises `AttributeError` instead. Eighteen test call sites and +four in `backend/tools/` now say `adventures.turns.`. The package root still +re-exports the pure helpers, so `adventures.ACTION_PAGE`, `adventures.undo_turn`, +and `chat.py`'s `from .adventures import SSE_HEADERS, sse` are unchanged. + +**`world_delta_of` lives in `turns.py`.** It reads as a world-state helper and sat +beside the world-state endpoints, but `_generate_turn` is its only caller. + +`_active_turns` behaved as the plan warned. Every module reaches it as +`turns._active_turns`, and a check confirms the four modules see one set object +and one lock. + +One test coupled to the router for an unrelated module: it called +`adventures.worldstate.instantiate`. It imports `app.worldstate` directly now. + +The split moved text rather than retyping it. An AST comparison against the +pre-split file confirms all 86 definitions are identical, once the `turns.` +prefix is normalized away. The 549 tests pass, and the OpenAPI schema still lists +the same 35 operations. + ### `worldstate/engine.py` becomes a package Split `backend/app/worldstate/engine.py` into four modules under From 8422ff24f6d4c97859acc76b15beb50dfd7e89ca Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sat, 29 Aug 2026 01:09:41 +0530 Subject: [PATCH 04/11] Split the world-state engine into four modules `backend/app/worldstate/engine.py` held 918 lines covering four separate jobs: reading a scenario's schema, parsing the block the model writes, applying a change within the schema's limits, and rendering state as prompt text. Each is now its own module, the largest 418 lines. An AST comparison against the old file confirms all 29 definitions are identical. No call site changes, because `worldstate/__init__.py` exports the same names it did before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r --- backend/app/worldstate/__init__.py | 36 +- backend/app/worldstate/apply.py | 418 +++++++++++++ backend/app/worldstate/engine.py | 918 ----------------------------- backend/app/worldstate/parse.py | 164 ++++++ backend/app/worldstate/render.py | 147 +++++ backend/app/worldstate/schema.py | 198 +++++++ 6 files changed, 950 insertions(+), 931 deletions(-) create mode 100644 backend/app/worldstate/apply.py delete mode 100644 backend/app/worldstate/engine.py create mode 100644 backend/app/worldstate/parse.py create mode 100644 backend/app/worldstate/render.py create mode 100644 backend/app/worldstate/schema.py diff --git a/backend/app/worldstate/__init__.py b/backend/app/worldstate/__init__.py index 9b54c89..ff4bc5c 100644 --- a/backend/app/worldstate/__init__.py +++ b/backend/app/worldstate/__init__.py @@ -1,28 +1,38 @@ """Phase 12: RPG world state. -The AI proposes stat and milestone deltas, and this module validates and clamps -them against a scenario's `stat_schema`. +The scenario carries a `stat_schema`, which is the template: which stats exist, +their bands and rules, and the milestones. An adventure carries a live +`world_state` instantiated from it. Each turn the AI proposes a delta holding +only what changed. `apply_delta` decides what the delta is allowed to do. It +clamps values to min and max, caps the change per turn, enforces cooldowns, and +makes milestones sticky. + +Nothing here raises on bad AI output. A malformed delta returns `{}` and the turn +continues, the same way a broken script never breaks a turn. + +The work is split four ways, and each module reads on its own: + + schema what a scenario's `stat_schema` allows, and building state from it + parse the block the model writes, and reading it back + apply every write to world state, and the limits it is held to + render turning state and schema into prompt text + +Import the names from this package rather than from those modules. The split is +an implementation detail, and the names below are the interface. """ -from .engine import ( +from .apply import apply_delta, apply_override +from .parse import ( EMIT_REMINDER, EMIT_RULE, applied_delta, - apply_delta, - apply_override, - band_label, extract_delta, - has_schema, - instantiate, - npc_name, - npc_triggers, - reconcile, refusals, render_delta_block, - render_reference, render_refusals, - render_state_section, ) +from .render import render_reference, render_state_section +from .schema import band_label, has_schema, instantiate, npc_name, npc_triggers, reconcile __all__ = [ "EMIT_REMINDER", diff --git a/backend/app/worldstate/apply.py b/backend/app/worldstate/apply.py new file mode 100644 index 0000000..c5dfe53 --- /dev/null +++ b/backend/app/worldstate/apply.py @@ -0,0 +1,418 @@ +"""Applying a delta or an override to world state, within the schema's limits. + +Every write to world state goes through here, so the clamping rules live in one +place. A proposed change that breaks a limit is recorded as refused rather than +dropped, because the player and the model both need to see that it did not land. +""" +import copy + +from .schema import STAT_SECTIONS, _initials, instantiate, npc_name + + +def _coerce_number(value): + if isinstance(value, bool): # `bool` is an `int` subclass, so reject it here. + return None + if isinstance(value, (int, float)): + return value + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return None + return None + + +def _names_phrase(kind: str, defs: dict, limit: int = 12) -> str: + """Lists what the model could have written instead, for a wrong name. + + A rejection that only says a name is unknown leaves the model guessing + again. Naming the alternatives turns it into a correction it can act on. + """ + names = [k for k in (defs or {}) if isinstance(k, str)] + if not names: + return f"This scenario tracks no {kind}." + shown = ", ".join(f"`{n}`" for n in names[:limit]) + more = f", and {len(names) - limit} more" if len(names) > limit else "" + plural = f"{kind}s" if not kind.endswith("s") else kind + return f"The {plural} are: {shown}{more}." + + +def _limits_phrase(stat_def: dict) -> str: + """Names a stat's numeric limits, for a correction sent back to the model.""" + lo, hi = stat_def.get("min"), stat_def.get("max") + cap = stat_def.get("max_delta_per_turn") + bits = [] + if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): + bits.append(f"it runs from {lo} to {hi}") + elif isinstance(lo, (int, float)): + bits.append(f"it never goes below {lo}") + elif isinstance(hi, (int, float)): + bits.append(f"it never goes above {hi}") + if isinstance(cap, (int, float)): + bits.append(f"it moves at most {cap} per turn") + return "; ".join(bits) + + +def _apply_stat(container: dict, key: str, stat_def: dict, change, + path: str, action_index: int, meta: dict, report: dict) -> None: + delta = _coerce_number(change) + if delta is None: + report["rejected"].append({ + "path": path, "reason": "not a number", + "fix": f"`{path}` takes a number, written as a change such as -5 or 8.", + }) + return + + cooldown = stat_def.get("cooldown") or 0 + last = meta["last_changed"].get(path) + if cooldown and last is not None and action_index - last < cooldown: + waited = action_index - last + report["rejected"].append({ + "path": path, "reason": "cooldown", + "fix": f"`{path}` changed {waited} turn(s) ago and cannot change again " + f"until {cooldown} turns have passed.", + }) + return + + if stat_def.get("type") == "counter" and delta < 0: + report["rejected"].append({ + "path": path, "reason": "counter can't decrease", + "fix": f"`{path}` only counts up. Send a positive change such as 1, " + f"never a negative and never the running total.", + }) + return + + clamped = False + cap = stat_def.get("max_delta_per_turn") + if cap is not None and abs(delta) > cap: + delta = cap if delta > 0 else -cap + clamped = True + + old = container.get(key, stat_def.get("initial", 0)) + new = old + delta + lo, hi = stat_def.get("min"), stat_def.get("max") + if lo is not None and new < lo: + new, clamped = lo, True + if hi is not None and new > hi: + new, clamped = hi, True + # Keep ints integral for display. + if isinstance(old, int) and float(new).is_integer(): + new = int(new) + + container[key] = new + meta["last_changed"][path] = action_index + entry = {"path": path, "old": old, "new": new} + if clamped and new == old: + # The clamp cancelled the change. Nothing moved, so the model needs the + # same correction a rejection gets: without it the only evidence is a + # value that stayed put, which reads as the change never being asked for. + edge = "maximum" if hi is not None and new == hi else "minimum" + entry["fix"] = ( + f"`{path}` did not move. It is already at its {edge} of {new}" + + (f" ({_limits_phrase(stat_def)})." if _limits_phrase(stat_def) else ".") + ) + report["applied"].append(entry) + if clamped: + report["clamped"].append(entry) + + +def _apply_text_stat(container: dict, key: str, stat_def: dict, change, + path: str, action_index: int, meta: dict, report: dict) -> None: + """Applies a free-text stat, which replaces rather than adds. + + The AI sends the new value in full rather than a delta. No clamping and no + bands apply. Only an optional cooldown and an optional `max_length` + truncation apply. + """ + if not isinstance(change, str): + report["rejected"].append({ + "path": path, "reason": "not a string", + "fix": f"`{path}` holds text. Send its new value in full, not a number " + f"and not a change.", + }) + return + + cooldown = stat_def.get("cooldown") or 0 + last = meta["last_changed"].get(path) + if cooldown and last is not None and action_index - last < cooldown: + waited = action_index - last + report["rejected"].append({ + "path": path, "reason": "cooldown", + "fix": f"`{path}` changed {waited} turn(s) ago and cannot change again " + f"until {cooldown} turns have passed.", + }) + return + + new = change.strip() + max_len = stat_def.get("max_length") + if isinstance(max_len, int) and max_len > 0 and len(new) > max_len: + new = new[:max_len] + + old = container.get(key, stat_def.get("initial", "")) + if new == old: + return # Nothing changed, so do nothing. + + container[key] = new + meta["last_changed"][path] = action_index + report["applied"].append({"path": path, "old": old, "new": new}) + + +def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tuple[dict, dict]: + """Sets live values directly, as a manual author edit rather than an AI turn. + + This differs from `apply_delta` in three ways. Numeric stats are set rather + than added to. `cooldown`, `max_delta_per_turn`, and the rule that a counter + cannot decrease are all ignored, because this is a deliberate correction + rather than an AI move to check. Milestones can be toggled in both + directions rather than only marked reached. + + Values are still validated against the schema, so an unknown path or a wrong + type is rejected, and numeric values still clamp to min and max. + """ + ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {} + if not ws: + ws = instantiate(stat_schema) + report: dict = {"applied": [], "rejected": []} + + if not isinstance(overrides, dict): + return ws, report + + milestones = stat_schema.get("milestones") or {} + flag_defs = stat_schema.get("flags") or {} + npcs = stat_schema.get("npcs") or {} + + def set_stat(container: dict, key: str, stat_def: dict, value, path: str) -> None: + if stat_def.get("type") == "text": + if not isinstance(value, str): + report["rejected"].append({"path": path, "reason": "not a string"}) + return + new = value.strip() + max_len = stat_def.get("max_length") + if isinstance(max_len, int) and max_len > 0 and len(new) > max_len: + new = new[:max_len] + old = container.get(key, stat_def.get("initial", "")) + container[key] = new + report["applied"].append({"path": path, "old": old, "new": new}) + return + + num = _coerce_number(value) + if num is None: + report["rejected"].append({"path": path, "reason": "not a number"}) + return + old = container.get(key, stat_def.get("initial", 0)) + lo, hi = stat_def.get("min"), stat_def.get("max") + if lo is not None and num < lo: + num = lo + if hi is not None and num > hi: + num = hi + if isinstance(old, int) and float(num).is_integer(): + num = int(num) + container[key] = num + report["applied"].append({"path": path, "old": old, "new": num}) + + for raw_path, value in overrides.items(): + path = str(raw_path) + parts = path.split(".") + + if parts[0] == "flags" and len(parts) == 2: + fid = parts[1] + if fid not in flag_defs: + report["rejected"].append({"path": path, "reason": "unknown flag"}) + continue + if not isinstance(value, bool): + report["rejected"].append({"path": path, "reason": "not a boolean"}) + continue + flags = ws.setdefault("flags", {}) + old = bool(flags.get(fid, False)) + flags[fid] = value + report["applied"].append({"path": path, "old": old, "new": value}) + continue + + if parts[0] == "milestones" and len(parts) == 2: + mid = parts[1] + if mid not in milestones: + report["rejected"].append({"path": path, "reason": "unknown milestone"}) + continue + if not isinstance(value, bool): + report["rejected"].append({"path": path, "reason": "not a boolean"}) + continue + reached = ws.setdefault("milestones", {}) + old = bool(reached.get(mid, {}).get("reached")) + if value: + reached[mid] = {"reached": True} + else: + reached.pop(mid, None) + report["applied"].append({"path": path, "old": old, "new": value}) + continue + + if parts[0] in STAT_SECTIONS and len(parts) == 2: + stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1]) + if not isinstance(stat_def, dict): + report["rejected"].append({ + "path": path, "reason": "unknown stat", + "fix": f"`{parts[0]}` has no stat `{parts[1]}`. " + f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}", + }) + continue + container = ws.setdefault(parts[0], {}) + set_stat(container, parts[1], stat_def, value, path) + continue + + if parts[0] == "npc" and len(parts) == 3: + ndef = npcs.get(parts[1]) + if not isinstance(ndef, dict): + report["rejected"].append({ + "path": path, "reason": "unknown npc", + "fix": f"There is no character `{parts[1]}`. " + f"{_names_phrase('character', npcs)}", + }) + continue + stat_defs = ndef.get("stats") or {} + stat_def = stat_defs.get(parts[2]) + if not isinstance(stat_def, dict): + report["rejected"].append({ + "path": path, "reason": "unknown npc stat", + "fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. " + f"{_names_phrase('stat', stat_defs)}", + }) + continue + npc_state = ws.setdefault("npc", {}) + container = npc_state.setdefault(parts[1], _initials(stat_defs)) + set_stat(container, parts[2], stat_def, value, path) + continue + + report["rejected"].append({ + "path": path, "reason": "unknown path", + "fix": f"`{path}` is not a tracked value. Use player., " + f"world., npc.., flags. or milestones..", + }) + + return ws, report + + +def apply_delta(world_state: dict, stat_schema: dict, delta: dict, + action_index: int) -> tuple[dict, dict]: + """Validates and clamps `delta` against `stat_schema`, then applies it. + + The delta is applied to a copy of `world_state`. The return value is + `(new_world_state, report)`. + """ + ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {} + if not ws: + ws = instantiate(stat_schema) + ws.setdefault("_meta", {}).setdefault("last_changed", {}) + meta = ws["_meta"] + report: dict = {"applied": [], "clamped": [], "rejected": []} + + if not isinstance(delta, dict): + return ws, report + + milestones = stat_schema.get("milestones") or {} + flag_defs = stat_schema.get("flags") or {} + npcs = stat_schema.get("npcs") or {} + + for raw_path, change in delta.items(): + path = str(raw_path) + parts = path.split(".") + + # `flags.` is a two-way boolean, and either value is accepted. + if parts[0] == "flags" and len(parts) == 2: + fid = parts[1] + if fid not in flag_defs: + report["rejected"].append({ + "path": path, "reason": "unknown flag", + "fix": f"There is no flag `{fid}`. {_names_phrase('flag', flag_defs)}", + }) + continue + if not isinstance(change, bool): + report["rejected"].append({ + "path": path, "reason": "not a boolean", + "fix": f"`{path}` takes true or false.", + }) + continue + flags = ws.setdefault("flags", {}) + old = bool(flags.get(fid, False)) + if change != old: + flags[fid] = change + report["applied"].append({"path": path, "old": old, "new": change}) + continue + + # `milestones.` is a sticky boolean, and only `true` is accepted. + if parts[0] == "milestones" and len(parts) == 2: + mid = parts[1] + if mid not in milestones: + report["rejected"].append({ + "path": path, "reason": "unknown milestone", + "fix": f"There is no milestone `{mid}`. " + f"{_names_phrase('milestone', milestones)}", + }) + continue + if change is not True: + report["rejected"].append({ + "path": path, "reason": "not true", + "fix": f"`{path}` can only be set to true. A milestone is " + f"reached once and never taken back.", + }) + continue + reached = ws.setdefault("milestones", {}) + if reached.get(mid, {}).get("reached"): + continue # Already reached, so do nothing. + reached[mid] = {"reached": True, "at": action_index} + report["applied"].append({"path": path, "old": False, "new": True}) + continue + + # world. / player. + if parts[0] in STAT_SECTIONS and len(parts) == 2: + stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1]) + if not isinstance(stat_def, dict): + report["rejected"].append({ + "path": path, "reason": "unknown stat", + "fix": f"`{parts[0]}` has no stat `{parts[1]}`. " + f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}", + }) + continue + container = ws.setdefault(parts[0], {}) + if stat_def.get("type") == "text": + _apply_text_stat(container, parts[1], stat_def, change, path, + action_index, meta, report) + else: + _apply_stat(container, parts[1], stat_def, change, path, + action_index, meta, report) + continue + + # `npc..`. Each NPC has its own stat definitions. + if parts[0] == "npc" and len(parts) == 3: + ndef = npcs.get(parts[1]) + if not isinstance(ndef, dict): + report["rejected"].append({ + "path": path, "reason": "unknown npc", + "fix": f"There is no character `{parts[1]}`. " + f"{_names_phrase('character', npcs)}", + }) + continue + stat_defs = ndef.get("stats") or {} + stat_def = stat_defs.get(parts[2]) + if not isinstance(stat_def, dict): + report["rejected"].append({ + "path": path, "reason": "unknown npc stat", + "fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. " + f"{_names_phrase('stat', stat_defs)}", + }) + continue + npc_state = ws.setdefault("npc", {}) + container = npc_state.setdefault(parts[1], _initials(stat_defs)) + if stat_def.get("type") == "text": + _apply_text_stat(container, parts[2], stat_def, change, path, + action_index, meta, report) + else: + _apply_stat(container, parts[2], stat_def, change, path, + action_index, meta, report) + continue + + report["rejected"].append({ + "path": path, "reason": "unknown path", + "fix": f"`{path}` is not a tracked value. Use player., " + f"world., npc.., flags. or milestones..", + }) + + return ws, report diff --git a/backend/app/worldstate/engine.py b/backend/app/worldstate/engine.py deleted file mode 100644 index b30af1d..0000000 --- a/backend/app/worldstate/engine.py +++ /dev/null @@ -1,918 +0,0 @@ -"""RPG world-state engine. - -The scenario carries a `stat_schema`, which is the template: which stats exist, -their bands and rules, and the milestones. An adventure carries a live -`world_state` instantiated from it. Each turn the AI proposes a delta holding -only what changed. `apply_delta` decides what the delta is allowed to do. It -clamps values to min and max, caps the change per turn, enforces cooldowns, and -makes milestones sticky. - -Nothing here raises on bad AI output. A malformed delta returns `{}` and the -turn continues, the same way a broken script never breaks a turn. -""" - -import copy -import json -import re - -# The `stat_schema` top-level sections that hold stat definitions. -STAT_SECTIONS = ("world", "player") - -# Appended once to the system prompt, so the model knows how to report -# changes. -EMIT_RULE = ( - "You maintain a numeric world state. Treat your own narration as authoritative: " - "whenever what you write implies a change to any tracked value — health or resources " - "going up or down, time passing, a relationship or mood shifting, a status turning on " - "or off, progress toward a goal, an item or piece of information gained or lost — you " - "MUST record it, including the numbers, not just on/off flags. After your narration, " - "append a fenced code block labelled `state` with a JSON object of the CHANGES ONLY, " - "as deltas (not new totals). Update every value the scene affected this turn, not only " - "the obvious ones. Read the range and band labels shown for each stat and keep every " - "change proportionate to the moment: an ordinary or minor event nudges a value slightly, " - "while a large change — or reaching a stat's minimum or maximum — is reserved for a " - "genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a " - "lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of " - 'its range in a single ordinary turn. Use the paths exactly as shown in the world state: ' - '"player.", ' - '"world.", "npc.." (use the id in parentheses, e.g. npc.gwen.trust, ' - 'not the display name); "flags.": true or false to toggle an on/off state; and ' - '"milestones.": true when an objective is completed. Some stats marked (free text) in ' - "the stat guide hold a short string instead of a number — for those, send the new value " - "in full (not a delta), e.g. what the player is now wearing or holding; only send it when " - "it actually changed. Send only things that actually " - "changed and never restate unchanged values; if truly nothing changed, omit the block. " - "Example:\n" - '```state\n{"player.hp": -15, "npc.gwen.trust": 5, "milestones.escaped": true, ' - '"player.outfit": "torn traveling cloak"}\n```' -) - -# A short reminder placed at the end of the prompt, which is the strongest -# recency position, so the emit rule is close to where the model generates. -EMIT_REMINDER = ( - "[Reminder: end your reply with a ```state block of the changes this turn " - "(deltas only), or omit it if truly nothing changed.]" -) - - -def render_delta_block(delta: dict) -> str: - """Renders a stored delta back into the fenced `state` block the AI emitted. - - The caller replays past turns into the context with this, so the model copies - the format. An empty delta returns an empty string, which means the turn - changed nothing. - """ - if not isinstance(delta, dict) or not delta: - return "" - return "```state\n" + json.dumps(delta, ensure_ascii=False) + "\n```" - - -def applied_delta(world_delta: dict | None) -> dict: - """Returns the changes the engine accepted, shaped as the AI sends them. - - Replaying the delta the AI sent would show it a refused change standing as - though it had been applied, contradicted by the live values in the same - prompt. The model has no way to read that as a correction, so it repeats - the change. Replaying what was accepted removes the contradiction. - - A numeric change that ended where it started is omitted, because it moved - nothing and a zero in the replayed block reads as a value worth sending. - """ - if not isinstance(world_delta, dict): - return {} - out: dict = {} - for entry in world_delta.get("applied") or []: - if not isinstance(entry, dict): - continue - path = str(entry.get("path", "")) - old, new = entry.get("old"), entry.get("new") - if path.startswith("milestones."): - out[path] = True - elif path.startswith("flags."): - out[path] = bool(new) - elif isinstance(old, (int, float)) and isinstance(new, (int, float)): - if new != old: - out[path] = new - old - else: - out[path] = new - return out - - -def refusals(world_delta: dict | None) -> list[str]: - """Returns a correction line for each change the engine did not carry out. - - Covers the changes that were lost: a rejection, and a clamp that left the - value where it started. A clamp that reduced a change but still moved the - value is deliberately absent. The model's intent landed in that case, and - reporting the shortfall invites it to send the remainder on the next turn, - which is the swing `max_delta_per_turn` exists to prevent. - """ - if not isinstance(world_delta, dict): - return [] - lines = [] - for entry in world_delta.get("rejected") or []: - if isinstance(entry, dict) and entry.get("fix"): - lines.append(str(entry["fix"])) - for entry in world_delta.get("clamped") or []: - if isinstance(entry, dict) and entry.get("fix"): - lines.append(str(entry["fix"])) - return lines - - -def render_refusals(world_delta: dict | None) -> str: - """Renders `refusals` as the note appended after the most recent AI turn.""" - lines = refusals(world_delta) - if not lines: - return "" - body = "\n".join(f"- {ln}" for ln in lines) - return ("[Part of your last state block was not applied. Correct it in this " - f"turn's block:\n{body}]") - -# ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL. -_FENCE_RE = re.compile(r"```(?:state|json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE) -# Fallback: a bare JSON object hugging the end of the text. -_TRAILING_RE = re.compile(r"(\{[^{}]*\})\s*$", re.DOTALL) - - -def has_schema(stat_schema: dict | None) -> bool: - """Returns `True` when a scenario defines an RPG layer.""" - if not isinstance(stat_schema, dict): - return False - return any( - isinstance(stat_schema.get(k), dict) and stat_schema[k] - for k in (*STAT_SECTIONS, "npcs", "milestones", "flags") - ) - - -def npc_name(ndef: dict, key: str) -> str: - name = ndef.get("name") - return name.strip() if isinstance(name, str) and name.strip() else key - - -def npc_triggers(ndef: dict, key: str) -> list[str]: - """Returns the lowercased trigger words that detect an NPC in a scene. - - The words come from the NPC's `keys` field, or from its display name when - `keys` is empty. - """ - raw = ndef.get("keys") or npc_name(ndef, key) - return [k.strip().lower() for k in str(raw).split(",") if k.strip()] - - -def _initials(defs: dict) -> dict: - return { - name: d.get("initial", "" if d.get("type") == "text" else 0) - for name, d in defs.items() - if isinstance(d, dict) - } - - -def instantiate(stat_schema: dict | None) -> dict: - """Builds a fresh live `world_state` from a schema, using initial values only.""" - if not has_schema(stat_schema): - return {} - ws: dict = {} - for section in STAT_SECTIONS: - ws[section] = _initials(stat_schema.get(section) or {}) - # Each defined NPC gets its own stat block from its own `stats` defs. - ws["npc"] = { - key: _initials(ndef.get("stats") or {}) - for key, ndef in (stat_schema.get("npcs") or {}).items() - if isinstance(ndef, dict) - } - ws["milestones"] = {} # only reached ones are stored - ws["flags"] = { - name: bool(d.get("initial", False)) - for name, d in (stat_schema.get("flags") or {}).items() - if isinstance(d, dict) - } - ws["_meta"] = {"last_changed": {}} - return ws - - -def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict, dict]: - """Brings a live `world_state` back in line with an edited schema. - - This is not `instantiate`. A value the schema still defines keeps whatever it - reached in play, because re-instantiating would restore the player to full - health and clear their milestones. Only the difference is applied. Stats, - NPCs, flags, and milestones the schema gained appear at their initial value, - and ones it no longer defines are removed along with their `last_changed` - bookkeeping. The return value is `(new_state, report)`, where the report - lists paths under `added` and `removed`, so the UI can show what a refresh - would do. - - The additions are mostly cosmetic. Rendering and delta application both fall - back to a stat definition's `initial` when the live state has no value for - it, so a newly added stat already behaves correctly. This function stores the - value, and unlike those read-through paths it also removes what the schema - dropped. - """ - report: dict = {"added": [], "removed": []} - if not has_schema(stat_schema): - # The scenario dropped its RPG layer, so the adventure drops it too. - stale = bool(world_state) - if stale: - report["removed"].append("(all world state)") - return {}, report - - ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {} - if not ws: - return instantiate(stat_schema), report - - def sync_section(container: dict, defs: dict, prefix: str) -> dict: - out = {} - for name, d in defs.items(): - if not isinstance(d, dict): - continue - if name in container: - out[name] = container[name] - else: - out[name] = d.get("initial", "" if d.get("type") == "text" else 0) - report["added"].append(f"{prefix}.{name}") - for name in container: - if name not in out: - report["removed"].append(f"{prefix}.{name}") - return out - - for section in STAT_SECTIONS: - ws[section] = sync_section( - ws.get(section) if isinstance(ws.get(section), dict) else {}, - stat_schema.get(section) or {}, - section, - ) - - npc_defs = stat_schema.get("npcs") or {} - old_npcs = ws.get("npc") if isinstance(ws.get("npc"), dict) else {} - new_npcs = {} - for key, ndef in npc_defs.items(): - if not isinstance(ndef, dict): - continue - old = old_npcs.get(key) if isinstance(old_npcs.get(key), dict) else {} - new_npcs[key] = sync_section(old, ndef.get("stats") or {}, f"npc.{key}") - for key in old_npcs: - if key not in new_npcs: - report["removed"].append(f"npc.{key}") - ws["npc"] = new_npcs - - flag_defs = stat_schema.get("flags") or {} - old_flags = ws.get("flags") if isinstance(ws.get("flags"), dict) else {} - new_flags = {} - for name, d in flag_defs.items(): - if not isinstance(d, dict): - continue - if name in old_flags: - new_flags[name] = bool(old_flags[name]) - else: - new_flags[name] = bool(d.get("initial", False)) - report["added"].append(f"flags.{name}") - for name in old_flags: - if name not in new_flags: - report["removed"].append(f"flags.{name}") - ws["flags"] = new_flags - - # Milestones store only the ones reached, so there is nothing to add here. - # An unreached milestone is absent. Drop reached milestones the scenario no - # longer defines, because they would otherwise stay in "Achieved" with no - # label. - milestone_defs = stat_schema.get("milestones") or {} - reached = ws.get("milestones") if isinstance(ws.get("milestones"), dict) else {} - ws["milestones"] = {k: v for k, v in reached.items() if k in milestone_defs} - for k in reached: - if k not in milestone_defs: - report["removed"].append(f"milestones.{k}") - - # Cooldown bookkeeping for paths that no longer exist is never read, but it - # accumulates in every stored snapshot, so remove it with the rest. - meta = ws.setdefault("_meta", {}) - last_changed = meta.get("last_changed") - if isinstance(last_changed, dict): - removed = set(report["removed"]) - meta["last_changed"] = { - path: at for path, at in last_changed.items() - if not any(path == r or path.startswith(f"{r}.") for r in removed) - } - else: - meta["last_changed"] = {} - - return ws, report - - -def band_label(stat_def: dict, value) -> str | None: - """Returns the word label for `value` from a stat definition's bands, if any. - - A band is `[lo, hi, label]` and matches when `lo <= value < hi`. The top band - includes its upper bound, so a stat at its maximum still gets a label. - """ - bands = stat_def.get("bands") - if not isinstance(bands, list) or not isinstance(value, (int, float)): - return None - last_hi = None - for band in bands: - if not (isinstance(band, list) and len(band) == 3): - continue - lo, hi, label = band - last_hi = hi - if lo <= value < hi: - return str(label) - # Inclusive top edge. - if bands and value == last_hi: - return str(bands[-1][2]) - return None - - -# --------------------------------------------------------------------------- # -# Delta extraction -# --------------------------------------------------------------------------- # - -def _tolerant_load(blob: str) -> dict: - # Strip trailing commas and leading + on numbers, both of which weaker - # free models emit and strict JSON rejects. - cleaned = re.sub(r",(\s*[}\]])", r"\1", blob) - cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned) - try: - parsed = json.loads(cleaned) - except (json.JSONDecodeError, ValueError): - return {} - return parsed if isinstance(parsed, dict) else {} - - -def extract_delta(text: str) -> tuple[str, dict]: - """Removes the trailing state block from an AI response. - - The return value is `(clean_text, delta)`. `delta` is `{}` when there is no - block or the block cannot be parsed, and `clean_text` has the block removed. - A bare trailing object is stripped only when it parses to a delta, so - ordinary prose that ends in `}` is left alone. - """ - matches = list(_FENCE_RE.finditer(text)) - if matches: - m = matches[-1] - delta = _tolerant_load(m.group(1)) - clean = (text[: m.start()] + text[m.end():]).strip() - return clean, delta - - m = _TRAILING_RE.search(text) - if m: - delta = _tolerant_load(m.group(1)) - if delta and all("." in str(k) for k in delta): - clean = text[: m.start()].strip() - return clean, delta - return text.strip(), {} - - -# --------------------------------------------------------------------------- # -# Delta application (the referee) -# --------------------------------------------------------------------------- # - -def _coerce_number(value): - if isinstance(value, bool): # `bool` is an `int` subclass, so reject it here. - return None - if isinstance(value, (int, float)): - return value - if isinstance(value, str): - try: - return float(value.strip()) - except ValueError: - return None - return None - - -def _names_phrase(kind: str, defs: dict, limit: int = 12) -> str: - """Lists what the model could have written instead, for a wrong name. - - A rejection that only says a name is unknown leaves the model guessing - again. Naming the alternatives turns it into a correction it can act on. - """ - names = [k for k in (defs or {}) if isinstance(k, str)] - if not names: - return f"This scenario tracks no {kind}." - shown = ", ".join(f"`{n}`" for n in names[:limit]) - more = f", and {len(names) - limit} more" if len(names) > limit else "" - plural = f"{kind}s" if not kind.endswith("s") else kind - return f"The {plural} are: {shown}{more}." - - -def _limits_phrase(stat_def: dict) -> str: - """Names a stat's numeric limits, for a correction sent back to the model.""" - lo, hi = stat_def.get("min"), stat_def.get("max") - cap = stat_def.get("max_delta_per_turn") - bits = [] - if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): - bits.append(f"it runs from {lo} to {hi}") - elif isinstance(lo, (int, float)): - bits.append(f"it never goes below {lo}") - elif isinstance(hi, (int, float)): - bits.append(f"it never goes above {hi}") - if isinstance(cap, (int, float)): - bits.append(f"it moves at most {cap} per turn") - return "; ".join(bits) - - -def _apply_stat(container: dict, key: str, stat_def: dict, change, - path: str, action_index: int, meta: dict, report: dict) -> None: - delta = _coerce_number(change) - if delta is None: - report["rejected"].append({ - "path": path, "reason": "not a number", - "fix": f"`{path}` takes a number, written as a change such as -5 or 8.", - }) - return - - cooldown = stat_def.get("cooldown") or 0 - last = meta["last_changed"].get(path) - if cooldown and last is not None and action_index - last < cooldown: - waited = action_index - last - report["rejected"].append({ - "path": path, "reason": "cooldown", - "fix": f"`{path}` changed {waited} turn(s) ago and cannot change again " - f"until {cooldown} turns have passed.", - }) - return - - if stat_def.get("type") == "counter" and delta < 0: - report["rejected"].append({ - "path": path, "reason": "counter can't decrease", - "fix": f"`{path}` only counts up. Send a positive change such as 1, " - f"never a negative and never the running total.", - }) - return - - clamped = False - cap = stat_def.get("max_delta_per_turn") - if cap is not None and abs(delta) > cap: - delta = cap if delta > 0 else -cap - clamped = True - - old = container.get(key, stat_def.get("initial", 0)) - new = old + delta - lo, hi = stat_def.get("min"), stat_def.get("max") - if lo is not None and new < lo: - new, clamped = lo, True - if hi is not None and new > hi: - new, clamped = hi, True - # Keep ints integral for display. - if isinstance(old, int) and float(new).is_integer(): - new = int(new) - - container[key] = new - meta["last_changed"][path] = action_index - entry = {"path": path, "old": old, "new": new} - if clamped and new == old: - # The clamp cancelled the change. Nothing moved, so the model needs the - # same correction a rejection gets: without it the only evidence is a - # value that stayed put, which reads as the change never being asked for. - edge = "maximum" if hi is not None and new == hi else "minimum" - entry["fix"] = ( - f"`{path}` did not move. It is already at its {edge} of {new}" - + (f" ({_limits_phrase(stat_def)})." if _limits_phrase(stat_def) else ".") - ) - report["applied"].append(entry) - if clamped: - report["clamped"].append(entry) - - -def _apply_text_stat(container: dict, key: str, stat_def: dict, change, - path: str, action_index: int, meta: dict, report: dict) -> None: - """Applies a free-text stat, which replaces rather than adds. - - The AI sends the new value in full rather than a delta. No clamping and no - bands apply. Only an optional cooldown and an optional `max_length` - truncation apply. - """ - if not isinstance(change, str): - report["rejected"].append({ - "path": path, "reason": "not a string", - "fix": f"`{path}` holds text. Send its new value in full, not a number " - f"and not a change.", - }) - return - - cooldown = stat_def.get("cooldown") or 0 - last = meta["last_changed"].get(path) - if cooldown and last is not None and action_index - last < cooldown: - waited = action_index - last - report["rejected"].append({ - "path": path, "reason": "cooldown", - "fix": f"`{path}` changed {waited} turn(s) ago and cannot change again " - f"until {cooldown} turns have passed.", - }) - return - - new = change.strip() - max_len = stat_def.get("max_length") - if isinstance(max_len, int) and max_len > 0 and len(new) > max_len: - new = new[:max_len] - - old = container.get(key, stat_def.get("initial", "")) - if new == old: - return # Nothing changed, so do nothing. - - container[key] = new - meta["last_changed"][path] = action_index - report["applied"].append({"path": path, "old": old, "new": new}) - - -def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tuple[dict, dict]: - """Sets live values directly, as a manual author edit rather than an AI turn. - - This differs from `apply_delta` in three ways. Numeric stats are set rather - than added to. `cooldown`, `max_delta_per_turn`, and the rule that a counter - cannot decrease are all ignored, because this is a deliberate correction - rather than an AI move to check. Milestones can be toggled in both - directions rather than only marked reached. - - Values are still validated against the schema, so an unknown path or a wrong - type is rejected, and numeric values still clamp to min and max. - """ - ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {} - if not ws: - ws = instantiate(stat_schema) - report: dict = {"applied": [], "rejected": []} - - if not isinstance(overrides, dict): - return ws, report - - milestones = stat_schema.get("milestones") or {} - flag_defs = stat_schema.get("flags") or {} - npcs = stat_schema.get("npcs") or {} - - def set_stat(container: dict, key: str, stat_def: dict, value, path: str) -> None: - if stat_def.get("type") == "text": - if not isinstance(value, str): - report["rejected"].append({"path": path, "reason": "not a string"}) - return - new = value.strip() - max_len = stat_def.get("max_length") - if isinstance(max_len, int) and max_len > 0 and len(new) > max_len: - new = new[:max_len] - old = container.get(key, stat_def.get("initial", "")) - container[key] = new - report["applied"].append({"path": path, "old": old, "new": new}) - return - - num = _coerce_number(value) - if num is None: - report["rejected"].append({"path": path, "reason": "not a number"}) - return - old = container.get(key, stat_def.get("initial", 0)) - lo, hi = stat_def.get("min"), stat_def.get("max") - if lo is not None and num < lo: - num = lo - if hi is not None and num > hi: - num = hi - if isinstance(old, int) and float(num).is_integer(): - num = int(num) - container[key] = num - report["applied"].append({"path": path, "old": old, "new": num}) - - for raw_path, value in overrides.items(): - path = str(raw_path) - parts = path.split(".") - - if parts[0] == "flags" and len(parts) == 2: - fid = parts[1] - if fid not in flag_defs: - report["rejected"].append({"path": path, "reason": "unknown flag"}) - continue - if not isinstance(value, bool): - report["rejected"].append({"path": path, "reason": "not a boolean"}) - continue - flags = ws.setdefault("flags", {}) - old = bool(flags.get(fid, False)) - flags[fid] = value - report["applied"].append({"path": path, "old": old, "new": value}) - continue - - if parts[0] == "milestones" and len(parts) == 2: - mid = parts[1] - if mid not in milestones: - report["rejected"].append({"path": path, "reason": "unknown milestone"}) - continue - if not isinstance(value, bool): - report["rejected"].append({"path": path, "reason": "not a boolean"}) - continue - reached = ws.setdefault("milestones", {}) - old = bool(reached.get(mid, {}).get("reached")) - if value: - reached[mid] = {"reached": True} - else: - reached.pop(mid, None) - report["applied"].append({"path": path, "old": old, "new": value}) - continue - - if parts[0] in STAT_SECTIONS and len(parts) == 2: - stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1]) - if not isinstance(stat_def, dict): - report["rejected"].append({ - "path": path, "reason": "unknown stat", - "fix": f"`{parts[0]}` has no stat `{parts[1]}`. " - f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}", - }) - continue - container = ws.setdefault(parts[0], {}) - set_stat(container, parts[1], stat_def, value, path) - continue - - if parts[0] == "npc" and len(parts) == 3: - ndef = npcs.get(parts[1]) - if not isinstance(ndef, dict): - report["rejected"].append({ - "path": path, "reason": "unknown npc", - "fix": f"There is no character `{parts[1]}`. " - f"{_names_phrase('character', npcs)}", - }) - continue - stat_defs = ndef.get("stats") or {} - stat_def = stat_defs.get(parts[2]) - if not isinstance(stat_def, dict): - report["rejected"].append({ - "path": path, "reason": "unknown npc stat", - "fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. " - f"{_names_phrase('stat', stat_defs)}", - }) - continue - npc_state = ws.setdefault("npc", {}) - container = npc_state.setdefault(parts[1], _initials(stat_defs)) - set_stat(container, parts[2], stat_def, value, path) - continue - - report["rejected"].append({ - "path": path, "reason": "unknown path", - "fix": f"`{path}` is not a tracked value. Use player., " - f"world., npc.., flags. or milestones..", - }) - - return ws, report - - -def apply_delta(world_state: dict, stat_schema: dict, delta: dict, - action_index: int) -> tuple[dict, dict]: - """Validates and clamps `delta` against `stat_schema`, then applies it. - - The delta is applied to a copy of `world_state`. The return value is - `(new_world_state, report)`. - """ - ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {} - if not ws: - ws = instantiate(stat_schema) - ws.setdefault("_meta", {}).setdefault("last_changed", {}) - meta = ws["_meta"] - report: dict = {"applied": [], "clamped": [], "rejected": []} - - if not isinstance(delta, dict): - return ws, report - - milestones = stat_schema.get("milestones") or {} - flag_defs = stat_schema.get("flags") or {} - npcs = stat_schema.get("npcs") or {} - - for raw_path, change in delta.items(): - path = str(raw_path) - parts = path.split(".") - - # `flags.` is a two-way boolean, and either value is accepted. - if parts[0] == "flags" and len(parts) == 2: - fid = parts[1] - if fid not in flag_defs: - report["rejected"].append({ - "path": path, "reason": "unknown flag", - "fix": f"There is no flag `{fid}`. {_names_phrase('flag', flag_defs)}", - }) - continue - if not isinstance(change, bool): - report["rejected"].append({ - "path": path, "reason": "not a boolean", - "fix": f"`{path}` takes true or false.", - }) - continue - flags = ws.setdefault("flags", {}) - old = bool(flags.get(fid, False)) - if change != old: - flags[fid] = change - report["applied"].append({"path": path, "old": old, "new": change}) - continue - - # `milestones.` is a sticky boolean, and only `true` is accepted. - if parts[0] == "milestones" and len(parts) == 2: - mid = parts[1] - if mid not in milestones: - report["rejected"].append({ - "path": path, "reason": "unknown milestone", - "fix": f"There is no milestone `{mid}`. " - f"{_names_phrase('milestone', milestones)}", - }) - continue - if change is not True: - report["rejected"].append({ - "path": path, "reason": "not true", - "fix": f"`{path}` can only be set to true. A milestone is " - f"reached once and never taken back.", - }) - continue - reached = ws.setdefault("milestones", {}) - if reached.get(mid, {}).get("reached"): - continue # Already reached, so do nothing. - reached[mid] = {"reached": True, "at": action_index} - report["applied"].append({"path": path, "old": False, "new": True}) - continue - - # world. / player. - if parts[0] in STAT_SECTIONS and len(parts) == 2: - stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1]) - if not isinstance(stat_def, dict): - report["rejected"].append({ - "path": path, "reason": "unknown stat", - "fix": f"`{parts[0]}` has no stat `{parts[1]}`. " - f"{_names_phrase('stat', stat_schema.get(parts[0]) or {})}", - }) - continue - container = ws.setdefault(parts[0], {}) - if stat_def.get("type") == "text": - _apply_text_stat(container, parts[1], stat_def, change, path, - action_index, meta, report) - else: - _apply_stat(container, parts[1], stat_def, change, path, - action_index, meta, report) - continue - - # `npc..`. Each NPC has its own stat definitions. - if parts[0] == "npc" and len(parts) == 3: - ndef = npcs.get(parts[1]) - if not isinstance(ndef, dict): - report["rejected"].append({ - "path": path, "reason": "unknown npc", - "fix": f"There is no character `{parts[1]}`. " - f"{_names_phrase('character', npcs)}", - }) - continue - stat_defs = ndef.get("stats") or {} - stat_def = stat_defs.get(parts[2]) - if not isinstance(stat_def, dict): - report["rejected"].append({ - "path": path, "reason": "unknown npc stat", - "fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. " - f"{_names_phrase('stat', stat_defs)}", - }) - continue - npc_state = ws.setdefault("npc", {}) - container = npc_state.setdefault(parts[1], _initials(stat_defs)) - if stat_def.get("type") == "text": - _apply_text_stat(container, parts[2], stat_def, change, path, - action_index, meta, report) - else: - _apply_stat(container, parts[2], stat_def, change, path, - action_index, meta, report) - continue - - report["rejected"].append({ - "path": path, "reason": "unknown path", - "fix": f"`{path}` is not a tracked value. Use player., " - f"world., npc.., flags. or milestones..", - }) - - return ws, report - - -# --------------------------------------------------------------------------- # -# Context rendering -# --------------------------------------------------------------------------- # - -def _stat_line(defs: dict, values: dict) -> str: - parts = [] - for name, d in defs.items(): - if not isinstance(d, dict): - continue - if d.get("type") == "text": - val = values.get(name, d.get("initial", "")) - parts.append(f'{name} "{val}"' if val else f"{name} (unset)") - continue - val = values.get(name, d.get("initial", 0)) - hi = d.get("max") - shown = f"{val}/{hi}" if hi is not None else f"{val}" - label = band_label(d, val) - parts.append(f"{name} {shown}" + (f" ({label})" if label else "")) - return ", ".join(parts) - - -def render_state_section(world_state: dict, stat_schema: dict, - visible_npcs: dict[str, str]) -> str: - """Returns the compact context block that every turn includes. - - `visible_npcs` maps a card id to a display name, for the NPCs currently in - the scene. - """ - ws = world_state if isinstance(world_state, dict) else {} - lines: list[str] = [] - - world_defs = stat_schema.get("world") or {} - world_line = _stat_line(world_defs, ws.get("world") or {}) - header = "World state" + (f" — {world_line}." if world_line else ".") - lines.append(header) - - player_defs = stat_schema.get("player") or {} - player_line = _stat_line(player_defs, ws.get("player") or {}) - if player_line: - lines.append(f"You: {player_line}.") - - npcs = stat_schema.get("npcs") or {} - npc_state = ws.get("npc") or {} - for npc_key, name in visible_npcs.items(): - ndef = npcs.get(npc_key) or {} - stat_defs = ndef.get("stats") or {} - values = npc_state.get(npc_key) or _initials(stat_defs) - npc_line = _stat_line(stat_defs, values) - if npc_line: - # Show the id so the AI can address it as npc... - lines.append(f"{name} (npc.{npc_key}): {npc_line}.") - - flag_defs = stat_schema.get("flags") or {} - flag_state = ws.get("flags") or {} - flag_parts = [ - f"{name} {'yes' if flag_state.get(name, bool(d.get('initial', False))) else 'no'}" - for name, d in flag_defs.items() if isinstance(d, dict) - ] - if flag_parts: - lines.append("Flags: " + ", ".join(flag_parts) + ".") - - # Show the id beside each goal, the same as NPCs and flags. The AI marks a - # milestone as `milestones.`, and `apply_delta` rejects an id the schema - # does not define, so a goal listed by description alone gives the model no - # way to name it and it can only guess. - milestones = stat_schema.get("milestones") or {} - reached = ws.get("milestones") or {} - goals = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items() - if not reached.get(mid, {}).get("reached")] - done = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items() - if reached.get(mid, {}).get("reached")] - if goals: - lines.append("Goals (mark with milestones.): " + "; ".join(goals) + ".") - if done: - lines.append("Achieved: " + "; ".join(done) + ".") - - return "\n".join(lines) - - -def _describe_stat(name: str, d: dict) -> str | None: - """Returns one reference line for a stat. - - The description and the band ladder are independent, and each is included - only when present, so a stat may have either, both, or neither. - """ - bits: list[str] = [] - desc = d.get("desc") - if isinstance(desc, str) and desc.strip(): - # Fragments are joined with "; " and end with a single ".", so remove - # any trailing period the author put on the description. - bits.append(desc.strip().rstrip(".")) - if d.get("type") == "text": - bits.append("free text") - return f"{name} — {'; '.join(bits)}." if bits else None - lo, hi = d.get("min"), d.get("max") - if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): - bits.append(f"range {lo}–{hi}") - bands = d.get("bands") - if isinstance(bands, list) and bands: - ladder = ", ".join( - f"{b[0]}–{b[1]} {b[2]}" - for b in bands if isinstance(b, list) and len(b) == 3 - ) - if ladder: - bits.append(f"bands: {ladder}") - return f"{name} — {'; '.join(bits)}." if bits else None - - -def render_reference(stat_schema: dict) -> str: - """Returns a fixed, per-scenario legend for the stats. - - Each line gives what a stat means, from its `desc`, and its band ladder. The - legend does not change from turn to turn, and it is separate from the live - values. - """ - lines: list[str] = [] - for section in STAT_SECTIONS: - for name, d in (stat_schema.get(section) or {}).items(): - if isinstance(d, dict): - row = _describe_stat(name, d) - if row: - lines.append(row) - for npc_key, ndef in (stat_schema.get("npcs") or {}).items(): - if not isinstance(ndef, dict): - continue - name = npc_name(ndef, npc_key) - desc = ndef.get("desc") - if isinstance(desc, str) and desc.strip(): - lines.append(f"NPC {name} ({npc_key}) — {desc.strip().rstrip('.')}.") - for sname, sdef in (ndef.get("stats") or {}).items(): - if isinstance(sdef, dict): - row = _describe_stat(f"{name} {sname}", sdef) - if row: - lines.append(row) - for name, d in (stat_schema.get("flags") or {}).items(): - if isinstance(d, dict): - desc = d.get("desc") - if isinstance(desc, str) and desc.strip(): - lines.append(f"{name} (flag) — {desc.strip().rstrip('.')}.") - if not lines: - return "" - return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines) diff --git a/backend/app/worldstate/parse.py b/backend/app/worldstate/parse.py new file mode 100644 index 0000000..775427e --- /dev/null +++ b/backend/app/worldstate/parse.py @@ -0,0 +1,164 @@ +"""The delta block: the rule the model is given, and reading back what it sent. + +The model writes one fenced block per turn. This module holds the instruction +text, the parser that survives the ways a model gets the format wrong, and the +report rendering that tells a player which changes were refused. +""" +import json +import re + + +# Appended once to the system prompt, so the model knows how to report +# changes. +EMIT_RULE = ( + "You maintain a numeric world state. Treat your own narration as authoritative: " + "whenever what you write implies a change to any tracked value — health or resources " + "going up or down, time passing, a relationship or mood shifting, a status turning on " + "or off, progress toward a goal, an item or piece of information gained or lost — you " + "MUST record it, including the numbers, not just on/off flags. After your narration, " + "append a fenced code block labelled `state` with a JSON object of the CHANGES ONLY, " + "as deltas (not new totals). Update every value the scene affected this turn, not only " + "the obvious ones. Read the range and band labels shown for each stat and keep every " + "change proportionate to the moment: an ordinary or minor event nudges a value slightly, " + "while a large change — or reaching a stat's minimum or maximum — is reserved for a " + "genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a " + "lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of " + 'its range in a single ordinary turn. Use the paths exactly as shown in the world state: ' + '"player.", ' + '"world.", "npc.." (use the id in parentheses, e.g. npc.gwen.trust, ' + 'not the display name); "flags.": true or false to toggle an on/off state; and ' + '"milestones.": true when an objective is completed. Some stats marked (free text) in ' + "the stat guide hold a short string instead of a number — for those, send the new value " + "in full (not a delta), e.g. what the player is now wearing or holding; only send it when " + "it actually changed. Send only things that actually " + "changed and never restate unchanged values; if truly nothing changed, omit the block. " + "Example:\n" + '```state\n{"player.hp": -15, "npc.gwen.trust": 5, "milestones.escaped": true, ' + '"player.outfit": "torn traveling cloak"}\n```' +) + + +# A short reminder placed at the end of the prompt, which is the strongest +# recency position, so the emit rule is close to where the model generates. +EMIT_REMINDER = ( + "[Reminder: end your reply with a ```state block of the changes this turn " + "(deltas only), or omit it if truly nothing changed.]" +) + + +def render_delta_block(delta: dict) -> str: + """Renders a stored delta back into the fenced `state` block the AI emitted. + + The caller replays past turns into the context with this, so the model copies + the format. An empty delta returns an empty string, which means the turn + changed nothing. + """ + if not isinstance(delta, dict) or not delta: + return "" + return "```state\n" + json.dumps(delta, ensure_ascii=False) + "\n```" + + +def applied_delta(world_delta: dict | None) -> dict: + """Returns the changes the engine accepted, shaped as the AI sends them. + + Replaying the delta the AI sent would show it a refused change standing as + though it had been applied, contradicted by the live values in the same + prompt. The model has no way to read that as a correction, so it repeats + the change. Replaying what was accepted removes the contradiction. + + A numeric change that ended where it started is omitted, because it moved + nothing and a zero in the replayed block reads as a value worth sending. + """ + if not isinstance(world_delta, dict): + return {} + out: dict = {} + for entry in world_delta.get("applied") or []: + if not isinstance(entry, dict): + continue + path = str(entry.get("path", "")) + old, new = entry.get("old"), entry.get("new") + if path.startswith("milestones."): + out[path] = True + elif path.startswith("flags."): + out[path] = bool(new) + elif isinstance(old, (int, float)) and isinstance(new, (int, float)): + if new != old: + out[path] = new - old + else: + out[path] = new + return out + + +def refusals(world_delta: dict | None) -> list[str]: + """Returns a correction line for each change the engine did not carry out. + + Covers the changes that were lost: a rejection, and a clamp that left the + value where it started. A clamp that reduced a change but still moved the + value is deliberately absent. The model's intent landed in that case, and + reporting the shortfall invites it to send the remainder on the next turn, + which is the swing `max_delta_per_turn` exists to prevent. + """ + if not isinstance(world_delta, dict): + return [] + lines = [] + for entry in world_delta.get("rejected") or []: + if isinstance(entry, dict) and entry.get("fix"): + lines.append(str(entry["fix"])) + for entry in world_delta.get("clamped") or []: + if isinstance(entry, dict) and entry.get("fix"): + lines.append(str(entry["fix"])) + return lines + + +def render_refusals(world_delta: dict | None) -> str: + """Renders `refusals` as the note appended after the most recent AI turn.""" + lines = refusals(world_delta) + if not lines: + return "" + body = "\n".join(f"- {ln}" for ln in lines) + return ("[Part of your last state block was not applied. Correct it in this " + f"turn's block:\n{body}]") + + +# ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL. +_FENCE_RE = re.compile(r"```(?:state|json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE) + + +# Fallback: a bare JSON object hugging the end of the text. +_TRAILING_RE = re.compile(r"(\{[^{}]*\})\s*$", re.DOTALL) + + +def _tolerant_load(blob: str) -> dict: + # Strip trailing commas and leading + on numbers, both of which weaker + # free models emit and strict JSON rejects. + cleaned = re.sub(r",(\s*[}\]])", r"\1", blob) + cleaned = re.sub(r"(:\s*)\+(\d)", r"\1\2", cleaned) + try: + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def extract_delta(text: str) -> tuple[str, dict]: + """Removes the trailing state block from an AI response. + + The return value is `(clean_text, delta)`. `delta` is `{}` when there is no + block or the block cannot be parsed, and `clean_text` has the block removed. + A bare trailing object is stripped only when it parses to a delta, so + ordinary prose that ends in `}` is left alone. + """ + matches = list(_FENCE_RE.finditer(text)) + if matches: + m = matches[-1] + delta = _tolerant_load(m.group(1)) + clean = (text[: m.start()] + text[m.end():]).strip() + return clean, delta + + m = _TRAILING_RE.search(text) + if m: + delta = _tolerant_load(m.group(1)) + if delta and all("." in str(k) for k in delta): + clean = text[: m.start()].strip() + return clean, delta + return text.strip(), {} diff --git a/backend/app/worldstate/render.py b/backend/app/worldstate/render.py new file mode 100644 index 0000000..5fe8fc6 --- /dev/null +++ b/backend/app/worldstate/render.py @@ -0,0 +1,147 @@ +"""Turning world state into the text the model and the player read. + +`render_state_section` writes the current state into the prompt. +`render_reference` writes the schema itself, so the model knows which stats +exist and what they are called. +""" +from .schema import STAT_SECTIONS, _initials, band_label, npc_name + + +def _stat_line(defs: dict, values: dict) -> str: + parts = [] + for name, d in defs.items(): + if not isinstance(d, dict): + continue + if d.get("type") == "text": + val = values.get(name, d.get("initial", "")) + parts.append(f'{name} "{val}"' if val else f"{name} (unset)") + continue + val = values.get(name, d.get("initial", 0)) + hi = d.get("max") + shown = f"{val}/{hi}" if hi is not None else f"{val}" + label = band_label(d, val) + parts.append(f"{name} {shown}" + (f" ({label})" if label else "")) + return ", ".join(parts) + + +def render_state_section(world_state: dict, stat_schema: dict, + visible_npcs: dict[str, str]) -> str: + """Returns the compact context block that every turn includes. + + `visible_npcs` maps a card id to a display name, for the NPCs currently in + the scene. + """ + ws = world_state if isinstance(world_state, dict) else {} + lines: list[str] = [] + + world_defs = stat_schema.get("world") or {} + world_line = _stat_line(world_defs, ws.get("world") or {}) + header = "World state" + (f" — {world_line}." if world_line else ".") + lines.append(header) + + player_defs = stat_schema.get("player") or {} + player_line = _stat_line(player_defs, ws.get("player") or {}) + if player_line: + lines.append(f"You: {player_line}.") + + npcs = stat_schema.get("npcs") or {} + npc_state = ws.get("npc") or {} + for npc_key, name in visible_npcs.items(): + ndef = npcs.get(npc_key) or {} + stat_defs = ndef.get("stats") or {} + values = npc_state.get(npc_key) or _initials(stat_defs) + npc_line = _stat_line(stat_defs, values) + if npc_line: + # Show the id so the AI can address it as npc... + lines.append(f"{name} (npc.{npc_key}): {npc_line}.") + + flag_defs = stat_schema.get("flags") or {} + flag_state = ws.get("flags") or {} + flag_parts = [ + f"{name} {'yes' if flag_state.get(name, bool(d.get('initial', False))) else 'no'}" + for name, d in flag_defs.items() if isinstance(d, dict) + ] + if flag_parts: + lines.append("Flags: " + ", ".join(flag_parts) + ".") + + # Show the id beside each goal, the same as NPCs and flags. The AI marks a + # milestone as `milestones.`, and `apply_delta` rejects an id the schema + # does not define, so a goal listed by description alone gives the model no + # way to name it and it can only guess. + milestones = stat_schema.get("milestones") or {} + reached = ws.get("milestones") or {} + goals = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items() + if not reached.get(mid, {}).get("reached")] + done = [f"{mid} — {d.get('desc', mid)}" for mid, d in milestones.items() + if reached.get(mid, {}).get("reached")] + if goals: + lines.append("Goals (mark with milestones.): " + "; ".join(goals) + ".") + if done: + lines.append("Achieved: " + "; ".join(done) + ".") + + return "\n".join(lines) + + +def _describe_stat(name: str, d: dict) -> str | None: + """Returns one reference line for a stat. + + The description and the band ladder are independent, and each is included + only when present, so a stat may have either, both, or neither. + """ + bits: list[str] = [] + desc = d.get("desc") + if isinstance(desc, str) and desc.strip(): + # Fragments are joined with "; " and end with a single ".", so remove + # any trailing period the author put on the description. + bits.append(desc.strip().rstrip(".")) + if d.get("type") == "text": + bits.append("free text") + return f"{name} — {'; '.join(bits)}." if bits else None + lo, hi = d.get("min"), d.get("max") + if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): + bits.append(f"range {lo}–{hi}") + bands = d.get("bands") + if isinstance(bands, list) and bands: + ladder = ", ".join( + f"{b[0]}–{b[1]} {b[2]}" + for b in bands if isinstance(b, list) and len(b) == 3 + ) + if ladder: + bits.append(f"bands: {ladder}") + return f"{name} — {'; '.join(bits)}." if bits else None + + +def render_reference(stat_schema: dict) -> str: + """Returns a fixed, per-scenario legend for the stats. + + Each line gives what a stat means, from its `desc`, and its band ladder. The + legend does not change from turn to turn, and it is separate from the live + values. + """ + lines: list[str] = [] + for section in STAT_SECTIONS: + for name, d in (stat_schema.get(section) or {}).items(): + if isinstance(d, dict): + row = _describe_stat(name, d) + if row: + lines.append(row) + for npc_key, ndef in (stat_schema.get("npcs") or {}).items(): + if not isinstance(ndef, dict): + continue + name = npc_name(ndef, npc_key) + desc = ndef.get("desc") + if isinstance(desc, str) and desc.strip(): + lines.append(f"NPC {name} ({npc_key}) — {desc.strip().rstrip('.')}.") + for sname, sdef in (ndef.get("stats") or {}).items(): + if isinstance(sdef, dict): + row = _describe_stat(f"{name} {sname}", sdef) + if row: + lines.append(row) + for name, d in (stat_schema.get("flags") or {}).items(): + if isinstance(d, dict): + desc = d.get("desc") + if isinstance(desc, str) and desc.strip(): + lines.append(f"{name} (flag) — {desc.strip().rstrip('.')}.") + if not lines: + return "" + return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines) diff --git a/backend/app/worldstate/schema.py b/backend/app/worldstate/schema.py new file mode 100644 index 0000000..f8b3a4c --- /dev/null +++ b/backend/app/worldstate/schema.py @@ -0,0 +1,198 @@ +"""Reading a scenario's `stat_schema` and building state that matches it. + +Nothing here decides what a turn changes. These functions answer what the schema +allows, what a fresh state looks like, and how an edited schema maps onto state +that already exists. +""" +import copy + + +# The `stat_schema` top-level sections that hold stat definitions. +STAT_SECTIONS = ("world", "player") + + +def has_schema(stat_schema: dict | None) -> bool: + """Returns `True` when a scenario defines an RPG layer.""" + if not isinstance(stat_schema, dict): + return False + return any( + isinstance(stat_schema.get(k), dict) and stat_schema[k] + for k in (*STAT_SECTIONS, "npcs", "milestones", "flags") + ) + + +def npc_name(ndef: dict, key: str) -> str: + name = ndef.get("name") + return name.strip() if isinstance(name, str) and name.strip() else key + + +def npc_triggers(ndef: dict, key: str) -> list[str]: + """Returns the lowercased trigger words that detect an NPC in a scene. + + The words come from the NPC's `keys` field, or from its display name when + `keys` is empty. + """ + raw = ndef.get("keys") or npc_name(ndef, key) + return [k.strip().lower() for k in str(raw).split(",") if k.strip()] + + +def _initials(defs: dict) -> dict: + return { + name: d.get("initial", "" if d.get("type") == "text" else 0) + for name, d in defs.items() + if isinstance(d, dict) + } + + +def instantiate(stat_schema: dict | None) -> dict: + """Builds a fresh live `world_state` from a schema, using initial values only.""" + if not has_schema(stat_schema): + return {} + ws: dict = {} + for section in STAT_SECTIONS: + ws[section] = _initials(stat_schema.get(section) or {}) + # Each defined NPC gets its own stat block from its own `stats` defs. + ws["npc"] = { + key: _initials(ndef.get("stats") or {}) + for key, ndef in (stat_schema.get("npcs") or {}).items() + if isinstance(ndef, dict) + } + ws["milestones"] = {} # only reached ones are stored + ws["flags"] = { + name: bool(d.get("initial", False)) + for name, d in (stat_schema.get("flags") or {}).items() + if isinstance(d, dict) + } + ws["_meta"] = {"last_changed": {}} + return ws + + +def reconcile(world_state: dict | None, stat_schema: dict | None) -> tuple[dict, dict]: + """Brings a live `world_state` back in line with an edited schema. + + This is not `instantiate`. A value the schema still defines keeps whatever it + reached in play, because re-instantiating would restore the player to full + health and clear their milestones. Only the difference is applied. Stats, + NPCs, flags, and milestones the schema gained appear at their initial value, + and ones it no longer defines are removed along with their `last_changed` + bookkeeping. The return value is `(new_state, report)`, where the report + lists paths under `added` and `removed`, so the UI can show what a refresh + would do. + + The additions are mostly cosmetic. Rendering and delta application both fall + back to a stat definition's `initial` when the live state has no value for + it, so a newly added stat already behaves correctly. This function stores the + value, and unlike those read-through paths it also removes what the schema + dropped. + """ + report: dict = {"added": [], "removed": []} + if not has_schema(stat_schema): + # The scenario dropped its RPG layer, so the adventure drops it too. + stale = bool(world_state) + if stale: + report["removed"].append("(all world state)") + return {}, report + + ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {} + if not ws: + return instantiate(stat_schema), report + + def sync_section(container: dict, defs: dict, prefix: str) -> dict: + out = {} + for name, d in defs.items(): + if not isinstance(d, dict): + continue + if name in container: + out[name] = container[name] + else: + out[name] = d.get("initial", "" if d.get("type") == "text" else 0) + report["added"].append(f"{prefix}.{name}") + for name in container: + if name not in out: + report["removed"].append(f"{prefix}.{name}") + return out + + for section in STAT_SECTIONS: + ws[section] = sync_section( + ws.get(section) if isinstance(ws.get(section), dict) else {}, + stat_schema.get(section) or {}, + section, + ) + + npc_defs = stat_schema.get("npcs") or {} + old_npcs = ws.get("npc") if isinstance(ws.get("npc"), dict) else {} + new_npcs = {} + for key, ndef in npc_defs.items(): + if not isinstance(ndef, dict): + continue + old = old_npcs.get(key) if isinstance(old_npcs.get(key), dict) else {} + new_npcs[key] = sync_section(old, ndef.get("stats") or {}, f"npc.{key}") + for key in old_npcs: + if key not in new_npcs: + report["removed"].append(f"npc.{key}") + ws["npc"] = new_npcs + + flag_defs = stat_schema.get("flags") or {} + old_flags = ws.get("flags") if isinstance(ws.get("flags"), dict) else {} + new_flags = {} + for name, d in flag_defs.items(): + if not isinstance(d, dict): + continue + if name in old_flags: + new_flags[name] = bool(old_flags[name]) + else: + new_flags[name] = bool(d.get("initial", False)) + report["added"].append(f"flags.{name}") + for name in old_flags: + if name not in new_flags: + report["removed"].append(f"flags.{name}") + ws["flags"] = new_flags + + # Milestones store only the ones reached, so there is nothing to add here. + # An unreached milestone is absent. Drop reached milestones the scenario no + # longer defines, because they would otherwise stay in "Achieved" with no + # label. + milestone_defs = stat_schema.get("milestones") or {} + reached = ws.get("milestones") if isinstance(ws.get("milestones"), dict) else {} + ws["milestones"] = {k: v for k, v in reached.items() if k in milestone_defs} + for k in reached: + if k not in milestone_defs: + report["removed"].append(f"milestones.{k}") + + # Cooldown bookkeeping for paths that no longer exist is never read, but it + # accumulates in every stored snapshot, so remove it with the rest. + meta = ws.setdefault("_meta", {}) + last_changed = meta.get("last_changed") + if isinstance(last_changed, dict): + removed = set(report["removed"]) + meta["last_changed"] = { + path: at for path, at in last_changed.items() + if not any(path == r or path.startswith(f"{r}.") for r in removed) + } + else: + meta["last_changed"] = {} + + return ws, report + + +def band_label(stat_def: dict, value) -> str | None: + """Returns the word label for `value` from a stat definition's bands, if any. + + A band is `[lo, hi, label]` and matches when `lo <= value < hi`. The top band + includes its upper bound, so a stat at its maximum still gets a label. + """ + bands = stat_def.get("bands") + if not isinstance(bands, list) or not isinstance(value, (int, float)): + return None + last_hi = None + for band in bands: + if not (isinstance(band, list) and len(band) == 3): + continue + lo, hi, label = band + last_hi = hi + if lo <= value < hi: + return str(label) + # Inclusive top edge. + if bands and value == last_hi: + return str(bands[-1][2]) + return None From 3a9090c20ab734e105f0c2f06da2b1d9513e7b9d Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sat, 29 Aug 2026 01:11:50 +0530 Subject: [PATCH 05/11] Split index.css into eighteen section files `frontend/src/index.css` held 2865 lines. It is now an import list, and each section is its own file under `frontend/src/styles/`. The order is unchanged. Two comments in the file already recorded that the cascade depends on it: several selectors in the tome section win only because they come after the base card rules, and the 720px query overrides the whole desktop design. The built CSS bundle is byte-identical before and after, at 56686 bytes. The plan asked for each `@media` block to move next to the rules it overrides. That is not done, and should not be. Moving a rule past a later rule of equal specificity changes which one wins, and there is no frontend test that would catch it. The 720px block is now `responsive.css`, imported last. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r --- frontend/src/index.css | 2893 +------------------------ frontend/src/styles/analytics.css | 287 +++ frontend/src/styles/auth.css | 145 ++ frontend/src/styles/banners.css | 25 + frontend/src/styles/base.css | 11 + frontend/src/styles/cards.css | 63 + frontend/src/styles/chat.css | 97 + frontend/src/styles/drawers.css | 266 +++ frontend/src/styles/forms.css | 64 + frontend/src/styles/insights.css | 203 ++ frontend/src/styles/modals.css | 57 + frontend/src/styles/nav.css | 120 + frontend/src/styles/panels.css | 65 + frontend/src/styles/play.css | 316 +++ frontend/src/styles/responsive.css | 219 ++ frontend/src/styles/schema-editor.css | 243 +++ frontend/src/styles/tokens.css | 50 + frontend/src/styles/tome.css | 475 ++++ frontend/src/styles/tree-map.css | 159 ++ 19 files changed, 2893 insertions(+), 2865 deletions(-) create mode 100644 frontend/src/styles/analytics.css create mode 100644 frontend/src/styles/auth.css create mode 100644 frontend/src/styles/banners.css create mode 100644 frontend/src/styles/base.css create mode 100644 frontend/src/styles/cards.css create mode 100644 frontend/src/styles/chat.css create mode 100644 frontend/src/styles/drawers.css create mode 100644 frontend/src/styles/forms.css create mode 100644 frontend/src/styles/insights.css create mode 100644 frontend/src/styles/modals.css create mode 100644 frontend/src/styles/nav.css create mode 100644 frontend/src/styles/panels.css create mode 100644 frontend/src/styles/play.css create mode 100644 frontend/src/styles/responsive.css create mode 100644 frontend/src/styles/schema-editor.css create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/src/styles/tome.css create mode 100644 frontend/src/styles/tree-map.css diff --git a/frontend/src/index.css b/frontend/src/index.css index 1324cc5..f83fac1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,2865 +1,28 @@ -:root { - --bg: #0a0a0f; - --bg-panel: #131320; - --bg-panel-glass: rgba(19, 19, 32, 0.82); - --bg-input: #1a1a2a; - --border: #2b2b3d; - --border-bright: #3d3d55; - --text: #e2ddd0; - --text-dim: #918c7d; - --accent: #d4a94e; - --accent-bright: #e8c476; - --accent-dim: #96773a; - --accent-glow: rgba(212, 169, 78, 0.25); - --danger: #d06565; - --player: #9fc7d1; - /* Chart hues (analytics dashboard). Deeper and more saturated than --accent - and --player, which are tuned for text and borders and turn muddy once - they are a 10px bar: these are checked against --bg-panel for lightness, - chroma, contrast and colour-blind separation as a set. */ - --chart-1: #b58a30; - --chart-2: #3d8ac4; - --chart-3: #a8608f; - --font-display: 'Cinzel', Georgia, serif; - --font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif; - --font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif; - font-family: var(--font-ui); - color-scheme: dark; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - background: - radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%), - radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%), - var(--bg); - background-attachment: fixed; - color: var(--text); - min-height: 100vh; -} - -#root { min-height: 100vh; display: flex; flex-direction: column; } - -h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; } -a { color: var(--accent); text-decoration: none; transition: color 0.15s; } -a:hover { color: var(--accent-bright); } - -::selection { background: var(--accent-dim); color: #0f0d08; } - -/* ---------- Scrollbars ---------- */ - -::-webkit-scrollbar { width: 10px; height: 10px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { - background: var(--border-bright); - border-radius: 5px; - border: 2px solid var(--bg); -} -::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); } - -/* ---------- Top navigation ---------- */ - -.topnav { - display: flex; - align-items: center; - gap: 26px; - padding: 0 28px; - height: 56px; - background: var(--bg-panel-glass); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid var(--border); - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); - position: sticky; - top: 0; - z-index: 10; -} -.topnav .brand { - font-family: var(--font-display); - font-size: 1.2rem; - font-weight: 700; - color: var(--accent); - letter-spacing: 0.14em; - text-shadow: 0 0 18px var(--accent-glow); - margin-right: 6px; -} -.topnav a.navlink { - color: var(--text-dim); - padding: 17px 2px 15px; - font-size: 0.9rem; - font-weight: 500; - letter-spacing: 0.04em; - border-bottom: 2px solid transparent; - transition: color 0.15s, border-color 0.2s; -} -.topnav a.navlink.active { - color: var(--accent-bright); - border-bottom-color: var(--accent); - text-shadow: 0 0 14px var(--accent-glow); -} -.topnav a.navlink:hover { color: var(--text); } - -/* On desktop the wrapper is transparent — its children (nav links + account) - participate directly in the .topnav flex row, exactly as before. The mobile - query below turns it into a dropdown. */ -.nav-links { display: contents; } -.nav-hamburger { - display: none; - background: none; - border: none; - color: var(--text); - font-size: 1.2rem; - padding: 6px 8px; - margin-left: auto; - line-height: 1; -} - -/* Phase 8: account area (hosted multi-user mode only) */ -.nav-account { - margin-left: auto; - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} -.nav-account button { padding: 5px 12px; font-size: 0.82rem; } -.guest-nudge { - color: var(--text-dim); - font-size: 0.78rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.account-email { - color: var(--text-dim); - font-size: 0.82rem; - max-width: 220px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -button.linklike { - background: none; - border: none; - padding: 0; - color: var(--accent); - font-size: 0.82rem; - cursor: pointer; -} -button.linklike:hover { text-decoration: underline; } -@media (max-width: 900px) { - .guest-nudge { display: none; } -} - -/* Phase 8: demo-key banner (Settings) */ -.demo-banner { - border: 1px solid var(--border-bright); - border-left: 3px solid var(--accent); - border-radius: 8px; - padding: 12px 16px; - margin-bottom: 18px; - color: var(--text-dim); - font-size: 0.88rem; - line-height: 1.5; -} -.demo-banner strong { color: var(--accent-bright); } - -.page { - flex: 1; - width: 100%; - max-width: 960px; - margin: 0 auto; - padding: 28px 24px; - animation: page-in 0.35s ease both; -} -@keyframes page-in { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: none; } -} - -/* ---------- Forms ---------- */ - -label.field { display: block; margin-bottom: 14px; } -label.field .label { - display: block; - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--text-dim); - margin-bottom: 5px; -} -input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select { - width: 100%; - background: var(--bg-input); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 9px 12px; - font-size: 0.95rem; - font-family: inherit; - transition: border-color 0.15s, box-shadow 0.15s; -} -textarea { resize: vertical; min-height: 70px; line-height: 1.5; } -input:focus, textarea:focus, select:focus { - outline: none; - border-color: var(--accent-dim); - box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12); -} -input::placeholder, textarea::placeholder { color: #5c584d; } - -button { - background: var(--bg-input); - color: var(--text); - border: 1px solid var(--border); - border-radius: 8px; - padding: 7px 16px; - font-size: 0.88rem; - font-weight: 500; - cursor: pointer; - font-family: inherit; - transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s; -} -button:hover { - border-color: var(--accent-dim); - box-shadow: 0 0 12px rgba(212, 169, 78, 0.1); -} -button:active { transform: translateY(1px); } -button.primary { - background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e); - border-color: var(--accent); - color: #191307; - font-weight: 600; - letter-spacing: 0.02em; - box-shadow: 0 2px 10px var(--accent-glow); -} -button.primary:hover { - box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45); - filter: brightness(1.08); -} -button.danger { color: var(--danger); } -button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); } -button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; } - -/* ---------- Cards & lists ---------- */ - -.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } -.card { - position: relative; - background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel); - border: 1px solid var(--border); - border-radius: 12px; - padding: 18px; - cursor: pointer; - transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; - overflow: hidden; -} -.card::before { - content: ''; - position: absolute; - inset: 0 0 auto 0; - height: 2px; - background: linear-gradient(90deg, transparent, var(--accent-dim), transparent); - opacity: 0; - transition: opacity 0.25s; -} -.card:hover { - border-color: var(--border-bright); - transform: translateY(-3px); - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06); -} -.card:hover::before { opacity: 1; } -.card h3 { - margin: 0 0 6px; - font-family: var(--font-display); - font-size: 1.02rem; - color: var(--accent); -} -.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; } -.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); } - -.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } -.page-header h1 { - margin: 0; - font-family: var(--font-display); - font-size: 1.55rem; - color: var(--accent-bright); - text-shadow: 0 0 24px var(--accent-glow); -} - -.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; } - -/* ---------- Story card editor ---------- */ - -.storycard { - background: var(--bg-input); - border: 1px solid var(--border); - border-left: 3px solid var(--accent-dim); - border-radius: 8px; - padding: 12px; - margin-bottom: 10px; - transition: border-color 0.15s; -} -.storycard:focus-within { border-color: var(--accent-dim); } -.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; } -.storycard .row > * { flex: 1; } - -/* ---------- Play page ---------- */ - -.play-page { - display: flex; - flex-direction: column; - min-height: calc(100dvh - 56px); - /* Room to scroll the last beat clear of a composer lifted by the keyboard - (see --kb-inset in keyboard.js); 0 on desktop and on browsers that shrink - the layout viewport themselves. Margin, not padding — .page already sets - padding and a longhand here would drop its bottom gutter. */ - margin-bottom: var(--kb-inset, 0px); -} - -/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story - scrolls — pin the header just below the 56px top nav. */ -.play-page .page-header { - position: sticky; - top: 56px; - z-index: 5; - background: var(--bg); - margin-bottom: 12px; - padding: 12px 0; - border-bottom: 1px solid var(--border); -} - -.story { - flex: 1; - font-family: var(--font-story); - font-size: 1.14rem; - line-height: 1.7; - white-space: pre-wrap; - padding-bottom: 16px; -} -/* The head of a windowed transcript: how much story is still above, and the - way back to it. Deliberately quiet — it is a seam in the page, not a - feature — and it sits inside .story so it inherits the story face. */ -.story-earlier { - text-align: center; - margin: 4px 0 22px; - font-size: 0.9rem; - /* No action-in animation here: this appears above content the reader is - already looking at, and a fade would read as the story moving. */ -} -.story-earlier .dim { color: var(--text-dim); font-style: italic; } -.story-earlier button { - background: none; - border: none; - border-bottom: 1px solid rgba(159, 199, 209, 0.3); - color: var(--text-dim); - font: inherit; - font-style: italic; - cursor: pointer; - padding: 2px 4px; -} -.story-earlier button:hover { - color: var(--accent-bright); - border-bottom-color: var(--accent-bright); -} -.story .action { - position: relative; - margin-bottom: 15px; - padding-right: 56px; - animation: action-in 0.4s ease both; -} -@keyframes action-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: none; } -} -.story .action.player { - color: var(--player); - border-left: 2px solid rgba(159, 199, 209, 0.35); - padding-left: 14px; - font-style: italic; -} -.story .action.streaming { color: var(--text); } -.story .cursor { - color: var(--accent-bright); - text-shadow: 0 0 8px var(--accent-glow); - animation: blink 1s steps(1) infinite; -} -@keyframes blink { 50% { opacity: 0; } } - -/* Reasoning-model thinking, collapsed above the AI's response */ -.story .reasoning { - margin: 0 0 8px; - font-size: 0.82rem; - line-height: 1.5; - color: var(--text-dim); - font-style: normal; -} -.story .reasoning summary { - cursor: pointer; - user-select: none; - color: var(--text-dim); - opacity: 0.8; -} -.story .reasoning summary:hover { opacity: 1; } -.story .reasoning .reasoning-text { - margin-top: 6px; - padding: 8px 12px; - border-left: 2px solid var(--border); - opacity: 0.85; - max-height: 260px; - overflow-y: auto; -} - -.story .action-tools { - position: absolute; - top: 0; - right: 0; - display: inline-flex; - gap: 4px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s; -} -.story .action:hover .action-tools { opacity: 1; pointer-events: auto; } -.story .action-tools button { - padding: 1px 8px; - font-size: 0.72rem; - font-family: var(--font-ui); - color: var(--text-dim); - background: var(--bg-panel-glass); - backdrop-filter: blur(6px); -} -.story .action-tools button:hover { color: var(--text); } - -/* The takes of one turn: ‹ 2/4 ›. Stays quiet until hovered — it's a footnote - on the message, not part of the prose. - - Small and unemphatic on purpose. Stepping through takes reads them and does - nothing else, so it should not carry the visual weight of a decision; the - decision is made by writing, which happens in the composer below. */ -.take-pager { - display: flex; - align-items: center; - gap: 2px; - margin-top: 6px; - font-family: var(--font-ui); - opacity: 0.45; - transition: opacity 0.15s; -} -.story .action:hover .take-pager, -.take-pager:focus-within { opacity: 1; } -.take-pager button { - width: 20px; - height: 20px; - padding: 0; - font-size: 0.9rem; - line-height: 1; - color: var(--text-dim); - background: transparent; - border: 1px solid transparent; - border-radius: 4px; -} -.take-pager button:hover:not(:disabled) { - color: var(--text); - border-color: var(--border-bright); -} -.take-pager button:disabled { opacity: 0.3; cursor: default; } -.take-count { - min-width: 30px; - text-align: center; - font-size: 0.72rem; - font-variant-numeric: tabular-nums; - color: var(--text-dim); -} -/* Only while a take that isn't the live one is on screen: it says what the - next thing typed will do, because that is the click that forks. */ -.take-note { - margin-left: 6px; - font-size: 0.72rem; - font-style: italic; - color: var(--accent-dim); -} - -/* ---------- Branches panel (Phase 14, SP7) ---------- */ - -.panel-empty { - padding: 18px 0; - color: var(--text-dim); - font-size: 0.85rem; -} -.branch-panel { display: flex; flex-direction: column; gap: 14px; } -.branch-intro { - margin: 0; - color: var(--text-dim); - font-size: 0.82rem; - line-height: 1.55; -} -.branch-list { display: flex; flex-direction: column; gap: 8px; } -.branch-row { - display: flex; - flex-direction: column; - gap: 5px; - padding: 9px 11px; - border: 1px solid var(--border); - border-radius: 5px; - background: var(--bg-input); -} -.branch-row.here { - border-color: var(--accent-dim); - background: rgba(212, 169, 78, 0.07); -} -.branch-head { display: flex; align-items: center; gap: 7px; } -.branch-glyph { color: var(--border-bright); font-size: 0.85rem; } -.branch-row.here .branch-glyph { color: var(--accent); } -.branch-name { - font-family: var(--font-story); - font-size: 0.98rem; - color: var(--text); -} -.branch-row.here .branch-name { color: var(--accent-bright); } -.branch-here { - margin-left: auto; - font-size: 0.64rem; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--accent-dim); -} -.branch-rename { - flex: 1; - min-width: 0; - padding: 3px 7px; - font-family: var(--font-story); - font-size: 0.95rem; -} -.branch-meta { - font-size: 0.72rem; - color: var(--text-dim); - font-variant-numeric: tabular-nums; -} -.branch-tools, .branch-confirm { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; } -.branch-tools button, .branch-confirm button { - padding: 3px 9px; - font-size: 0.72rem; - color: var(--text-dim); - background: transparent; - border: 1px solid var(--border); -} -.branch-tools button:hover:not(:disabled), -.branch-confirm button:hover:not(:disabled) { - color: var(--accent-bright); - border-color: var(--border-bright); -} -.branch-tools button.danger:hover:not(:disabled), -.branch-confirm button.danger:hover:not(:disabled) { - color: var(--danger); - border-color: var(--danger); -} -.branch-tools button:disabled, .branch-confirm button:disabled { opacity: 0.4; cursor: default; } -.branch-confirm span { - font-size: 0.74rem; - color: var(--danger); - flex-basis: 100%; -} - -.action-edit { margin-bottom: 14px; } -/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually - several paragraphs, and the old fixed 110px turned that into a keyhole. - min-height keeps a short player action from collapsing; max-height stops a - very long beat from pushing Save/Cancel off the screen (it scrolls instead). - resize stays on, so the height is a starting point, not a cage. */ -.action-edit textarea { - min-height: 110px; - max-height: 65vh; - overflow-y: auto; - font-family: inherit; - font-size: inherit; -} -.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; } - -.play-controls { - position: sticky; - /* Sits on the keyboard's top edge when one is open, on the viewport bottom - otherwise — --kb-inset is 0 unless a virtual keyboard is covering us. */ - bottom: var(--kb-inset, 0px); - background: linear-gradient(to top, var(--bg) 75%, transparent); - padding: 18px 0 16px; - border-top: 1px solid var(--border); -} -.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; } -.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); } -.turn-buttons button:hover:not(:disabled) { color: var(--text); } - -.input-bar { display: flex; gap: 8px; align-items: flex-end; } -.input-bar input { flex: 1; } -/* Auto-growing action box: starts at one line, grows to ~4, then scrolls. */ -.input-bar textarea { - flex: 1; - resize: none; - min-height: 0; - max-height: calc(6em + 20px); /* ~4 lines (1.5em each) + padding/border */ - overflow-y: auto; - line-height: 1.5; -} - -.mode-select { display: flex; } -.mode-select button { - border-radius: 0; - border-right-width: 0; - padding: 7px 14px; - color: var(--text-dim); - text-transform: capitalize; -} -.mode-select button:first-child { border-radius: 8px 0 0 8px; } -.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; } -.mode-select button.active { - color: var(--accent-bright); - background: var(--bg-panel); - border-color: var(--accent-dim); - border-right-width: 1px; - box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08); -} -.mode-select button.active + button { border-left-width: 0; } - -/* ---------- Side panel (Plot / Insights) ---------- */ - -.play-layout { flex: 1; display: flex; min-height: calc(100dvh - 56px); } -.play-layout .play-page { max-width: 960px; } -.play-layout.with-panel .play-page { max-width: none; } - -.panel-toggles { display: flex; gap: 8px; } -.panel-toggles button.active { - color: var(--accent-bright); - border-color: var(--accent-dim); - box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08); -} - -.side-panel { - width: 420px; - flex-shrink: 0; - border-left: 1px solid var(--border); - background: var(--bg-panel); - padding: 20px; - overflow-y: auto; - height: calc(100dvh - 56px); - position: sticky; - top: 56px; - animation: panel-in 0.25s ease both; -} - -/* ---------- In-play script viewer ---------- */ - -.adv-script { - border-bottom: 1px solid var(--border); - padding: 10px 0; -} -.adv-script-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.adv-script-desc { font-size: 0.82rem; margin-top: 4px; } -.linklike { - border: none; - background: none; - padding: 0; - color: var(--accent-bright); - font-size: 0.8rem; - cursor: pointer; - flex-shrink: 0; -} -.linklike:hover { text-decoration: underline; } -.adv-script-code { margin-top: 8px; } -.adv-script-hook { - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-dim); - margin: 8px 0 2px; -} -.adv-script-code pre { - margin: 0; - padding: 10px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 6px; - overflow-x: auto; - font-family: var(--font-mono, monospace); - font-size: 0.76rem; - line-height: 1.5; - white-space: pre; -} - -/* ---------- Left status drawer (script state) ---------- */ - -.status-drawer { - flex-shrink: 0; - position: sticky; - top: 56px; - height: calc(100dvh - 56px); - display: flex; - align-items: flex-start; - border-right: 1px solid var(--border); - background: var(--bg-panel); -} -.status-drawer.open { width: 300px; } -.status-toggle { - flex-shrink: 0; - height: 100%; - width: 30px; - padding: 12px 0; - border: none; - border-radius: 0; - background: transparent; - color: var(--text-dim); - cursor: pointer; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} -.status-toggle:hover { color: var(--text); background: var(--bg-input); } -.status-toggle-label { - writing-mode: vertical-rl; - text-orientation: mixed; - letter-spacing: 0.12em; - font-size: 0.72rem; - text-transform: uppercase; -} -.status-body { - flex: 1; - min-width: 0; - height: 100%; - overflow-y: auto; - padding: 20px 16px; - animation: panel-in 0.2s ease both; -} -.status-vars { list-style: none; margin: 0; padding: 0; } -.status-vars li { - display: flex; - flex-direction: column; - gap: 2px; - padding: 8px 0; - border-bottom: 1px solid var(--border); -} -.status-key { - font-family: var(--font-mono, monospace); - font-size: 0.82rem; - color: var(--accent-bright); - word-break: break-word; -} -.status-val { - font-family: var(--font-mono, monospace); - font-size: 0.82rem; - color: var(--text); - white-space: pre-wrap; - word-break: break-word; -} -/* Recursive script-state value tree */ -.jv-string { color: var(--text); } -.jv-number { color: var(--player); } -.jv-bool { color: var(--accent-bright); } -.jv-null { color: var(--text-dim); font-style: italic; } -.jv-empty { color: var(--text-dim); } -.jv-tree { display: inline-block; vertical-align: top; max-width: 100%; } -.jv-toggle { - background: none; border: none; padding: 0; cursor: pointer; - font-family: inherit; font-size: 0.72rem; color: var(--text-dim); -} -.jv-toggle:hover { color: var(--accent-bright); } -.jv-children { - list-style: none; margin: 2px 0 2px 0; padding-left: 12px; - border-left: 1px solid var(--border); -} -.jv-children li { padding: 2px 0; word-break: break-word; } -.jv-key { color: var(--accent); margin-right: 6px; } -.jv-key::after { content: ':'; color: var(--text-dim); } -@keyframes panel-in { - from { opacity: 0; transform: translateX(16px); } - to { opacity: 1; transform: none; } -} - -/* ---------- World State drawer (Phase 12 RPG) ---------- */ -.ws-group { margin-bottom: 18px; } -.ws-group-title { - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-dim); - margin: 0 0 8px; -} -.ws-stat { padding: 5px 0; } -.ws-stat-head { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 8px; - font-size: 0.85rem; -} -.ws-stat-name { color: var(--text); text-transform: capitalize; } -.ws-stat-val { - color: var(--text-dim); - font-variant-numeric: tabular-nums; - font-size: 0.8rem; - text-align: right; -} -.ws-band { color: var(--accent-bright); } -.ws-bar { - margin-top: 4px; - height: 5px; - border-radius: 3px; - background: var(--bg-input); - overflow: hidden; -} -.ws-bar-fill { - height: 100%; - border-radius: 3px; - background: var(--accent); - transition: width 0.35s ease; -} -/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of - five doesn't read as five more unrelated sections in the rail. */ -.ws-npc { - padding: 7px 9px; - margin-bottom: 7px; - border: 1px solid var(--border); - border-left: 2px solid var(--accent-dim); - border-radius: 7px; - background: rgba(255, 255, 255, 0.022); -} -.ws-npc:last-child { margin-bottom: 0; } -.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; } -.ws-npc-avatar { - flex-shrink: 0; - width: 22px; height: 22px; - border-radius: 50%; - display: grid; place-items: center; - font-family: var(--font-display); - font-size: 0.6rem; - color: #14100a; - background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim)); -} -.ws-npc-name { - font-family: var(--font-display); - font-size: 0.85rem; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; } -.ws-milestones { list-style: none; margin: 0; padding: 0; } -.ws-milestones li { - display: flex; - gap: 7px; - align-items: flex-start; - padding: 4px 0; - font-size: 0.85rem; - color: var(--text); -} -.ws-milestones li.done { color: var(--text-dim); } -.ws-check { flex-shrink: 0; color: var(--accent-bright); } -.ws-milestones li.done .ws-check { color: var(--player); } -.ws-flags { list-style: none; margin: 0; padding: 0; } -.ws-flags li { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 0; - font-size: 0.85rem; - color: var(--text); -} -.ws-flag-dot { - width: 9px; height: 9px; border-radius: 50%; - flex-shrink: 0; - background: var(--bg-input); - border: 1px solid var(--border); -} -.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); } -.ws-flag-name { flex: 1; } -.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; } -.ws-flag-edit { display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%; } - -.ws-edit-actions { display: flex; gap: 6px; } -.ws-edit-input { - width: 100%; - margin-top: 3px; - padding: 4px 7px; - font-size: 0.82rem; - border-radius: 6px; -} - -.schema-editor { - width: 100%; - font-family: var(--font-mono, monospace); - font-size: 0.82rem; - line-height: 1.5; - tab-size: 2; - white-space: pre; - overflow-x: auto; -} -.schema-error { - margin-top: 6px; - color: var(--danger, #e5484d); - font-size: 0.82rem; -} -.schema-json-actions { - display: flex; - align-items: center; - gap: 12px; - margin-top: 8px; -} -.schema-json-actions .schema-error { margin-top: 0; } -.schema-preview { - margin-top: 14px; - padding: 14px 16px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-panel); -} -.schema-preview-title { - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--text-dim); - margin-bottom: 10px; -} -.schema-group { margin-bottom: 16px; } -.schema-group:last-child { margin-bottom: 0; } -.schema-group-title { - font-family: Georgia, serif; - font-size: 0.98rem; - color: var(--accent-bright); - margin-bottom: 6px; -} -.schema-npc-id { - font-family: var(--font-mono, monospace); - font-size: 0.72rem; - color: var(--text-dim); - margin-left: 6px; -} -.schema-stat { padding: 4px 0; border-bottom: 1px solid var(--border); } -.schema-stat:last-child { border-bottom: none; } -.schema-stat-head { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; -} -.schema-stat-name { color: var(--text); text-transform: capitalize; } -.schema-stat-meta { - color: var(--text-dim); - font-size: 0.8rem; - font-variant-numeric: tabular-nums; - text-align: right; -} -.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; } -.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; } - -/* ---- Form-based schema editor ---- - One shape for everything: a section is a panel, every entry inside it is a - `.se-row` tile, and every control wears a `.se-field` caption. Adding a new - kind of entry means reusing those three, not inventing a fourth box. */ -.se-section { - margin-bottom: 16px; - padding: 13px 15px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-panel); -} -.se-section-nested { - margin: 10px 0 0; - padding: 11px 12px; - background: rgba(0, 0, 0, 0.22); - border-radius: 8px; -} -.se-section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 8px; -} -.se-section-title { - font-size: 0.74rem; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--accent); -} -.se-hint { - margin: -2px 0 10px; - font-size: 0.78rem; - line-height: 1.45; - color: var(--text-dim); -} -.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; } -.se-add, .se-add-sm { - font-size: 0.78rem; - padding: 3px 10px; - white-space: nowrap; -} -.se-add-sm { margin-top: 6px; } -.se-remove { - background: transparent; - border: none; - color: var(--text-dim); - cursor: pointer; - padding: 2px 6px; - font-size: 0.9rem; - line-height: 1; - flex-shrink: 0; -} -.se-remove:hover { color: var(--danger, #e5484d); } - -/* Every control in the editor, one look. The global field rule keys off - `input[type="…"]`, so the plain ``s here (keys, descriptions) never - matched it and fell back to browser defaults — that mismatch is most of why - this panel used to read as a pile of unrelated boxes. Everything below is - qualified with `.se` so it outranks that global rule (0,1,1) too. */ -/* `:where()` so the exclusion costs no specificity — a plain - `:not([type="checkbox"])` would rank this above the `.se .se-*` overrides - below and swallow every one of them. */ -.se input:not(:where([type="checkbox"])), .se textarea, .se select { - width: 100%; - background: var(--bg-input); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - padding: 5px 8px; - font-size: 0.84rem; - font-family: inherit; - transition: border-color 0.15s, box-shadow 0.15s; -} - -/* One entry — a stat, a flag, a milestone. */ -.se-row { - padding: 10px 12px; - margin-bottom: 8px; - border: 1px solid var(--border); - border-radius: 8px; - background: rgba(255, 255, 255, 0.018); - transition: border-color 0.15s; -} -.se-row:last-child { margin-bottom: 0; } -.se-row:focus-within { border-color: var(--border-bright); } -/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and - an input's default `min-width: auto` would push the ✕ off-screen instead. */ -.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; } -.se .se-key { - font-family: var(--font-mono, monospace); - font-size: 0.82rem; - min-width: 0; - max-width: 220px; -} -/* Only in the row context — `.se-field` is a *column* flex, where a basis of - 140px would set the input's height instead of its width. */ -.se .se-row-top .se-key { flex: 1 1 140px; } -.se-field { display: flex; flex-direction: column; gap: 3px; } -.se-field > span { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.09em; - color: var(--text-dim); -} -.se-fields { - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - align-items: flex-end; - margin-bottom: 8px; -} -.se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; } -.se .se-kind select { width: 132px; } -.se .se-initial-text input { width: 220px; max-width: 100%; } -.se-check { - display: flex; align-items: center; gap: 5px; - font-size: 0.8rem; color: var(--text-dim); - white-space: nowrap; -} -.se-check input { width: auto; } -/* The global textarea rule also forces min-height:70px, which a 2-row inline - field doesn't want. */ -.se .se-area { min-height: 0; line-height: 1.45; resize: vertical; } -.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; } -.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; } -/* Qualified so it outweighs the global input[type="number"] rule (0,1,1) — - as a bare class it lost, and the band bounds rendered full-width. */ -.se .se-band-n { width: 64px; padding: 4px 6px; } -.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; } -.se-bands { margin-top: 8px; } - -/* ---- NPC roster (its own page section) ---- */ -.se-npc-grid { - display: grid; - /* min() so a 320px phone gets one column instead of a 340px overflow */ - grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr)); - gap: 14px; - align-items: start; -} -.se-npc { - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-panel); - overflow: hidden; -} -.se-npc:focus-within { border-color: var(--border-bright); } -.se-npc-head { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-bottom: 1px solid var(--border); - background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent); -} -.se-avatar { - flex-shrink: 0; - width: 34px; height: 34px; - border-radius: 50%; - display: grid; place-items: center; - font-family: var(--font-display); - font-size: 0.8rem; - letter-spacing: 0.02em; - color: #14100a; - background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim)); -} -.se-npc-ident { flex: 1; min-width: 0; } -.se .se-npc-name { - padding: 2px 6px; - font-family: var(--font-display); - font-size: 0.95rem; - background: transparent; - border-color: transparent; -} -.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); } -.se-npc-addr { - display: block; - margin: 1px 0 0 6px; - font-family: var(--font-mono, monospace); - font-size: 0.7rem; - color: var(--text-dim); -} -.se-npc-count { - flex-shrink: 0; - font-size: 0.7rem; - color: var(--text-dim); - white-space: nowrap; -} -.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; } -.se-npc-idfield .se-key { max-width: 100%; } -.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; } -.ws-report li { padding: 2px 0; color: var(--text); } - -/* Inline per-turn change chips under an AI message */ -.turn-changes { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 8px; -} -.chg { - font-size: 0.74rem; - padding: 2px 8px; - border-radius: 10px; - border: 1px solid var(--border); - background: var(--bg-input); - color: var(--text-dim); - font-variant-numeric: tabular-nums; - /* A chip must never be wider than the column it sits in: an NPC stat like - "bandit leader aggression +10" overruns a phone, and the chip row is - inside the story text, which has nothing to scroll. The label may break - mid-word as a last resort; the value is glued to it by its own nowrap so - the sign and number never end up on separate lines. */ - max-width: 100%; - overflow-wrap: anywhere; -} -.chg-val { white-space: nowrap; } -.chg-up { color: var(--player, #4caf82); border-color: color-mix(in srgb, var(--player, #4caf82) 40%, var(--border)); } -.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); } -.chg-flag { color: var(--accent-bright); } -.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); } -/* A refused change, and a clamp that left the value where it started. The - dashed border separates it from the changes that took effect. It is not - styled as an error, because the rules refusing a change is them working. */ -.chg-refused { - border-style: dashed; - color: var(--text-dim); - border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); -} -/* A clamp that reduced a change but still moved the value. */ -.chg-limited { opacity: 0.75; font-style: italic; } -.ws-report code { color: var(--accent-bright); } -.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; } -.ws-flag { color: var(--text-dim); font-style: italic; } -.ws-rejected { color: var(--text-dim); } -.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } -.side-panel-header h2 { - margin: 0; - font-family: var(--font-display); - font-size: 1.05rem; - color: var(--accent); -} - -/* ---------- Insights ---------- */ - -.insights-meta { margin-bottom: 16px; font-size: 0.85rem; } -.insights-mode { color: var(--text-dim); margin-bottom: 8px; } -.insights-mode button { padding: 2px 8px; font-size: 0.75rem; } -.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; } -.token-total.over { color: var(--danger); } -/* Stacked share-of-context bar. Segment colours come from SECTION_COLORS in - Play.jsx so the bar, its legend and each section header stay in sync. */ -.token-split { - display: flex; - height: 14px; - background: var(--bg-input); - border-radius: 4px; - overflow: hidden; - margin-bottom: 8px; -} -.token-split.over { box-shadow: 0 0 0 1px var(--danger); } -.token-seg { - min-width: 2px; /* a 0.2% section is still worth seeing */ - border-right: 1px solid var(--bg-panel); - transition: opacity 0.15s ease; -} -.token-seg:last-child { border-right: none; } -.token-seg-free { background: transparent; min-width: 0; border-right: none; } -.token-seg.faded, .token-legend-item.faded { opacity: 0.25; } - -.token-legend { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: 1px 12px; - margin-bottom: 10px; -} -.token-legend-item { - display: flex; - align-items: center; - gap: 7px; - background: none; - border: none; - border-radius: 4px; - padding: 2px 5px; - font-size: 0.78rem; - color: var(--text-dim); - text-align: left; - transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; -} -.token-legend-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); } -.token-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; } -.token-legend-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.token-legend-pct { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } -.token-legend-tok { - width: 4.5em; - text-align: right; - opacity: 0.65; - font-variant-numeric: tabular-nums; -} -.token-legend-more { - justify-self: start; - background: none; - border: none; - padding: 2px 5px; - font-size: 0.75rem; - color: var(--text-dim); -} -.token-legend-more:hover { color: var(--accent); background: none; } -.insights-history { color: var(--text-dim); margin-bottom: 8px; } -.insights-cards div { margin-bottom: 3px; } -.insights-cards .dropped { color: var(--danger); } - -.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; } -.ctx-section .ctx-header { - display: flex; - justify-content: space-between; - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-dim); - padding: 5px 10px 3px; -} -.ctx-section pre { - margin: 0; - padding: 6px 10px 8px; - white-space: pre-wrap; - word-break: break-word; - font-family: Consolas, monospace; - font-size: 0.8rem; - line-height: 1.5; - color: var(--text); - max-height: 260px; - overflow-y: auto; -} -/* Per-section border/header colours are applied inline from SECTION_COLORS - (Play.jsx) — one palette drives the bar, the legend and these headers. */ - -/* ---------- Scripts ---------- */ - -.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; } -.slot-tabs button { - border-radius: 0; - border-right-width: 0; - color: var(--text-dim); -} -.slot-tabs button:first-child { border-radius: 8px 0 0 8px; } -.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; } -.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); } -.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; } - -.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; } -.cm-editor.cm-focused { - outline: none; - border-color: var(--accent-dim); - box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1); -} - -.test-run .row { display: flex; gap: 12px; } -.test-result { - margin-top: 12px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 12px; - font-size: 0.85rem; -} -.test-result .label { - text-transform: uppercase; - font-size: 0.7rem; - letter-spacing: 0.08em; - color: var(--text-dim); - margin-right: 6px; -} -.test-result pre { - display: inline-block; - margin: 2px 0; - white-space: pre-wrap; - word-break: break-word; - font-family: Consolas, monospace; - vertical-align: top; -} -.test-error { color: var(--danger); white-space: pre-wrap; } - -.script-attach { - display: flex; - align-items: baseline; - gap: 8px; - padding: 9px 12px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - margin-bottom: 6px; - cursor: pointer; - transition: border-color 0.15s; -} -.script-attach:hover { border-color: var(--border-bright); } -.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; } - -/* ---------- Memory bank (Phase 6) ---------- */ - -.memory-row { - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - padding: 9px 12px; - margin-bottom: 6px; -} -.memory-row.forgotten { opacity: 0.55; } -.memory-text { font-size: 0.9rem; white-space: pre-wrap; } -.memory-meta { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 10px; - margin-top: 5px; - font-size: 0.78rem; -} -.memory-meta .dim { color: var(--text-dim); } -.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; } -.memory-meta .action-tools button { - padding: 1px 8px; - font-size: 0.72rem; - color: var(--text-dim); -} -.memory-meta .action-tools button:hover { color: var(--text); } -.memory-badge { - color: var(--accent); - border: 1px solid var(--border); - border-radius: 999px; - padding: 0 8px; -} - -.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; } -.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; } -.script-logs { - background: var(--bg-input); - border-radius: 8px; - padding: 8px 10px; - font-family: Consolas, monospace; - font-size: 0.8rem; - white-space: pre-wrap; - word-break: break-word; - margin: 6px 0; -} - -/* ---------- Filter bar & tags ---------- */ - -.filter-bar { margin-bottom: 18px; } -.search-input { max-width: 340px; } -.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } -.tag { - padding: 2px 12px; - font-size: 0.76rem; - border-radius: 999px; - color: var(--text-dim); -} -.tag.active { - color: var(--accent-bright); - border-color: var(--accent-dim); - box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1); -} -.tag.small { - display: inline-block; - background: var(--bg-input); - border: 1px solid var(--border); - padding: 1px 9px; - margin-right: 4px; - cursor: default; -} - -/* ---------- Modal ---------- */ - -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(5, 5, 9, 0.7); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 50; - animation: page-in 0.2s ease both; -} -.modal { - width: min(480px, calc(100vw - 40px)); - max-height: 80vh; - overflow-y: auto; - background: var(--bg-panel); - border: 1px solid var(--border-bright); - border-radius: 14px; - padding: 26px 28px; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06); -} -.modal h2 { - margin: 0 0 4px; - font-family: var(--font-display); - font-size: 1.2rem; - color: var(--accent-bright); -} -.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; } -.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } - -/* ---------- Auth (log in / sign up) ---------- */ - -.auth-modal { width: min(400px, calc(100vw - 40px)); text-align: center; padding-top: 22px; } -.auth-crest { - font-size: 1.1rem; - color: var(--accent-dim); - letter-spacing: 0.3em; - margin-bottom: 6px; -} -.auth-modal h2 { font-size: 1.35rem; } -.auth-modal .auth-hint { margin-bottom: 16px; line-height: 1.45; } -/* Fields go back to left-aligned — only the header is centred. */ -.auth-modal label.field { text-align: left; } - -.auth-tabs { - display: flex; - gap: 4px; - padding: 4px; - margin-bottom: 18px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 10px; -} -.auth-tab { - flex: 1; - padding: 7px 0; - border: 1px solid transparent; - border-radius: 7px; - background: transparent; - color: var(--text-dim); - font-family: var(--font-display); - font-size: 0.78rem; - letter-spacing: 0.1em; - text-transform: uppercase; -} -.auth-tab:hover { color: var(--text); background: rgba(212, 169, 78, 0.06); } -.auth-tab.active { - background: var(--bg-panel); - border-color: var(--accent-dim); - color: var(--accent-bright); -} - -.auth-password { display: flex; gap: 8px; } -.auth-password input { flex: 1; min-width: 0; } -.auth-reveal { - flex: none; - padding: 0 12px; - font-size: 0.78rem; - color: var(--text-dim); -} -.auth-reveal:hover { color: var(--accent-bright); } - -.auth-help { - display: block; - margin-top: 6px; - font-size: 0.76rem; - color: var(--text-dim); -} -.auth-error { - margin-top: 4px; - padding: 9px 12px; - border: 1px solid rgba(208, 101, 101, 0.45); - border-radius: 8px; - background: rgba(208, 101, 101, 0.09); - color: var(--danger); - font-size: 0.85rem; - text-align: left; - white-space: pre-wrap; -} - -/* "Update from scenario" — a wider modal, since it lists a diff. */ -.refresh-modal { width: min(620px, calc(100vw - 40px)); } -.refresh-changes { - border: 1px solid var(--border); - border-radius: 10px; - padding: 12px 14px; - font-size: 0.86rem; -} -.refresh-change + .refresh-change { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--border); -} -.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; } -.refresh-old { text-decoration: line-through; } -.refresh-new { color: var(--accent-bright); } -/* The button sits above the plot fields it would overwrite. */ -.plot-source { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 1px solid var(--border); - font-size: 0.82rem; -} -.plot-source .linklike { flex-shrink: 0; } - -/* ---------- Debug log (Settings) ---------- */ - -.debug-entry { - border: 1px solid var(--border); - border-radius: 8px; - margin-bottom: 8px; - overflow: hidden; -} -.debug-entry-head { - display: flex; - align-items: baseline; - gap: 12px; - padding: 9px 12px; - cursor: pointer; - font-size: 0.86rem; - background: var(--bg-input); -} -.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; } -.debug-status { - font-size: 0.68rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - padding: 1px 8px; - border-radius: 999px; - border: 1px solid var(--border-bright); - color: var(--text-dim); -} -.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; } -.debug-status.error { color: var(--danger); border-color: var(--danger); } -.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); } -.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; } -.debug-entry-body pre { - margin: 2px 0 8px; - padding: 8px 10px; - background: var(--bg-input); - border-radius: 6px; - white-space: pre-wrap; - word-break: break-word; - font-family: Consolas, monospace; - font-size: 0.78rem; - max-height: 300px; - overflow-y: auto; -} - -/* ---------- Play screen error/success banner ---------- - Persistent (no auto-dismiss) and can carry a Retry action, so it stays - separate from the transient global toasts further down this file. */ - -.play-toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - background: var(--bg-panel-glass); - backdrop-filter: blur(10px); - border: 1px solid var(--danger); - color: var(--text); - padding: 11px 20px; - border-radius: 10px; - z-index: 100; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); - animation: toast-in 0.25s ease both; -} -.play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); } -@keyframes toast-in { - from { opacity: 0; transform: translate(-50%, 12px); } - to { opacity: 1; transform: translate(-50%, 0); } -} - -/* ============================================================ - Illuminated tome — ambient layer, ornament, art plates, cards - Everything below styles the home/library surfaces. It comes after - the base .card rules on purpose: several selectors here tie with - earlier ones on specificity and rely on source order to win. - ============================================================ */ - -/* ---------- Ambient drifting motes ---------- */ - -/* Behind every page: fixed, unclickable, and out of the stacking flow. The - canvas is sized in JS (see Embers.jsx); z-index 0 with the app's own - surfaces at 1+ keeps it under content without needing a negative index - (which would drop it behind the body background). */ -.embers { - position: fixed; - inset: 0; - z-index: 0; - pointer-events: none; - opacity: 0.85; -} -/* .topnav already carries z-index 10 — deliberately not touched here, so it - keeps sitting above page content as well as above the canvas. */ -.page { position: relative; z-index: 1; } - -/* A third gradient pool warming the middle of the ground. `background` is a - shorthand, so background-attachment has to be restated or it resets to - scroll and the gradients start sliding with the page. */ -body { - background: - radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.07), transparent 60%), - radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.1), transparent 55%), - radial-gradient(760px 520px at 50% 42%, rgba(150, 119, 58, 0.05), transparent 62%), - var(--bg); - background-attachment: fixed; -} - -/* ---------- Home banner ---------- */ - -.hall { - display: flex; - flex-direction: column; - gap: 5px; - padding: 6px 0 26px; - margin-bottom: 4px; - border-bottom: 1px solid var(--border); - position: relative; -} -/* Gold hairline that fades out toward both ends, sat on the border above. */ -.hall::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(90deg, transparent, var(--accent-dim) 22%, var(--accent-dim) 78%, transparent); - opacity: 0.55; -} -.hall-eyebrow { - margin: 0; - font-size: 0.68rem; - letter-spacing: 0.24em; - text-transform: uppercase; - color: var(--accent-dim); - font-weight: 600; -} -.hall-title { - margin: 0; - font-family: var(--font-display); - font-size: clamp(1.7rem, 4.6vw, 2.3rem); - font-weight: 600; - letter-spacing: 0.05em; - color: var(--accent-bright); - text-shadow: 0 0 30px var(--accent-glow); - text-wrap: balance; -} -.hall-sub { - margin: 0; - color: var(--text-dim); - font-family: var(--font-story); - font-size: 1.02rem; -} - -/* ---------- Ornamental section rules ---------- */ - -.home-section { margin-top: 34px; } - -.rule-head { - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 16px; -} -.rule { - flex: 1; - display: flex; - align-items: center; - gap: 14px; - margin: 0; - font-family: var(--font-display); - font-size: 0.82rem; - font-weight: 600; - letter-spacing: 0.26em; - text-transform: uppercase; - color: var(--accent-bright); - white-space: nowrap; -} -/* Rules grow to fill whatever the label leaves — the label stays optically - centred in its own space without needing a fixed width. */ -.rule::before, .rule::after { - content: ''; - height: 1px; - flex: 1; - background: linear-gradient(90deg, transparent, var(--accent-dim) 45%, var(--accent-dim) 55%, transparent); -} -.rule::before { flex: 0 0 26px; } -.rule > span { flex: none; } - -button.linklike.ornate { - font-family: var(--font-display); - font-size: 0.74rem; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--accent-dim); - white-space: nowrap; - transition: color 0.15s; -} -button.linklike.ornate:hover { color: var(--accent-bright); text-decoration: none; } - -/* ---------- Art plates ---------- */ - -.art { - width: 38px; - height: 38px; - flex: none; - border-radius: 3px; - border: 1px solid var(--accent-dim); - box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7); - overflow: hidden; - display: grid; - place-items: center; - line-height: 1; -} -.art-lg { width: 46px; height: 46px; } -.art-photo img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} -.art-icon { font-size: 1.15rem; } -.art-lg.art-icon { font-size: 1.4rem; } -.art-mono { - font-family: var(--font-display); - font-size: 0.82rem; - font-weight: 700; - letter-spacing: 0.06em; - color: rgba(232, 196, 118, 0.92); - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.65); -} -.art-lg.art-mono { font-size: 0.95rem; } - -/* ---------- Tome cards ---------- */ - -.card-grid.wide { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } - -.card.tome { - border-radius: 3px; - padding: 16px 17px 14px; - background: - linear-gradient(168deg, rgba(212, 169, 78, 0.05), transparent 55%), - var(--bg-panel); - /* Inner dark gutter + a gold keyline: the double-rule border of a plate in - an old book, done with shadows so it costs no extra element. */ - box-shadow: - inset 0 0 0 1px rgba(10, 10, 15, 0.9), - inset 0 0 0 2px rgba(150, 119, 58, 0.22); -} - -/* Filigree corner brackets. These override the base .card::before gradient - bar (equal specificity, later in the file wins). */ -.card.tome::before, -.card.tome::after { - content: ''; - position: absolute; - width: 12px; - height: 12px; - border: 1px solid var(--accent-dim); - opacity: 0.5; - transition: opacity 0.2s; - background: none; - inset: auto; -} -.card.tome::before { - top: 6px; - left: 6px; - border-right: 0; - border-bottom: 0; -} -.card.tome::after { - bottom: 6px; - right: 6px; - border-left: 0; - border-top: 0; -} -.card.tome:hover::before, -.card.tome:hover::after { opacity: 1; } -.card.tome:hover { - border-color: var(--accent-dim); - box-shadow: - inset 0 0 0 1px rgba(10, 10, 15, 0.9), - inset 0 0 0 2px rgba(212, 169, 78, 0.4), - 0 12px 30px rgba(0, 0, 0, 0.5), - 0 0 26px rgba(212, 169, 78, 0.08); -} - -.card-head { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; } -/* Keep headings clear of the wax seal, which is absolutely positioned over - the card's top-right corner. Only cards that actually carry one pay the - indent, so scenario cards keep their full width. */ -.card.tome:has(> .seal) .card-head { padding-right: 26px; } -.card-headings { min-width: 0; } -.card.tome h3 { - margin: 0; - font-family: var(--font-display); - font-size: 1rem; - font-weight: 600; - letter-spacing: 0.045em; - color: var(--accent); - overflow-wrap: anywhere; -} -.card-from { - margin: 3px 0 0; - font-size: 0.75rem; - color: var(--text-dim); - overflow-wrap: anywhere; -} - -/* Wax seal marking a story in progress. */ -.seal { - position: absolute; - top: 12px; - right: 12px; - width: 24px; - height: 24px; - border-radius: 50%; - background: radial-gradient(circle at 34% 30%, #b8553f, #7d2f24 72%); - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6), inset 0 -1px 3px rgba(0, 0, 0, 0.45); - display: grid; - place-items: center; - font-size: 0.6rem; - color: #f0d5b0; - z-index: 1; -} - -.snippet { - margin: 0; - font-family: var(--font-story); - font-size: 0.98rem; - line-height: 1.5; - color: #b3ac9c; - /* Clamp so a long beat can't stretch one card taller than its row. */ - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} -.snippet.muted { color: var(--text-dim); font-style: italic; } -.snippet.dropcap::first-letter { - float: left; - font-family: var(--font-display); - font-size: 2.3rem; - line-height: 0.85; - font-weight: 700; - color: var(--accent); - padding: 3px 7px 0 0; -} - -.card-foot { - display: flex; - align-items: center; - gap: 8px; - margin-top: 13px; - padding-top: 11px; - border-top: 1px solid var(--border); - font-size: 0.74rem; - color: var(--text-dim); - flex-wrap: wrap; -} -.card-foot .turns { font-variant-numeric: tabular-nums; } -.card-foot .dot { opacity: 0.5; } -.card-foot .resume { - margin-left: auto; - color: var(--accent-dim); - font-family: var(--font-ui); - letter-spacing: 0.1em; - text-transform: uppercase; - font-size: 0.68rem; - font-weight: 600; - transition: color 0.15s; -} -.card.tome:hover .resume { color: var(--accent-bright); } -.tag-cluster { display: flex; gap: 5px; flex-wrap: wrap; min-width: 0; } -.card-actions { margin-left: auto; display: inline-flex; gap: 5px; } - -button.tiny { padding: 2px 9px; font-size: 0.72rem; } -button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: auto; } - -/* Staggered card entrance. The per-card delay is set inline. */ -.enter { animation: card-in 0.4s ease both; } -@keyframes card-in { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: none; } -} - -/* ---------- Loading skeletons ---------- */ - -.skeleton-card { - cursor: default; - pointer-events: none; - animation: card-in 0.3s ease both; -} -.skeleton-card::before, .skeleton-card::after { content: none; } -.skeleton-card:hover { transform: none; border-color: var(--border); box-shadow: none; } -.sk { - border-radius: 3px; - background: linear-gradient(90deg, #1c1c2b 25%, #262639 50%, #1c1c2b 75%); - background-size: 220% 100%; - animation: shimmer 1.4s ease-in-out infinite; -} -.sk-plate { width: 38px; height: 38px; margin-bottom: 12px; } -.sk-title { height: 13px; width: 58%; margin-bottom: 12px; } -.sk-line { height: 9px; margin-bottom: 8px; } -@keyframes shimmer { - from { background-position: 120% 0; } - to { background-position: -120% 0; } -} - -/* ---------- Global toasts ---------- */ - -.toast-host { - position: fixed; - bottom: 22px; - left: 50%; - transform: translateX(-50%); - z-index: 120; - display: flex; - flex-direction: column-reverse; - gap: 8px; - align-items: center; - width: max-content; - max-width: min(92vw, 520px); - pointer-events: none; -} -.toast { - pointer-events: auto; - display: flex; - align-items: flex-start; - gap: 9px; - text-align: left; - font-family: var(--font-ui); - font-size: 0.86rem; - line-height: 1.45; - padding: 10px 16px; - border-radius: 8px; - background: var(--bg-panel-glass); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--border-bright); - border-left: 3px solid var(--accent); - color: var(--text); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); - animation: rise 0.25s ease both; -} -.toast:hover { border-color: var(--accent-dim); } -.toast-error { border-left-color: var(--danger); } -.toast-mark { color: var(--accent); flex: none; line-height: 1.5; } -.toast-error .toast-mark { color: var(--danger); } -@keyframes rise { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: none; } -} - -/* ---------- Story text polish ---------- */ - -/* Drop cap opening the story's first narrated beat. */ -.story .action.opening::first-letter { - float: left; - font-family: var(--font-display); - font-size: 3.1rem; - line-height: 0.82; - font-weight: 700; - color: var(--accent); - padding: 5px 10px 0 2px; - text-shadow: 0 0 22px var(--accent-glow); -} - -/* Ornamental separator between turns — a diamond flanked by fading rules. - Marks the beat change without the heaviness of a full
. */ -.scene-break { - display: flex; - align-items: center; - gap: 12px; - margin: 4px 0 16px; - color: var(--accent-dim); - font-size: 0.6rem; - user-select: none; -} -.scene-break::before, .scene-break::after { - content: ''; - height: 1px; - flex: 1; - background: linear-gradient(90deg, transparent, rgba(150, 119, 58, 0.4), transparent); -} - -/* Thinking indicator — three motes pulsing in sequence. */ -.thinking { - display: inline-flex; - align-items: center; - gap: 10px; - font-family: var(--font-ui); - font-size: 0.78rem; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--accent-dim); -} -.thinking i { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 8px var(--accent-glow); - animation: mote 1.25s ease-in-out infinite; -} -.thinking i:nth-child(2) { animation-delay: 0.18s; } -.thinking i:nth-child(3) { animation-delay: 0.36s; } -@keyframes mote { - 0%, 100% { opacity: 0.25; transform: scale(0.8); } - 45% { opacity: 1; transform: scale(1.15); } -} - -/* ---------- Scenario art picker (editor) ---------- */ - -.art-picker { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; } -.art-preview { - width: 76px; - height: 76px; - flex: none; - border-radius: 3px; - border: 1px solid var(--accent-dim); - box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7); - overflow: hidden; - display: grid; - place-items: center; -} -.art-preview img { width: 100%; height: 100%; object-fit: cover; display: block; } -.art-preview .glyph { font-size: 2rem; line-height: 1; } -.art-preview .mono { - font-family: var(--font-display); - font-size: 1.4rem; - font-weight: 700; - color: rgba(232, 196, 118, 0.92); -} -.art-controls { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 9px; } -.art-buttons { display: flex; gap: 8px; flex-wrap: wrap; } -.icon-row { display: flex; gap: 5px; flex-wrap: wrap; } -.icon-row button { - padding: 3px 8px; - font-size: 1rem; - line-height: 1.2; - min-width: 34px; -} -.icon-row button.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); } -.art-hint { font-size: 0.76rem; color: var(--text-dim); line-height: 1.45; margin: 0; } - -/* ---------- AI Chat (power users) ---------- - A plain chat scratchpad, styled as a transcript rather than as the story - page: UI font, role labels, and a sticky composer at the bottom. */ - -.chat-page { - max-width: 820px; - display: flex; - flex-direction: column; - margin-bottom: var(--kb-inset, 0px); /* see .play-page */ -} -.chat-meta { - font-size: 0.78rem; - margin: -10px 0 16px; - word-break: break-word; -} -.chat-options { - border: 1px solid var(--border); - border-radius: 10px; - padding: 14px 16px 4px; - margin-bottom: 18px; - background: var(--bg-panel); -} - -.chat-transcript { flex: 1; display: flex; flex-direction: column; gap: 16px; padding-bottom: 20px; } - -.chat-msg { - border: 1px solid var(--border); - border-radius: 10px; - padding: 10px 14px 12px; - background: var(--bg-panel); -} -.chat-msg.user { border-left: 3px solid var(--player); background: var(--bg-input); } -.chat-msg.assistant { border-left: 3px solid var(--accent-dim); } -.chat-msg-head { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 6px; - font-size: 0.72rem; - letter-spacing: 0.14em; - text-transform: uppercase; -} -.chat-msg .chat-role { color: var(--accent-dim); font-weight: 600; } -.chat-msg.user .chat-role { color: var(--player); } -.chat-model-tag { text-transform: none; letter-spacing: 0; font-size: 0.74rem; } -.chat-msg-actions { - margin-left: auto; - display: flex; - gap: 12px; - opacity: 0; - transition: opacity 0.15s; -} -.chat-msg:hover .chat-msg-actions, .chat-msg:focus-within .chat-msg-actions { opacity: 1; } -.chat-msg-body { - white-space: pre-wrap; - overflow-wrap: anywhere; - line-height: 1.6; - font-size: 0.95rem; -} -.chat-msg .cursor { - color: var(--accent-bright); - text-shadow: 0 0 8px var(--accent-glow); - animation: blink 1s steps(1) infinite; -} - -/* Same collapsed thinking block as the story page, re-scoped for the transcript. */ -.chat-msg .reasoning { margin: 0 0 8px; font-size: 0.82rem; line-height: 1.5; color: var(--text-dim); } -.chat-msg .reasoning summary { cursor: pointer; user-select: none; opacity: 0.8; } -.chat-msg .reasoning summary:hover { opacity: 1; } -.chat-msg .reasoning .reasoning-text { - margin-top: 6px; - padding: 8px 12px; - border-left: 2px solid var(--border); - white-space: pre-wrap; - opacity: 0.85; - max-height: 260px; - overflow-y: auto; -} - -.chat-composer { - position: sticky; - bottom: var(--kb-inset, 0px); /* clears the on-screen keyboard — see .play-controls */ - display: flex; - gap: 10px; - align-items: flex-end; - padding: 14px 0; - background: linear-gradient(to top, var(--bg) 55%, transparent); -} -.chat-composer textarea { - flex: 1; - min-height: 44px; - max-height: 220px; - resize: none; - overflow-y: auto; -} -.chat-composer button { flex: none; height: 40px; } - -/* ---------- The story tree, drawn (branch map overlay) ---------- */ - -.branch-map-open { - align-self: flex-start; - padding: 5px 12px; - font-size: 0.74rem; - letter-spacing: 0.08em; - color: var(--accent); - background: transparent; - border: 1px solid var(--border-bright); -} -.branch-map-open:hover { color: var(--accent-bright); border-color: var(--accent-dim); } - -/* The overlay is deliberately wider and shorter than .modal: a tree is read - across, and the number of branches decides the height. */ -.branch-map-overlay { z-index: 60; } -.branch-map { - display: flex; - flex-direction: column; - width: min(1080px, calc(100vw - 40px)); - max-height: min(760px, calc(100vh - 60px)); - background: var(--bg-panel); - border: 1px solid var(--border-bright); - border-radius: 14px; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06); -} -.branch-map-header { - display: flex; - align-items: baseline; - gap: 12px; - padding: 18px 22px 12px; - border-bottom: 1px solid var(--border); -} -.branch-map-header h2 { - margin: 0; - font-family: var(--font-display); - font-size: 1.15rem; - color: var(--accent-bright); -} -.branch-map-scale { - font-size: 0.72rem; - color: var(--text-dim); - font-variant-numeric: tabular-nums; -} -.branch-map-close { - margin-left: auto; - align-self: center; - padding: 2px 8px; - color: var(--text-dim); - background: transparent; - border: 1px solid transparent; -} -.branch-map-close:hover { color: var(--accent-bright); border-color: var(--border); } - -.branch-map-canvas { - flex: 1; - min-height: 120px; - /* Height comes from the branch count, so a deep tree scrolls rather than the - diagram shrinking until nobody can read it. Width never scrolls — the map - is scaled to fit, which is the whole point of drawing it to one clock. */ - overflow-y: auto; - overflow-x: hidden; - padding: 4px 12px 8px; -} -.branch-map-svg { display: block; } - -.bm-tick line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 6; } -.bm-tick text { - fill: var(--text-dim); - font-family: var(--font-ui); - font-size: 10px; - font-variant-numeric: tabular-nums; -} - -.bm-lane { cursor: pointer; } -.bm-hit { fill: transparent; } -.bm-lane:hover .bm-hit { fill: rgba(212, 169, 78, 0.035); } -.bm-lane.picked .bm-hit { fill: rgba(212, 169, 78, 0.07); } -.bm-lane:focus { outline: none; } -.bm-lane:focus-visible .bm-hit { fill: rgba(212, 169, 78, 0.12); } - -.bm-line { - stroke: var(--border-bright); - stroke-width: 3; - stroke-linecap: round; -} -.bm-fork { - fill: none; - stroke: var(--border-bright); - stroke-width: 2; -} -.bm-fork-dot, .bm-tip { fill: var(--border-bright); } - -.bm-lane:hover .bm-line, .bm-lane.picked .bm-line { stroke: var(--accent-dim); } -.bm-lane:hover .bm-fork, .bm-lane.picked .bm-fork { stroke: var(--accent-dim); } -.bm-lane:hover .bm-tip, .bm-lane.picked .bm-tip { fill: var(--accent); } - -/* The line being read: brighter, and its tip carries the glow so where you - are standing is findable before a word is read. */ -.bm-lane.here .bm-line { stroke: var(--accent); } -.bm-lane.here .bm-fork { stroke: var(--accent-dim); } -.bm-lane.here .bm-tip { - fill: var(--accent-bright); - filter: drop-shadow(0 0 6px var(--accent-glow)); -} -.bm-lane.here .bm-name { fill: var(--accent-bright); } - -.bm-name { - fill: var(--text); - font-family: var(--font-story); - font-size: 15px; -} -.bm-meta { - fill: var(--text-dim); - font-family: var(--font-ui); - font-size: 10.5px; - font-variant-numeric: tabular-nums; -} - -.branch-map-hint { - margin: 0; - padding: 0 22px 10px; - color: var(--text-dim); - font-size: 0.82rem; - line-height: 1.55; -} - -.branch-map-detail { - display: flex; - flex-direction: column; - gap: 6px; - padding: 14px 22px 18px; - border-top: 1px solid var(--border); -} -.branch-map-detail.here { background: rgba(212, 169, 78, 0.05); } -.bmd-head { display: flex; align-items: center; gap: 8px; } -.branch-map-detail .branch-name { font-size: 1.05rem; } -.branch-map-detail.here .branch-name { color: var(--accent-bright); } -.branch-map-detail .branch-tools button, .branch-map-detail .branch-confirm button { - padding: 4px 11px; - font-size: 0.74rem; - color: var(--text-dim); - background: transparent; - border: 1px solid var(--border); -} -.branch-map-detail .branch-tools button:hover:not(:disabled), -.branch-map-detail .branch-confirm button:hover:not(:disabled) { - color: var(--accent-bright); - border-color: var(--border-bright); -} - -@media (prefers-reduced-motion: reduce) { - .enter, .skeleton-card, .sk, .toast, .thinking i, - .story .action, .page { - animation: none !important; - } - .card.tome:hover { transform: none; } -} - -/* ---------- Visit analytics (owner dashboard) ---------- */ - -/* Wider than the reading pages: this one is a grid of small figures, not - prose, and 960px puts two charts and eight tiles into a column. */ -.an-page { max-width: 1180px; } -.an-ranges { display: flex; flex-wrap: wrap; gap: 6px; } - -/* Four across, not auto-fit: there are eight tiles, and letting them flow - leaves a single orphan on the second row at most widths. */ -.an-tiles { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 18px; -} -@media (max-width: 900px) { - .an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} -.an-tile { - background: var(--bg-panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 14px 16px; -} -.an-tile-value { - font-family: var(--font-display); - font-size: 1.7rem; - line-height: 1.1; - color: var(--accent-bright); -} -.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; } -.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; } - -.an-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); - gap: 16px; - margin-bottom: 16px; -} -.an-card { - position: relative; - background: var(--bg-panel); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px 18px 14px; - margin-bottom: 16px; -} -.an-grid .an-card { margin-bottom: 0; } -.an-card-head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - margin-bottom: 14px; -} -.an-card-head h2 { - margin: 0; - font-family: var(--font-display); - font-size: 0.95rem; - letter-spacing: 0.06em; - color: var(--accent); -} -.an-note { font-size: 0.72rem; color: var(--text-dim); } -.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; } - -.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); } -.an-legend-item { display: inline-flex; align-items: center; gap: 5px; } -.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; } - -/* ----- Day columns ----- */ - -.an-plot { position: relative; height: 170px; margin-top: 12px; } -.an-gridline { - position: absolute; - left: 0; - right: 0; - border-top: 1px dashed var(--border); - pointer-events: none; -} -.an-gridline span { - position: absolute; - right: 0; - top: -0.62em; - font-size: 0.68rem; - color: var(--text-dim); - background: var(--bg-panel); - padding: 0 4px; - /* The dashed rule belongs behind the bars; its value does not. Columns are - positioned, so without this the tallest bar swallows the label. */ - z-index: 3; -} -.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; } -.an-column { - position: relative; - flex: 1 1 0; - min-width: 0; - height: 100%; - display: flex; - align-items: flex-end; - border-radius: 4px 4px 0 0; - outline: none; -} -.an-column.hot { background: rgba(212, 169, 78, 0.07); } -.an-stack { - width: 100%; - /* Capped so a 7-day range draws bars and not slabs; the column around it - stays full width, so the hover target does not shrink with the mark. */ - max-width: 44px; - margin: 0 auto; - height: 100%; - display: flex; - flex-direction: column; - justify-content: flex-end; - /* A 2px gap of surface between stacked segments: the boundary reads as a - boundary without a border, which would eat a thin bar entirely. */ - gap: 2px; -} -.an-bar { width: 100%; transition: filter 0.15s; } -.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; } -.an-column.hot .an-bar { filter: brightness(1.2); } -.an-tip { - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - gap: 3px; - padding: 8px 10px; - background: var(--bg-panel); - border: 1px solid var(--border-bright); - border-radius: 8px; - box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55); - font-size: 0.74rem; - white-space: nowrap; - pointer-events: none; - z-index: 4; -} -.an-tip strong { font-weight: 600; color: var(--accent-bright); } -.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); } - -.an-axis { display: flex; gap: 2px; margin-top: 7px; } -.an-axis span { - flex: 1 1 0; - min-width: 0; - font-size: 0.68rem; - color: var(--text-dim); - text-align: center; - white-space: nowrap; -} -.an-axis span:first-child { text-align: left; } -.an-axis span:last-child { text-align: right; } - -.an-overlay-empty { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-dim); - font-style: italic; - font-size: 0.85rem; - pointer-events: none; -} - -/* ----- Funnel ----- */ - -.an-funnel { display: flex; flex-direction: column; gap: 10px; } -.an-funnel-row { - display: grid; - grid-template-columns: 168px 1fr 96px; - align-items: center; - gap: 12px; -} -.an-funnel-label { font-size: 0.84rem; } -.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; } -.an-funnel-bar { - height: 100%; - min-width: 2px; - background: var(--chart-1); - border-radius: 7px; - transition: width 0.4s ease; -} -.an-funnel-value { - text-align: right; - font-size: 0.86rem; - display: flex; - justify-content: flex-end; - align-items: baseline; - gap: 8px; -} -.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); } - -/* ----- Ranked lists ----- */ - -.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; } -.an-list li { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 7px 10px; - border-radius: 6px; - font-size: 0.84rem; -} -/* The bar sits behind the row rather than beside it, so a long label keeps the - full width and the magnitude is still visible at a glance. */ -.an-list-fill { - position: absolute; - top: 0; - bottom: 0; - left: 0; - background: rgba(181, 138, 48, 0.2); - border-radius: 6px; -} -.an-list-label { - position: relative; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.an-list-value { position: relative; color: var(--text-dim); flex: none; } - -/* ----- Tabs, and the access log table ----- */ - -.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); } -.an-tabs button { - background: none; - border: none; - border-bottom: 2px solid transparent; - border-radius: 0; - padding: 8px 14px; - color: var(--text-dim); - font-family: var(--font-display); - font-size: 0.82rem; - letter-spacing: 0.06em; - cursor: pointer; -} -.an-tabs button:hover { color: var(--text); } -.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); } - -.an-log-head { flex-wrap: wrap; row-gap: 10px; } -.an-log-search { max-width: 260px; } - -/* The table is the one thing here with a minimum width — six columns of real - data don't compress. It scrolls inside its own box so the page never does. */ -.an-table-scroll { overflow-x: auto; } -.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; } -.an-table th { - text-align: left; - font-weight: 500; - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-dim); - padding: 0 10px 8px; - border-bottom: 1px solid var(--border); - white-space: nowrap; -} -.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } -.an-table tr:last-child td { border-bottom: none; } -.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); } -/* A failed attempt is the row you are scanning for; it gets the only colour in - the table, on a border rather than the text, which stays readable. */ -.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); } -.an-table tr.failed td:nth-child(3) { color: var(--danger); } -.an-cell-dim { color: var(--text-dim); } -.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; } -.an-tag { - margin-left: 8px; - padding: 1px 6px; - border: 1px solid var(--border-bright); - border-radius: 10px; - font-size: 0.66rem; - color: var(--text-dim); -} -.an-more { display: flex; justify-content: center; padding-top: 14px; } - -.an-footnote { - color: var(--text-dim); - font-size: 0.74rem; - line-height: 1.6; - margin: 22px 0 0; - max-width: 70ch; -} - -/* ============================================================ - Mobile / narrow screens (≤ 720px) - The desktop Play screen is a horizontal row of up to four columns - (two left rails + story + right side-panel). Phones can't fit that, - so here we collapse to a single story column and turn the rails and - side-panel into full-height overlays. Everything above this query is - the untouched desktop design. - ============================================================ */ -@media (max-width: 720px) { - - /* ---------- Top nav → hamburger dropdown ---------- */ - .topnav { gap: 12px; padding: 0 16px; } - .nav-hamburger { display: block; } - .nav-links { - display: none; - position: absolute; - top: 56px; - left: 0; - right: 0; - flex-direction: column; - align-items: stretch; - background: var(--bg-panel); - border-bottom: 1px solid var(--border); - box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55); - padding: 4px 0; - } - .nav-links.open { display: flex; } - .topnav a.navlink { - padding: 13px 20px; - border-bottom: none; - border-left: 3px solid transparent; - } - .topnav a.navlink.active { - border-bottom-color: transparent; - border-left-color: var(--accent); - } - .nav-account { - margin-left: 0; - padding: 12px 20px; - border-top: 1px solid var(--border); - flex-wrap: wrap; - } - .account-email { max-width: 100%; } - - /* ---------- Global page padding / typography ---------- */ - .page { padding: 18px 14px; } - /* 16px inputs stop iOS Safari auto-zooming when a field is focused. */ - input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select { - font-size: 16px; - } - - /* ---------- Play screen → single column ---------- */ - .play-layout { flex-direction: column; } - .play-layout .play-page, - .play-layout.with-panel .play-page { max-width: 100%; } - .play-page .page-header h1 { font-size: 1.25rem; } - .panel-toggles { flex-wrap: wrap; } - .story { font-size: 1.06rem; } - /* Reserve a left gutter so the fixed World/State edge-tabs never sit on - top of the story text as it scrolls past them. */ - .story, .play-page .empty { padding-left: 26px; } - .story .action { padding-right: 0; } - /* No hover on touch — keep per-action edit/delete tools visible. */ - .story .action-tools { opacity: 1; pointer-events: auto; } - - /* ---------- Left rails (World State / Script State) → edge tabs + overlay ---------- */ - /* Closed: a compact tab pinned to the left edge instead of a full-height - column that steals the story's width. World State sits above Script State. - (World State only renders for RPG adventures, so it may be absent.) */ - .status-drawer { - position: fixed; - left: 0; - z-index: 40; - } - .status-drawer:not(.open) { - top: 170px; - height: auto; - background: var(--bg-panel); - border: 1px solid var(--border); - border-left: none; - border-radius: 0 10px 10px 0; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); - } - .ws-drawer:not(.open) { top: 170px; } - .status-drawer:not(.ws-drawer):not(.open) { top: 260px; } - .status-drawer:not(.open) .status-toggle { height: auto; padding: 14px 4px; } - - /* Open: slide-in overlay covering most of the screen. */ - .status-drawer.open { - top: 56px; - height: calc(100dvh - 56px); - width: min(88vw, 320px); - box-shadow: 0 0 50px rgba(0, 0, 0, 0.65); - border-right: 1px solid var(--border-bright); - } - - /* ---------- Right side-panel (Plot/Memory/Scripts/Insights) → full overlay ---------- */ - .side-panel { - position: fixed; - inset: 56px 0 0 0; - width: auto; - height: auto; - z-index: 45; - border-left: none; - padding: 16px; - } - - /* ---------- Home / library surfaces ---------- */ - - /* Both grids go single-column: the wide variant's 320px minimum would - otherwise overflow a 360px phone once card padding is counted. */ - .card-grid, .card-grid.wide { grid-template-columns: 1fr; } - - .hall { padding: 2px 0 20px; } - .hall-sub { font-size: 0.95rem; } - .home-section { margin-top: 26px; } - - /* The label eats most of a narrow line, so drop the short leading rule and - let the trailing one take whatever space is left. */ - .rule { letter-spacing: 0.16em; font-size: 0.76rem; gap: 10px; } - .rule::before { display: none; } - .rule-head { gap: 10px; } - - /* Ornament scales down rather than crowding the corners on a small card. */ - .card.tome { padding: 14px 15px 12px; } - .card.tome::before, .card.tome::after { width: 9px; height: 9px; } - /* Always-on filigree: there's no hover on touch. */ - .card.tome::before, .card.tome::after { opacity: 0.8; } - .seal { width: 21px; height: 21px; top: 10px; right: 10px; font-size: 0.54rem; } - .card.tome:has(> .seal) .card-head { padding-right: 22px; } - .snippet { font-size: 0.94rem; -webkit-line-clamp: 4; } - .snippet.dropcap::first-letter { font-size: 2rem; padding-right: 6px; } - - /* Motes are pure decoration — thin them out where the GPU is weakest and - the effect is least visible. (Embers.jsx already scales count to the - viewport; this drops the opacity too.) */ - .embers { opacity: 0.55; } - - /* Full-width toasts, clear of the sticky play controls. */ - .toast-host { - left: 12px; - right: 12px; - bottom: 14px; - transform: none; - width: auto; - max-width: none; - align-items: stretch; - } - .toast { font-size: 0.82rem; } - - /* Art picker stacks; the emoji grid stays tappable at 34px minimum. */ - .art-picker { gap: 12px; } - .art-preview { width: 64px; height: 64px; } - .art-controls { min-width: 0; } - - /* The drop cap on the opening beat competes with the edge tabs at this - width, so shrink it and keep it inside the 26px story inset. */ - .story .action.opening::first-letter { - font-size: 2.5rem; - padding: 4px 8px 0 0; - } - - /* ---------- Composers: give the typing area the full width ---------- - Both composers are one flex row on desktop, which on a phone left the - textarea with a sliver: the Play bar spends ~180px on Do/Say/Story plus - ~75px on Send, and the chat bar ~90px on Send. Letting the row wrap and - moving the buttons onto their own line hands that width back to the - textarea, which is the part you actually type into. - - Play: Do/Say/Story and Send share the top row (Send pushed to the right - edge), and `order` drops the textarea onto its own full-width row beneath - them. Two rows, and the typing area goes from ~138px to the full width — - measured at a 390px composer. */ - .input-bar { flex-wrap: wrap; } - .mode-select button { flex: 1; padding: 7px 10px; } - .input-bar > button { margin-left: auto; } - /* min-width:0 lets a flex item shrink below its content width. */ - .input-bar textarea { order: 1; flex: 1 0 100%; margin-top: 8px; min-width: 0; } - - /* ---------- AI Chat ---------- */ - /* No hover on touch, so the per-message copy/delete links stay visible. */ - .chat-msg-actions { opacity: 1; } - .chat-msg { padding: 9px 11px 11px; } - .chat-composer { padding: 10px 0 14px; flex-wrap: wrap; } - /* Full-width textarea; Send wraps beneath it and sits at the right edge. */ - .chat-composer textarea { flex: 1 1 100%; max-height: 140px; } - .chat-composer button { margin-left: auto; } - - /* ---------- The branch map ---------- */ - /* A phone has no room for a dialog inset from the edges, and the tree is the - one thing on this screen that wants every pixel across. */ - .branch-map { - width: 100vw; - max-width: none; - max-height: 100dvh; - height: 100dvh; - border: none; - border-radius: 0; - } - .branch-map-header { padding: 14px 16px 10px; flex-wrap: wrap; } - .branch-map-canvas { padding: 4px 6px 8px; } - .branch-map-detail { padding: 12px 16px 16px; } - .branch-map-hint { padding: 0 16px 10px; } - - /* ---------- Analytics dashboard ---------- */ - .an-page .page-header { flex-direction: column; align-items: flex-start; gap: 10px; } - .an-tiles { gap: 8px; } - .an-tile { padding: 10px 12px; } - .an-tile-value { font-size: 1.35rem; } - .an-grid { grid-template-columns: 1fr; } - .an-plot { height: 140px; } - .an-tabs button { padding: 8px 10px; font-size: 0.78rem; } - .an-log-search { max-width: none; width: 100%; } - /* Three columns don't fit; the label goes above its own bar instead. */ - .an-funnel-row { grid-template-columns: 1fr auto; gap: 4px 10px; } - .an-funnel-track { grid-column: 1; } - .an-funnel-label { grid-column: 1 / -1; } - .an-funnel-value { grid-column: 2; } -} +/* The stylesheet, split into sections and imported in order. + + The order is load-bearing and must not change. Several selectors in + `tome.css` tie with earlier ones on specificity and win only because they + come later, and `responsive.css` overrides the whole desktop design at + narrow widths. Both are noted where the rules are. + + Add a new section by adding a file and an `@import` for it. Put the import + where the rules belong in the cascade, not at the end by habit. +*/ +@import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */ +@import './styles/base.css'; /* scrollbars */ +@import './styles/nav.css'; /* the top navigation bar */ +@import './styles/forms.css'; /* inputs, labels, and buttons */ +@import './styles/cards.css'; /* cards, lists, and the story-card editor */ +@import './styles/play.css'; /* the Play screen and the branches panel */ +@import './styles/panels.css'; /* the side panel and the in-play script viewer */ +@import './styles/drawers.css'; /* the status drawer and the world-state drawer */ +@import './styles/schema-editor.css'; /* the stat-schema form and the NPC roster */ +@import './styles/insights.css'; /* insights, scripts, and the memory bank */ +@import './styles/modals.css'; /* the filter bar, tags, and modals */ +@import './styles/auth.css'; /* log in, sign up, and the settings debug log */ +@import './styles/banners.css'; /* the Play screen's persistent banner */ +@import './styles/tome.css'; /* the illuminated-tome surfaces */ +@import './styles/chat.css'; /* the AI Chat scratchpad */ +@import './styles/tree-map.css'; /* the branch map overlay */ +@import './styles/analytics.css'; /* the visitor dashboard */ +@import './styles/responsive.css'; /* narrow screens, 720px and below */ diff --git a/frontend/src/styles/analytics.css b/frontend/src/styles/analytics.css new file mode 100644 index 0000000..b56cdbf --- /dev/null +++ b/frontend/src/styles/analytics.css @@ -0,0 +1,287 @@ +/* ---------- Visit analytics (owner dashboard) ---------- */ + +/* Wider than the reading pages: this one is a grid of small figures, not + prose, and 960px puts two charts and eight tiles into a column. */ +.an-page { max-width: 1180px; } +.an-ranges { display: flex; flex-wrap: wrap; gap: 6px; } + +/* Four across, not auto-fit: there are eight tiles, and letting them flow + leaves a single orphan on the second row at most widths. */ +.an-tiles { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} +@media (max-width: 900px) { + .an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +.an-tile { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; +} +.an-tile-value { + font-family: var(--font-display); + font-size: 1.7rem; + line-height: 1.1; + color: var(--accent-bright); +} +.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; } +.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; } + +.an-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} +.an-card { + position: relative; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 18px 14px; + margin-bottom: 16px; +} +.an-grid .an-card { margin-bottom: 0; } +.an-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.an-card-head h2 { + margin: 0; + font-family: var(--font-display); + font-size: 0.95rem; + letter-spacing: 0.06em; + color: var(--accent); +} +.an-note { font-size: 0.72rem; color: var(--text-dim); } +.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; } + +.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); } +.an-legend-item { display: inline-flex; align-items: center; gap: 5px; } +.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; } + +/* ----- Day columns ----- */ + +.an-plot { position: relative; height: 170px; margin-top: 12px; } +.an-gridline { + position: absolute; + left: 0; + right: 0; + border-top: 1px dashed var(--border); + pointer-events: none; +} +.an-gridline span { + position: absolute; + right: 0; + top: -0.62em; + font-size: 0.68rem; + color: var(--text-dim); + background: var(--bg-panel); + padding: 0 4px; + /* The dashed rule belongs behind the bars; its value does not. Columns are + positioned, so without this the tallest bar swallows the label. */ + z-index: 3; +} +.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; } +.an-column { + position: relative; + flex: 1 1 0; + min-width: 0; + height: 100%; + display: flex; + align-items: flex-end; + border-radius: 4px 4px 0 0; + outline: none; +} +.an-column.hot { background: rgba(212, 169, 78, 0.07); } +.an-stack { + width: 100%; + /* Capped so a 7-day range draws bars and not slabs; the column around it + stays full width, so the hover target does not shrink with the mark. */ + max-width: 44px; + margin: 0 auto; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + /* A 2px gap of surface between stacked segments: the boundary reads as a + boundary without a border, which would eat a thin bar entirely. */ + gap: 2px; +} +.an-bar { width: 100%; transition: filter 0.15s; } +.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; } +.an-column.hot .an-bar { filter: brightness(1.2); } +.an-tip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 10px; + background: var(--bg-panel); + border: 1px solid var(--border-bright); + border-radius: 8px; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55); + font-size: 0.74rem; + white-space: nowrap; + pointer-events: none; + z-index: 4; +} +.an-tip strong { font-weight: 600; color: var(--accent-bright); } +.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); } + +.an-axis { display: flex; gap: 2px; margin-top: 7px; } +.an-axis span { + flex: 1 1 0; + min-width: 0; + font-size: 0.68rem; + color: var(--text-dim); + text-align: center; + white-space: nowrap; +} +.an-axis span:first-child { text-align: left; } +.an-axis span:last-child { text-align: right; } + +.an-overlay-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + font-style: italic; + font-size: 0.85rem; + pointer-events: none; +} + +/* ----- Funnel ----- */ + +.an-funnel { display: flex; flex-direction: column; gap: 10px; } +.an-funnel-row { + display: grid; + grid-template-columns: 168px 1fr 96px; + align-items: center; + gap: 12px; +} +.an-funnel-label { font-size: 0.84rem; } +.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; } +.an-funnel-bar { + height: 100%; + min-width: 2px; + background: var(--chart-1); + border-radius: 7px; + transition: width 0.4s ease; +} +.an-funnel-value { + text-align: right; + font-size: 0.86rem; + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 8px; +} +.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); } + +/* ----- Ranked lists ----- */ + +.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; } +.an-list li { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 10px; + border-radius: 6px; + font-size: 0.84rem; +} +/* The bar sits behind the row rather than beside it, so a long label keeps the + full width and the magnitude is still visible at a glance. */ +.an-list-fill { + position: absolute; + top: 0; + bottom: 0; + left: 0; + background: rgba(181, 138, 48, 0.2); + border-radius: 6px; +} +.an-list-label { + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.an-list-value { position: relative; color: var(--text-dim); flex: none; } + +/* ----- Tabs, and the access log table ----- */ + +.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); } +.an-tabs button { + background: none; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 8px 14px; + color: var(--text-dim); + font-family: var(--font-display); + font-size: 0.82rem; + letter-spacing: 0.06em; + cursor: pointer; +} +.an-tabs button:hover { color: var(--text); } +.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); } + +.an-log-head { flex-wrap: wrap; row-gap: 10px; } +.an-log-search { max-width: 260px; } + +/* The table is the one thing here with a minimum width — six columns of real + data don't compress. It scrolls inside its own box so the page never does. */ +.an-table-scroll { overflow-x: auto; } +.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; } +.an-table th { + text-align: left; + font-weight: 500; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); + padding: 0 10px 8px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } +.an-table tr:last-child td { border-bottom: none; } +.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); } +/* A failed attempt is the row you are scanning for; it gets the only colour in + the table, on a border rather than the text, which stays readable. */ +.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); } +.an-table tr.failed td:nth-child(3) { color: var(--danger); } +.an-cell-dim { color: var(--text-dim); } +.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; } +.an-tag { + margin-left: 8px; + padding: 1px 6px; + border: 1px solid var(--border-bright); + border-radius: 10px; + font-size: 0.66rem; + color: var(--text-dim); +} +.an-more { display: flex; justify-content: center; padding-top: 14px; } + +.an-footnote { + color: var(--text-dim); + font-size: 0.74rem; + line-height: 1.6; + margin: 22px 0 0; + max-width: 70ch; +} + diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css new file mode 100644 index 0000000..8e7b017 --- /dev/null +++ b/frontend/src/styles/auth.css @@ -0,0 +1,145 @@ +/* ---------- Auth (log in / sign up) ---------- */ + +.auth-modal { width: min(400px, calc(100vw - 40px)); text-align: center; padding-top: 22px; } +.auth-crest { + font-size: 1.1rem; + color: var(--accent-dim); + letter-spacing: 0.3em; + margin-bottom: 6px; +} +.auth-modal h2 { font-size: 1.35rem; } +.auth-modal .auth-hint { margin-bottom: 16px; line-height: 1.45; } +/* Fields go back to left-aligned — only the header is centred. */ +.auth-modal label.field { text-align: left; } + +.auth-tabs { + display: flex; + gap: 4px; + padding: 4px; + margin-bottom: 18px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 10px; +} +.auth-tab { + flex: 1; + padding: 7px 0; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--text-dim); + font-family: var(--font-display); + font-size: 0.78rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.auth-tab:hover { color: var(--text); background: rgba(212, 169, 78, 0.06); } +.auth-tab.active { + background: var(--bg-panel); + border-color: var(--accent-dim); + color: var(--accent-bright); +} + +.auth-password { display: flex; gap: 8px; } +.auth-password input { flex: 1; min-width: 0; } +.auth-reveal { + flex: none; + padding: 0 12px; + font-size: 0.78rem; + color: var(--text-dim); +} +.auth-reveal:hover { color: var(--accent-bright); } + +.auth-help { + display: block; + margin-top: 6px; + font-size: 0.76rem; + color: var(--text-dim); +} +.auth-error { + margin-top: 4px; + padding: 9px 12px; + border: 1px solid rgba(208, 101, 101, 0.45); + border-radius: 8px; + background: rgba(208, 101, 101, 0.09); + color: var(--danger); + font-size: 0.85rem; + text-align: left; + white-space: pre-wrap; +} + +/* "Update from scenario" — a wider modal, since it lists a diff. */ +.refresh-modal { width: min(620px, calc(100vw - 40px)); } +.refresh-changes { + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + font-size: 0.86rem; +} +.refresh-change + .refresh-change { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; } +.refresh-old { text-decoration: line-through; } +.refresh-new { color: var(--accent-bright); } +/* The button sits above the plot fields it would overwrite. */ +.plot-source { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); + font-size: 0.82rem; +} +.plot-source .linklike { flex-shrink: 0; } + +/* ---------- Debug log (Settings) ---------- */ + +.debug-entry { + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 8px; + overflow: hidden; +} +.debug-entry-head { + display: flex; + align-items: baseline; + gap: 12px; + padding: 9px 12px; + cursor: pointer; + font-size: 0.86rem; + background: var(--bg-input); +} +.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; } +.debug-status { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--border-bright); + color: var(--text-dim); +} +.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; } +.debug-status.error { color: var(--danger); border-color: var(--danger); } +.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); } +.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; } +.debug-entry-body pre { + margin: 2px 0 8px; + padding: 8px 10px; + background: var(--bg-input); + border-radius: 6px; + white-space: pre-wrap; + word-break: break-word; + font-family: Consolas, monospace; + font-size: 0.78rem; + max-height: 300px; + overflow-y: auto; +} + diff --git a/frontend/src/styles/banners.css b/frontend/src/styles/banners.css new file mode 100644 index 0000000..07658ea --- /dev/null +++ b/frontend/src/styles/banners.css @@ -0,0 +1,25 @@ +/* ---------- Play screen error/success banner ---------- + Persistent (no auto-dismiss) and can carry a Retry action, so it stays + separate from the transient global toasts further down this file. */ + +.play-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-panel-glass); + backdrop-filter: blur(10px); + border: 1px solid var(--danger); + color: var(--text); + padding: 11px 20px; + border-radius: 10px; + z-index: 100; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + animation: toast-in 0.25s ease both; +} +.play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); } +@keyframes toast-in { + from { opacity: 0; transform: translate(-50%, 12px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css new file mode 100644 index 0000000..293e9c6 --- /dev/null +++ b/frontend/src/styles/base.css @@ -0,0 +1,11 @@ +/* ---------- Scrollbars ---------- */ + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border-bright); + border-radius: 5px; + border: 2px solid var(--bg); +} +::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); } + diff --git a/frontend/src/styles/cards.css b/frontend/src/styles/cards.css new file mode 100644 index 0000000..01d96a8 --- /dev/null +++ b/frontend/src/styles/cards.css @@ -0,0 +1,63 @@ +/* ---------- Cards & lists ---------- */ + +.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } +.card { + position: relative; + background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px; + cursor: pointer; + transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; + overflow: hidden; +} +.card::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent-dim), transparent); + opacity: 0; + transition: opacity 0.25s; +} +.card:hover { + border-color: var(--border-bright); + transform: translateY(-3px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06); +} +.card:hover::before { opacity: 1; } +.card h3 { + margin: 0 0 6px; + font-family: var(--font-display); + font-size: 1.02rem; + color: var(--accent); +} +.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; } +.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); } + +.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } +.page-header h1 { + margin: 0; + font-family: var(--font-display); + font-size: 1.55rem; + color: var(--accent-bright); + text-shadow: 0 0 24px var(--accent-glow); +} + +.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; } + +/* ---------- Story card editor ---------- */ + +.storycard { + background: var(--bg-input); + border: 1px solid var(--border); + border-left: 3px solid var(--accent-dim); + border-radius: 8px; + padding: 12px; + margin-bottom: 10px; + transition: border-color 0.15s; +} +.storycard:focus-within { border-color: var(--accent-dim); } +.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; } +.storycard .row > * { flex: 1; } + diff --git a/frontend/src/styles/chat.css b/frontend/src/styles/chat.css new file mode 100644 index 0000000..3cd4332 --- /dev/null +++ b/frontend/src/styles/chat.css @@ -0,0 +1,97 @@ +/* ---------- AI Chat (power users) ---------- + A plain chat scratchpad, styled as a transcript rather than as the story + page: UI font, role labels, and a sticky composer at the bottom. */ + +.chat-page { + max-width: 820px; + display: flex; + flex-direction: column; + margin-bottom: var(--kb-inset, 0px); /* see .play-page */ +} +.chat-meta { + font-size: 0.78rem; + margin: -10px 0 16px; + word-break: break-word; +} +.chat-options { + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px 4px; + margin-bottom: 18px; + background: var(--bg-panel); +} + +.chat-transcript { flex: 1; display: flex; flex-direction: column; gap: 16px; padding-bottom: 20px; } + +.chat-msg { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 14px 12px; + background: var(--bg-panel); +} +.chat-msg.user { border-left: 3px solid var(--player); background: var(--bg-input); } +.chat-msg.assistant { border-left: 3px solid var(--accent-dim); } +.chat-msg-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.chat-msg .chat-role { color: var(--accent-dim); font-weight: 600; } +.chat-msg.user .chat-role { color: var(--player); } +.chat-model-tag { text-transform: none; letter-spacing: 0; font-size: 0.74rem; } +.chat-msg-actions { + margin-left: auto; + display: flex; + gap: 12px; + opacity: 0; + transition: opacity 0.15s; +} +.chat-msg:hover .chat-msg-actions, .chat-msg:focus-within .chat-msg-actions { opacity: 1; } +.chat-msg-body { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.6; + font-size: 0.95rem; +} +.chat-msg .cursor { + color: var(--accent-bright); + text-shadow: 0 0 8px var(--accent-glow); + animation: blink 1s steps(1) infinite; +} + +/* Same collapsed thinking block as the story page, re-scoped for the transcript. */ +.chat-msg .reasoning { margin: 0 0 8px; font-size: 0.82rem; line-height: 1.5; color: var(--text-dim); } +.chat-msg .reasoning summary { cursor: pointer; user-select: none; opacity: 0.8; } +.chat-msg .reasoning summary:hover { opacity: 1; } +.chat-msg .reasoning .reasoning-text { + margin-top: 6px; + padding: 8px 12px; + border-left: 2px solid var(--border); + white-space: pre-wrap; + opacity: 0.85; + max-height: 260px; + overflow-y: auto; +} + +.chat-composer { + position: sticky; + bottom: var(--kb-inset, 0px); /* clears the on-screen keyboard — see .play-controls */ + display: flex; + gap: 10px; + align-items: flex-end; + padding: 14px 0; + background: linear-gradient(to top, var(--bg) 55%, transparent); +} +.chat-composer textarea { + flex: 1; + min-height: 44px; + max-height: 220px; + resize: none; + overflow-y: auto; +} +.chat-composer button { flex: none; height: 40px; } + diff --git a/frontend/src/styles/drawers.css b/frontend/src/styles/drawers.css new file mode 100644 index 0000000..86d387a --- /dev/null +++ b/frontend/src/styles/drawers.css @@ -0,0 +1,266 @@ +/* ---------- Left status drawer (script state) ---------- */ + +.status-drawer { + flex-shrink: 0; + position: sticky; + top: 56px; + height: calc(100dvh - 56px); + display: flex; + align-items: flex-start; + border-right: 1px solid var(--border); + background: var(--bg-panel); +} +.status-drawer.open { width: 300px; } +.status-toggle { + flex-shrink: 0; + height: 100%; + width: 30px; + padding: 12px 0; + border: none; + border-radius: 0; + background: transparent; + color: var(--text-dim); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +.status-toggle:hover { color: var(--text); background: var(--bg-input); } +.status-toggle-label { + writing-mode: vertical-rl; + text-orientation: mixed; + letter-spacing: 0.12em; + font-size: 0.72rem; + text-transform: uppercase; +} +.status-body { + flex: 1; + min-width: 0; + height: 100%; + overflow-y: auto; + padding: 20px 16px; + animation: panel-in 0.2s ease both; +} +.status-vars { list-style: none; margin: 0; padding: 0; } +.status-vars li { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.status-key { + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + color: var(--accent-bright); + word-break: break-word; +} +.status-val { + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} +/* Recursive script-state value tree */ +.jv-string { color: var(--text); } +.jv-number { color: var(--player); } +.jv-bool { color: var(--accent-bright); } +.jv-null { color: var(--text-dim); font-style: italic; } +.jv-empty { color: var(--text-dim); } +.jv-tree { display: inline-block; vertical-align: top; max-width: 100%; } +.jv-toggle { + background: none; border: none; padding: 0; cursor: pointer; + font-family: inherit; font-size: 0.72rem; color: var(--text-dim); +} +.jv-toggle:hover { color: var(--accent-bright); } +.jv-children { + list-style: none; margin: 2px 0 2px 0; padding-left: 12px; + border-left: 1px solid var(--border); +} +.jv-children li { padding: 2px 0; word-break: break-word; } +.jv-key { color: var(--accent); margin-right: 6px; } +.jv-key::after { content: ':'; color: var(--text-dim); } +@keyframes panel-in { + from { opacity: 0; transform: translateX(16px); } + to { opacity: 1; transform: none; } +} + +/* ---------- World State drawer (Phase 12 RPG) ---------- */ +.ws-group { margin-bottom: 18px; } +.ws-group-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); + margin: 0 0 8px; +} +.ws-stat { padding: 5px 0; } +.ws-stat-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-size: 0.85rem; +} +.ws-stat-name { color: var(--text); text-transform: capitalize; } +.ws-stat-val { + color: var(--text-dim); + font-variant-numeric: tabular-nums; + font-size: 0.8rem; + text-align: right; +} +.ws-band { color: var(--accent-bright); } +.ws-bar { + margin-top: 4px; + height: 5px; + border-radius: 3px; + background: var(--bg-input); + overflow: hidden; +} +.ws-bar-fill { + height: 100%; + border-radius: 3px; + background: var(--accent); + transition: width 0.35s ease; +} +/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of + five doesn't read as five more unrelated sections in the rail. */ +.ws-npc { + padding: 7px 9px; + margin-bottom: 7px; + border: 1px solid var(--border); + border-left: 2px solid var(--accent-dim); + border-radius: 7px; + background: rgba(255, 255, 255, 0.022); +} +.ws-npc:last-child { margin-bottom: 0; } +.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; } +.ws-npc-avatar { + flex-shrink: 0; + width: 22px; height: 22px; + border-radius: 50%; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 0.6rem; + color: #14100a; + background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim)); +} +.ws-npc-name { + font-family: var(--font-display); + font-size: 0.85rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; } +.ws-milestones { list-style: none; margin: 0; padding: 0; } +.ws-milestones li { + display: flex; + gap: 7px; + align-items: flex-start; + padding: 4px 0; + font-size: 0.85rem; + color: var(--text); +} +.ws-milestones li.done { color: var(--text-dim); } +.ws-check { flex-shrink: 0; color: var(--accent-bright); } +.ws-milestones li.done .ws-check { color: var(--player); } +.ws-flags { list-style: none; margin: 0; padding: 0; } +.ws-flags li { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 0.85rem; + color: var(--text); +} +.ws-flag-dot { + width: 9px; height: 9px; border-radius: 50%; + flex-shrink: 0; + background: var(--bg-input); + border: 1px solid var(--border); +} +.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); } +.ws-flag-name { flex: 1; } +.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; } +.ws-flag-edit { display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%; } + +.ws-edit-actions { display: flex; gap: 6px; } +.ws-edit-input { + width: 100%; + margin-top: 3px; + padding: 4px 7px; + font-size: 0.82rem; + border-radius: 6px; +} + +.schema-editor { + width: 100%; + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + line-height: 1.5; + tab-size: 2; + white-space: pre; + overflow-x: auto; +} +.schema-error { + margin-top: 6px; + color: var(--danger, #e5484d); + font-size: 0.82rem; +} +.schema-json-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; +} +.schema-json-actions .schema-error { margin-top: 0; } +.schema-preview { + margin-top: 14px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel); +} +.schema-preview-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-dim); + margin-bottom: 10px; +} +.schema-group { margin-bottom: 16px; } +.schema-group:last-child { margin-bottom: 0; } +.schema-group-title { + font-family: Georgia, serif; + font-size: 0.98rem; + color: var(--accent-bright); + margin-bottom: 6px; +} +.schema-npc-id { + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + color: var(--text-dim); + margin-left: 6px; +} +.schema-stat { padding: 4px 0; border-bottom: 1px solid var(--border); } +.schema-stat:last-child { border-bottom: none; } +.schema-stat-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} +.schema-stat-name { color: var(--text); text-transform: capitalize; } +.schema-stat-meta { + color: var(--text-dim); + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + text-align: right; +} +.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; } +.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; } + diff --git a/frontend/src/styles/forms.css b/frontend/src/styles/forms.css new file mode 100644 index 0000000..afe0f52 --- /dev/null +++ b/frontend/src/styles/forms.css @@ -0,0 +1,64 @@ +/* ---------- Forms ---------- */ + +label.field { display: block; margin-bottom: 14px; } +label.field .label { + display: block; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-dim); + margin-bottom: 5px; +} +input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select { + width: 100%; + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 12px; + font-size: 0.95rem; + font-family: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} +textarea { resize: vertical; min-height: 70px; line-height: 1.5; } +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--accent-dim); + box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12); +} +input::placeholder, textarea::placeholder { color: #5c584d; } + +button { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 7px 16px; + font-size: 0.88rem; + font-weight: 500; + cursor: pointer; + font-family: inherit; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s; +} +button:hover { + border-color: var(--accent-dim); + box-shadow: 0 0 12px rgba(212, 169, 78, 0.1); +} +button:active { transform: translateY(1px); } +button.primary { + background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e); + border-color: var(--accent); + color: #191307; + font-weight: 600; + letter-spacing: 0.02em; + box-shadow: 0 2px 10px var(--accent-glow); +} +button.primary:hover { + box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45); + filter: brightness(1.08); +} +button.danger { color: var(--danger); } +button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); } +button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; } + diff --git a/frontend/src/styles/insights.css b/frontend/src/styles/insights.css new file mode 100644 index 0000000..2998733 --- /dev/null +++ b/frontend/src/styles/insights.css @@ -0,0 +1,203 @@ +/* ---------- Insights ---------- */ + +.insights-meta { margin-bottom: 16px; font-size: 0.85rem; } +.insights-mode { color: var(--text-dim); margin-bottom: 8px; } +.insights-mode button { padding: 2px 8px; font-size: 0.75rem; } +.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; } +.token-total.over { color: var(--danger); } +/* Stacked share-of-context bar. Segment colours come from SECTION_COLORS in + Play.jsx so the bar, its legend and each section header stay in sync. */ +.token-split { + display: flex; + height: 14px; + background: var(--bg-input); + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; +} +.token-split.over { box-shadow: 0 0 0 1px var(--danger); } +.token-seg { + min-width: 2px; /* a 0.2% section is still worth seeing */ + border-right: 1px solid var(--bg-panel); + transition: opacity 0.15s ease; +} +.token-seg:last-child { border-right: none; } +.token-seg-free { background: transparent; min-width: 0; border-right: none; } +.token-seg.faded, .token-legend-item.faded { opacity: 0.25; } + +.token-legend { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 1px 12px; + margin-bottom: 10px; +} +.token-legend-item { + display: flex; + align-items: center; + gap: 7px; + background: none; + border: none; + border-radius: 4px; + padding: 2px 5px; + font-size: 0.78rem; + color: var(--text-dim); + text-align: left; + transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease; +} +.token-legend-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); } +.token-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; } +.token-legend-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.token-legend-pct { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } +.token-legend-tok { + width: 4.5em; + text-align: right; + opacity: 0.65; + font-variant-numeric: tabular-nums; +} +.token-legend-more { + justify-self: start; + background: none; + border: none; + padding: 2px 5px; + font-size: 0.75rem; + color: var(--text-dim); +} +.token-legend-more:hover { color: var(--accent); background: none; } +.insights-history { color: var(--text-dim); margin-bottom: 8px; } +.insights-cards div { margin-bottom: 3px; } +.insights-cards .dropped { color: var(--danger); } + +.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; } +.ctx-section .ctx-header { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); + padding: 5px 10px 3px; +} +.ctx-section pre { + margin: 0; + padding: 6px 10px 8px; + white-space: pre-wrap; + word-break: break-word; + font-family: Consolas, monospace; + font-size: 0.8rem; + line-height: 1.5; + color: var(--text); + max-height: 260px; + overflow-y: auto; +} +/* Per-section border/header colours are applied inline from SECTION_COLORS + (Play.jsx) — one palette drives the bar, the legend and these headers. */ + +/* ---------- Scripts ---------- */ + +.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; } +.slot-tabs button { + border-radius: 0; + border-right-width: 0; + color: var(--text-dim); +} +.slot-tabs button:first-child { border-radius: 8px 0 0 8px; } +.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; } +.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); } +.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; } + +.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; } +.cm-editor.cm-focused { + outline: none; + border-color: var(--accent-dim); + box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1); +} + +.test-run .row { display: flex; gap: 12px; } +.test-result { + margin-top: 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.85rem; +} +.test-result .label { + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.08em; + color: var(--text-dim); + margin-right: 6px; +} +.test-result pre { + display: inline-block; + margin: 2px 0; + white-space: pre-wrap; + word-break: break-word; + font-family: Consolas, monospace; + vertical-align: top; +} +.test-error { color: var(--danger); white-space: pre-wrap; } + +.script-attach { + display: flex; + align-items: baseline; + gap: 8px; + padding: 9px 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 6px; + cursor: pointer; + transition: border-color 0.15s; +} +.script-attach:hover { border-color: var(--border-bright); } +.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; } + +/* ---------- Memory bank (Phase 6) ---------- */ + +.memory-row { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 12px; + margin-bottom: 6px; +} +.memory-row.forgotten { opacity: 0.55; } +.memory-text { font-size: 0.9rem; white-space: pre-wrap; } +.memory-meta { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 10px; + margin-top: 5px; + font-size: 0.78rem; +} +.memory-meta .dim { color: var(--text-dim); } +.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; } +.memory-meta .action-tools button { + padding: 1px 8px; + font-size: 0.72rem; + color: var(--text-dim); +} +.memory-meta .action-tools button:hover { color: var(--text); } +.memory-badge { + color: var(--accent); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0 8px; +} + +.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; } +.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; } +.script-logs { + background: var(--bg-input); + border-radius: 8px; + padding: 8px 10px; + font-family: Consolas, monospace; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; + margin: 6px 0; +} + diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css new file mode 100644 index 0000000..41b9cb7 --- /dev/null +++ b/frontend/src/styles/modals.css @@ -0,0 +1,57 @@ +/* ---------- Filter bar & tags ---------- */ + +.filter-bar { margin-bottom: 18px; } +.search-input { max-width: 340px; } +.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.tag { + padding: 2px 12px; + font-size: 0.76rem; + border-radius: 999px; + color: var(--text-dim); +} +.tag.active { + color: var(--accent-bright); + border-color: var(--accent-dim); + box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1); +} +.tag.small { + display: inline-block; + background: var(--bg-input); + border: 1px solid var(--border); + padding: 1px 9px; + margin-right: 4px; + cursor: default; +} + +/* ---------- Modal ---------- */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(5, 5, 9, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + animation: page-in 0.2s ease both; +} +.modal { + width: min(480px, calc(100vw - 40px)); + max-height: 80vh; + overflow-y: auto; + background: var(--bg-panel); + border: 1px solid var(--border-bright); + border-radius: 14px; + padding: 26px 28px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06); +} +.modal h2 { + margin: 0 0 4px; + font-family: var(--font-display); + font-size: 1.2rem; + color: var(--accent-bright); +} +.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; } +.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; } + diff --git a/frontend/src/styles/nav.css b/frontend/src/styles/nav.css new file mode 100644 index 0000000..67a271a --- /dev/null +++ b/frontend/src/styles/nav.css @@ -0,0 +1,120 @@ +/* ---------- Top navigation ---------- */ + +.topnav { + display: flex; + align-items: center; + gap: 26px; + padding: 0 28px; + height: 56px; + background: var(--bg-panel-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + position: sticky; + top: 0; + z-index: 10; +} +.topnav .brand { + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.14em; + text-shadow: 0 0 18px var(--accent-glow); + margin-right: 6px; +} +.topnav a.navlink { + color: var(--text-dim); + padding: 17px 2px 15px; + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.2s; +} +.topnav a.navlink.active { + color: var(--accent-bright); + border-bottom-color: var(--accent); + text-shadow: 0 0 14px var(--accent-glow); +} +.topnav a.navlink:hover { color: var(--text); } + +/* On desktop the wrapper is transparent — its children (nav links + account) + participate directly in the .topnav flex row, exactly as before. The mobile + query below turns it into a dropdown. */ +.nav-links { display: contents; } +.nav-hamburger { + display: none; + background: none; + border: none; + color: var(--text); + font-size: 1.2rem; + padding: 6px 8px; + margin-left: auto; + line-height: 1; +} + +/* Phase 8: account area (hosted multi-user mode only) */ +.nav-account { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.nav-account button { padding: 5px 12px; font-size: 0.82rem; } +.guest-nudge { + color: var(--text-dim); + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.account-email { + color: var(--text-dim); + font-size: 0.82rem; + max-width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +button.linklike { + background: none; + border: none; + padding: 0; + color: var(--accent); + font-size: 0.82rem; + cursor: pointer; +} +button.linklike:hover { text-decoration: underline; } +@media (max-width: 900px) { + .guest-nudge { display: none; } +} + +/* Phase 8: demo-key banner (Settings) */ +.demo-banner { + border: 1px solid var(--border-bright); + border-left: 3px solid var(--accent); + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 18px; + color: var(--text-dim); + font-size: 0.88rem; + line-height: 1.5; +} +.demo-banner strong { color: var(--accent-bright); } + +.page { + flex: 1; + width: 100%; + max-width: 960px; + margin: 0 auto; + padding: 28px 24px; + animation: page-in 0.35s ease both; +} +@keyframes page-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + diff --git a/frontend/src/styles/panels.css b/frontend/src/styles/panels.css new file mode 100644 index 0000000..45c3a3f --- /dev/null +++ b/frontend/src/styles/panels.css @@ -0,0 +1,65 @@ +/* ---------- Side panel (Plot / Insights) ---------- */ + +.play-layout { flex: 1; display: flex; min-height: calc(100dvh - 56px); } +.play-layout .play-page { max-width: 960px; } +.play-layout.with-panel .play-page { max-width: none; } + +.panel-toggles { display: flex; gap: 8px; } +.panel-toggles button.active { + color: var(--accent-bright); + border-color: var(--accent-dim); + box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08); +} + +.side-panel { + width: 420px; + flex-shrink: 0; + border-left: 1px solid var(--border); + background: var(--bg-panel); + padding: 20px; + overflow-y: auto; + height: calc(100dvh - 56px); + position: sticky; + top: 56px; + animation: panel-in 0.25s ease both; +} + +/* ---------- In-play script viewer ---------- */ + +.adv-script { + border-bottom: 1px solid var(--border); + padding: 10px 0; +} +.adv-script-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.adv-script-desc { font-size: 0.82rem; margin-top: 4px; } +.linklike { + border: none; + background: none; + padding: 0; + color: var(--accent-bright); + font-size: 0.8rem; + cursor: pointer; + flex-shrink: 0; +} +.linklike:hover { text-decoration: underline; } +.adv-script-code { margin-top: 8px; } +.adv-script-hook { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + margin: 8px 0 2px; +} +.adv-script-code pre { + margin: 0; + padding: 10px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + overflow-x: auto; + font-family: var(--font-mono, monospace); + font-size: 0.76rem; + line-height: 1.5; + white-space: pre; +} + diff --git a/frontend/src/styles/play.css b/frontend/src/styles/play.css new file mode 100644 index 0000000..117c668 --- /dev/null +++ b/frontend/src/styles/play.css @@ -0,0 +1,316 @@ +/* ---------- Play page ---------- */ + +.play-page { + display: flex; + flex-direction: column; + min-height: calc(100dvh - 56px); + /* Room to scroll the last beat clear of a composer lifted by the keyboard + (see --kb-inset in keyboard.js); 0 on desktop and on browsers that shrink + the layout viewport themselves. Margin, not padding — .page already sets + padding and a longhand here would drop its bottom gutter. */ + margin-bottom: var(--kb-inset, 0px); +} + +/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story + scrolls — pin the header just below the 56px top nav. */ +.play-page .page-header { + position: sticky; + top: 56px; + z-index: 5; + background: var(--bg); + margin-bottom: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.story { + flex: 1; + font-family: var(--font-story); + font-size: 1.14rem; + line-height: 1.7; + white-space: pre-wrap; + padding-bottom: 16px; +} +/* The head of a windowed transcript: how much story is still above, and the + way back to it. Deliberately quiet — it is a seam in the page, not a + feature — and it sits inside .story so it inherits the story face. */ +.story-earlier { + text-align: center; + margin: 4px 0 22px; + font-size: 0.9rem; + /* No action-in animation here: this appears above content the reader is + already looking at, and a fade would read as the story moving. */ +} +.story-earlier .dim { color: var(--text-dim); font-style: italic; } +.story-earlier button { + background: none; + border: none; + border-bottom: 1px solid rgba(159, 199, 209, 0.3); + color: var(--text-dim); + font: inherit; + font-style: italic; + cursor: pointer; + padding: 2px 4px; +} +.story-earlier button:hover { + color: var(--accent-bright); + border-bottom-color: var(--accent-bright); +} +.story .action { + position: relative; + margin-bottom: 15px; + padding-right: 56px; + animation: action-in 0.4s ease both; +} +@keyframes action-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} +.story .action.player { + color: var(--player); + border-left: 2px solid rgba(159, 199, 209, 0.35); + padding-left: 14px; + font-style: italic; +} +.story .action.streaming { color: var(--text); } +.story .cursor { + color: var(--accent-bright); + text-shadow: 0 0 8px var(--accent-glow); + animation: blink 1s steps(1) infinite; +} +@keyframes blink { 50% { opacity: 0; } } + +/* Reasoning-model thinking, collapsed above the AI's response */ +.story .reasoning { + margin: 0 0 8px; + font-size: 0.82rem; + line-height: 1.5; + color: var(--text-dim); + font-style: normal; +} +.story .reasoning summary { + cursor: pointer; + user-select: none; + color: var(--text-dim); + opacity: 0.8; +} +.story .reasoning summary:hover { opacity: 1; } +.story .reasoning .reasoning-text { + margin-top: 6px; + padding: 8px 12px; + border-left: 2px solid var(--border); + opacity: 0.85; + max-height: 260px; + overflow-y: auto; +} + +.story .action-tools { + position: absolute; + top: 0; + right: 0; + display: inline-flex; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; +} +.story .action:hover .action-tools { opacity: 1; pointer-events: auto; } +.story .action-tools button { + padding: 1px 8px; + font-size: 0.72rem; + font-family: var(--font-ui); + color: var(--text-dim); + background: var(--bg-panel-glass); + backdrop-filter: blur(6px); +} +.story .action-tools button:hover { color: var(--text); } + +/* The takes of one turn: ‹ 2/4 ›. Stays quiet until hovered — it's a footnote + on the message, not part of the prose. + + Small and unemphatic on purpose. Stepping through takes reads them and does + nothing else, so it should not carry the visual weight of a decision; the + decision is made by writing, which happens in the composer below. */ +.take-pager { + display: flex; + align-items: center; + gap: 2px; + margin-top: 6px; + font-family: var(--font-ui); + opacity: 0.45; + transition: opacity 0.15s; +} +.story .action:hover .take-pager, +.take-pager:focus-within { opacity: 1; } +.take-pager button { + width: 20px; + height: 20px; + padding: 0; + font-size: 0.9rem; + line-height: 1; + color: var(--text-dim); + background: transparent; + border: 1px solid transparent; + border-radius: 4px; +} +.take-pager button:hover:not(:disabled) { + color: var(--text); + border-color: var(--border-bright); +} +.take-pager button:disabled { opacity: 0.3; cursor: default; } +.take-count { + min-width: 30px; + text-align: center; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + color: var(--text-dim); +} +/* Only while a take that isn't the live one is on screen: it says what the + next thing typed will do, because that is the click that forks. */ +.take-note { + margin-left: 6px; + font-size: 0.72rem; + font-style: italic; + color: var(--accent-dim); +} + +/* ---------- Branches panel (Phase 14, SP7) ---------- */ + +.panel-empty { + padding: 18px 0; + color: var(--text-dim); + font-size: 0.85rem; +} +.branch-panel { display: flex; flex-direction: column; gap: 14px; } +.branch-intro { + margin: 0; + color: var(--text-dim); + font-size: 0.82rem; + line-height: 1.55; +} +.branch-list { display: flex; flex-direction: column; gap: 8px; } +.branch-row { + display: flex; + flex-direction: column; + gap: 5px; + padding: 9px 11px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-input); +} +.branch-row.here { + border-color: var(--accent-dim); + background: rgba(212, 169, 78, 0.07); +} +.branch-head { display: flex; align-items: center; gap: 7px; } +.branch-glyph { color: var(--border-bright); font-size: 0.85rem; } +.branch-row.here .branch-glyph { color: var(--accent); } +.branch-name { + font-family: var(--font-story); + font-size: 0.98rem; + color: var(--text); +} +.branch-row.here .branch-name { color: var(--accent-bright); } +.branch-here { + margin-left: auto; + font-size: 0.64rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent-dim); +} +.branch-rename { + flex: 1; + min-width: 0; + padding: 3px 7px; + font-family: var(--font-story); + font-size: 0.95rem; +} +.branch-meta { + font-size: 0.72rem; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} +.branch-tools, .branch-confirm { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; } +.branch-tools button, .branch-confirm button { + padding: 3px 9px; + font-size: 0.72rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); +} +.branch-tools button:hover:not(:disabled), +.branch-confirm button:hover:not(:disabled) { + color: var(--accent-bright); + border-color: var(--border-bright); +} +.branch-tools button.danger:hover:not(:disabled), +.branch-confirm button.danger:hover:not(:disabled) { + color: var(--danger); + border-color: var(--danger); +} +.branch-tools button:disabled, .branch-confirm button:disabled { opacity: 0.4; cursor: default; } +.branch-confirm span { + font-size: 0.74rem; + color: var(--danger); + flex-basis: 100%; +} + +.action-edit { margin-bottom: 14px; } +/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually + several paragraphs, and the old fixed 110px turned that into a keyhole. + min-height keeps a short player action from collapsing; max-height stops a + very long beat from pushing Save/Cancel off the screen (it scrolls instead). + resize stays on, so the height is a starting point, not a cage. */ +.action-edit textarea { + min-height: 110px; + max-height: 65vh; + overflow-y: auto; + font-family: inherit; + font-size: inherit; +} +.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; } + +.play-controls { + position: sticky; + /* Sits on the keyboard's top edge when one is open, on the viewport bottom + otherwise — --kb-inset is 0 unless a virtual keyboard is covering us. */ + bottom: var(--kb-inset, 0px); + background: linear-gradient(to top, var(--bg) 75%, transparent); + padding: 18px 0 16px; + border-top: 1px solid var(--border); +} +.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; } +.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); } +.turn-buttons button:hover:not(:disabled) { color: var(--text); } + +.input-bar { display: flex; gap: 8px; align-items: flex-end; } +.input-bar input { flex: 1; } +/* Auto-growing action box: starts at one line, grows to ~4, then scrolls. */ +.input-bar textarea { + flex: 1; + resize: none; + min-height: 0; + max-height: calc(6em + 20px); /* ~4 lines (1.5em each) + padding/border */ + overflow-y: auto; + line-height: 1.5; +} + +.mode-select { display: flex; } +.mode-select button { + border-radius: 0; + border-right-width: 0; + padding: 7px 14px; + color: var(--text-dim); + text-transform: capitalize; +} +.mode-select button:first-child { border-radius: 8px 0 0 8px; } +.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; } +.mode-select button.active { + color: var(--accent-bright); + background: var(--bg-panel); + border-color: var(--accent-dim); + border-right-width: 1px; + box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08); +} +.mode-select button.active + button { border-left-width: 0; } + diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css new file mode 100644 index 0000000..61e2a68 --- /dev/null +++ b/frontend/src/styles/responsive.css @@ -0,0 +1,219 @@ +/* ============================================================ + Mobile / narrow screens (≤ 720px) + The desktop Play screen is a horizontal row of up to four columns + (two left rails + story + right side-panel). Phones can't fit that, + so here we collapse to a single story column and turn the rails and + side-panel into full-height overlays. Everything above this query is + the untouched desktop design. + ============================================================ */ +@media (max-width: 720px) { + + /* ---------- Top nav → hamburger dropdown ---------- */ + .topnav { gap: 12px; padding: 0 16px; } + .nav-hamburger { display: block; } + .nav-links { + display: none; + position: absolute; + top: 56px; + left: 0; + right: 0; + flex-direction: column; + align-items: stretch; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55); + padding: 4px 0; + } + .nav-links.open { display: flex; } + .topnav a.navlink { + padding: 13px 20px; + border-bottom: none; + border-left: 3px solid transparent; + } + .topnav a.navlink.active { + border-bottom-color: transparent; + border-left-color: var(--accent); + } + .nav-account { + margin-left: 0; + padding: 12px 20px; + border-top: 1px solid var(--border); + flex-wrap: wrap; + } + .account-email { max-width: 100%; } + + /* ---------- Global page padding / typography ---------- */ + .page { padding: 18px 14px; } + /* 16px inputs stop iOS Safari auto-zooming when a field is focused. */ + input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select { + font-size: 16px; + } + + /* ---------- Play screen → single column ---------- */ + .play-layout { flex-direction: column; } + .play-layout .play-page, + .play-layout.with-panel .play-page { max-width: 100%; } + .play-page .page-header h1 { font-size: 1.25rem; } + .panel-toggles { flex-wrap: wrap; } + .story { font-size: 1.06rem; } + /* Reserve a left gutter so the fixed World/State edge-tabs never sit on + top of the story text as it scrolls past them. */ + .story, .play-page .empty { padding-left: 26px; } + .story .action { padding-right: 0; } + /* No hover on touch — keep per-action edit/delete tools visible. */ + .story .action-tools { opacity: 1; pointer-events: auto; } + + /* ---------- Left rails (World State / Script State) → edge tabs + overlay ---------- */ + /* Closed: a compact tab pinned to the left edge instead of a full-height + column that steals the story's width. World State sits above Script State. + (World State only renders for RPG adventures, so it may be absent.) */ + .status-drawer { + position: fixed; + left: 0; + z-index: 40; + } + .status-drawer:not(.open) { + top: 170px; + height: auto; + background: var(--bg-panel); + border: 1px solid var(--border); + border-left: none; + border-radius: 0 10px 10px 0; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); + } + .ws-drawer:not(.open) { top: 170px; } + .status-drawer:not(.ws-drawer):not(.open) { top: 260px; } + .status-drawer:not(.open) .status-toggle { height: auto; padding: 14px 4px; } + + /* Open: slide-in overlay covering most of the screen. */ + .status-drawer.open { + top: 56px; + height: calc(100dvh - 56px); + width: min(88vw, 320px); + box-shadow: 0 0 50px rgba(0, 0, 0, 0.65); + border-right: 1px solid var(--border-bright); + } + + /* ---------- Right side-panel (Plot/Memory/Scripts/Insights) → full overlay ---------- */ + .side-panel { + position: fixed; + inset: 56px 0 0 0; + width: auto; + height: auto; + z-index: 45; + border-left: none; + padding: 16px; + } + + /* ---------- Home / library surfaces ---------- */ + + /* Both grids go single-column: the wide variant's 320px minimum would + otherwise overflow a 360px phone once card padding is counted. */ + .card-grid, .card-grid.wide { grid-template-columns: 1fr; } + + .hall { padding: 2px 0 20px; } + .hall-sub { font-size: 0.95rem; } + .home-section { margin-top: 26px; } + + /* The label eats most of a narrow line, so drop the short leading rule and + let the trailing one take whatever space is left. */ + .rule { letter-spacing: 0.16em; font-size: 0.76rem; gap: 10px; } + .rule::before { display: none; } + .rule-head { gap: 10px; } + + /* Ornament scales down rather than crowding the corners on a small card. */ + .card.tome { padding: 14px 15px 12px; } + .card.tome::before, .card.tome::after { width: 9px; height: 9px; } + /* Always-on filigree: there's no hover on touch. */ + .card.tome::before, .card.tome::after { opacity: 0.8; } + .seal { width: 21px; height: 21px; top: 10px; right: 10px; font-size: 0.54rem; } + .card.tome:has(> .seal) .card-head { padding-right: 22px; } + .snippet { font-size: 0.94rem; -webkit-line-clamp: 4; } + .snippet.dropcap::first-letter { font-size: 2rem; padding-right: 6px; } + + /* Motes are pure decoration — thin them out where the GPU is weakest and + the effect is least visible. (Embers.jsx already scales count to the + viewport; this drops the opacity too.) */ + .embers { opacity: 0.55; } + + /* Full-width toasts, clear of the sticky play controls. */ + .toast-host { + left: 12px; + right: 12px; + bottom: 14px; + transform: none; + width: auto; + max-width: none; + align-items: stretch; + } + .toast { font-size: 0.82rem; } + + /* Art picker stacks; the emoji grid stays tappable at 34px minimum. */ + .art-picker { gap: 12px; } + .art-preview { width: 64px; height: 64px; } + .art-controls { min-width: 0; } + + /* The drop cap on the opening beat competes with the edge tabs at this + width, so shrink it and keep it inside the 26px story inset. */ + .story .action.opening::first-letter { + font-size: 2.5rem; + padding: 4px 8px 0 0; + } + + /* ---------- Composers: give the typing area the full width ---------- + Both composers are one flex row on desktop, which on a phone left the + textarea with a sliver: the Play bar spends ~180px on Do/Say/Story plus + ~75px on Send, and the chat bar ~90px on Send. Letting the row wrap and + moving the buttons onto their own line hands that width back to the + textarea, which is the part you actually type into. + + Play: Do/Say/Story and Send share the top row (Send pushed to the right + edge), and `order` drops the textarea onto its own full-width row beneath + them. Two rows, and the typing area goes from ~138px to the full width — + measured at a 390px composer. */ + .input-bar { flex-wrap: wrap; } + .mode-select button { flex: 1; padding: 7px 10px; } + .input-bar > button { margin-left: auto; } + /* min-width:0 lets a flex item shrink below its content width. */ + .input-bar textarea { order: 1; flex: 1 0 100%; margin-top: 8px; min-width: 0; } + + /* ---------- AI Chat ---------- */ + /* No hover on touch, so the per-message copy/delete links stay visible. */ + .chat-msg-actions { opacity: 1; } + .chat-msg { padding: 9px 11px 11px; } + .chat-composer { padding: 10px 0 14px; flex-wrap: wrap; } + /* Full-width textarea; Send wraps beneath it and sits at the right edge. */ + .chat-composer textarea { flex: 1 1 100%; max-height: 140px; } + .chat-composer button { margin-left: auto; } + + /* ---------- The branch map ---------- */ + /* A phone has no room for a dialog inset from the edges, and the tree is the + one thing on this screen that wants every pixel across. */ + .branch-map { + width: 100vw; + max-width: none; + max-height: 100dvh; + height: 100dvh; + border: none; + border-radius: 0; + } + .branch-map-header { padding: 14px 16px 10px; flex-wrap: wrap; } + .branch-map-canvas { padding: 4px 6px 8px; } + .branch-map-detail { padding: 12px 16px 16px; } + .branch-map-hint { padding: 0 16px 10px; } + + /* ---------- Analytics dashboard ---------- */ + .an-page .page-header { flex-direction: column; align-items: flex-start; gap: 10px; } + .an-tiles { gap: 8px; } + .an-tile { padding: 10px 12px; } + .an-tile-value { font-size: 1.35rem; } + .an-grid { grid-template-columns: 1fr; } + .an-plot { height: 140px; } + .an-tabs button { padding: 8px 10px; font-size: 0.78rem; } + .an-log-search { max-width: none; width: 100%; } + /* Three columns don't fit; the label goes above its own bar instead. */ + .an-funnel-row { grid-template-columns: 1fr auto; gap: 4px 10px; } + .an-funnel-track { grid-column: 1; } + .an-funnel-label { grid-column: 1 / -1; } + .an-funnel-value { grid-column: 2; } +} diff --git a/frontend/src/styles/schema-editor.css b/frontend/src/styles/schema-editor.css new file mode 100644 index 0000000..dacc093 --- /dev/null +++ b/frontend/src/styles/schema-editor.css @@ -0,0 +1,243 @@ +/* ---- Form-based schema editor ---- + One shape for everything: a section is a panel, every entry inside it is a + `.se-row` tile, and every control wears a `.se-field` caption. Adding a new + kind of entry means reusing those three, not inventing a fourth box. */ +.se-section { + margin-bottom: 16px; + padding: 13px 15px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-panel); +} +.se-section-nested { + margin: 10px 0 0; + padding: 11px 12px; + background: rgba(0, 0, 0, 0.22); + border-radius: 8px; +} +.se-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.se-section-title { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--accent); +} +.se-hint { + margin: -2px 0 10px; + font-size: 0.78rem; + line-height: 1.45; + color: var(--text-dim); +} +.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; } +.se-add, .se-add-sm { + font-size: 0.78rem; + padding: 3px 10px; + white-space: nowrap; +} +.se-add-sm { margin-top: 6px; } +.se-remove { + background: transparent; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 2px 6px; + font-size: 0.9rem; + line-height: 1; + flex-shrink: 0; +} +.se-remove:hover { color: var(--danger, #e5484d); } + +/* Every control in the editor, one look. The global field rule keys off + `input[type="…"]`, so the plain ``s here (keys, descriptions) never + matched it and fell back to browser defaults — that mismatch is most of why + this panel used to read as a pile of unrelated boxes. Everything below is + qualified with `.se` so it outranks that global rule (0,1,1) too. */ +/* `:where()` so the exclusion costs no specificity — a plain + `:not([type="checkbox"])` would rank this above the `.se .se-*` overrides + below and swallow every one of them. */ +.se input:not(:where([type="checkbox"])), .se textarea, .se select { + width: 100%; + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 8px; + font-size: 0.84rem; + font-family: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} + +/* One entry — a stat, a flag, a milestone. */ +.se-row { + padding: 10px 12px; + margin-bottom: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.018); + transition: border-color 0.15s; +} +.se-row:last-child { margin-bottom: 0; } +.se-row:focus-within { border-color: var(--border-bright); } +/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and + an input's default `min-width: auto` would push the ✕ off-screen instead. */ +.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; } +.se .se-key { + font-family: var(--font-mono, monospace); + font-size: 0.82rem; + min-width: 0; + max-width: 220px; +} +/* Only in the row context — `.se-field` is a *column* flex, where a basis of + 140px would set the input's height instead of its width. */ +.se .se-row-top .se-key { flex: 1 1 140px; } +.se-field { display: flex; flex-direction: column; gap: 3px; } +.se-field > span { + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--text-dim); +} +.se-fields { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: flex-end; + margin-bottom: 8px; +} +.se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; } +.se .se-kind select { width: 132px; } +.se .se-initial-text input { width: 220px; max-width: 100%; } +.se-check { + display: flex; align-items: center; gap: 5px; + font-size: 0.8rem; color: var(--text-dim); + white-space: nowrap; +} +.se-check input { width: auto; } +/* The global textarea rule also forces min-height:70px, which a 2-row inline + field doesn't want. */ +.se .se-area { min-height: 0; line-height: 1.45; resize: vertical; } +.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; } +.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; } +/* Qualified so it outweighs the global input[type="number"] rule (0,1,1) — + as a bare class it lost, and the band bounds rendered full-width. */ +.se .se-band-n { width: 64px; padding: 4px 6px; } +.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; } +.se-bands { margin-top: 8px; } + +/* ---- NPC roster (its own page section) ---- */ +.se-npc-grid { + display: grid; + /* min() so a 320px phone gets one column instead of a 340px overflow */ + grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr)); + gap: 14px; + align-items: start; +} +.se-npc { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-panel); + overflow: hidden; +} +.se-npc:focus-within { border-color: var(--border-bright); } +.se-npc-head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent); +} +.se-avatar { + flex-shrink: 0; + width: 34px; height: 34px; + border-radius: 50%; + display: grid; place-items: center; + font-family: var(--font-display); + font-size: 0.8rem; + letter-spacing: 0.02em; + color: #14100a; + background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim)); +} +.se-npc-ident { flex: 1; min-width: 0; } +.se .se-npc-name { + padding: 2px 6px; + font-family: var(--font-display); + font-size: 0.95rem; + background: transparent; + border-color: transparent; +} +.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); } +.se-npc-addr { + display: block; + margin: 1px 0 0 6px; + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + color: var(--text-dim); +} +.se-npc-count { + flex-shrink: 0; + font-size: 0.7rem; + color: var(--text-dim); + white-space: nowrap; +} +.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; } +.se-npc-idfield .se-key { max-width: 100%; } +.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; } +.ws-report li { padding: 2px 0; color: var(--text); } + +/* Inline per-turn change chips under an AI message */ +.turn-changes { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.chg { + font-size: 0.74rem; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text-dim); + font-variant-numeric: tabular-nums; + /* A chip must never be wider than the column it sits in: an NPC stat like + "bandit leader aggression +10" overruns a phone, and the chip row is + inside the story text, which has nothing to scroll. The label may break + mid-word as a last resort; the value is glued to it by its own nowrap so + the sign and number never end up on separate lines. */ + max-width: 100%; + overflow-wrap: anywhere; +} +.chg-val { white-space: nowrap; } +.chg-up { color: var(--player, #4caf82); border-color: color-mix(in srgb, var(--player, #4caf82) 40%, var(--border)); } +.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); } +.chg-flag { color: var(--accent-bright); } +.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); } +/* A refused change, and a clamp that left the value where it started. The + dashed border separates it from the changes that took effect. It is not + styled as an error, because the rules refusing a change is them working. */ +.chg-refused { + border-style: dashed; + color: var(--text-dim); + border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); +} +/* A clamp that reduced a change but still moved the value. */ +.chg-limited { opacity: 0.75; font-style: italic; } +.ws-report code { color: var(--accent-bright); } +.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; } +.ws-flag { color: var(--text-dim); font-style: italic; } +.ws-rejected { color: var(--text-dim); } +.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.side-panel-header h2 { + margin: 0; + font-family: var(--font-display); + font-size: 1.05rem; + color: var(--accent); +} + diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..17e380a --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,50 @@ +:root { + --bg: #0a0a0f; + --bg-panel: #131320; + --bg-panel-glass: rgba(19, 19, 32, 0.82); + --bg-input: #1a1a2a; + --border: #2b2b3d; + --border-bright: #3d3d55; + --text: #e2ddd0; + --text-dim: #918c7d; + --accent: #d4a94e; + --accent-bright: #e8c476; + --accent-dim: #96773a; + --accent-glow: rgba(212, 169, 78, 0.25); + --danger: #d06565; + --player: #9fc7d1; + /* Chart hues (analytics dashboard). Deeper and more saturated than --accent + and --player, which are tuned for text and borders and turn muddy once + they are a 10px bar: these are checked against --bg-panel for lightness, + chroma, contrast and colour-blind separation as a set. */ + --chart-1: #b58a30; + --chart-2: #3d8ac4; + --chart-3: #a8608f; + --font-display: 'Cinzel', Georgia, serif; + --font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif; + --font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif; + font-family: var(--font-ui); + color-scheme: dark; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: + radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%), + radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%), + var(--bg); + background-attachment: fixed; + color: var(--text); + min-height: 100vh; +} + +#root { min-height: 100vh; display: flex; flex-direction: column; } + +h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; } +a { color: var(--accent); text-decoration: none; transition: color 0.15s; } +a:hover { color: var(--accent-bright); } + +::selection { background: var(--accent-dim); color: #0f0d08; } + diff --git a/frontend/src/styles/tome.css b/frontend/src/styles/tome.css new file mode 100644 index 0000000..c592564 --- /dev/null +++ b/frontend/src/styles/tome.css @@ -0,0 +1,475 @@ +/* ============================================================ + Illuminated tome — ambient layer, ornament, art plates, cards + Everything below styles the home/library surfaces. It comes after + the base .card rules on purpose: several selectors here tie with + earlier ones on specificity and rely on source order to win. + ============================================================ */ + +/* ---------- Ambient drifting motes ---------- */ + +/* Behind every page: fixed, unclickable, and out of the stacking flow. The + canvas is sized in JS (see Embers.jsx); z-index 0 with the app's own + surfaces at 1+ keeps it under content without needing a negative index + (which would drop it behind the body background). */ +.embers { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.85; +} +/* .topnav already carries z-index 10 — deliberately not touched here, so it + keeps sitting above page content as well as above the canvas. */ +.page { position: relative; z-index: 1; } + +/* A third gradient pool warming the middle of the ground. `background` is a + shorthand, so background-attachment has to be restated or it resets to + scroll and the gradients start sliding with the page. */ +body { + background: + radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.07), transparent 60%), + radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.1), transparent 55%), + radial-gradient(760px 520px at 50% 42%, rgba(150, 119, 58, 0.05), transparent 62%), + var(--bg); + background-attachment: fixed; +} + +/* ---------- Home banner ---------- */ + +.hall { + display: flex; + flex-direction: column; + gap: 5px; + padding: 6px 0 26px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border); + position: relative; +} +/* Gold hairline that fades out toward both ends, sat on the border above. */ +.hall::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent-dim) 22%, var(--accent-dim) 78%, transparent); + opacity: 0.55; +} +.hall-eyebrow { + margin: 0; + font-size: 0.68rem; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--accent-dim); + font-weight: 600; +} +.hall-title { + margin: 0; + font-family: var(--font-display); + font-size: clamp(1.7rem, 4.6vw, 2.3rem); + font-weight: 600; + letter-spacing: 0.05em; + color: var(--accent-bright); + text-shadow: 0 0 30px var(--accent-glow); + text-wrap: balance; +} +.hall-sub { + margin: 0; + color: var(--text-dim); + font-family: var(--font-story); + font-size: 1.02rem; +} + +/* ---------- Ornamental section rules ---------- */ + +.home-section { margin-top: 34px; } + +.rule-head { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} +.rule { + flex: 1; + display: flex; + align-items: center; + gap: 14px; + margin: 0; + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.26em; + text-transform: uppercase; + color: var(--accent-bright); + white-space: nowrap; +} +/* Rules grow to fill whatever the label leaves — the label stays optically + centred in its own space without needing a fixed width. */ +.rule::before, .rule::after { + content: ''; + height: 1px; + flex: 1; + background: linear-gradient(90deg, transparent, var(--accent-dim) 45%, var(--accent-dim) 55%, transparent); +} +.rule::before { flex: 0 0 26px; } +.rule > span { flex: none; } + +button.linklike.ornate { + font-family: var(--font-display); + font-size: 0.74rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent-dim); + white-space: nowrap; + transition: color 0.15s; +} +button.linklike.ornate:hover { color: var(--accent-bright); text-decoration: none; } + +/* ---------- Art plates ---------- */ + +.art { + width: 38px; + height: 38px; + flex: none; + border-radius: 3px; + border: 1px solid var(--accent-dim); + box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7); + overflow: hidden; + display: grid; + place-items: center; + line-height: 1; +} +.art-lg { width: 46px; height: 46px; } +.art-photo img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.art-icon { font-size: 1.15rem; } +.art-lg.art-icon { font-size: 1.4rem; } +.art-mono { + font-family: var(--font-display); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.06em; + color: rgba(232, 196, 118, 0.92); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.65); +} +.art-lg.art-mono { font-size: 0.95rem; } + +/* ---------- Tome cards ---------- */ + +.card-grid.wide { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } + +.card.tome { + border-radius: 3px; + padding: 16px 17px 14px; + background: + linear-gradient(168deg, rgba(212, 169, 78, 0.05), transparent 55%), + var(--bg-panel); + /* Inner dark gutter + a gold keyline: the double-rule border of a plate in + an old book, done with shadows so it costs no extra element. */ + box-shadow: + inset 0 0 0 1px rgba(10, 10, 15, 0.9), + inset 0 0 0 2px rgba(150, 119, 58, 0.22); +} + +/* Filigree corner brackets. These override the base .card::before gradient + bar (equal specificity, later in the file wins). */ +.card.tome::before, +.card.tome::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border: 1px solid var(--accent-dim); + opacity: 0.5; + transition: opacity 0.2s; + background: none; + inset: auto; +} +.card.tome::before { + top: 6px; + left: 6px; + border-right: 0; + border-bottom: 0; +} +.card.tome::after { + bottom: 6px; + right: 6px; + border-left: 0; + border-top: 0; +} +.card.tome:hover::before, +.card.tome:hover::after { opacity: 1; } +.card.tome:hover { + border-color: var(--accent-dim); + box-shadow: + inset 0 0 0 1px rgba(10, 10, 15, 0.9), + inset 0 0 0 2px rgba(212, 169, 78, 0.4), + 0 12px 30px rgba(0, 0, 0, 0.5), + 0 0 26px rgba(212, 169, 78, 0.08); +} + +.card-head { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; } +/* Keep headings clear of the wax seal, which is absolutely positioned over + the card's top-right corner. Only cards that actually carry one pay the + indent, so scenario cards keep their full width. */ +.card.tome:has(> .seal) .card-head { padding-right: 26px; } +.card-headings { min-width: 0; } +.card.tome h3 { + margin: 0; + font-family: var(--font-display); + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.045em; + color: var(--accent); + overflow-wrap: anywhere; +} +.card-from { + margin: 3px 0 0; + font-size: 0.75rem; + color: var(--text-dim); + overflow-wrap: anywhere; +} + +/* Wax seal marking a story in progress. */ +.seal { + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + border-radius: 50%; + background: radial-gradient(circle at 34% 30%, #b8553f, #7d2f24 72%); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6), inset 0 -1px 3px rgba(0, 0, 0, 0.45); + display: grid; + place-items: center; + font-size: 0.6rem; + color: #f0d5b0; + z-index: 1; +} + +.snippet { + margin: 0; + font-family: var(--font-story); + font-size: 0.98rem; + line-height: 1.5; + color: #b3ac9c; + /* Clamp so a long beat can't stretch one card taller than its row. */ + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.snippet.muted { color: var(--text-dim); font-style: italic; } +.snippet.dropcap::first-letter { + float: left; + font-family: var(--font-display); + font-size: 2.3rem; + line-height: 0.85; + font-weight: 700; + color: var(--accent); + padding: 3px 7px 0 0; +} + +.card-foot { + display: flex; + align-items: center; + gap: 8px; + margin-top: 13px; + padding-top: 11px; + border-top: 1px solid var(--border); + font-size: 0.74rem; + color: var(--text-dim); + flex-wrap: wrap; +} +.card-foot .turns { font-variant-numeric: tabular-nums; } +.card-foot .dot { opacity: 0.5; } +.card-foot .resume { + margin-left: auto; + color: var(--accent-dim); + font-family: var(--font-ui); + letter-spacing: 0.1em; + text-transform: uppercase; + font-size: 0.68rem; + font-weight: 600; + transition: color 0.15s; +} +.card.tome:hover .resume { color: var(--accent-bright); } +.tag-cluster { display: flex; gap: 5px; flex-wrap: wrap; min-width: 0; } +.card-actions { margin-left: auto; display: inline-flex; gap: 5px; } + +button.tiny { padding: 2px 9px; font-size: 0.72rem; } +button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: auto; } + +/* Staggered card entrance. The per-card delay is set inline. */ +.enter { animation: card-in 0.4s ease both; } +@keyframes card-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} + +/* ---------- Loading skeletons ---------- */ + +.skeleton-card { + cursor: default; + pointer-events: none; + animation: card-in 0.3s ease both; +} +.skeleton-card::before, .skeleton-card::after { content: none; } +.skeleton-card:hover { transform: none; border-color: var(--border); box-shadow: none; } +.sk { + border-radius: 3px; + background: linear-gradient(90deg, #1c1c2b 25%, #262639 50%, #1c1c2b 75%); + background-size: 220% 100%; + animation: shimmer 1.4s ease-in-out infinite; +} +.sk-plate { width: 38px; height: 38px; margin-bottom: 12px; } +.sk-title { height: 13px; width: 58%; margin-bottom: 12px; } +.sk-line { height: 9px; margin-bottom: 8px; } +@keyframes shimmer { + from { background-position: 120% 0; } + to { background-position: -120% 0; } +} + +/* ---------- Global toasts ---------- */ + +.toast-host { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + z-index: 120; + display: flex; + flex-direction: column-reverse; + gap: 8px; + align-items: center; + width: max-content; + max-width: min(92vw, 520px); + pointer-events: none; +} +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 9px; + text-align: left; + font-family: var(--font-ui); + font-size: 0.86rem; + line-height: 1.45; + padding: 10px 16px; + border-radius: 8px; + background: var(--bg-panel-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border-bright); + border-left: 3px solid var(--accent); + color: var(--text); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + animation: rise 0.25s ease both; +} +.toast:hover { border-color: var(--accent-dim); } +.toast-error { border-left-color: var(--danger); } +.toast-mark { color: var(--accent); flex: none; line-height: 1.5; } +.toast-error .toast-mark { color: var(--danger); } +@keyframes rise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} + +/* ---------- Story text polish ---------- */ + +/* Drop cap opening the story's first narrated beat. */ +.story .action.opening::first-letter { + float: left; + font-family: var(--font-display); + font-size: 3.1rem; + line-height: 0.82; + font-weight: 700; + color: var(--accent); + padding: 5px 10px 0 2px; + text-shadow: 0 0 22px var(--accent-glow); +} + +/* Ornamental separator between turns — a diamond flanked by fading rules. + Marks the beat change without the heaviness of a full
. */ +.scene-break { + display: flex; + align-items: center; + gap: 12px; + margin: 4px 0 16px; + color: var(--accent-dim); + font-size: 0.6rem; + user-select: none; +} +.scene-break::before, .scene-break::after { + content: ''; + height: 1px; + flex: 1; + background: linear-gradient(90deg, transparent, rgba(150, 119, 58, 0.4), transparent); +} + +/* Thinking indicator — three motes pulsing in sequence. */ +.thinking { + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--font-ui); + font-size: 0.78rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent-dim); +} +.thinking i { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); + animation: mote 1.25s ease-in-out infinite; +} +.thinking i:nth-child(2) { animation-delay: 0.18s; } +.thinking i:nth-child(3) { animation-delay: 0.36s; } +@keyframes mote { + 0%, 100% { opacity: 0.25; transform: scale(0.8); } + 45% { opacity: 1; transform: scale(1.15); } +} + +/* ---------- Scenario art picker (editor) ---------- */ + +.art-picker { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; } +.art-preview { + width: 76px; + height: 76px; + flex: none; + border-radius: 3px; + border: 1px solid var(--accent-dim); + box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7); + overflow: hidden; + display: grid; + place-items: center; +} +.art-preview img { width: 100%; height: 100%; object-fit: cover; display: block; } +.art-preview .glyph { font-size: 2rem; line-height: 1; } +.art-preview .mono { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 700; + color: rgba(232, 196, 118, 0.92); +} +.art-controls { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 9px; } +.art-buttons { display: flex; gap: 8px; flex-wrap: wrap; } +.icon-row { display: flex; gap: 5px; flex-wrap: wrap; } +.icon-row button { + padding: 3px 8px; + font-size: 1rem; + line-height: 1.2; + min-width: 34px; +} +.icon-row button.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); } +.art-hint { font-size: 0.76rem; color: var(--text-dim); line-height: 1.45; margin: 0; } + diff --git a/frontend/src/styles/tree-map.css b/frontend/src/styles/tree-map.css new file mode 100644 index 0000000..6e7533e --- /dev/null +++ b/frontend/src/styles/tree-map.css @@ -0,0 +1,159 @@ +/* ---------- The story tree, drawn (branch map overlay) ---------- */ + +.branch-map-open { + align-self: flex-start; + padding: 5px 12px; + font-size: 0.74rem; + letter-spacing: 0.08em; + color: var(--accent); + background: transparent; + border: 1px solid var(--border-bright); +} +.branch-map-open:hover { color: var(--accent-bright); border-color: var(--accent-dim); } + +/* The overlay is deliberately wider and shorter than .modal: a tree is read + across, and the number of branches decides the height. */ +.branch-map-overlay { z-index: 60; } +.branch-map { + display: flex; + flex-direction: column; + width: min(1080px, calc(100vw - 40px)); + max-height: min(760px, calc(100vh - 60px)); + background: var(--bg-panel); + border: 1px solid var(--border-bright); + border-radius: 14px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06); +} +.branch-map-header { + display: flex; + align-items: baseline; + gap: 12px; + padding: 18px 22px 12px; + border-bottom: 1px solid var(--border); +} +.branch-map-header h2 { + margin: 0; + font-family: var(--font-display); + font-size: 1.15rem; + color: var(--accent-bright); +} +.branch-map-scale { + font-size: 0.72rem; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} +.branch-map-close { + margin-left: auto; + align-self: center; + padding: 2px 8px; + color: var(--text-dim); + background: transparent; + border: 1px solid transparent; +} +.branch-map-close:hover { color: var(--accent-bright); border-color: var(--border); } + +.branch-map-canvas { + flex: 1; + min-height: 120px; + /* Height comes from the branch count, so a deep tree scrolls rather than the + diagram shrinking until nobody can read it. Width never scrolls — the map + is scaled to fit, which is the whole point of drawing it to one clock. */ + overflow-y: auto; + overflow-x: hidden; + padding: 4px 12px 8px; +} +.branch-map-svg { display: block; } + +.bm-tick line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 6; } +.bm-tick text { + fill: var(--text-dim); + font-family: var(--font-ui); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.bm-lane { cursor: pointer; } +.bm-hit { fill: transparent; } +.bm-lane:hover .bm-hit { fill: rgba(212, 169, 78, 0.035); } +.bm-lane.picked .bm-hit { fill: rgba(212, 169, 78, 0.07); } +.bm-lane:focus { outline: none; } +.bm-lane:focus-visible .bm-hit { fill: rgba(212, 169, 78, 0.12); } + +.bm-line { + stroke: var(--border-bright); + stroke-width: 3; + stroke-linecap: round; +} +.bm-fork { + fill: none; + stroke: var(--border-bright); + stroke-width: 2; +} +.bm-fork-dot, .bm-tip { fill: var(--border-bright); } + +.bm-lane:hover .bm-line, .bm-lane.picked .bm-line { stroke: var(--accent-dim); } +.bm-lane:hover .bm-fork, .bm-lane.picked .bm-fork { stroke: var(--accent-dim); } +.bm-lane:hover .bm-tip, .bm-lane.picked .bm-tip { fill: var(--accent); } + +/* The line being read: brighter, and its tip carries the glow so where you + are standing is findable before a word is read. */ +.bm-lane.here .bm-line { stroke: var(--accent); } +.bm-lane.here .bm-fork { stroke: var(--accent-dim); } +.bm-lane.here .bm-tip { + fill: var(--accent-bright); + filter: drop-shadow(0 0 6px var(--accent-glow)); +} +.bm-lane.here .bm-name { fill: var(--accent-bright); } + +.bm-name { + fill: var(--text); + font-family: var(--font-story); + font-size: 15px; +} +.bm-meta { + fill: var(--text-dim); + font-family: var(--font-ui); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.branch-map-hint { + margin: 0; + padding: 0 22px 10px; + color: var(--text-dim); + font-size: 0.82rem; + line-height: 1.55; +} + +.branch-map-detail { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 22px 18px; + border-top: 1px solid var(--border); +} +.branch-map-detail.here { background: rgba(212, 169, 78, 0.05); } +.bmd-head { display: flex; align-items: center; gap: 8px; } +.branch-map-detail .branch-name { font-size: 1.05rem; } +.branch-map-detail.here .branch-name { color: var(--accent-bright); } +.branch-map-detail .branch-tools button, .branch-map-detail .branch-confirm button { + padding: 4px 11px; + font-size: 0.74rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); +} +.branch-map-detail .branch-tools button:hover:not(:disabled), +.branch-map-detail .branch-confirm button:hover:not(:disabled) { + color: var(--accent-bright); + border-color: var(--border-bright); +} + +@media (prefers-reduced-motion: reduce) { + .enter, .skeleton-card, .sk, .toast, .thinking i, + .story .action, .page { + animation: none !important; + } + .card.tome:hover { transform: none; } +} + From 0623dd8b780f42a31a41eb64e61828153563dcea Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sat, 29 Aug 2026 01:33:14 +0530 Subject: [PATCH 06/11] Split Play.jsx into a directory of twelve files `frontend/src/pages/Play.jsx` was 2280 lines holding 27 components. It is now `frontend/src/pages/Play/`: `index.jsx` with the page component, five panels, two drawers, the reports, the take pager, the refresh dialog, and the shared formatting helpers. Every line moved verbatim. A coverage check confirms every non-blank line of the original appears exactly once, in order, across the twelve files, and a name-resolution check confirms every identifier each file references is defined or imported there, with no unused imports. The line split stranded a comment at six of the boundaries. A leading comment sits above the section it describes, so each cut left one at the end of the file before it. All six moved to the section they describe, rewritten in the house style. `usePlaySession.js` is not here. The page component still owns all of the session state. Moving eighteen `useState` calls and seven `useEffect` calls is a rewrite rather than a move, and no frontend test would catch a mistake in it today, so it waits for Stage 5. Four stand-in providers under `backend/tools/` now define `last_usage = None`. The turn engine reads that attribute after every call, and the fixtures never defined it, so `tools.tree_fixture` crashed. That break predates this branch. Verified by 549 passing tests, a clean `npm run lint` and `npm run build`, and by driving the Play screen: the story view, all five panels, both drawers including the world-state edit form, the branch map, the refresh dialog, and the take pager stepping onto a take that lives on another branch. No console errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014Dix4oGV3njgWRdu7P9t6r --- backend/tools/branch_fixture.py | 3 + backend/tools/shots_fixture.py | 3 + backend/tools/stress_session.py | 4 + backend/tools/tree_fixture.py | 3 + frontend/src/main.jsx | 2 +- frontend/src/pages/Play.jsx | 2280 ----------------- frontend/src/pages/Play/RefreshModal.jsx | 105 + frontend/src/pages/Play/TakePager.jsx | 88 + .../src/pages/Play/drawers/StatusDrawer.jsx | 99 + .../pages/Play/drawers/WorldStateDrawer.jsx | 295 +++ frontend/src/pages/Play/format.js | 67 + frontend/src/pages/Play/index.jsx | 783 ++++++ .../src/pages/Play/panels/BranchPanel.jsx | 175 ++ .../src/pages/Play/panels/InsightsPanel.jsx | 121 + .../src/pages/Play/panels/MemoryPanel.jsx | 151 ++ frontend/src/pages/Play/panels/PlotPanel.jsx | 142 + .../src/pages/Play/panels/ScriptsPanel.jsx | 116 + frontend/src/pages/Play/reports.jsx | 246 ++ plan/17-refactor.md | 44 +- 19 files changed, 2445 insertions(+), 2282 deletions(-) delete mode 100644 frontend/src/pages/Play.jsx create mode 100644 frontend/src/pages/Play/RefreshModal.jsx create mode 100644 frontend/src/pages/Play/TakePager.jsx create mode 100644 frontend/src/pages/Play/drawers/StatusDrawer.jsx create mode 100644 frontend/src/pages/Play/drawers/WorldStateDrawer.jsx create mode 100644 frontend/src/pages/Play/format.js create mode 100644 frontend/src/pages/Play/index.jsx create mode 100644 frontend/src/pages/Play/panels/BranchPanel.jsx create mode 100644 frontend/src/pages/Play/panels/InsightsPanel.jsx create mode 100644 frontend/src/pages/Play/panels/MemoryPanel.jsx create mode 100644 frontend/src/pages/Play/panels/PlotPanel.jsx create mode 100644 frontend/src/pages/Play/panels/ScriptsPanel.jsx create mode 100644 frontend/src/pages/Play/reports.jsx diff --git a/backend/tools/branch_fixture.py b/backend/tools/branch_fixture.py index d53b65e..679da92 100644 --- a/backend/tools/branch_fixture.py +++ b/backend/tools/branch_fixture.py @@ -67,6 +67,9 @@ modifier(text); class ScriptedProvider: replies: list = [] + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None calls = 0 def __init__(self, *a, **k): diff --git a/backend/tools/shots_fixture.py b/backend/tools/shots_fixture.py index 88279f6..2dc0de1 100644 --- a/backend/tools/shots_fixture.py +++ b/backend/tools/shots_fixture.py @@ -151,6 +151,9 @@ class ScriptedProvider: That difference is what the screenshots show. """ + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None next_reply = ("", {}) diff --git a/backend/tools/stress_session.py b/backend/tools/stress_session.py index 35ab40a..515b9ed 100644 --- a/backend/tools/stress_session.py +++ b/backend/tools/stress_session.py @@ -196,6 +196,10 @@ class FakeProvider: time. """ + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None + def __init__(self, *a, **k): pass diff --git a/backend/tools/tree_fixture.py b/backend/tools/tree_fixture.py index fb8aa71..184a81d 100644 --- a/backend/tools/tree_fixture.py +++ b/backend/tools/tree_fixture.py @@ -57,6 +57,9 @@ PROSE = [ class ScriptedProvider: calls = 0 + # The turn engine records the cost of the call, so a stand-in provider has + # to carry this attribute even when it never calls anything. + last_usage = None def __init__(self, *a, **k): pass diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index bb6f621..1a4311f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -6,7 +6,7 @@ import Home from './pages/Home.jsx' import Adventures from './pages/Adventures.jsx' import Scenarios from './pages/Scenarios.jsx' import ScenarioEditor from './pages/ScenarioEditor.jsx' -import Play from './pages/Play.jsx' +import Play from './pages/Play' import Scripts from './pages/Scripts.jsx' import ScriptEditor from './pages/ScriptEditor.jsx' import Settings from './pages/Settings.jsx' diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx deleted file mode 100644 index 1854b40..0000000 --- a/frontend/src/pages/Play.jsx +++ /dev/null @@ -1,2280 +0,0 @@ -import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { createPortal } from 'react-dom' -import { useNavigate, useParams } from 'react-router-dom' -import { api } from '../api' -import { AutoTextarea, Field, StoryCardRow, downloadJSON, npcInitials, pickJSONFile, useToast } from '../components' -import { BranchMap } from '../BranchMap' -import { branchLabel, headLineage, orderBranches } from '../branches' - -const MODES = ['do', 'say', 'story'] -const PLAYER_TYPES = ['do', 'say', 'story'] - -// Models often emit light markdown emphasis; render **bold** / *italic* -// instead of showing raw asterisks. Everything else stays plain text. -function renderEmphasis(text) { - const re = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g - const parts = [] - let last = 0 - let match - while ((match = re.exec(text)) !== null) { - if (match.index > last) parts.push(text.slice(last, match.index)) - parts.push(match[1] !== undefined - ? {match[1]} - : {match[2]}) - last = match.index + match[0].length - } - if (parts.length === 0) return text - if (last < text.length) parts.push(text.slice(last)) - return parts -} - -function ReasoningBlock({ text, streaming }) { - if (!text) return null - return ( -
- 💭 Reasoning{streaming ? '…' : ''} -
{text}
-
- ) -} - -const SECTION_LABELS = { - narrator: 'Narrator prompt', - script_context: 'Script context', - ai_instructions: 'AI Instructions', - plot_essentials: 'Plot Essentials', - story_summary: 'Story Summary', - used_memories: 'Used Memories (memory bank)', - world_state_guide: 'World State (stat guide)', - world_state: 'World State (RPG)', - world_state_rule: 'World State (reporting rule)', - world_lore: 'World Lore (story cards)', - history: 'Story history', - authors_note: "Author's Note", - recent_history: 'Recent history', - front_memory: 'Front memory', - length_hint: 'Length guidance', - world_state_reminder: 'World State (emit reminder)', -} - -// One colour per context section, and the single source of truth for it: the -// token bar, the legend and each section's own header all read from here, so a -// slice of the bar and the text it stands for always carry the same colour. -// Related sections share a hue family but never an exact shade — in a stacked -// bar two identical colours read as one section. -const SECTION_COLORS = { - narrator: '#7d8fc9', - ai_instructions: '#9c8fd6', - plot_essentials: '#c97dc0', - script_context: '#d99ad0', - story_summary: '#7dc9a2', - used_memories: '#5fb8c9', - world_lore: '#c9b47d', - world_state: '#d79a63', - world_state_guide: '#b8834a', - world_state_rule: '#9d7a52', - world_state_reminder: '#8a6f52', - history: '#74748c', - recent_history: '#9d9db4', - authors_note: '#c97d7d', - front_memory: '#d99a9a', - length_hint: '#98a06b', -} -const SECTION_FALLBACK = '#6a6a78' -const sectionColor = (label) => SECTION_COLORS[label] || SECTION_FALLBACK - -// Share of the prompt, rounded for glanceability. Sections too small to round -// to a whole percent still say so rather than showing a misleading 0%. -const pctLabel = (pct) => (pct > 0 && pct < 1 ? '<1%' : `${Math.round(pct)}%`) - -const FIELD_LABELS = { - memory: 'Plot Essentials (Memory)', - authors_note: "Author's Note", - ai_instructions: 'AI Instructions', -} - -function clip(text, n = 90) { - const one = (text || '').replace(/\s+/g, ' ').trim() - return one.length > n ? `${one.slice(0, n)}…` : (one || '(empty)') -} - -// Confirms "Update from scenario" by showing exactly what it would change, and -// collects any ${Placeholder} answers the adventure has no stored value for -// (adventures started before those were saved, or a placeholder the author -// added since). Destructive by design — it overwrites plot text and -// scenario-derived cards — so nothing happens until Update is pressed. -function RefreshModal({ plan, onConfirm, onCancel }) { - const [values, setValues] = useState( - Object.fromEntries((plan.placeholders_needed || []).map((n) => [n, ''])), - ) - const [busy, setBusy] = useState(false) - const { added = [], updated = [], removed = [] } = plan.cards || {} - const world = plan.world_state || {} - const fields = Object.entries(plan.fields || {}) - - const submit = (e) => { - e.preventDefault() - setBusy(true) - onConfirm(values).finally(() => setBusy(false)) - } - - // Portalled to : this modal is opened from inside .side-panel, whose - // panel-in animation (fill mode `both`) makes it the containing block for - // position:fixed children — an overlay rendered in place would be trapped in - // the 420px panel and clipped by its overflow. Same trap for any future modal - // opened from a drawer or panel. - return createPortal( -
-
e.stopPropagation()} onSubmit={submit}> -

Update from scenario

- {!plan.has_changes ? ( -

- This adventure already matches “{plan.scenario_title}”. Nothing to update. -

- ) : ( - <> -

- Pull the current content of “{plan.scenario_title}” down over this adventure. - Your story, its title, summary and your own story cards are untouched — but - the plot text below is overwritten, including any edits you made here. -

-
- {fields.map(([field, diff]) => ( -
- {FIELD_LABELS[field] || field} -
− {clip(diff.old)}
-
+ {clip(diff.new)}
-
- ))} - {added.length > 0 && ( -
Story cards added: {added.join(', ')}
- )} - {updated.length > 0 && ( -
Story cards overwritten: {updated.join(', ')}
- )} - {removed.length > 0 && ( -
Story cards removed: {removed.join(', ')}
- )} - {world.added?.length > 0 && ( -
- New stats (at their starting value): {world.added.join(', ')} -
- )} - {world.removed?.length > 0 && ( -
Stats removed: {world.removed.join(', ')}
- )} -
- {(plan.placeholders_needed || []).length > 0 && ( - <> -

- This scenario asks a few questions, and this adventure has no saved - answers for them. They'll be remembered from now on. -

- {plan.placeholders_needed.map((name) => ( - - ))} - - )} - - )} -
- - {plan.has_changes && ( - - )} -
-
-
, - document.body, - ) -} - -function PlotPanel({ adventure, setAdventure, onWorldStateChanged }) { - const toast = useToast() - const [plan, setPlan] = useState(null) // non-null while the modal is open - const [planning, setPlanning] = useState(false) - // One timer per field/card: a single shared timer would cancel the pending - // save of whatever was edited previously within the debounce window. - const saveTimers = useRef(new Map()) - const debounceSave = (key, fn) => { - clearTimeout(saveTimers.current.get(key)) - saveTimers.current.set(key, setTimeout(fn, 600)) - } - - const setField = (field, value) => { - setAdventure({ ...adventure, [field]: value }) - debounceSave(field, () => api.updateAdventure(adventure.id, { [field]: value })) - } - - const addCard = async () => { - const card = await api.createStoryCard({ adventure_id: adventure.id }) - setAdventure({ ...adventure, story_cards: [...adventure.story_cards, card] }) - } - - const updateCard = (card) => { - setAdventure({ - ...adventure, - story_cards: adventure.story_cards.map((c) => (c.id === card.id ? card : c)), - }) - debounceSave(`card-${card.id}`, () => { - api.updateStoryCard(card.id, { - name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes, - }) - }) - } - - const deleteCard = async (cardId) => { - await api.deleteStoryCard(cardId) - setAdventure({ - ...adventure, - story_cards: adventure.story_cards.filter((c) => c.id !== cardId), - }) - } - - const exportCards = async () => { - const cards = await api.exportStoryCards({ adventure_id: adventure.id }) - downloadJSON(cards, `${(adventure.title || 'adventure').replace(/\W+/g, '-')}-cards.json`) - } - - const importCards = async () => { - try { - const parsed = await pickJSONFile() - const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards) - if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error') - const created = await api.importStoryCards({ adventure_id: adventure.id, cards }) - setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] }) - } catch (err) { - toast(err.message, 'error') - } - } - - // Ask the server what a refresh would do, then let the player confirm it. - const openRefresh = async () => { - setPlanning(true) - try { - setPlan(await api.previewRefresh(adventure.id)) - } catch (err) { - // 404 = the scenario was deleted or unshared; there's nothing to sync to. - toast(err.message, 'error') - } finally { - setPlanning(false) - } - } - - const applyRefresh = async (placeholders) => { - try { - const updated = await api.refreshFromScenario(adventure.id, placeholders) - setAdventure(updated) - setPlan(null) - onWorldStateChanged?.() - toast('Updated from scenario.') - } catch (err) { - toast(err.message, 'error') - } - } - - return ( -
- {adventure.scenario_id != null && ( -
- - Copied from its scenario when the adventure began; later scenario edits don't - reach it on their own. - - -
- )} - {plan && ( - setPlan(null)} /> - )} - - setField('memory', v)} textarea - placeholder="Key facts the AI should always remember." /> - setField('authors_note', v)} textarea rows={2} - placeholder="Style/theme guidance, injected near the end of context." /> - setField('ai_instructions', v)} textarea rows={2} - placeholder="Behavioral guidance for the model." /> - setField('story_summary', v)} textarea - placeholder="Running summary of events so far. Updated automatically every 15 actions when auto-summarization is on; your edits are kept as the base for the next update." /> - -
-

Story Cards

-
- - - -
-
- {adventure.story_cards.length === 0 && ( -
No story cards yet.
- )} - {adventure.story_cards.map((card) => ( - deleteCard(card.id)} /> - ))} -
- ) -} - -function MemoryRow({ memory, onChange, onDelete }) { - const [editText, setEditText] = useState(null) - - const save = () => { - const text = editText.trim() - setEditText(null) - if (text && text !== memory.text) onChange({ text }) - } - - return ( -
- {editText !== null ? ( -
- setEditText(e.target.value)} /> -
- - -
-
- ) : ( - <> -
{memory.text}
-
- {memory.pinned && 📌 pinned} - {memory.forgotten && forgotten} - {!memory.embedded && !memory.forgotten && ( - not embedded yet - )} - {memory.source_start != null && ( - actions {memory.source_start}–{memory.source_end} - )} - used {memory.use_count}× - - - {memory.forgotten && ( - - )} - - - -
- - )} -
- ) -} - -function MemoryPanel({ adventure, setAdventure, refreshKey }) { - const [memories, setMemories] = useState(null) - const [newText, setNewText] = useState('') - - const load = useCallback(() => { - api.listMemories(adventure.id).then(setMemories).catch(() => setMemories([])) - }, [adventure.id]) - - // Summarization runs in the background after a turn, so also refresh shortly after. - useEffect(() => { - load() - const timer = setTimeout(load, 4000) - return () => clearTimeout(timer) - }, [load, refreshKey]) - - const setFlag = async (field, value) => { - setAdventure({ ...adventure, [field]: value }) - await api.updateAdventure(adventure.id, { [field]: value }) - } - - const change = async (memory, data) => { - const updated = await api.updateMemory(adventure.id, memory.id, data) - setMemories((prev) => prev.map((m) => (m.id === memory.id ? updated : m))) - } - - const remove = async (memory) => { - await api.deleteMemory(adventure.id, memory.id) - setMemories((prev) => prev.filter((m) => m.id !== memory.id)) - } - - const add = async () => { - const text = newText.trim() - if (!text) return - setNewText('') - const memory = await api.createMemory(adventure.id, text) - setMemories((prev) => [...prev, memory]) - } - - const active = memories?.filter((m) => !m.forgotten) ?? [] - const forgotten = memories?.filter((m) => m.forgotten) ?? [] - - return ( -
- - - -
-

Memories {memories && `(${active.length})`}

- -
- {!memories &&
Loading…
} - {memories && active.length === 0 && ( -
- No memories yet. They are generated automatically as you play (from action 12 - onward), or add one below. -
- )} - {active.map((m) => ( - change(m, data)} onDelete={() => remove(m)} /> - ))} - -
- setNewText(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') add() }} /> - -
- - {forgotten.length > 0 && ( - <> -
-

Forgotten ({forgotten.length})

-
- {forgotten.map((m) => ( - change(m, data)} onDelete={() => remove(m)} /> - ))} - - )} -
- ) -} - -const SCRIPT_HOOKS = [ - ['Shared library', 'library_js'], - ['onInput', 'input_js'], - ['onModelContext', 'context_js'], - ['onOutput', 'output_js'], -] - -function ScriptsPanel({ advId }) { - const [scripts, setScripts] = useState(null) - const [openId, setOpenId] = useState(null) - - useEffect(() => { - api.listAdventureScripts(advId).then(setScripts).catch(() => setScripts([])) - }, [advId]) - - const [syncingId, setSyncingId] = useState(null) - - const toggle = async (script) => { - const updated = await api.updateAdventureScript(advId, script.id, { enabled: !script.enabled }) - setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) - } - - // Pull the latest code from the library script this copy was made from. - const sync = async (script) => { - setSyncingId(script.id) - try { - const updated = await api.syncAdventureScript(advId, script.id) - setScripts((prev) => prev.map((s) => (s.id === script.id ? updated : s))) - } finally { - setSyncingId(null) - } - } - - // Download as an import-compatible bundle (matches /scripts export), so demo - // scripts can be forked into your own library via the Scripts page's Import. - const download = (s) => { - downloadJSON( - { - name: s.name, - description: s.description, - library: s.library_js, - input: s.input_js, - context: s.context_js, - output: s.output_js, - }, - `${(s.name || 'script').replace(/\W+/g, '-')}.json`, - ) - } - - if (!scripts) return
Loading…
- if (scripts.length === 0) { - return ( -
- No scripts on this adventure. Attach scripts to a scenario before starting an - adventure from it. -
- ) - } - return ( -
- {scripts.map((s) => { - const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim()) - const open = openId === s.id - return ( -
-
- -
- {s.out_of_date && ( - - )} - - -
-
- {s.description &&
{s.description}
} - {open && ( -
- {hooks.length === 0 ? ( -
This script has no code.
- ) : ( - hooks.map(([label, f]) => ( -
-
{label}
-
{s[f]}
-
- )) - )} -
- )} -
- ) - })} -
- ) -} - -// Renders one script-state value: primitives inline (typed/coloured), and -// objects/arrays as a collapsible, indented tree — recursing into nesting so -// deep state shows structure instead of a flat JSON blob. -function StateValue({ value, depth = 0 }) { - if (value === null || value === undefined) return null - if (typeof value === 'boolean') return {String(value)} - if (typeof value === 'number') return {value} - if (typeof value === 'string') return {value} - if (Array.isArray(value)) return [i, v])} empty="[ ]" depth={depth} /> - if (typeof value === 'object') return - return {String(value)} -} - -// Only the first level is expanded by default (depth 0); nested trees start -// collapsed and can be opened on demand. -function StateTree({ entries, empty, depth = 0 }) { - const [open, setOpen] = useState(depth < 1) - if (entries.length === 0) return {empty} - return ( -
- - {open && ( -
    - {entries.map(([k, v]) => ( -
  • - {k} - -
  • - ))} -
- )} -
- ) -} - -// Collapsible left rail showing the scripting `state` object — every variable -// scripts read/write via state.x, refreshed after each turn. -function StatusDrawer({ advId, refreshKey }) { - const [open, setOpen] = useState(false) - const [state, setState] = useState(null) - const [failed, setFailed] = useState(false) - - const load = useCallback(() => { - api.getScriptState(advId) - .then((r) => { setState(r.state || {}); setFailed(false) }) - .catch(() => setFailed(true)) - }, [advId]) - - // Only fetch while open; re-fetch after each turn so values stay live. - useEffect(() => { if (open) load() }, [open, refreshKey, load]) - - const entries = state ? Object.entries(state) : [] - - return ( -
- - {open && ( -
-
-

Script State

- -
- {failed ? ( -
Couldn’t load state.
- ) : entries.length === 0 ? ( -
- No variables yet. Scripts that use state will appear here after a turn. -
- ) : ( -
    - {entries.map(([k, v]) => ( -
  • - {k} -
    -
  • - ))} -
- )} -
- )} -
- ) -} - -// Word label for a value from a stat def's bands (mirrors worldstate.band_label). -function bandLabel(def, value) { - const bands = def?.bands - if (!Array.isArray(bands) || typeof value !== 'number') return null - for (const b of bands) { - if (Array.isArray(b) && b.length === 3 && value >= b[0] && value < b[1]) return b[2] - } - const last = bands[bands.length - 1] - if (last && value === last[1]) return last[2] - return null -} - -function StatRow({ name, def, value, editing, onChange }) { - const isText = def?.type === 'text' - if (editing) { - return ( -
-
- {name} -
- {isText ? ( - onChange(e.target.value)} /> - ) : ( - onChange(e.target.value === '' ? 0 : Number(e.target.value))} /> - )} -
- ) - } - if (isText) { - const text = typeof value === 'string' ? value : (def?.initial ?? '') - return ( -
-
- {name} - {text || '(unset)'} -
-
- ) - } - const val = typeof value === 'number' ? value : (def?.initial ?? 0) - const { min, max } = def || {} - const hasRange = typeof min === 'number' && typeof max === 'number' && max > min - const pct = hasRange ? Math.max(0, Math.min(100, ((val - min) / (max - min)) * 100)) : null - const label = bandLabel(def, val) - return ( -
-
- {name} - - {val}{typeof max === 'number' ? `/${max}` : ''} - {label ? · {label} : null} - -
- {pct != null && ( -
- )} -
- ) -} - -// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode) -// is a slice of the drawer's flat path->value map for this group's prefix. -// `nested` = the caller already drew a heading (an NPC card), so this drops the -// group's own spacing and says "nothing here" rather than vanishing and leaving -// that heading dangling over empty space. -function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) { - const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object') - if (entries.length === 0) { - return nested ?
No tracked stats.
: null - } - return ( -
- {title &&

{title}

} - {entries.map(([name, def]) => ( - onEdit(name, v) : undefined} /> - ))} -
- ) -} - -// Flatten a schema+state into path->value pairs ("player.hp", "npc.gwen.trust", -// "flags.has_key", "milestones.escaped") to seed an edit-mode draft. -function buildWorldStateDraft(schema, state) { - const draft = {} - const addStats = (prefix, defs, values) => { - for (const [name, def] of Object.entries(defs || {})) { - if (!def || typeof def !== 'object') continue - const fallback = def.type === 'text' ? '' : 0 - draft[`${prefix}.${name}`] = values?.[name] ?? def.initial ?? fallback - } - } - addStats('world', schema.world, state.world) - addStats('player', schema.player, state.player) - const npcState = state.npc || {} - for (const [id, ndef] of Object.entries(schema.npcs || {})) { - addStats(`npc.${id}`, ndef?.stats, npcState[id]) - } - const flagState = state.flags || {} - for (const [name, def] of Object.entries(schema.flags || {})) { - draft[`flags.${name}`] = flagState[name] ?? !!def?.initial - } - const reached = state.milestones || {} - for (const mid of Object.keys(schema.milestones || {})) { - draft[`milestones.${mid}`] = !!reached[mid]?.reached - } - return draft -} - -// Slice a flat path->value draft down to one group's {statName: value} map. -function sliceDraft(draft, prefix) { - const out = {} - const p = `${prefix}.` - for (const [k, v] of Object.entries(draft || {})) { - if (k.startsWith(p)) out[k.slice(p.length)] = v - } - return out -} - -// Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC -// stats with bands + bars, and a milestones checklist. Renders nothing unless -// the adventure's scenario defines a stat_schema. An edit mode lets the -// player/author directly override the live values (a manual correction, not -// a turn) — it never adds new stats, only edits ones the schema already defines. -function WorldStateDrawer({ advId, refreshKey }) { - const [open, setOpen] = useState(false) - const [data, setData] = useState(null) // { state, schema } - const [failed, setFailed] = useState(false) - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState(null) // flat path->value, only while editing - const [saving, setSaving] = useState(false) - - const load = useCallback(() => { - api.getWorldState(advId) - .then((r) => { setData(r); setFailed(false) }) - .catch(() => setFailed(true)) - }, [advId]) - - // Load once to learn whether there's an RPG layer, then refresh after turns. - useEffect(() => { load() }, [load, refreshKey]) - - const schema = data?.schema - if (!schema) return null // no RPG layer for this adventure - - const state = data?.state || {} - const npcState = state.npc || {} - const npcs = Object.entries(schema.npcs || {}) // [id, def] — each with its own stats - const flags = Object.entries(schema.flags || {}) - const flagState = state.flags || {} - const milestones = Object.entries(schema.milestones || {}) - const reached = state.milestones || {} - - const startEdit = () => { setDraft(buildWorldStateDraft(schema, state)); setEditing(true) } - const cancelEdit = () => { setEditing(false); setDraft(null) } - const setPath = (path, value) => setDraft((d) => ({ ...d, [path]: value })) - const saveEdit = () => { - setSaving(true) - api.overrideWorldState(advId, draft) - .then(() => { setEditing(false); setDraft(null); load() }) - .catch(() => setFailed(true)) - .finally(() => setSaving(false)) - } - - return ( -
- - {open && ( -
-
-

World State

- {editing ? ( -
- - -
- ) : ( -
- - -
- )} -
- {failed ? ( -
Couldn’t load world state.
- ) : ( - <> - setPath(`world.${name}`, v)} /> - setPath(`player.${name}`, v)} /> - {npcs.length > 0 && ( -
-

Cast

- {npcs.map(([id, def]) => ( -
-
- - {def.name || id} -
- setPath(`npc.${id}.${name}`, v)} - nested /> -
- ))} -
- )} - {flags.length > 0 && ( -
-

Flags

-
    - {flags.map(([fid, def]) => { - const on = editing ? !!draft?.[`flags.${fid}`] : (flagState[fid] ?? !!def.initial) - return ( -
  • - {editing ? ( - - ) : ( - <> - - {fid} - {on ? 'yes' : 'no'} - - )} -
  • - ) - })} -
-
- )} - {milestones.length > 0 && ( -
-

Milestones

-
    - {milestones.map(([mid, def]) => { - const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached - return ( -
  • - {editing ? ( - - ) : ( - <> - {done ? '☑' : '☐'} - {def.desc || mid} - - )} -
  • - ) - })} -
-
- )} - - )} -
- )} -
- ) -} - -// The takes of one turn: ‹ 2/4 ›, and nothing else. -// -// SP7 shipped chips instead, on the grounds that a pager can only step between -// takes while a chip could also offer "take this path". Driving it by hand said -// otherwise. The chip meant two different things depending on where the reader -// was standing — a real switch at the tip, a preview needing a second button -// above it — and two meanings in one control is what made the tree unusable. -// -// So the pager comes back, and stepping is all it does. Stepping is free: it -// tells the server nothing, because reading a take is not a decision. The -// decision is made by *writing* below one, and that is where the branch is -// created (SP9, `after_id`). -// -// One step still reaches the server, and it is not a fork either. A take that -// has a story of its own lives on its own branch, so going there is a branch -// switch — the story below has to change, and only the server can say to what. -// A take on this branch is a leaf by construction: whatever was played after -// this turn was played after the take that is live, so a take that is not live -// has nothing under it and the transcript simply ends there. -function TakePager({ - advId, action, busy, preview, takesKey, onPreview, onSwitchedBranch, onError, -}) { - const [takes, setTakes] = useState(null) - const [loading, setLoading] = useState(false) - // The cached list is only as good as the text in it. Editing a take - // rewrites one of those rows, so the page says so and the list is fetched - // again on the next step. - useEffect(() => { setTakes(null) }, [takesKey]) - const count = action.take_count - const live = action.take_index - const current = preview ? preview.index : live - - async function step(delta) { - const next = current + delta - if (next < 0 || next >= count || loading || busy) return - setLoading(true) - try { - // Fetched once per message, then cached — walking back and forth through - // the takes should not re-hit the server for a list that has not changed. - const list = takes || await api.listTakes(advId, action.id) - if (!takes) setTakes(list) - const target = list[next] - if (target.branch_id !== action.branch_id) { - // It has a story of its own. Only the server knows what is under it. - onPreview(null) - onSwitchedBranch(await api.switchBranch(advId, target.branch_id)) - } else if (next === live) { - onPreview(null) - } else { - onPreview({ - actionId: action.id, - index: next, - // The take's own node id, never its ordinal: the group renumbers - // whenever a take is added, and an ordinal held across that points - // at a different one. This is what `after_id` is given if the reader - // writes from here. - takeId: target.id, - text: target.text, - reasoning: target.reasoning, - }) - } - } catch (err) { - onError(err.message) - } finally { - setLoading(false) - } - } - - if (count < 2) return null - return ( -
- - {current + 1}/{count} - - {preview && !preview.written && ( - write below to keep this one - )} -
- ) -} - -// Every line the story has taken, and the three things you can do to one. -// -// Drawn from a single request: `fork_depth` says where a branch leaves its -// parent and `depth` where it currently ends, so the whole shape is two -// numbers a row rather than a walk. -// -// Delete is here rather than in some later subphase because nothing prunes a -// tree on its own — this panel is the first place a fork can be made, so it -// has to be the first place one can be unmade. -function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError }) { - const [branches, setBranches] = useState(null) - const [failed, setFailed] = useState(null) - const [busyId, setBusyId] = useState(null) - const [renaming, setRenaming] = useState(null) // { id, text } - const [confirming, setConfirming] = useState(null) - const [mapOpen, setMapOpen] = useState(false) - const [tick, setTick] = useState(0) - - useEffect(() => { - let cancelled = false - setFailed(null) - api.listBranches(advId) - .then((list) => { if (!cancelled) setBranches(list) }) - .catch((err) => { if (!cancelled) setFailed(err.message) }) - return () => { cancelled = true } - }, [advId, refreshKey, tick]) - - // Answers whether it worked. Both callers keep an editor open on a refusal — - // a rename the server turned down must not take the typed name with it. - async function run(branchId, work) { - setBusyId(branchId) - try { - await work() - setTick((t) => t + 1) - // Deleting a branch takes its memories with it, and nothing else on the - // screen would hear about that — no turn is played, and the story on the - // current path does not change by a single action. - onTreeChanged() - return true - } catch (err) { - onError(err.message) - return false - } finally { - setBusyId(null) - } - } - - // One copy of each operation. The list below and the map overlay both go - // through these, so a rule cannot hold in one view and not the other, and a - // failure is reported one way wherever it was asked for. - const switchTo = (b) => run(b.id, async () => onSwitched(await api.switchBranch(advId, b.id))) - const renameTo = (b, name) => run(b.id, () => api.renameBranch(advId, b.id, name)) - const removeBranch = (b) => run(b.id, () => api.deleteBranch(advId, b.id)) - - const saveName = async (b) => { if (await renameTo(b, renaming.text)) setRenaming(null) } - const remove = async (b) => { if (await removeBranch(b)) setConfirming(null) } - - if (failed) return
Couldn’t read the branches — {failed}
- if (!branches) return
Reading the tree…
- - const lineage = headLineage(branches) - - return ( -
- {/* The list says which lines exist; the map says where they parted and - how much story each one is, which is the part a list cannot draw. */} - - {branches.length === 1 && ( -

- One thread so far. Retry a turn, then take an attempt the story moved - past — that is what makes a second one. -

- )} -
- {orderBranches(branches).map(({ branch, indent }) => { - const isRenaming = renaming?.id === branch.id - const isConfirming = confirming === branch.id - const busy = busyId === branch.id - // The server refuses to delete the line being read or any line it - // was forked from. The button said nothing about that and answered - // with a toast; it now says so before it is pressed. - const loadBearing = lineage.has(branch.id) - return ( -
-
- - {isRenaming ? ( - setRenaming({ ...renaming, text: e.target.value })} - onKeyDown={(e) => { - if (e.key === 'Enter') saveName(branch) - if (e.key === 'Escape') setRenaming(null) - }} - /> - ) : ( - {branchLabel(branch)} - )} - {branch.is_head && reading} -
- -
- {branch.own_actions} of its own - {branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`} - {` · ends at ${branch.depth + 1}`} -
- - {isConfirming ? ( -
- Delete this branch and everything forked from it? - - -
- ) : ( -
- {!branch.is_head && ( - - )} - {isRenaming ? ( - <> - - - - ) : ( - - )} - {/* The root holds the turns every other branch borrows, and - the server refuses it — so it is not offered. */} - {branch.parent_branch_id !== null && ( - - )} -
- )} -
- ) - })} -
- - {mapOpen && ( - setMapOpen(false)} - /> - )} -
- ) -} - -// Reasons the engine gives for refusing a change, in the player's words. -const REJECT_REASONS = { - 'not a number': 'expected a number', - 'not a string': 'expected text', - 'not a boolean': 'expected on or off', - 'not true': 'a milestone can only be set', - "counter can't decrease": 'this only counts up', - cooldown: 'changed too recently', - 'unknown stat': 'no such stat', - 'unknown npc': 'no such character', - 'unknown npc stat': 'no such stat', - 'unknown flag': 'no such flag', - 'unknown milestone': 'no such milestone', - 'unknown path': 'unrecognized name', -} - -// Compact chips shown under an AI message summarizing what state changed. -// -// Refused changes appear here too. A clamped stat is marked, and a stat whose -// clamp left it exactly where it started reads "no change" rather than "+0", -// which looked like an ordinary update. -function StateChangeChips({ changes }) { - if (!changes?.length) return null - const nice = (s) => String(s).replace(/_/g, ' ') - return ( -
- {changes.map((c, i) => { - if (c.kind === 'flag') { - return {nice(c.label)}: {c.on ? 'on' : 'off'} - } - if (c.kind === 'milestone') { - return ✓ {nice(c.label)} - } - if (c.kind === 'rejected') { - const why = REJECT_REASONS[c.reason] || c.reason - // The engine's own wording quotes the real limits, so prefer it. - return ( - - {nice(c.label)} refused — {why} - - ) - } - const d = c.delta - // A clamp that cancels the change entirely is its own outcome. It is - // neither an update nor a refusal, and "+0" read as the former. - const blocked = c.clamped && d === 0 - const dir = blocked ? 'refused' : typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat' - const txt = blocked - ? 'no change — at its limit' - : typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}` - const title = blocked - ? c.fix || 'The story asked to change this and it is already at the limit the scenario allows.' - : c.clamped - ? 'The scenario limits how far this can move in one turn, so the change was reduced.' - : undefined - return ( - - {nice(c.label)} {txt} - {c.clamped && !blocked ? (limited) : null} - - ) - })} -
- ) -} - -// Per-turn RPG state change, shown when inspecting a past turn's snapshot. -function WorldStateReport({ worldState }) { - if (!worldState) return null - const delta = worldState.delta || {} - const report = worldState.report || {} - const paths = Object.keys(delta) - const rejected = report.rejected || [] - const clamped = new Set((report.clamped || []).map((c) => c.path)) - if (paths.length === 0 && rejected.length === 0) { - return ( -
-
World State
-
No changes this turn.
-
- ) - } - return ( -
-
World State changes
-
    - {paths.map((p) => ( -
  • - {p} {String(delta[p])} - {clamped.has(p) && clamped} -
  • - ))} - {rejected.map((r, i) => ( -
  • - {r.path} rejected — {r.reason} -
  • - ))} -
-
- ) -} - -function ScriptReport({ script }) { - if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) { - return null - } - return ( -
-
Scripts
- {script.errors?.map((e, i) =>
⚠ {e}
)} - {script.logs?.length > 0 && ( -
{script.logs.join('\n')}
- )} - {script.context_changed && ( - <> -
-
- Context before script -
-
{script.context_before}
-
-
-
- Context after script (sent to AI) -
-
{script.context_after}
-
- - )} -
- ) -} - -// What the endpoint charged for the turn, and how much of the prompt it read -// back out of its cache instead of billing in full. Only shown on a past turn: -// the "next turn" view has not been sent anywhere yet, so it has no usage. A -// cached read costs a tenth of a fresh one, which is the whole reason the -// prompt is laid out static-first — so this is the number that says whether -// that layout is working. -function CacheReport({ usage }) { - if (!usage) return null - const prompt = usage.prompt_tokens || 0 - const cached = usage.prompt_tokens_details?.cached_tokens || 0 - if (!prompt) return null - const pct = Math.round((cached / prompt) * 100) - return ( -
- Prompt cache: {cached} of {prompt} prompt tokens read from cache ({pct}%) - {usage.cost != null && ` · cost $${Number(usage.cost).toFixed(5)}`} -
- ) -} - -const LEGEND_VISIBLE = 6 // enough to cover what actually moves the budget - -// Where the prompt's tokens actually went: one stacked bar scaled to the -// budget (so leftover width IS the remaining headroom) plus a matching legend. -// Bar and legend both run biggest-share-first — the order that answers "what is -// eating my context?" — rather than the prompt order the sections below use. -function TokenBreakdown({ sections, tokens, used, onJump }) { - const [hovered, setHovered] = useState(null) - const [expanded, setExpanded] = useState(false) - if (used <= 0) return null - const budget = tokens.budget || 0 - // Over budget there is no headroom to draw, so the bar scales to what the - // prompt actually costs and the total line above it carries the warning. - const scale = Math.max(budget, used) - const free = Math.max(0, budget - used) - const ranked = sections - .map((s, i) => ({ ...s, i, pct: (s.tokens / used) * 100 })) - .filter((s) => s.tokens > 0) - .sort((a, b) => b.tokens - a.tokens) - // The panel is narrow, so the legend is one column: list the sections that - // actually move the budget and fold the long tail behind a count. - const shown = expanded ? ranked : ranked.slice(0, LEGEND_VISIBLE) - const rest = ranked.slice(shown.length) - const restPct = rest.reduce((n, s) => n + s.pct, 0) - const describe = (s) => - `${SECTION_LABELS[s.label] || s.label} — ${s.tokens} tok · ${pctLabel(s.pct)}` - - return ( - <> -
budget ? 'over' : ''}`} - role="img" - aria-label={`Context breakdown: ${ranked.map(describe).join(', ')}`} - > - {ranked.map((s) => ( -
setHovered(s.i)} - onMouseLeave={() => setHovered(null)} - /> - ))} - {free > 0 && ( -
- )} -
-
- {shown.map((s) => ( - - ))} - {rest.length > 0 && ( - - )} - {expanded && ranked.length > LEGEND_VISIBLE && ( - - )} -
- - ) -} - -function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) { - const [report, setReport] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - let stale = false // a slow earlier request must not clobber a newer one - setError(null) - const load = inspectActionId - ? api.getActionContext(advId, inspectActionId) - : api.getAdventureContext(advId) - load - .then((r) => { if (!stale) setReport(r) }) - .catch((err) => { if (!stale) { setReport(null); setError(err.message) } }) - return () => { stale = true } - }, [advId, inspectActionId, refreshKey]) - - if (error) return
{error}
- if (!report) return
Loading…
- - const { tokens, cards, history, sections } = report - const overBudget = tokens.total > tokens.budget - // The per-section sum, not tokens.total: the total also counts the separators - // between sections, and percentages have to add up to 100 for the reader. - const sectionTotal = sections.reduce((n, s) => n + s.tokens, 0) - const jumpToSection = (i) => { - document.getElementById(`ctx-sec-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - - return ( -
-
- {inspectActionId ? ( -
- Snapshot of a past turn - -
- ) : ( -
What will be sent on the next turn
- )} -
- {tokens.total} / {tokens.budget} tokens -
- -
- History: {history.included} of {history.total} actions in context - {history.total > history.included && ' (older history trimmed)'} - {history.oldest_truncated && ' — oldest entry cut mid-text'} -
- - {cards.length > 0 && ( -
- {cards.map((c, i) => ( -
- ▸ {c.name || '(unnamed card)'} triggered on “{c.keyword}” - {!c.included && ' — dropped (over card budget)'} -
- ))} -
- )} - {report.memories && ( -
- {report.memories.error && ( -
⚠ Memory bank: {report.memories.error}
- )} - {report.memories.used?.map((m, i) => ( -
- ▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}): - {' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text} -
- ))} - {!report.memories.error && report.memories.used?.length === 0 && ( -
Memory bank on — no memories retrieved.
- )} -
- )} -
- - {sections.map((s, i) => ( -
-
- {SECTION_LABELS[s.label] || s.label} - - {s.tokens} tok - {sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`} - -
-
{s.text}
-
- ))} - - - {report.raw_output && ( -
-
- Raw AI output (before state block was stripped) -
-
{report.raw_output}
-
- )} -
- ) -} - -export default function Play() { - const { id } = useParams() - const navigate = useNavigate() - const [adventure, setAdventure] = useState(null) - const [actions, setActions] = useState([]) - const [mode, setMode] = useState('do') - const [input, setInput] = useState('') - const [streaming, setStreaming] = useState(null) - const [reasoningStream, setReasoningStream] = useState(null) - const [busy, setBusy] = useState(false) - const [toast, setToast] = useState(null) - const [editing, setEditing] = useState(null) - const [panel, setPanel] = useState(null) // null | 'plot' | 'insights' - // Bumped when something outside the turn loop changes the drawers' state - // (currently "Update from scenario"), which no action count would reflect. - const [stateKey, setStateKey] = useState(0) - const [inspectActionId, setInspectActionId] = useState(null) - // Which take is being read, when it is not the live one (see TakePager). - // One at a time; null when every message is showing the take the story tells. - // - // Purely local: the server is not told, because reading a take is not a - // decision. It becomes one when something is written below it, and that is - // what `after_id` carries. - const [preview, setPreview] = useState(null) - // The take a turn was just written below, held from the moment Send is - // pressed until the re-read lands. - // - // Writing below a take is the one moment the server IS told (`after_id`), and - // it obeys immediately — the take is made live before a single token is - // generated. The transcript only learns that from the resync afterwards, so - // dropping the preview at Send time put the *replaced* take back on screen - // for the whole length of the turn, and left it there for good if the resync - // never landed (a failed turn, a lost connection, a closed tab) — the story - // read one way and reloading the page read another. - // - // So the text stays pinned to what was chosen. Unlike `preview` it does not - // truncate the transcript below it: the turn being played goes there. - const [pinned, setPinned] = useState(null) - // Bumped when a take's stored text changes under the pagers, which cache the - // list they fetched. Nothing else invalidates it: a take is added by playing - // a turn, and that re-reads the whole window anyway. - const [takesKey, setTakesKey] = useState(0) - // The transcript is a window on the story, not the whole of it: the page - // load brings the newest page and older ones arrive as the reader scrolls - // up. `total` is the story's real length, for the "N earlier" line. - const [total, setTotal] = useState(0) - const [hasMore, setHasMore] = useState(false) - const [loadingOlder, setLoadingOlder] = useState(false) - const storyEndRef = useRef(null) - const abortRef = useRef(null) - const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom - const inputRef = useRef(null) - // Set just before older actions are prepended, read once afterwards to put - // the reader back where they were. See the layout effect below. - const restoreScrollRef = useRef(null) - const loadingOlderRef = useRef(false) - // Earliest time another attempt is allowed after a failure. See the catch - // in loadOlder. - const retryAfterRef = useRef(0) - - // The drop cap belongs to the story's first narrated beat. `start` is the - // scenario's opening prompt, so it's usually that; an adventure begun blank - // has no `start` action and the first AI reply takes it instead. Tracked by - // id rather than position so deleting earlier turns moves the cap correctly - // instead of stranding it on a removed row. - const firstNarrationId = useMemo( - () => actions.find((a) => a.type === 'start' || a.type === 'ai')?.id ?? null, - [actions], - ) - // Where the transcript stops while a take that is not the live one is being - // read. Such a take is a leaf by construction — whatever was played after - // this turn was played after the take that *is* live — so there is nothing - // under it, and showing the rest would attach one line's story to another's - // text. -1 while nothing is being previewed, which is the ordinary case. - const previewCutoff = useMemo( - () => (preview ? actions.findIndex((a) => a.id === preview.actionId) : -1), - [preview, actions], - ) - // send() sets streaming to '' before the request goes out; reasoningStream - // stays null until reasoning tokens (if any) arrive. Both still at those - // values means the request is in flight with nothing to show yet. - const waitingForFirstToken = streaming === '' && reasoningStream === null - - // Grow the action box with its content (CSS caps it at ~4 lines, then - // scrolls); shrinks back after send() clears the text. - useEffect(() => { - const el = inputRef.current - if (!el) return - el.style.height = 'auto' - el.style.height = `${el.scrollHeight}px` - }, [input]) - - useEffect(() => { - api.getAdventure(id) - .then((adv) => { - setAdventure(adv) - setActions(adv.actions) - setTotal(adv.action_count ?? adv.actions.length) - setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) - }) - .catch(() => navigate('/')) - }, [id, navigate]) - - // Take the story the server just handed back, whole. - // - // Switching a branch and forking one both answer with the newest window of - // the story as it now stands, so there is nothing to merge — the window on - // screen belonged to a path that is no longer the one being read. Pinning - // back to the bottom is deliberate: a switch lands the reader at the tip of - // the line they moved to, which is where the next turn will appear. - const adoptWindow = useCallback((page) => { - pinnedRef.current = true - setPreview(null) - setPinned(null) - setActions(page.actions) - setTotal(page.total) - setHasMore(page.has_more) - // The script and world state come back to what that branch's tip left - // behind, so anything drawn from them is now showing another line's - // numbers until it re-reads. - setStateKey((k) => k + 1) - }, []) - - // Fetch the page above the one on screen and prepend it. - // - // Anchored on the oldest action we hold rather than on a count, so a turn - // landing while the reader scrolls cannot shift the page. Guarded by a ref - // as well as state because scroll fires far faster than React re-renders, - // and two in-flight requests would fetch the same page twice. - const loadOlder = useCallback(async () => { - if (loadingOlderRef.current || !hasMore) return - if (Date.now() < retryAfterRef.current) return - const oldest = actions[0] - if (!oldest) return - loadingOlderRef.current = true - setLoadingOlder(true) - try { - const page = await api.getActions(id, { beforeId: oldest.id }) - if (page.actions.length) { - // Reading backwards is the opposite of following along, so stop - // autoscrolling. Without this the bottom-pinning effect below fires on - // the same `actions` change and throws the reader to the end of the - // story — worst exactly where the button matters, on a window short - // enough that it never scrolled and so never un-pinned itself. - pinnedRef.current = false - // Record the height before the prepend; the layout effect below uses - // it to keep the reader looking at the same paragraph. - restoreScrollRef.current = { - height: document.documentElement.scrollHeight, - top: window.scrollY, - } - setActions((prev) => { - // Defensive: never let a page the reader already holds duplicate a - // message. Cheap, and the alternative is a visibly doubled turn. - const known = new Set(prev.map((a) => a.id)) - return [...page.actions.filter((a) => !known.has(a.id)), ...prev] - }) - } - setTotal(page.total) - setHasMore(page.has_more) - } catch { - // Leave hasMore alone: a failed fetch should let the reader try again - // by scrolling, not permanently hide the rest of their story. But hold - // off briefly first — parked near the top, momentum scrolling fires this - // dozens of times a second, and against an endpoint that is failing that - // is a retry storm rather than a retry. - retryAfterRef.current = Date.now() + 3000 - } finally { - loadingOlderRef.current = false - setLoadingOlder(false) - } - }, [actions, hasMore, id]) - - // Put the viewport back after a prepend. useLayoutEffect, not useEffect: - // this has to run before the browser paints, or the reader sees the story - // jump and then snap back. - useLayoutEffect(() => { - const mark = restoreScrollRef.current - if (!mark) return - restoreScrollRef.current = null - const grown = document.documentElement.scrollHeight - mark.height - if (grown > 0) window.scrollTo({ top: mark.top + grown }) - }, [actions]) - - useEffect(() => { - const onScroll = () => { - pinnedRef.current = - window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120 - // Start the next page before the reader reaches the top, so the story - // is usually already there by the time they would have noticed its end. - if (window.scrollY < 400) loadOlder() - } - window.addEventListener('scroll', onScroll, { passive: true }) - return () => window.removeEventListener('scroll', onScroll) - }, [loadOlder]) - - useEffect(() => { - // Snap to the real document bottom (below the sticky composer), not to - // storyEndRef — that ref sits above the composer, so block:'end' would - // stop short and fight a reader scrolling down. Instant, not smooth: at - // streaming speed a queued smooth animation never settles. - if (pinnedRef.current) { - window.scrollTo({ top: document.documentElement.scrollHeight }) - } - }, [actions, streaming, reasoningStream]) - - const handleScriptReport = useCallback((script) => { - if (!script) return - if (script.errors?.length) { - setToast({ text: `Script error: ${script.errors[0]}`, isError: true }) - } else if (script.message) { - setToast({ text: script.message, isError: false }) - } - }, []) - - const handleEvent = useCallback((event) => { - if (event.type === 'player') { - setActions((prev) => [...prev, event.action]) - // The window grew at the bottom, so the story did too. Kept in step by - // hand because nothing re-reads the count between turns. - setTotal((n) => n + 1) - } else if (event.type === 'chunk') { - setStreaming((prev) => (prev ?? '') + event.text) - } else if (event.type === 'reasoning') { - setReasoningStream((prev) => (prev ?? '') + event.text) - } else if (event.type === 'done') { - setStreaming(null) - setReasoningStream(null) - setActions((prev) => [...prev, event.action]) - setTotal((n) => n + 1) - handleScriptReport(event.script) - } else if (event.type === 'stopped') { - setStreaming(null) - setReasoningStream(null) - handleScriptReport(event.script) - } else if (event.type === 'error') { - setStreaming(null) - setReasoningStream(null) - setToast({ text: event.detail, isError: true }) - } - }, [handleScriptReport]) - - async function runTurn(run) { - const controller = new AbortController() - abortRef.current = controller - setBusy(true) - setToast(null) - setStreaming('') - pinnedRef.current = true - try { - await run(controller.signal) - } catch (err) { - if (err.name === 'AbortError') { - setToast({ text: 'Generation stopped.', isError: false }) - } else { - setToast({ text: err.message, isError: true }) - } - } finally { - abortRef.current = null - setStreaming(null) - setReasoningStream(null) - setBusy(false) - } - } - - function stopGeneration() { - abortRef.current?.abort() - } - - function send(type = mode) { - const text = input.trim() - // Where the reader is standing. Stepping to a take the story moved past - // told the server nothing; this is the moment it has to be told, and it is - // the moment the branch is made (SP9). - const after_id = preview?.takeId - // The window below belongs to the line being left, so it is re-read rather - // than appended to — same reasoning as `addTake`. - const run = (payload) => runTurn(async (signal) => { - try { - await api.sendAction(id, payload, handleEvent, signal) - } finally { - if (after_id) await resync() - } - }) - // Keep the chosen take on screen while the turn runs. The server has - // already been told to stand on it, so this is not optimism — it is the - // transcript catching up with a decision that is already made. - if (preview) setPinned({ ...preview, written: true }) - setPreview(null) - if (type === 'continue') { - // Continue never consumes typed text — leave it in the box. - run({ type: 'continue', text: '', after_id }) - return - } - const payload = { type: text ? type : 'continue', text, after_id } - setInput('') - run(payload) - } - - function retry() { - setPreview(null) - setActions((prev) => - prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev) - runTurn(async (signal) => { - try { - await api.retry(id, handleEvent, signal) - } catch (err) { - // Failed retry (409, network): the optimistically removed action may - // still exist server-side — resync instead of guessing. Resyncing - // collapses the transcript back to the newest window, which is the - // right call: the reader's place is already lost by the failure. - api.getAdventure(id).then((adv) => { - setActions(adv.actions) - setTotal(adv.action_count ?? adv.actions.length) - setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) - }).catch(() => {}) - throw err - } - }) - } - - async function undo() { - setToast(null) - setPreview(null) - setPinned(null) - try { - // A window, not the whole story — undo is the action most likely to be - // repeated several times running, so it must not re-fetch everything. - const page = await api.undo(id) - setActions(page.actions) - setTotal(page.total) - setHasMore(page.has_more) - } catch (err) { - setToast({ text: err.message, isError: true }) - } - } - - // Ctrl+Z undo / Ctrl+R retry, ignored while typing in a field. - useEffect(() => { - const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai' - const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start' - const onKey = (e) => { - if (!(e.ctrlKey || e.metaKey) || busy) return - if (e.target.closest?.('input, textarea, select, [contenteditable]')) return - if (e.key.toLowerCase() === 'z' && canUndo) { - e.preventDefault() - undo() - } else if (e.key.toLowerCase() === 'r' && lastIsAi) { - e.preventDefault() - retry() - } - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }) - - // A take-edit belongs to the preview that opened it. Anything that leaves - // that take — playing a turn, switching branch — takes the box with it, so - // the pending edit goes too rather than being saved onto a take nobody is - // looking at any more. - useEffect(() => { - if (editing?.take && preview?.takeId !== editing.id) setEditing(null) - }, [editing, preview]) - - async function saveEdit() { - const { id: actionId, text, fork, take } = editing - setEditing(null) - if (fork) { - // Not an edit at all: the turn is played again with this text, and what - // the story made of the old text is kept on the line it was written on. - addTake(actionId, text) - return - } - try { - const updated = await api.updateAction(id, actionId, text) - // A take that is only being read is not in `actions` — the row there is - // the live one — so the new text goes back into the preview, which is - // what that row is drawing. The pager holds the take list it fetched, so - // it is told to drop it: stepping away and back would otherwise show the - // words before the edit. - if (take) { - setPreview((p) => (p && p.takeId === actionId ? { ...p, text: updated.text } : p)) - setTakesKey((k) => k + 1) - } else { - setActions((prev) => prev.map((a) => (a.id === actionId ? updated : a))) - } - } catch (err) { - setToast({ text: err.message, isError: true }) - } - } - - // Play a turn again, differently. Anywhere in the story, either kind of node. - // - // The transcript is re-read rather than appended to, which is the difference - // from an ordinary turn: a take above the tip leaves the line it was on and - // the whole window below it belongs to a story this branch no longer tells. - // `handleEvent` appends the new node as it streams; the resync afterwards is - // what drops everything that is no longer under it. - function addTake(actionId, text) { - setPreview(null) - runTurn(async (signal) => { - try { - await api.addTake(id, actionId, text, handleEvent, signal) - } finally { - await resync() - } - }) - } - - // Re-read the newest window from the server. - // - // For a turn that left the line it was on: `handleEvent` appends the new node - // as it streams, and everything already on screen below the take belongs to a - // story this branch no longer tells. Only the server can say what replaces - // it. A failed resync leaves the transcript stale rather than wrong, so it is - // swallowed — the next page load settles it. - async function resync() { - try { - const adv = await api.getAdventure(id) - // The window now says which take is live, so the pin has done its job. - // Left in place if this read fails, which is the whole point of it. - setPinned(null) - setActions(adv.actions) - setTotal(adv.action_count ?? adv.actions.length) - setHasMore(adv.actions.length < (adv.action_count ?? adv.actions.length)) - // The branch, the script state and the world state can all have moved. - setStateKey((k) => k + 1) - } catch { /* stale beats wrong */ } - } - - async function removeAction(actionId) { - try { - await api.deleteAction(id, actionId) - setActions((prev) => prev.filter((a) => a.id !== actionId)) - setTotal((n) => Math.max(0, n - 1)) - } catch (err) { - setToast({ text: err.message, isError: true }) - } - } - - function inspect(actionId) { - setInspectActionId(actionId) - setPanel('insights') - } - - if (!adventure) return null - - const lastIsAi = actions.length > 0 && actions[actions.length - 1].type === 'ai' - const canUndo = actions.length > 0 && actions[actions.length - 1].type !== 'start' - - return ( -
- {/* Both drawers read per-adventure state that a branch switch puts back, - so neither can key on the story's length alone: switching between two - branches whose windows are both full changes every number in here - without changing `actions.length` by one. */} - - -
-
-

{adventure.title}

-
- - - - - -
-
- -
- {actions.length === 0 && streaming === null && ( -
A blank page. Type something below to begin your story.
- )} - {/* Scrolling up loads the rest. The button is not decoration: on a - short viewport the story may not be tall enough to scroll at all, - and a reader who cannot scroll must still be able to get back to - the beginning. */} - {hasMore && ( -
- {loadingOlder ? ( - Turning back the pages… - ) : ( - - )} -
- )} - {actions.map((action, i) => { - // Below the take being read there is nothing on this line yet. - if (previewCutoff !== -1 && i > previewCutoff) return null - const isPlayer = PLAYER_TYPES.includes(action.type) - // A player action opens a new turn, so that's where the ornamental - // break belongs — never above the very first line on the page. - const sceneBreak = isPlayer && i > 0 - // Drop cap goes on the first narrated beat only. `firstNarrationId` - // is derived once above rather than per row. - const opening = action.id === firstNarrationId - // Non-null while the reader is browsing an older attempt of this - // message without making it active (earlier turns only). - const previewing = preview?.actionId === action.id ? preview : null - // The take this row was written below, still on screen because the - // re-read has not landed yet. Same text override as a preview, but - // it never truncates the story under it — see `pinned`. - const shown = previewing - || (pinned?.actionId === action.id ? pinned : null) - - // The editor stands in for the row it was opened from. That row is - // keyed by the live node, so an edit on a take the pager is parked - // on carries the take's id instead and is matched through the - // preview. - const editingHere = editing - && (editing.take ? previewing?.takeId === editing.id : editing.id === action.id) - - return editingHere ? ( -
- setEditing({ ...editing, text: e.target.value })} - /> -
- - -
-
- ) : ( - - {sceneBreak && } -
- - {renderEmphasis(shown ? shown.text : action.text)} - {/* The chips describe the *active* attempt's state changes, - which the take on screen didn't make — so they're hidden - rather than shown against the wrong text. */} - {action.type === 'ai' && !shown && ( - - )} - {/* On every kind of node, not only the AI's: a player's own - turn can be played again too (SP9), so it can have takes - to step through. The pager draws nothing for a count of - one, which is most turns. */} - setToast({ text: message, isError: true })} - /> - {!busy && ( - - {action.type === 'ai' && ( - - )} - {/* Edits the take that is *on screen*, which is not the - live one while the pager is parked on another. The - row is keyed by the live node's id, so seeding from - `action` here opened the editor on take 4/4's text - while 2/4 was being read — and saved over it. A take - is an ordinary row to the edit endpoint, on the path - or not, so its own id is all this needs. */} - - {/* Play this turn again, differently. On the AI's turn - that is a regeneration; on your own it opens the text - so you can say something else. Either way the story - that followed the old take is kept, on the line it - was written on. - - The id stays the live node's even while another take - is being read: adding a take branches just above the - turn, and the server only accepts a turn that is on - the path. Only the seeded text follows the screen, so - varying the take you are reading starts from its - words. */} - {action.type !== 'start' && ( - - )} - - - )} -
-
- ) - })} - {waitingForFirstToken && ( -
- - Weaving -
- )} - {streaming !== null && !waitingForFirstToken && ( -
- - {renderEmphasis(streaming)} - ▋ -
- )} -
-
- -
-
- - - -
-
-
- {MODES.map((m) => ( - - ))} -
-