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/backend/app/attempts.py b/backend/app/attempts.py index e43807d..aeb4154 100644 --- a/backend/app/attempts.py +++ b/backend/app/attempts.py @@ -2,7 +2,7 @@ A retry used to rewrite the AI action in place and append the discarded attempt to a JSON list on the same row. Seven separate bugs came from that arrangement. -The row's `text` duplicated one entry of a repeating group, `variant_count` +The row's `text` duplicated one entry of a repeating group, a second column duplicated its length, and every reader that touched the story during a retry had to be told to ignore the row. @@ -25,10 +25,11 @@ that maintains either one: avoid. The prompt therefore moves with the live flag, and a superseded sibling keeps only its own slices. -Ordering inside a group comes from `variant_index`, which is an explicit ordinal -rather than `created_at`. Two attempts made in the same second still have to page -in the order they were made, and the migration that split the old JSON lists had -to state the order rather than reconstruct it. +Ordering inside a group comes from `id`, not from `created_at`. Two attempts made +in the same second still have to page in the order they were made, and `id` +increases with every insert. SP8 dropped `variant_index`, an explicit ordinal +that carried the same order, once a run of the suite confirmed that the two +agreed in every group. """ import copy @@ -82,7 +83,7 @@ def group(db: Session, action: models.Action) -> list[models.Action]: models.Action.depth == action.depth, models.Action.parent_id.is_(None), ) - .order_by(models.Action.variant_index, models.Action.id) + .order_by(models.Action.id) .all() ) return ( @@ -91,7 +92,7 @@ def group(db: Session, action: models.Action) -> list[models.Action]: models.Action.adventure_id == action.adventure_id, models.Action.parent_id == action.parent_id, ) - .order_by(models.Action.variant_index, models.Action.id) + .order_by(models.Action.id) .all() ) @@ -190,8 +191,8 @@ def add_attempt( """Places `replacement` next to `previous` as the newer attempt at that turn. The placement is done here rather than through `tree.place_action`, which - would read the depth from the legacy `index` and move the head. A sibling is - not a new turn. It is another attempt at the turn the head is already on. + moves the head. A sibling is not a new turn. It is another attempt at the + turn the head is already on. """ replacement.branch_id = previous.branch_id replacement.depth = previous.depth @@ -202,18 +203,10 @@ def add_attempt( # away from this turn. replacement.parent_id = previous.parent_id replacement.live = True - # Use the end of the group rather than one past `previous`. The two match - # only when `previous` is the newest attempt. Switch a three-attempt turn - # back to attempt 1 and retry, and `previous.variant_index + 1` collides with - # attempt 2. `renumber` then breaks the tie by id and places the new attempt - # between attempts 2 and 3, so the pager walks the attempts in an order they - # were not made in. `group` returns oldest first, and `replacement` is not in - # it yet. - siblings = group(db, previous) - replacement.variant_index = 1 + max( - (s.variant_index for s in siblings if s.variant_index is not None), - default=previous.variant_index or 0, - ) + # The replacement takes its place at the end of the group, because `group` + # orders by `id` and this row has no id yet. Switching a three-attempt turn + # back to attempt 1 and retrying therefore still pages 1, 2, 3, 4, which is + # the order the attempts were made in. previous.live = False # The replacement was assembled with a fresh snapshot, so the prompt for # this turn is now the one it carries. The superseded attempt keeps only the @@ -226,8 +219,7 @@ def make_live( ) -> list[models.Action]: """Makes `node` the attempt the story tells, and restores its outcome. - Returns the group, renumbered, so that a caller reporting on it does not read - it twice. + Returns the group, so that a caller reporting on it does not read it twice. """ rows = group(db, node) previous = live_in(rows) @@ -236,23 +228,9 @@ def make_live( for row in rows: row.live = row is node restore_state(adventure, node) - renumber(rows) return rows -def renumber(rows: list[models.Action]) -> None: - """Refreshes the group-shape cache that the page response reads. - - `variant_count` is 0 rather than 1 for a turn nobody retried, because the - pager asks whether there is anything to page through, and for a single - attempt the answer is no. - """ - count = len(rows) if len(rows) > 1 else 0 - for i, row in enumerate(rows): - row.variant_index = i - row.variant_count = count - - # ------------------------------------------------- the prompt, stored once def keep_own_slices(node: models.Action) -> None: diff --git a/backend/app/bundle.py b/backend/app/bundle.py index a7eac5e..5f368af 100644 --- a/backend/app/bundle.py +++ b/backend/app/bundle.py @@ -14,8 +14,8 @@ Version 2 carries the tree. It holds three things version 1 could not, and each one is required: * The branches, because a forked adventure is two stories and a flat list holds - one. A version 1 export interleaved them by `index`, which read as a garbled - story rather than as lost data. + one. A version 1 export interleaved them by turn number, which read as a + garbled story rather than as lost data. * `live`, because a coordinate can hold several attempts at one turn and exactly one of them is the story. * Both after-snapshots, because they are what a branch switch and an undo @@ -26,22 +26,14 @@ one is required: A bundle carries what was chosen, never what is derived. The head branch, the fork points, the live flags, and the anchors are decisions somebody made, so -they are in the file. `lineage`, the head depth, `index`, and the variant -ordinals are all computed from those, and the import recomputes them: +they are in the file. `lineage` and the head depth are computed from those, and +the import recomputes them: * `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would put a second source of truth for one fact into a file anyone can hand-edit, and the two could then disagree without any read reporting it. * The head depth is the tip of the head branch, which is a fact about the nodes that arrived with it. -* `index` is the legacy column SP8 drops. Its one remaining job is to give the - next row a number nothing else holds, which is a fact about the adventure - rather than about a path, so `depth` cannot serve: two branches each have a - node at depth 4. The import allocates one index per turn instead, which keeps - `max_action_index` correct and keeps siblings sharing an index the way SP4 - leaves them. -* `attempts.renumber` maintains the variant ordinals, and it is the only place - allowed to. Every hand-editable coordinate is therefore checked before a row is written, in `plan`, rather than repaired afterwards. An import that fails partway leaves an @@ -98,8 +90,7 @@ def export(db: Session, adventure: models.Adventure) -> dict: undefer(models.Action.world_state_after), ) .order_by( - models.Action.branch_id, models.Action.depth, - models.Action.variant_index, models.Action.id, + models.Action.branch_id, models.Action.depth, models.Action.id, ) .all() ) @@ -168,8 +159,7 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict: def _exported_node(action: models.Action, local: dict[int, int]) -> dict: node = { "branch": _local(action.branch_id, local), - # A pre-tree row's depth is the number `index` already holds. - "depth": action.depth if action.depth is not None else action.index, + "depth": action.depth, "live": bool(action.live), "type": action.type, "text": action.text, @@ -496,24 +486,17 @@ def _write_nodes( ) -> None: """Writes the nodes, grouped into the turns they are attempts at. - Two values are allocated here rather than read from the file. `index` is - issued once per turn, in the order the bundle lists them, so siblings share - one index and no two coordinates do. `max_action_index` needs that to keep - issuing numbers nothing holds. Exactly one attempt in each group is also made - live, because a file can name none or several, and a turn with no live node - disappears from the story. + One value is decided here rather than read from the file. Exactly one + attempt in each group is made live, because a file can name none or several, + and a turn with no live node disappears from the story. """ groups: dict[tuple[int, int], list[models.Action]] = {} - indices: dict[tuple[int, int], int] = {} for spec in specs: key = (spec["branch"], spec["depth"]) - if key not in indices: - indices[key] = len(indices) action = models.Action( adventure_id=adventure.id, branch_id=ids[spec["branch"]], depth=spec["depth"], - index=indices[key], type=spec["type"], text=spec["text"], reasoning=spec["reasoning"], @@ -531,7 +514,6 @@ def _write_nodes( live = next((row for row in rows if row.live), rows[0]) for row in rows: row.live = row is live - attempts.renumber(rows) def _write_memories( @@ -605,31 +587,22 @@ def _write_anchors( cursor.anchor(adventure, ids[branch] if branch is not None else None, depth) -def settle(db: Session, adventure: models.Adventure, story: dict) -> None: - """Aligns the two coordinate systems, once the nodes exist. +def settle(adventure: models.Adventure, story: dict) -> None: + """Resolves a version 1 bundle's counts into anchors, once the nodes exist. - The anchors and the legacy counts describe the same boundary in different - terms, and each version of the bundle carries one of them. A version 1 file - carries the count, so the anchor is found by counting that far along the - story. A version 2 file carries the anchor, so the count is read back from - it. The legacy columns are written either way, because a rolled-back build - reads them. + A version 1 file records how far the memories and the summary have read as + a count, so the anchor is found by counting that far along the story. A + version 2 file carries the anchor itself, which `_write_anchors` has + already stored, so there is nothing left to do. - The caller runs this after the flush, because both directions need the - actions to be queryable. + The caller runs this after the flush, because counting needs the actions to + be queryable. """ positions = story["positions"] + if positions is None: + return for cursor in cursors.ALL: - if positions is not None: - setattr(adventure, f"{cursor.name}_cursor", positions[cursor.name]) - cursors.anchor_at_position(adventure, cursor, positions[cursor.name]) - else: - setattr( - adventure, f"{cursor.name}_cursor", - cursors.position_of(adventure, cursor.depth(db, adventure)), - ) - - + cursors.anchor_at_position(adventure, cursor, positions[cursor.name]) def materialize( @@ -691,7 +664,7 @@ def materialize( write(db, adventure, story) db.flush() db.expire(adventure, ["actions"]) - settle(db, adventure, story) + settle(adventure, story) return adventure # ------------------------------------------------------------------ reading diff --git a/backend/app/context/builder.py b/backend/app/context/builder.py index 950e9d1..2b97716 100644 --- a/backend/app/context/builder.py +++ b/backend/app/context/builder.py @@ -353,7 +353,7 @@ def build_context( # Even the newest action alone is over budget: hard-truncate it. included_actions.append( models.Action( - adventure_id=action.adventure_id, index=action.index, + adventure_id=action.adventure_id, type=action.type, text=truncate_to_last_tokens(action.text, history_budget), ) diff --git a/backend/app/context/history.py b/backend/app/context/history.py index 744637a..df279df 100644 --- a/backend/app/context/history.py +++ b/backend/app/context/history.py @@ -417,30 +417,6 @@ def newest(adventure: models.Adventure) -> models.Action | None: return rows[0] if rows else None -def max_action_index(adventure: models.Adventure) -> int: - """Highest `Action.index` in the adventure, story text or not. -1 if empty. - - This is the only read in the module that is deliberately not scoped to a - path. `index` is a legacy column that remains unread until SP8 drops it. Its - one remaining job is to give the next row a number that no other row holds, - which is a fact about the adventure rather than about the story being - played. Scoping the query to a branch would let two branches issue the same - index. - """ - loaded = _loaded_actions(adventure) - if loaded is not None: - return max((a.index for a in loaded), default=-1) - db = _session(adventure) - if db is None: - return -1 - highest = ( - db.query(func.max(models.Action.index)) - .filter(models.Action.adventure_id == adventure.id) - .scalar() - ) - return -1 if highest is None else highest - - def window_covering( adventure: models.Adventure, budget_tokens: int, diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 2078fbd..65ebab8 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -23,7 +23,10 @@ import json import re from datetime import datetime -from sqlalchemy import inspect, text +from sqlalchemy import ( + JSON, Boolean, Column, DateTime, Integer, MetaData, String, Table, Text, + inspect, text, +) from sqlalchemy.engine import Engine from . import compression, vectors @@ -297,6 +300,38 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [ # under. (63, "ALTER TABLE actions ADD COLUMN parent_id INTEGER REFERENCES actions(id) ON DELETE SET NULL"), (64, "CREATE INDEX IF NOT EXISTS ix_actions_parent ON actions (parent_id)"), + # Phase 17: `Settings.stream` was dead state. Nothing ever read it, and every + # turn streams. This is item S1 in `docs/self-review.md`. The table holds one + # row per user, so the rewrite is small and needs no VACUUM FULL. + (65, "ALTER TABLE settings DROP COLUMN stream"), + # Phase 17, SP8: drop the eight columns the story tree replaced. Each one was + # kept past the migration that superseded it so that a rollback found a real + # value on the rows the newer build wrote. The tree has run in production + # since Phase 14, so the rollback window is closed. + # + # `actions.index` was the global turn number. `depth` replaced it, and the + # last reader, the number issued to a new row, went with this migration. + # `variants` held the retry history as a repeating group, and `variant_index` + # and `variant_count` described that group's shape. Each attempt is its own + # row now, ordered by `id`. `state_before` and `world_state_before` recorded + # the state a node started from, which every attempt at a turn shares; the + # "after" columns record each attempt's own outcome instead. + # `adventures.memory_cursor` and `summary_cursor` were positions into a flat + # list, and the branch and depth pairs beside them replaced both. + # + # Dropping a column rewrites the toasted values on Postgres and nothing + # reclaims that space on its own. Run `VACUUM FULL actions;` on the direct + # Neon endpoint after the deploy, not the pooled one. It takes an ACCESS + # EXCLUSIVE lock, so the app blocks on `actions` while it runs. + (66, "ALTER TABLE actions DROP COLUMN variants"), + (67, "ALTER TABLE actions DROP COLUMN variant_index"), + (68, "ALTER TABLE actions DROP COLUMN variant_count"), + (69, "ALTER TABLE actions DROP COLUMN state_before"), + (70, "ALTER TABLE actions DROP COLUMN world_state_before"), + # `index` is a keyword in SQLite, so the column name is quoted. + (71, 'ALTER TABLE actions DROP COLUMN "index"'), + (72, "ALTER TABLE adventures DROP COLUMN memory_cursor"), + (73, "ALTER TABLE adventures DROP COLUMN summary_cursor"), ] LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) @@ -406,6 +441,8 @@ def _backfill_variant_count(conn) -> None: in Python would read every stored attempt over the network once in order to stop reading it on every request. """ + if not _has_columns(conn, "actions", "variants", "variant_count"): + return if conn.dialect.name == "sqlite": sql = """ UPDATE actions SET variant_count = json_array_length(variants) @@ -427,6 +464,41 @@ def _for_dialect(sql: str | dict[str, str], dialect: str) -> str: # Matches the ADD COLUMN migrations in this file. Every one is written above, # so this pattern parses only SQL this file controls. _ADD_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+\"?(\w+)\"?", re.I) +_DROP_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\w+)\s+DROP\s+COLUMN\s+\"?(\w+)\"?", re.I) + + +def _has_columns(conn, table: str, *columns: str) -> bool: + """Returns `True` when `table` has every one of `columns`. + + The data passes below read columns that later migrations drop. A pass only + ever has real work to do on a database old enough to still carry them, and + a `create_all` database is already current, so the guard skips the pass + rather than failing on a column that is not there. This is the same rule + `_column_already_there` applies to DDL, written for the passes. + """ + inspector = inspect(conn) + if table not in inspector.get_table_names(): + return False + present = {col["name"] for col in inspector.get_columns(table)} + return all(column in present for column in columns) + + +def _column_already_gone(conn, sql: str) -> bool: + """Returns `True` when `sql` drops a column the table no longer has. + + This is the counterpart to `_column_already_there`, for the same reason. + `create_all` builds the current schema, which is already missing every + column a migration drops. Replaying from an older stamp against a database + built that way would fail on a column that is not there. + """ + match = _DROP_COLUMN.match(sql) + if match is None: + return False + table, column = match.group(1), match.group(2) + inspector = inspect(conn) + if table not in inspector.get_table_names(): + return False + return column not in {col["name"] for col in inspector.get_columns(table)} def _column_already_there(conn, sql: str) -> bool: @@ -566,6 +638,8 @@ def _backfill_tree(conn) -> None: target being unset, so a run that fails partway through resumes rather than applying twice. """ + if not _has_columns(conn, "actions", "index"): + return sqlite = conn.dialect.name == "sqlite" # 1. A root branch per adventure. Its lineage names the row's own id, which @@ -675,6 +749,8 @@ def _backfill_cursor_anchors(conn) -> None: two forms return the same answer, because the rows the partition would separate are the rows the filter removes. """ + if not _has_columns(conn, "adventures", "memory_cursor", "summary_cursor"): + return sqlite = conn.dialect.name == "sqlite" story = _story_text_sql("text", sqlite) for name in ("memory", "summary"): @@ -727,6 +803,8 @@ def _backfill_state_after(conn) -> None: SP4 is the first migration where `depth` and `index` can disagree, and it has not run when this pass does. """ + if not _has_columns(conn, "actions", "state_before", "world_state_before"): + return for column, live in ( ("state_after", "script_state"), ("world_state_after", "world_state"), @@ -755,6 +833,39 @@ def _backfill_state_after(conn) -> None: """)) +# The `actions` table as migration 60 finds it, declared here rather than read +# from `Base.metadata`. The split pass writes JSON values, and only a column +# type knows how to render a dict on this dialect, so it needs a Table. Reading +# the live one would tie a migration to the current model: migration 66 drops +# five of these columns, and the pass would then fail to compile on a database +# that still has them. This declaration is a snapshot of a past schema and must +# not be updated to track `models.py`. +# +# It carries its own `MetaData`, so `create_all` never sees it. +_ACTIONS_AT_60 = Table( + "actions", MetaData(), + Column("id", Integer, primary_key=True), + Column("adventure_id", Integer), + Column("index", Integer), + Column("branch_id", Integer), + Column("depth", Integer), + Column("live", Boolean), + Column("type", String(20)), + Column("text", Text), + Column("reasoning", Text), + Column("context_snapshot", compression.CompressedJSON), + Column("world_delta", JSON), + Column("state_before", JSON), + Column("world_state_before", JSON), + Column("state_after", JSON), + Column("world_state_after", JSON), + Column("variants", JSON), + Column("variant_count", Integer), + Column("variant_index", Integer), + Column("created_at", DateTime), +) + + def _split_variants_into_siblings(conn) -> None: """Gives every discarded retry attempt its own row. @@ -779,7 +890,9 @@ def _split_variants_into_siblings(conn) -> None: The pass is resumable. A group that already has as many rows as its `variant_count` claims has been split, so it is skipped. """ - actions = Base.metadata.tables["actions"] + if not _has_columns(conn, "actions", "variants", "variant_index"): + return + actions = _ACTIONS_AT_60 last_id = 0 while True: rows = conn.execute( @@ -831,7 +944,8 @@ def _split_one_action(conn, actions, row, entries: list) -> None: kept["world_state_after"] = live_world # Use the Table rather than `text()`, here and below. These values are dicts # bound for JSON columns, and the column type is the only thing that knows - # how to render one on this dialect. + # how to render one on this dialect. The table is `_ACTIONS_AT_60`, frozen + # at the schema this pass runs against. conn.execute(actions.update().where(actions.c.id == row["id"]).values(**kept)) siblings = [ { @@ -933,20 +1047,28 @@ def _set_version(conn, version: int) -> None: conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version}) -def bootstrap(engine: Engine) -> None: +def bootstrap(engine: Engine, through: int = LATEST_VERSION) -> None: + """Brings the database up to `through`, which defaults to the newest version. + + The app always takes the default. A test passes an older version when it + asserts on something a later migration removes. A migration test that stops + at the version it is about keeps reading the columns that migration wrote, + rather than the schema those columns became several versions later. + """ fresh = not inspect(engine).get_table_names() Base.metadata.create_all(bind=engine) with engine.begin() as conn: if fresh: - _set_version(conn, LATEST_VERSION) + _set_version(conn, through) return current = _get_version(conn) for version, sql in MIGRATIONS: - if version > current: + if current < version <= through: statement = _for_dialect(sql, conn.dialect.name) # Skip the DDL when it has already run. The data pass below it # still runs. - if not _column_already_there(conn, statement): + if not (_column_already_there(conn, statement) + or _column_already_gone(conn, statement)): conn.execute(text(statement)) if version == WORLD_DELTA_VERSION: _backfill_world_delta(conn) diff --git a/backend/app/models.py b/backend/app/models.py index 2c697c1..94d146f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -118,15 +118,8 @@ class Adventure(Base): # Phase 6: opt-in per adventure (extra AI calls) auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False) memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False) - # Legacy columns from Phase 6. Each holds a count of the actions that were - # folded into the memories or the story summary, expressed as a position in - # the story. Nothing has read them since SP3, and nothing writes them except - # a v1 import. They remain for one release so that a rollback resumes from a - # real number. SP8 drops them along with `actions.index`. The columns below - # hold the marks that this code actually uses. - memory_cursor: Mapped[int] = mapped_column(Integer, default=0) - summary_cursor: Mapped[int] = mapped_column(Integer, default=0) - # Phase 14, SP3: the same two marks expressed as coordinates. Each pair + # Phase 14, SP3: how far the memory pass and the summary pass have read, + # each as a coordinate. Each pair # holds the branch and depth of the last action that pass covered. A # position moves when an action in front of it is deleted, so the mark # silently starts covering an action it never read. A depth is a coordinate @@ -168,7 +161,7 @@ class Adventure(Base): actions: Mapped[list["Action"]] = relationship( back_populates="adventure", cascade="all, delete-orphan", - order_by="Action.index", + order_by="Action.id", ) scripts: Mapped[list["AdventureScript"]] = relationship( back_populates="adventure", @@ -261,8 +254,7 @@ class Memory(Base): ) # The stretch of story this memory summarizes, given as depths on # `branch_id`. Both are NULL for a hand-written memory, which summarizes no - # actions. Before SP3 these columns held `Action.index` values, which were - # the same numbers. `source_end` is the depth of the node the memory + # actions. `source_end` is the depth of the node the memory # attaches to, and `depth` below mirrors it. `source_start` is where the # stretch begins, which is where the summarizer resumes if the memory is # withdrawn. @@ -283,10 +275,9 @@ class Memory(Base): ForeignKey("branches.id", ondelete="CASCADE"), nullable=True ) depth: Mapped[int | None] = mapped_column(Integer, nullable=True) - # Whether `embedding_blob` is set. `memorybank.set_vector` keeps this - # column current, for the same reason that `actions.variant_count` sits - # beside `actions.variants`. Readers need only the yes-or-no answer, and - # fetching six kilobytes of vector to get it is too expensive. + # Whether `embedding_blob` is set. `memorybank.set_vector` keeps this column + # current. Readers need only the yes-or-no answer, and fetching six + # kilobytes of vector to get it is too expensive. embedded: Mapped[bool] = mapped_column(Boolean, default=False) pinned: Mapped[bool] = mapped_column(Boolean, default=False) forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI @@ -346,18 +337,15 @@ class Action(Base): id: Mapped[int] = mapped_column(primary_key=True) adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE")) - index: Mapped[int] = mapped_column(Integer) # Phase 14: the node's place in the tree. `depth` is a position along one # path rather than a global turn number. Node A4 and node B4 are - # alternatives, not duplicates. `depth` replaces `index` as the ordering - # key. + # alternatives, not duplicates. # # Both columns are nullable because ALTER TABLE cannot add a NOT NULL column # without a default, and no default makes sense for a branch. The migration # fills these columns for existing rows, and `tree.place_action` fills them # for new rows. From SP2 onward, a NULL `branch_id` marks a row that no read - # can see. The legacy `index` column stays alongside, unread, until the tree - # is proven in production. SP8 drops it. + # can see. branch_id: Mapped[int | None] = mapped_column( ForeignKey("branches.id", ondelete="CASCADE"), nullable=True ) @@ -417,20 +405,6 @@ class Action(Base): # and for re-attaching the emit block when replaying history to the model. # Mirrors the active variant, same as text/reasoning/context_snapshot. world_delta: Mapped[dict | None] = mapped_column(JSON, nullable=True) - # Legacy columns from before SP4. They hold `Adventure.script_state` and - # `Adventure.world_state` as they were immediately before this action's - # script hooks ran. Nothing has written or read them since SP4. The pair of - # "after" columns below replaced them, because each sibling attempt needs - # its own outcome and every attempt at a turn shares the same starting - # state. These columns remain for one release so that a rolled-back build - # still finds a real snapshot on the rows it wrote. SP8 drops them along - # with `index` and `variants`. - state_before: Mapped[dict | None] = mapped_column( - JSON, nullable=True, deferred=True - ) - world_state_before: Mapped[dict | None] = mapped_column( - JSON, nullable=True, deferred=True - ) # Phase 14, SP4: the shared script state and the RPG world state as they # stood after this node was played. These columns record the node's outcome # rather than its starting position. @@ -453,24 +427,6 @@ class Action(Base): world_state_after: Mapped[dict | None] = mapped_column( JSON, nullable=True, deferred=True ) - # A legacy column from before SP4. It holds the retry history as a repeating - # group inside a JSON list. Every attempt at a turn is now its own row, as - # described on `live` above and in `app/attempts.py`, so nothing reads this - # column. It remains until SP8 for the same reason `index` does. Migration - # 60 reads it once more, to turn each entry into a sibling row. - variants: Mapped[list | None] = mapped_column(JSON, nullable=True, deferred=True) - # Where the row sits in its sibling group. `variant_index` is this - # attempt's ordinal, counting from the oldest. `variant_count` is the number - # of attempts in the group. It is 0 rather than 1 when the turn was never - # retried, which the pager treats as having nothing to page through. - # - # Both columns are caches, and `attempts.renumber` is the only place that - # maintains them. The reason matches why `variant_count` once cached - # `len(variants)`: a page response needs both numbers for every row and - # cannot afford one query per turn. SP7 replaces the pager with the branch - # view, and SP8 drops both columns. - variant_count: Mapped[int] = mapped_column(Integer, default=0) - variant_index: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) adventure: Mapped[Adventure] = relationship(back_populates="actions") @@ -619,7 +575,6 @@ class Settings(Base): "for the player's next action." ), ) - stream: Mapped[bool] = mapped_column(Boolean, default=True) # Phase 6: auto-summarization + memory bank summary_model: Mapped[str] = mapped_column(String(200), default="") # "" = main model embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py deleted file mode 100644 index e58dc2b..0000000 --- a/backend/app/routers/adventures.py +++ /dev/null @@ -1,2371 +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") - # The lock is held for the same reason undo holds it: this endpoint puts - # the shared state back, and a turn that is still generating is about to - # write it. - acquire_turn_lock(adventure_id) - try: - # This works like undo. The turn is deleted with all of its attempts, - # and whatever it produced is withdrawn. 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) - # The script state and the world state belong to the adventure, not to - # the node, so deleting the node does not take back what it did to - # them. Put them back to what the story now ends with, which is the - # same restore a branch switch does. - # - # The world state carries the cooldown clock in `_meta.last_changed`, - # and that clock is a depth. Leaving it set marked the deleted turn's - # changes as having happened at the depth the next turn is played at, - # so the referee refused them as changed too recently — on a turn the - # story no longer contains. Deleting a middle action restores the tip's - # own outcome, which is the state the adventure is already in. - attempts.restore_state(adventure, db_tip(db, adventure)) - db.commit() - finally: - _active_turns.discard(adventure_id) diff --git a/backend/app/routers/adventures/__init__.py b/backend/app/routers/adventures/__init__.py new file mode 100644 index 0000000..0996c11 --- /dev/null +++ b/backend/app/routers/adventures/__init__.py @@ -0,0 +1,56 @@ +"""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 world_delta_of + +__all__ = [ + "ACTION_PAGE", + "SNIPPET_MAX", + "_snippet", + "limits", + "retry_action", + "router", + "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..7c46ce1 --- /dev/null +++ b/backend/app/routers/adventures/actions.py @@ -0,0 +1,105 @@ +"""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 attempts, models, schemas, tree +from ...database import get_db + +from . import turns +from .deps import CurrentUser, current_adventure, router +from .nodes import db_tip, delete_turn +from .paging import ACTION_PAGE, action_window, annotate_takes + + +@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage) +def list_actions( + before_id: int | None = None, + limit: int = ACTION_PAGE, + db: Session = Depends(get_db), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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), + adventure: models.Adventure = Depends(current_adventure), +): + 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), + adventure: models.Adventure = Depends(current_adventure), +): + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + # The lock is held for the same reason undo holds it: this endpoint puts + # the shared state back, and a turn that is still generating is about to + # write it. + turns.acquire_turn_lock(adventure_id) + try: + # This works like undo. The turn is deleted with all of its attempts, + # and whatever it produced is withdrawn. 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) + # The script state and the world state belong to the adventure, not to + # the node, so deleting the node does not take back what it did to + # them. Put them back to what the story now ends with, which is the + # same restore a branch switch does. + # + # The world state carries the cooldown clock in `_meta.last_changed`, + # and that clock is a depth. Leaving it set marked the deleted turn's + # changes as having happened at the depth the next turn is played at, + # so the referee refused them as changed too recently — on a turn the + # story no longer contains. Deleting a middle action restores the tip's + # own outcome, which is the state the adventure is already in. + attempts.restore_state(adventure, db_tip(db, adventure)) + db.commit() + finally: + turns._active_turns.discard(adventure_id) diff --git a/backend/app/routers/adventures/branches.py b/backend/app/routers/adventures/branches.py new file mode 100644 index 0000000..a09f73a --- /dev/null +++ b/backend/app/routers/adventures/branches.py @@ -0,0 +1,259 @@ +"""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, current_adventure, router +from .nodes import db_tip +from .paging import current_window + + +@router.get("/{adventure_id}/branches", response_model=list[schemas.BranchOut]) +def list_branches( + db: Session = Depends(get_db), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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( + branch_id: int, + payload: schemas.BranchRename, + db: Session = Depends(get_db), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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) + # 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. + db.delete(branch) + adventure.updated_at = models.utcnow() + db.commit() + finally: + turns._active_turns.discard(adventure_id) + +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), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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..3c1fd50 --- /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, current_adventure, router + + +@router.get("/{adventure_id}/export") +def export_adventure( + db: Session = Depends(get_db), + adv: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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..f42052e --- /dev/null +++ b/backend/app/routers/adventures/crud.py @@ -0,0 +1,334 @@ +"""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, current_adventure, 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, + 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( + db: Session = Depends(get_db), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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( + overrides: dict = Body(...), + db: Session = Depends(get_db), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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( + payload: schemas.AdventureUpdate, + db: Session = Depends(get_db), + adventure: models.Adventure = Depends(current_adventure), +): + 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), + adventure: models.Adventure = Depends(current_adventure), +): + 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..d5e3137 --- /dev/null +++ b/backend/app/routers/adventures/deps.py @@ -0,0 +1,42 @@ +"""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 +from ...database import get_db + + +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 + + +def current_adventure( + adventure_id: int, + db: Session = Depends(get_db), + user: models.User = Depends(auth.get_current_user), +) -> models.Adventure: + """Resolves the `{adventure_id}` in the path to the caller's own adventure. + + Declare this in a handler's signature. The ownership check then reads in the + signature, where you look for it, and it runs before the handler body. + + FastAPI caches a dependency for the length of one request, so the handler's + own `db` is the same session this one loaded the adventure from. + """ + return get_adventure_or_404(adventure_id, db, user) diff --git a/backend/app/routers/adventures/insights.py b/backend/app/routers/adventures/insights.py new file mode 100644 index 0000000..254f676 --- /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, current_adventure, router + + +@router.get("/{adventure_id}/context") +async def dry_run_context( + db: Session = Depends(get_db), + user: models.User = CurrentUser, + adventure: models.Adventure = Depends(current_adventure), +): + """Returns what the app would send to the AI if the player continued now.""" + 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), + adventure: models.Adventure = Depends(current_adventure), +): + 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..119b8d3 --- /dev/null +++ b/backend/app/routers/adventures/memories.py @@ -0,0 +1,116 @@ +"""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, current_adventure, 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), + adventure: models.Adventure = Depends(current_adventure), +): + # 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( + payload: schemas.MemoryCreate, + db: Session = Depends(get_db), + user: models.User = CurrentUser, + adventure: models.Adventure = Depends(current_adventure), +): + """Adds a memory manually. The next post-turn pass embeds it.""" + 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), + adventure: models.Adventure = Depends(current_adventure), +): + 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), + adventure: models.Adventure = Depends(current_adventure), +): + 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..c55b279 --- /dev/null +++ b/backend/app/routers/adventures/nodes.py @@ -0,0 +1,139 @@ +"""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 lineage + + +def next_depth(adventure: models.Adventure) -> int: + """Returns the depth for the next node played onto this story, one past the tip. + + A depth is a position along one story, and the branch is what makes it + unambiguous. Two branches each hold a node at depth 4, and they are + alternatives rather than duplicates. + """ + 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..92c0c23 --- /dev/null +++ b/backend/app/routers/adventures/paging.py @@ -0,0 +1,168 @@ +"""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.type, + models.Action.text, + models.Action.reasoning, + models.Action.world_delta, + # 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. + + 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 parent, 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) + .filter( + models.Action.adventure_id == adventure_id, + models.Action.parent_id.in_(parents), + ) + .order_by(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..c83fdff --- /dev/null +++ b/backend/app/routers/adventures/refresh.py @@ -0,0 +1,195 @@ +"""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, current_adventure, 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( + db: Session = Depends(get_db), + user: models.User = CurrentUser, + adventure: models.Adventure = Depends(current_adventure), +): + """Returns what "Update from scenario" would change, for the confirm dialog.""" + 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, + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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..5e878ec --- /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, current_adventure, 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( + db: Session = Depends(get_db), + user: models.User = CurrentUser, + adventure: models.Adventure = Depends(current_adventure), +): + 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, + adventure: models.Adventure = Depends(current_adventure), +): + """Overwrites this copy's code with the latest from its library script. + + `enabled`, `position`, and the adventure's shared `script_state` are kept. + """ + 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), + adventure: models.Adventure = Depends(current_adventure), +): + 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..13bfb37 --- /dev/null +++ b/backend/app/routers/adventures/takes.py @@ -0,0 +1,388 @@ +"""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 ...sse import SSE_HEADERS + +from . import turns +from .deps import CurrentUser, current_adventure, router +from .nodes import delete_turn, last_action, stand_on +from .paging import action_window, annotate_takes, current_window + + +@router.post("/{adventure_id}/retry") +def retry_action( + adventure_id: int, + request: Request, + db: Session = Depends(get_db), + user: models.User = CurrentUser, + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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), + adventure: models.Adventure = Depends(current_adventure), +): + """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`. + """ + 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), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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, + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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), + adventure: models.Adventure = Depends(current_adventure), +): + """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. + """ + 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..1e0369a --- /dev/null +++ b/backend/app/routers/adventures/turns.py @@ -0,0 +1,418 @@ +"""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 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 ...sse import SSE_HEADERS, sse, turn_error +from ..settings import get_settings + +from .deps import CurrentUser, current_adventure, router +from .nodes import _move_to_after, next_depth +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 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, + 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) + # Flush so the new attempt has an id. The session does not autoflush, + # and attempts page in id order, so a read taken before this point puts + # the newest attempt nowhere. + db.flush() + 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, + 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` for the AI action does 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: models.Adventure = Depends(current_adventure), +): + 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/app/routers/chat.py b/backend/app/routers/chat.py index 78da113..379db67 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -19,7 +19,7 @@ from sqlalchemy.orm import Session from .. import auth, limits, models, schemas from ..database import get_db from ..providers import OpenAICompatibleProvider, ProviderError -from .adventures import SSE_HEADERS, sse +from ..sse import SSE_HEADERS, sse from .settings import get_settings, list_endpoint_models router = APIRouter(prefix="/api/chat", tags=["chat"]) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7fa2cd1..905659b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -186,27 +186,20 @@ class RefreshPlan(BaseModel): class ActionOut(ORMModel): id: int adventure_id: int - index: int type: str text: str reasoning: str | None = None # Phase 12: the compact RPG state changes for this turn, read from the # model property. world_changes: list[dict] = [] - # Retry history: how many attempts exist for this turn, where 0 means the - # turn was never retried, and which attempt is live. The attempts themselves - # come from `GET /actions/{id}/variants`, so this payload stays small. - variant_count: int = 0 - variant_index: int = 0 # SP9: the pager, such as `2/4`. It reports how many attempts this turn has # and which one is on screen. It is keyed on the parent, so it counts the # attempts of this turn rather than every node that shares a depth, and it # keeps counting them after one has been forked onto its own branch. # # A turn nobody has retaken reads 1/1, which is most turns, and the client - # draws no pager for a count of one. `variant_count` uses a different - # convention and reports 0 for the same case. Those two fields are the - # pre-SP9 pair, and SP8 drops them. + # draws no pager for a count of one. The attempts themselves come from + # `GET /actions/{id}/variants`, so this payload stays small. take_count: int = 1 take_index: int = 0 # Which line this node is on, so the pager can distinguish the two kinds of @@ -449,7 +442,6 @@ class SettingsOut(ORMModel): reasoning_max_tokens: int context_token_budget: int narrator_prompt: str - stream: bool summary_model: str embedding_model: str memory_bank_capacity: int @@ -496,7 +488,6 @@ class SettingsUpdate(BaseModel): reasoning_max_tokens: Annotated[int, Field(ge=-1, le=100_000)] | None = None context_token_budget: Annotated[int, Field(ge=256, le=200_000)] | None = None narrator_prompt: Prose | None = None - stream: bool | None = None summary_model: Name | None = None embedding_model: Name | None = None memory_bank_capacity: Annotated[int, Field(ge=1, le=1000)] | None = None diff --git a/backend/app/sse.py b/backend/app/sse.py new file mode 100644 index 0000000..21365d4 --- /dev/null +++ b/backend/app/sse.py @@ -0,0 +1,30 @@ +"""Server-sent events: the wire format, the headers, and the error frame. + +Two routers stream: the turn engine in `routers/adventures/turns.py` and the +chat scratchpad in `routers/chat.py`. Both send JSON objects as SSE `data:` +frames, so the format lives here rather than in either one. +""" +import json + +from . import analytics + +# `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 sse(obj: dict) -> str: + """Returns one SSE frame carrying `obj` as JSON.""" + 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}) diff --git a/backend/app/tree.py b/backend/app/tree.py index bd17ae3..78a194c 100644 --- a/backend/app/tree.py +++ b/backend/app/tree.py @@ -118,8 +118,8 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model raise ValueError("cannot fork from a node that is not on a branch") fork_depth = node.depth - 1 # Read the sibling attempts before moving the node. The session does not - # autoflush, so a later read still finds the node here and renumbers it back - # into the group it just left. + # autoflush, so a later read still finds the node here and hands the live + # flag back to it. remaining = [ row for row in db.query(models.Action) .filter( @@ -127,7 +127,7 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model models.Action.branch_id == parent.id, models.Action.depth == node.depth, ) - .order_by(models.Action.variant_index, models.Action.id) + .order_by(models.Action.id) .all() if row is not node ] @@ -160,14 +160,12 @@ def fork(db: Session, adventure: models.Adventure, node: models.Action) -> model depth = node.depth node.branch_id = new_id node.live = True - node.variant_index = 0 - node.variant_count = 0 + # The group the node left needs a live attempt again. Taking the oldest is + # arbitrary, and it has to be somebody: a coordinate with no live attempt + # disappears from the story on the branch it was left on. if remaining and not any(row.live for row in remaining): remaining[0].live = True - for i, row in enumerate(remaining): - row.variant_index = i - row.variant_count = len(remaining) if len(remaining) > 1 else 0 adventure.head_branch_id = new_id adventure.head_depth = depth @@ -228,9 +226,10 @@ def place_action( ) -> models.Branch: """Puts `action` on the head branch and moves the head to it. - `depth` follows `index` while both columns exist. The two must agree, - because a read that orders by depth and a cursor that counts in index space - describe the same story, and SP2 swaps one for the other in a single step. + An action with no `depth` of its own goes one step past the tip, which is + where the next turn belongs. The opening of a new adventure lands at 0 that + way, because an adventure with nothing played has a head depth of + `NO_DEPTH`. Pass `branch` when you have already resolved the head and are placing several nodes at once. See `place_new_nodes` for why that is worth doing. @@ -244,7 +243,7 @@ def place_action( branch = branch or head_branch(db, adventure) action.branch_id = branch.id if action.depth is None: - action.depth = action.index + action.depth = adventure.head_depth + 1 if parent is not None: action.parent_id = parent.id elif action.parent_id is None and action.depth: 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..336ab7a --- /dev/null +++ b/backend/app/worldstate/apply.py @@ -0,0 +1,407 @@ +"""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 typing import NamedTuple + +from .schema import STAT_SECTIONS, _initials, instantiate, npc_name + + +class _Target(NamedTuple): + """Where one path writes, and what the schema says about the value there.""" + + kind: str # "flag", "milestone", or "stat" + section: str # "flags", "milestones", "player", "world", or "npc" + key: str # the key inside the container + stat_def: dict | None # the stat's definition, or None for a flag or milestone + npc_id: str | None # the character, for `npc..`, else None + npc_stats: dict | None # that character's stat defs, used to build a new block + + def container(self, ws: dict) -> dict: + """Returns the dict this path writes into, and creates it if it is missing. + + Call this only when you are about to write. Creating the container is a + side effect, and a rejected path must not leave an empty section behind. + """ + if self.npc_id is None: + return ws.setdefault(self.section, {}) + return ws.setdefault("npc", {}).setdefault(self.npc_id, _initials(self.npc_stats)) + + +def _resolve(path: str, stat_schema: dict) -> tuple[_Target | None, dict | None]: + """Routes one path to the value it names, without deciding what happens to it. + + Returns `(target, None)` when the schema defines the path, and + `(None, rejection)` when it does not. The rejection is the entry that goes + into a report's `rejected` list, with its reason and its fix already worded. + + `apply_delta` and `apply_override` route paths identically and differ only in + what they write, so the routing lives here and each function keeps its own + write rule. + """ + parts = path.split(".") + + # `flags.` is a boolean the scenario declares. + if parts[0] == "flags" and len(parts) == 2: + flag_defs = stat_schema.get("flags") or {} + if parts[1] not in flag_defs: + return None, { + "path": path, "reason": "unknown flag", + "fix": f"There is no flag `{parts[1]}`. {_names_phrase('flag', flag_defs)}", + } + return _Target("flag", "flags", parts[1], None, None, None), None + + # `milestones.` records that a milestone is reached. + if parts[0] == "milestones" and len(parts) == 2: + milestones = stat_schema.get("milestones") or {} + if parts[1] not in milestones: + return None, { + "path": path, "reason": "unknown milestone", + "fix": f"There is no milestone `{parts[1]}`. " + f"{_names_phrase('milestone', milestones)}", + } + return _Target("milestone", "milestones", parts[1], None, None, None), None + + # `world.` and `player.`. + if parts[0] in STAT_SECTIONS and len(parts) == 2: + stat_defs = stat_schema.get(parts[0]) or {} + stat_def = stat_defs.get(parts[1]) + if not isinstance(stat_def, dict): + return None, { + "path": path, "reason": "unknown stat", + "fix": f"`{parts[0]}` has no stat `{parts[1]}`. " + f"{_names_phrase('stat', stat_defs)}", + } + return _Target("stat", parts[0], parts[1], stat_def, None, None), None + + # `npc..`. Each character carries its own stat definitions. + if parts[0] == "npc" and len(parts) == 3: + npcs = stat_schema.get("npcs") or {} + ndef = npcs.get(parts[1]) + if not isinstance(ndef, dict): + return None, { + "path": path, "reason": "unknown npc", + "fix": f"There is no character `{parts[1]}`. " + f"{_names_phrase('character', npcs)}", + } + stat_defs = ndef.get("stats") or {} + stat_def = stat_defs.get(parts[2]) + if not isinstance(stat_def, dict): + return None, { + "path": path, "reason": "unknown npc stat", + "fix": f"`{npc_name(ndef, parts[1])}` has no stat `{parts[2]}`. " + f"{_names_phrase('stat', stat_defs)}", + } + return _Target("stat", "npc", parts[2], stat_def, parts[1], stat_defs), None + + return None, { + "path": path, "reason": "unknown path", + "fix": f"`{path}` is not a tracked value. Use player., " + f"world., npc.., flags. or milestones..", + } + + +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 + + 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) + target, rejection = _resolve(path, stat_schema) + if rejection is not None: + report["rejected"].append(rejection) + continue + + if target.kind == "flag": + if not isinstance(value, bool): + report["rejected"].append({ + "path": path, "reason": "not a boolean", + "fix": f"`{path}` takes true or false.", + }) + continue + flags = target.container(ws) + old = bool(flags.get(target.key, False)) + flags[target.key] = value + report["applied"].append({"path": path, "old": old, "new": value}) + + elif target.kind == "milestone": + # An override toggles a milestone in both directions, so unlike a + # delta it takes false as well as true. + if not isinstance(value, bool): + report["rejected"].append({ + "path": path, "reason": "not a boolean", + "fix": f"`{path}` takes true or false.", + }) + continue + reached = target.container(ws) + old = bool(reached.get(target.key, {}).get("reached")) + if value: + reached[target.key] = {"reached": True} + else: + reached.pop(target.key, None) + report["applied"].append({"path": path, "old": old, "new": value}) + + else: + set_stat(target.container(ws), target.key, target.stat_def, value, path) + + 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 + + for raw_path, change in delta.items(): + path = str(raw_path) + target, rejection = _resolve(path, stat_schema) + if rejection is not None: + report["rejected"].append(rejection) + continue + + if target.kind == "flag": + # A flag goes both ways, so either value is accepted. + if not isinstance(change, bool): + report["rejected"].append({ + "path": path, "reason": "not a boolean", + "fix": f"`{path}` takes true or false.", + }) + continue + flags = target.container(ws) + old = bool(flags.get(target.key, False)) + if change != old: + flags[target.key] = change + report["applied"].append({"path": path, "old": old, "new": change}) + + elif target.kind == "milestone": + # A milestone is sticky, so only true is accepted. + 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 = target.container(ws) + if reached.get(target.key, {}).get("reached"): + continue # Already reached, so do nothing. + reached[target.key] = {"reached": True, "at": action_index} + report["applied"].append({"path": path, "old": False, "new": True}) + + elif target.stat_def.get("type") == "text": + _apply_text_stat(target.container(ws), target.key, target.stat_def, change, + path, action_index, meta, report) + + else: + _apply_stat(target.container(ws), target.key, target.stat_def, change, + path, action_index, meta, report) + + return ws, report diff --git a/backend/app/worldstate/engine.py b/backend/app/worldstate/engine.py deleted file mode 100644 index 2b587d3..0000000 --- a/backend/app/worldstate/engine.py +++ /dev/null @@ -1,939 +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. Every stat is listed by its exact path in the ' - 'stat guide and again beside its live value — copy a path from there rather than ' - 'building one out of a name. The shapes are "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, named by the path the AI writes. - - `name` is that path. The guide is the model's only complete list of what - exists, so a stat named any other way leaves it to work the path out from - the live values, and those cover only what is on screen this turn. - - The description and the band ladder are independent, and each is included - only when present, so a stat may have either, both, or neither. - """ - is_text = d.get("type") == "text" - # `EMIT_RULE` sends the model here to find out which stats take a whole - # value instead of a change, and it names this marker, so the two have to - # be written the same way. - label = f"{name} (free text)" if is_text else name - 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 is_text: - # Free text has no range and no bands, so the marker is the whole - # entry. It still earns a line without a description, because the - # marker is what stops the model sending a delta. - return f"{label} — {'; '.join(bits)}." if bits else f"{label}." - 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"{label} — {'; '.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(f"{section}.{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") - # The header is written even for an NPC with no description, because it - # is the only line that ties a display name to the id the AI has to - # address. The live values state it too, but only for the NPCs a scene - # has mentioned, so without this an NPC off screen can only be guessed - # at — and a guess is refused as a character or a stat that does not - # exist. - head = f"NPC {name} (npc.{npc_key})" - lines.append( - f"{head} — {desc.strip().rstrip('.')}." - if isinstance(desc, str) and desc.strip() else f"{head}." - ) - for sname, sdef in (ndef.get("stats") or {}).items(): - if isinstance(sdef, dict): - row = _describe_stat(f"npc.{npc_key}.{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"flags.{name} — {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..6c91e75 --- /dev/null +++ b/backend/app/worldstate/parse.py @@ -0,0 +1,165 @@ +"""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. Every stat is listed by its exact path in the ' + 'stat guide and again beside its live value — copy a path from there rather than ' + 'building one out of a name. The shapes are "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..421a5bc --- /dev/null +++ b/backend/app/worldstate/render.py @@ -0,0 +1,167 @@ +"""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, named by the path the AI writes. + + `name` is that path. The guide is the model's only complete list of what + exists, so a stat named any other way leaves it to work the path out from + the live values, and those cover only what is on screen this turn. + + The description and the band ladder are independent, and each is included + only when present, so a stat may have either, both, or neither. + """ + is_text = d.get("type") == "text" + # `EMIT_RULE` sends the model here to find out which stats take a whole + # value instead of a change, and it names this marker, so the two have to + # be written the same way. + label = f"{name} (free text)" if is_text else name + 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 is_text: + # Free text has no range and no bands, so the marker is the whole + # entry. It still earns a line without a description, because the + # marker is what stops the model sending a delta. + return f"{label} — {'; '.join(bits)}." if bits else f"{label}." + 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"{label} — {'; '.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(f"{section}.{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") + # The header is written even for an NPC with no description, because it + # is the only line that ties a display name to the id the AI has to + # address. The live values state it too, but only for the NPCs a scene + # has mentioned, so without this an NPC off screen can only be guessed + # at — and a guess is refused as a character or a stat that does not + # exist. + head = f"NPC {name} (npc.{npc_key})" + lines.append( + f"{head} — {desc.strip().rstrip('.')}." + if isinstance(desc, str) and desc.strip() else f"{head}." + ) + for sname, sdef in (ndef.get("stats") or {}).items(): + if isinstance(sdef, dict): + row = _describe_stat(f"npc.{npc_key}.{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"flags.{name} — {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 diff --git a/backend/seed_demo.py b/backend/seed_demo.py index 0d6a6dd..0266406 100644 --- a/backend/seed_demo.py +++ b/backend/seed_demo.py @@ -252,7 +252,7 @@ try: for position, s in enumerate(SCRIPTS): db.add(models.AdventureScript(adventure_id=adventure.id, position=position, **s)) opening = models.Action( - adventure_id=adventure.id, index=0, type="start", text=scenario.prompt + adventure_id=adventure.id, type="start", text=scenario.prompt ) # Through the same door create_adventure uses, so the seeded adventure has a # story tree like every other one. 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..f314f42 100644 --- a/backend/tests/test_action_paging.py +++ b/backend/tests/test_action_paging.py @@ -12,14 +12,7 @@ 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 re import pytest from fastapi import Depends @@ -47,7 +40,7 @@ def client(monkeypatch): setup.flush() for i in range(TOTAL): setup.add(models.Action( - adventure_id=adventure.id, index=i, + adventure_id=adventure.id, type="start" if i == 0 else ("ai" if i % 2 else "do"), text=f"Action {i}." + "word " * 200, )) @@ -71,6 +64,17 @@ def client(monkeypatch): Base.metadata.drop_all(bind=engine) +def ordinal(action) -> int: + """Returns which turn this is, read out of the fixture's own text. + + The payload used to carry `index`, a story-wide turn number that SP8 + dropped. Nothing replaced it: a depth is a position along one branch, and + the pager keys on ids. The fixture numbers its own actions, so these tests + read the number back rather than reintroduce one. + """ + return int(re.match(r"Action (\d+)\.", action["text"]).group(1)) + + def page(client, before_id=None, limit=None): params = {} if before_id is not None: @@ -85,10 +89,8 @@ def page(client, before_id=None, limit=None): def add_action(client, text="A new turn.") -> int: db = SessionLocal() try: - highest = db.query(models.Action.index).order_by( - models.Action.index.desc()).first()[0] action = models.Action( - adventure_id=client.adv_id, index=highest + 1, type="ai", text=text + adventure_id=client.adv_id, type="ai", text=text ) db.add(action) db.commit() @@ -106,14 +108,14 @@ def test_the_page_load_returns_only_the_newest_window(client): assert len(body["actions"]) == ACTION_PAGE assert body["action_count"] == TOTAL # It is the newest window, ending on the last action. - assert body["actions"][-1]["index"] == TOTAL - 1 - assert body["actions"][0]["index"] == TOTAL - ACTION_PAGE + assert ordinal(body["actions"][-1]) == TOTAL - 1 + assert ordinal(body["actions"][0]) == TOTAL - ACTION_PAGE def test_a_short_story_is_returned_whole(client): db = SessionLocal() try: - db.query(models.Action).filter(models.Action.index >= 5).delete() + db.query(models.Action).filter(models.Action.depth >= 5).delete() db.commit() finally: db.close() @@ -151,19 +153,19 @@ def test_the_first_page_is_the_newest(client): assert len(body["actions"]) == ACTION_PAGE assert body["total"] == TOTAL assert body["has_more"] is True - assert body["actions"][-1]["index"] == TOTAL - 1 + assert ordinal(body["actions"][-1]) == TOTAL - 1 def test_paging_up_covers_the_whole_story_exactly_once(client): seen = [] body = page(client) - seen = [a["index"] for a in body["actions"]] + seen = [ordinal(a) for a in body["actions"]] guard = 0 while body["has_more"]: guard += 1 assert guard < 20, "paging did not terminate" body = page(client, before_id=body["actions"][0]["id"]) - seen = [a["index"] for a in body["actions"]] + seen + seen = [ordinal(a) for a in body["actions"]] + seen assert seen == list(range(TOTAL)), "gap, duplicate or reordering while paging" @@ -172,12 +174,12 @@ def test_has_more_is_false_at_the_beginning_of_the_story(client): body = page(client) while body["has_more"]: body = page(client, before_id=body["actions"][0]["id"]) - assert body["actions"][0]["index"] == 0 + assert ordinal(body["actions"][0]) == 0 def test_each_page_is_ordered_oldest_first(client): body = page(client) - indices = [a["index"] for a in body["actions"]] + indices = [ordinal(a) for a in body["actions"]] assert indices == sorted(indices) @@ -194,9 +196,10 @@ def test_a_turn_arriving_mid_scroll_does_not_shift_the_next_page(client): add_action(client) older = page(client, before_id=oldest_held["id"]) - assert older["actions"][-1]["index"] == oldest_held["index"] - 1, \ + assert ordinal(older["actions"][-1]) == ordinal(oldest_held) - 1, ( "the page shifted when a turn landed" - assert all(a["index"] < oldest_held["index"] for a in older["actions"]) + ) + assert all(ordinal(a) < ordinal(oldest_held) for a in older["actions"]) # The new turn changes the total, which is expected. It must not move the window. assert older["total"] == TOTAL + 1 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..c8c97ec 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) @@ -73,7 +49,7 @@ def client(monkeypatch): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave.")) + setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave.")) setup.add(models.AdventureScript( adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, )) @@ -84,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) @@ -100,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) @@ -136,7 +112,7 @@ def _rows(adv_id) -> list[models.Action]: undefer(models.Action.world_state_after), undefer(models.Action.context_snapshot), ) - .order_by(models.Action.depth, models.Action.variant_index) + .order_by(models.Action.depth, models.Action.id) .all() ) finally: @@ -354,16 +330,22 @@ def test_a_memory_on_an_earlier_turn_survives_a_retry(client): # -------------------------------------------------------------- the group -def test_the_group_cache_is_renumbered_as_attempts_arrive(client): +def test_the_group_grows_in_the_order_the_attempts_arrive(client): + """The attempts page in the order they were made, and the pager counts them. + + Ordering used to come from `variant_index`, an explicit ordinal that SP8 + dropped. `id` carries the same order, because a row is inserted when its + attempt is made. + """ ScriptedProvider.replies = ["One.", "Two.", "Three."] _play(client) - assert _page(client)["actions"][-1]["variant_count"] == 0 # never retried + assert _page(client)["actions"][-1]["take_count"] == 1 # never retried _retry(client) _retry(client) ai = [a for a in _rows(client.adv_id) if a.type == "ai"] - assert [a.variant_index for a in ai] == [0, 1, 2] - assert {a.variant_count for a in ai} == {3} + assert [a.text for a in ai] == ["One.", "Two.", "Three."] + assert _page(client)["actions"][-1]["take_count"] == 3 def test_attempts_module_agrees_with_the_endpoint(client): @@ -418,9 +400,8 @@ def test_a_retry_after_switching_back_files_the_new_attempt_last(client): `add_attempt` used to number a new take one past the take it replaced. That numbering is correct only when the story is standing on the newest take. Switch a three-take turn back to the first and retry, and the new - attempt collides with take 2. `renumber` broke the tie by id and placed - the new attempt between takes 2 and 3, so the pager listed them in an - order the player never produced. + attempt collided with take 2, which put it between takes 2 and 3. The + group orders by `id` now, so a new attempt is always last. """ ScriptedProvider.replies = ["One.", "Two.", "Three.", "Four."] _play(client) @@ -438,7 +419,6 @@ def test_a_retry_after_switching_back_files_the_new_attempt_last(client): _retry(client) ai = [a for a in _rows(client.adv_id) if a.type == "ai"] assert [a.text for a in ai] == ["One.", "Two.", "Three.", "Four."] - assert [a.variant_index for a in ai] == [0, 1, 2, 3] assert attempts.live_in(ai).text == "Four." diff --git a/backend/tests/test_branch_clause.py b/backend/tests/test_branch_clause.py index cf84fdf..d557211 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 @@ -73,7 +64,6 @@ def make_branch(db, adventure, parent=None, fork_depth=None): def add_node(db, adventure, branch, depth, label, index=None): action = models.Action( adventure_id=adventure.id, - index=depth if index is None else index, branch_id=branch.id, depth=depth, type="start" if depth == 0 else ("ai" if depth % 2 else "do"), @@ -337,13 +327,16 @@ def test_a_node_written_without_a_branch_is_placed_anyway(forked): """ db, adventure, ids = forked written = models.Action( - adventure_id=adventure.id, index=99, type="do", text="C8" + adventure_id=adventure.id, type="do", text="C8" ) db.add(written) db.commit() assert written.branch_id == ids["c"] - assert written.depth == 99 - assert adventure.head_depth == 99 + # The node lands one step past the tip of C. SP8 dropped `index`, which is + # where a caller used to name its own depth, so an unplaced node now always + # follows the head. + assert written.depth == adventure.head_depth + assert adventure.head_depth == 8 assert labels(history.tail(adventure, 2)) == ["C7", "C8"] @@ -371,7 +364,7 @@ def test_placing_a_flush_of_nodes_reads_the_branch_once(forked, emitted_sql): emitted_sql.clear() for i in range(50): db.add(models.Action( - adventure_id=adventure.id, index=500 + i, type="do", text=f"bulk {i}" + adventure_id=adventure.id, type="do", text=f"bulk {i}" )) db.commit() branch_reads = [s for s in emitted_sql if s.startswith("SELECT") and "FROM branches" in s] diff --git a/backend/tests/test_branch_forking.py b/backend/tests/test_branch_forking.py index 41cac9b..8b5bb40 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) @@ -83,7 +59,7 @@ def client(monkeypatch): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave.")) + setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave.")) setup.add(models.AdventureScript( adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, )) @@ -94,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) @@ -110,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) @@ -156,7 +132,7 @@ def _rows(adv_id) -> list[models.Action]: return ( db.query(models.Action) .filter(models.Action.adventure_id == adv_id) - .order_by(models.Action.depth, models.Action.variant_index) + .order_by(models.Action.depth, models.Action.id) .all() ) finally: @@ -257,7 +233,6 @@ def test_the_parent_keeps_a_live_attempt_where_the_fork_left(client): # offers a page through attempts that diverged onto another branch. parent_turn = per_coordinate[(parent_id, 2)] assert len(parent_turn) == 1 - assert parent_turn[0].variant_count == 0 finally: db.close() diff --git a/backend/tests/test_branch_management.py b/backend/tests/test_branch_management.py index 27023ea..9d94090 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() @@ -69,14 +47,14 @@ def client(monkeypatch): setup.add(adv) setup.flush() setup.add(models.Action( - adventure_id=adv.id, index=0, type="start", text="You enter a cave.")) + adventure_id=adv.id, type="start", text="You enter a cave.")) setup.commit() adv_id, user_id = adv.id, user.id setup.close() 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) @@ -92,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 cb2ff28..20f8eec 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) @@ -89,7 +67,7 @@ def client(monkeypatch): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text=OPENING)) + setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING)) setup.add(models.AdventureScript( adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, )) @@ -99,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) @@ -115,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) @@ -185,7 +163,7 @@ def _rows(adv_id) -> list[models.Action]: db.query(models.Action) .filter(models.Action.adventure_id == adv_id) .order_by(models.Action.branch_id, models.Action.depth, - models.Action.variant_index) + models.Action.id) .all() ) finally: @@ -363,29 +341,20 @@ def test_the_lineage_is_rebuilt_rather_than_carried(client): assert lineage.entries_of(forked) == [(forked.id, None), (root.id, forked.fork_depth)] -def test_the_legacy_index_is_reissued_so_two_branches_never_share_one(client): - """`index` describes the adventure. `depth` describes a path. +def test_two_branches_each_keep_their_own_node_at_one_depth(client): + """A depth describes a path, not the adventure. - Two branches can each have a node at depth 3, so depth cannot be the - number `max_action_index` hands out next. The import allocates one - index per turn instead. Siblings share an index, the way SP4 leaves - them, and no two coordinates share one. + The fork and the branch it left both hold a turn at depth 2, and they are + not the same turn. The import used to issue a global `index` per turn as + well, so that every coordinate had a number nothing else held. SP8 dropped + that column, so the coordinate is all there is, and it has to survive the + round trip on its own. """ copy = _imported(client, _export(client, _forked_story(client))) rows = _rows(copy) - by_index: dict[int, set] = {} - for row in rows: - by_index.setdefault(row.index, set()).add((row.branch_id, row.depth)) - assert all(len(coords) == 1 for coords in by_index.values()), \ - "one index per turn, whatever branch it is on" - assert len(by_index) == len({(r.branch_id, r.depth) for r in rows}) - # This is the case that makes the rule necessary: the fork and the - # branch it left both hold a turn at depth 2, and they are not the - # same turn. at_depth_2 = [r for r in rows if r.depth == 2] assert len({r.branch_id for r in at_depth_2}) == 2 - assert len({r.index for r in at_depth_2}) == 2, "same depth, different turns" # ------------------------------------------------------- a file that is wrong @@ -509,7 +478,6 @@ def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client): db = SessionLocal() try: adventure = db.get(models.Adventure, copy) - assert adventure.memory_cursor == 2, "the legacy count is kept as given" assert adventure.memory_cursor_branch_id is not None assert adventure.memory_cursor_depth == 1, "the second story action" finally: @@ -517,8 +485,7 @@ def test_a_v1_bundle_with_a_cursor_lands_it_on_a_node(client): def test_a_v2_bundle_brings_its_anchors_back(client): - """The other direction: v2 carries the anchor directly, and the legacy - count is derived from it.""" + """The other direction: v2 carries the anchor directly.""" original = _forked_story(client) tip = [a for a in _rows(original) if a.live][-1] db = SessionLocal() @@ -540,7 +507,6 @@ def test_a_v2_bundle_brings_its_anchors_back(client): branches = [b.id for b in _branch_rows(copy)] assert branches.index(adventure.memory_cursor_branch_id) == 1 assert adventure.memory_cursor_depth == tip.depth - assert adventure.memory_cursor > 0, "the legacy count was read back off it" finally: db.close() 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_delete_state.py b/backend/tests/test_delete_state.py index 1b4aea1..533d121 100644 --- a/backend/tests/test_delete_state.py +++ b/backend/tests/test_delete_state.py @@ -15,15 +15,6 @@ change comes back refused as "changed too recently". python -m pytest tests/test_delete_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,8 +22,8 @@ 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 # `mana` carries a cooldown, so a clock that was not rolled back shows up as # a refusal rather than as a number that is merely off. @@ -56,20 +47,6 @@ modifier(text); DRAIN = 'Drained.\n```state\n{"player.mana": -10}\n```' -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) @@ -87,7 +64,7 @@ def client(monkeypatch): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You begin.")) + setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin.")) setup.add(models.AdventureScript( adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, )) @@ -97,7 +74,7 @@ def client(monkeypatch): ScriptedProvider.replies = [DRAIN] 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) @@ -113,7 +90,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) @@ -227,11 +204,11 @@ def test_delete_is_blocked_while_a_turn_is_generating(client): _play(client) action_id = _ai_rows(client.adv_id)[-1].id - adventures.acquire_turn_lock(client.adv_id) # a turn is "generating" + adventures.turns.acquire_turn_lock(client.adv_id) # a turn is "generating" try: assert _delete(client, action_id).status_code == 409 # The refused delete must not have released someone else's lock. - assert client.adv_id in adventures._active_turns + assert client.adv_id in adventures.turns._active_turns finally: - adventures._active_turns.discard(client.adv_id) + adventures.turns._active_turns.discard(client.adv_id) assert len(_ai_rows(client.adv_id)) == 1, "and the turn is still there" diff --git a/backend/tests/test_egress.py b/backend/tests/test_egress.py index 64cf5b5..25e5bd1 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 @@ -98,15 +89,11 @@ def client(monkeypatch): setup.flush() for i in range(12): setup.add(models.Action( - adventure_id=adventure.id, index=i, + adventure_id=adventure.id, type="ai" if i % 2 else "do", text=f"Action {i}.", context_snapshot=BIG_SNAPSHOT, world_delta={"delta": {"player.hp": -15}, "applied": [{"path": "player.hp", "old": 100, "new": 85}]}, - # Every AI action has been retried twice, so `variants` is carrying - # weight the list response must not pay for. - variants=BIG_VARIANTS if i % 2 else None, - variant_count=len(BIG_VARIANTS) if i % 2 else 0, )) setup.commit() adv_id, user_id = adventure.id, user.id @@ -146,16 +133,12 @@ def test_loading_an_adventure_does_not_fetch_context_snapshot(client, sql_log): def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log): - """All four are rollback snapshots, only ever needed for the single node - being undone, retried past or switched to. - - The `_after` pair is the live one since SP4, and the `_before` pair is - unused until SP8 drops it. A page load must pay for neither. + """Both are rollback snapshots, only ever needed for the single node being + undone, retried past or switched to. A page load must pay for neither. """ client.get(f"/api/adventures/{client.adv_id}") selects = action_selects(sql_log) - for column in ("state_before", "world_state_before", - "state_after", "world_state_after"): + for column in ("state_after", "world_state_after"): offenders = [s for s in selects if column in s] assert offenders == [], f"{column} was fetched in bulk" @@ -170,29 +153,6 @@ def test_world_changes_still_works_without_the_snapshot(client): ] -def test_loading_an_adventure_does_not_fetch_variants(client, sql_log): - """Same failure as context_snapshot, one size down: the payload carries - only `variant_count`, but loading the column to compute it made every retry - a permanent tax on every later load of that adventure.""" - r = client.get(f"/api/adventures/{client.adv_id}") - assert r.status_code == 200, r.text - - selects = action_selects(sql_log) - assert selects, "expected at least one SELECT against actions" - offenders = [s for s in selects if "variants" in s] - assert offenders == [], f"variants was fetched in bulk:\n{offenders[0][:400]}" - - -def test_variant_count_survives_variants_being_deferred(client): - """The pager reads this number; it has to be right without the column.""" - r = client.get(f"/api/adventures/{client.adv_id}") - by_type = {} - for action in r.json()["actions"]: - by_type.setdefault(action["type"], []).append(action) - assert all(a["variant_count"] == len(BIG_VARIANTS) for a in by_type["ai"]) - assert all(a["variant_count"] == 0 for a in by_type["do"]) - - def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log): """A count that wraps the entity select in a subquery names every column in the emitted SQL. @@ -207,8 +167,7 @@ def test_counting_actions_does_not_name_the_deferred_columns(client, sql_log): assert history.count(adventure) == 12 counts = [s for s in sql_log if "count" in s.lower()] assert counts, "expected a COUNT to be emitted" - for column in ("context_snapshot", "state_after", "world_state_after", - "state_before", "world_state_before", "variants"): + for column in ("context_snapshot", "state_after", "world_state_after"): assert not any(column in s for s in counts), ( f"{column} is named by the count query:\n{counts[0][:400]}" ) @@ -277,20 +236,33 @@ def test_backfill_populates_variant_count_from_existing_variants(client): Reading them into Python to count them would fetch the column over the wire once in order to stop fetching it on every request. + + Migration 68 drops `variant_count` and 66 drops `variants`, so this test + puts both columns back before it calls the pass, the same way + `as_json_snapshot_column` above rebuilds the column migration 36 needs. The + assertions read raw SQL, because the model no longer has either attribute. """ db = SessionLocal() try: - db.execute(text("UPDATE actions SET variant_count = 0")) + db.execute(text("ALTER TABLE actions ADD COLUMN variants JSON")) + db.execute(text( + "ALTER TABLE actions ADD COLUMN variant_count INTEGER NOT NULL DEFAULT 0" + )) + db.execute( + text("UPDATE actions SET variants = :v WHERE type = 'ai'"), + {"v": json.dumps(BIG_VARIANTS)}, + ) db.commit() with engine.begin() as conn: migrations._backfill_variant_count(conn) - db.expire_all() - actions = db.query(models.Action).order_by(models.Action.index).all() - for action in actions: - expected = len(BIG_VARIANTS) if action.type == "ai" else 0 - assert action.variant_count == expected, f"action {action.index}" + rows = db.execute(text( + "SELECT type, variant_count FROM actions ORDER BY id" + )).all() + assert rows, "fixture should have actions" + for kind, count in rows: + assert count == (len(BIG_VARIANTS) if kind == "ai" else 0) finally: db.close() 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..7e11b30 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 @@ -178,7 +171,7 @@ def test_deletes_the_whole_data_graph(db): db.add(adventure) db.commit() db.add_all([ - models.Action(adventure_id=adventure.id, index=0, type="ai", text="t"), + models.Action(adventure_id=adventure.id, type="ai", text="t"), models.Memory(adventure_id=adventure.id, text="m", source_start=0, source_end=0), models.StoryCard(adventure_id=adventure.id, name="c"), models.Settings(user_id=user.id), diff --git a/backend/tests/test_history_window.py b/backend/tests/test_history_window.py index d0a5635..b050523 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 @@ -75,7 +66,7 @@ def story(): entry="A scout with sharp eyes.", type="lore")) for i in range(ACTION_COUNT): db.add(models.Action( - adventure_id=adventure.id, index=i, + adventure_id=adventure.id, type="ai" if i % 2 else "do", text=f"[{i}] {NARRATION}", world_delta={"delta": {"player.hp": -1}, @@ -140,7 +131,7 @@ def test_window_matches_on_the_retry_shape(story, monkeypatch): last = history.tail(adventure, 1)[0] windowed = builder.build_context(adventure, settings, exclude_action_id=last.id) - assert f"[{last.index}]" not in windowed[1] + assert f"[{last.depth}]" not in windowed[1] monkeypatch.setattr(builder.history, "window_covering", full_window) db.expire(adventure) @@ -201,7 +192,6 @@ def test_helpers_agree_with_the_full_list(story): assert len(actions) == ACTION_COUNT assert history.count(adventure) == len(actions) - assert history.max_action_index(adventure) == max(a.index for a in actions) assert [a.id for a in history.tail(adventure, 4)] == [a.id for a in actions[-4:]] assert [a.id for a in history.slice_(adventure, 10, 6)] == [a.id for a in actions[10:16]] assert [a.id for a in history.tail_range(adventure, 5, 3)] == \ @@ -254,7 +244,6 @@ def test_blank_actions_are_excluded_the_same_way_in_sql_and_python(story): db, adventure, settings = story for blank in ("", " ", "\n", "\t\n "): db.add(models.Action(adventure_id=adventure.id, - index=history.max_action_index(adventure) + 1, type="story", text=blank)) db.commit() db.expire(adventure) diff --git a/backend/tests/test_length_hint.py b/backend/tests/test_length_hint.py index 7477e29..b092956 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 @@ -57,7 +50,7 @@ def story(): db.add(adventure) db.flush() for i in range(4): - db.add(models.Action(adventure_id=adventure.id, index=i, + db.add(models.Action(adventure_id=adventure.id, type="ai" if i % 2 else "do", text=f"[{i}] Onward.")) db.commit() db.expire_all() @@ -213,7 +206,7 @@ def test_prompt_stays_inside_the_budget_on_a_long_story(story): db, adventure, settings, _ = story for i in range(4, 120): db.add(models.Action( - adventure_id=adventure.id, index=i, type="ai" if i % 2 else "do", + adventure_id=adventure.id, type="ai" if i % 2 else "do", text=f"[{i}] " + "The road bends past the burnt mill and the smoke. " * 12, )) db.commit() diff --git a/backend/tests/test_memory_nodes.py b/backend/tests/test_memory_nodes.py index e2fa158..da4b774 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 @@ -76,7 +67,6 @@ def make_branch(db, adventure, parent=None, fork_depth=None): def add_node(db, adventure, branch, depth, label, index=None): action = models.Action( adventure_id=adventure.id, - index=depth if index is None else index, branch_id=branch.id, depth=depth, type="ai" if depth % 2 else "do", diff --git a/backend/tests/test_memory_retrieval.py b/backend/tests/test_memory_retrieval.py index 110902d..ba4cfe4 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 @@ -85,7 +76,7 @@ def adventure(db, settings): # returns before ranking anything. for i in range(2): db.add(models.Action( - adventure_id=adv.id, index=i, type="ai", text=f"Something happened {i}." + adventure_id=adv.id, type="ai", text=f"Something happened {i}." )) db.commit() return adv diff --git a/backend/tests/test_memory_settling.py b/backend/tests/test_memory_settling.py index fa0fdb8..4f98335 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 @@ -75,7 +68,7 @@ def make_adventure(db, action_count: int) -> models.Adventure: db.flush() for i in range(action_count): db.add(models.Action( - adventure_id=adventure.id, index=i, + adventure_id=adventure.id, type="ai" if i % 2 else "do", text=f"Action {i}.", )) db.commit() @@ -88,11 +81,8 @@ def cover(db, adventure, position: int) -> None: Written as a position and translated to the node it names, because that is what every adventure in the database looked like before SP3 and what a v1 - bundle still carries. `memory_cursor` keeps the old number so the two - coordinate systems can be compared where a test cares. + bundle still carries. """ - adventure.memory_cursor = position - adventure.summary_cursor = position cursors.anchor_at_position(adventure, cursors.MEMORY, position) cursors.anchor_at_position(adventure, cursors.SUMMARY, position) db.commit() @@ -144,7 +134,7 @@ def test_the_first_memory_lands_at_memory_start(db, monkeypatch): assert stub.excerpts == [] # too short to have started at all db.add(models.Action( - adventure_id=adventure.id, index=memorybank.MEMORY_START - 1, + adventure_id=adventure.id, type="do", text="Later.", )) db.commit() @@ -176,7 +166,7 @@ def test_legacy_caught_up_adventure_is_not_rewound(db, monkeypatch): # Grow the story and let the next block form. for i in range(12, 25): - db.add(models.Action(adventure_id=adventure.id, index=i, type="do", text=f"Action {i}.")) + db.add(models.Action(adventure_id=adventure.id, type="do", text=f"Action {i}.")) db.commit() db.refresh(adventure) run_memories(db, adventure, StubSummarizer(), monkeypatch) @@ -220,7 +210,7 @@ def summarized_adventure(db): adventure = make_adventure(db, 13) for text, start, end in (("A", 0, 5), ("B", 6, 11)): node = db.query(models.Action).filter_by( - adventure_id=adventure.id, index=end + adventure_id=adventure.id, depth=end ).one() memory = models.Memory( adventure_id=adventure.id, text=text, source_start=start, source_end=end @@ -244,7 +234,7 @@ def test_deleting_a_middle_action_leaves_the_mark_where_it_was(db): # Node 4 is inside memory A's block but is not the node it hangs off, # so nothing is withdrawn. The old code read it the same way: only a # memory whose end had fallen off the story was pruned. - victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=4).one() + victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=4).one() assert memorybank.forget_node(db, adventure, victim) == 0 db.delete(victim) @@ -258,7 +248,7 @@ def test_deleting_a_middle_action_leaves_the_mark_where_it_was(db): def test_deleting_a_later_action_leaves_the_mark_alone(db): adventure = summarized_adventure(db) - victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=12).one() + victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=12).one() memorybank.forget_node(db, adventure, victim) db.delete(victim) @@ -279,7 +269,7 @@ def test_deleting_a_summarized_node_withdraws_its_memory(db): is a lookup. """ adventure = summarized_adventure(db) - victim = db.query(models.Action).filter_by(adventure_id=adventure.id, index=11).one() + victim = db.query(models.Action).filter_by(adventure_id=adventure.id, depth=11).one() assert memorybank.forget_node(db, adventure, victim) == 1 db.delete(victim) 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..e9d65c3 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 @@ -140,7 +134,7 @@ def story(): db.add(adventure) db.flush() for i in range(6): - db.add(models.Action(adventure_id=adventure.id, index=i, + db.add(models.Action(adventure_id=adventure.id, type="ai" if i % 2 else "do", text=f"[{i}] The road bends onward past the treeline.")) db.commit() @@ -208,7 +202,7 @@ def test_live_sections_are_still_charged_to_the_budget(story): db, adventure, settings = story for i in range(6, 90): db.add(models.Action( - adventure_id=adventure.id, index=i, type="do", + adventure_id=adventure.id, type="do", text=f"[{i}] " + "The road bends onward past the treeline. " * 6, )) settings.context_token_budget = 4000 @@ -232,7 +226,7 @@ def test_a_new_turn_only_appends_to_the_cached_prefix(story): prefix has to still contain the whole static block and the older history.""" db, adventure, settings = story system_a, story_a, _ = builder.build_context(adventure, settings) - db.add(models.Action(adventure_id=adventure.id, index=6, type="do", + db.add(models.Action(adventure_id=adventure.id, type="do", text="[6] You step into the clearing.")) db.commit() db.expire_all() 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..484188f 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) @@ -72,7 +45,7 @@ def client(monkeypatch): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave.")) + setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave.")) setup.add(models.AdventureScript( adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, )) @@ -83,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) @@ -99,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) @@ -141,8 +114,8 @@ def test_retry_keeps_the_discarded_attempt(client): assert [a["type"] for a in actions] == ["start", "do", "ai"] last = actions[-1] assert last["text"] == "Attempt two." - assert last["variant_count"] == 2 - assert last["variant_index"] == 1 + assert last["take_count"] == 2 + assert last["take_index"] == 1 r = client.get(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants") assert r.status_code == 200, r.text @@ -182,7 +155,7 @@ def test_retry_context_keeps_earlier_ai_turns(client): def test_never_retried_action_has_no_variants(client): _play(client) last = _actions(client)[-1] - assert last["variant_count"] == 0 + assert last["take_count"] == 1 # itself, and nothing to page to assert client.get( f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json() == [] @@ -193,8 +166,8 @@ def test_three_attempts_all_kept_in_order(client): _retry(client) _retry(client) last = _actions(client)[-1] - assert last["variant_count"] == 3 - assert last["variant_index"] == 2 + assert last["take_count"] == 3 + assert last["take_index"] == 2 variants = client.get( f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json() assert [v["text"] for v in variants] == ["One.", "Two.", "Three."] @@ -219,7 +192,7 @@ def test_switching_back_restores_that_attempt_state(client): f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", json={"index": 0}) assert r.status_code == 200, r.text assert r.json()["text"].startswith("You take a scratch") - assert r.json()["variant_index"] == 0 + assert r.json()["take_index"] == 0 # The stats follow the narration back. script_state, world_state = _adv(client.adv_id) assert world_state["player"]["hp"] == 95 @@ -316,14 +289,33 @@ def test_editing_the_text_updates_the_live_variant(client): assert _actions(client)[-1]["text"] == "Two, but better." -def test_retry_keeps_the_turn_index(client): +def _live_depth(client) -> int: + """Returns the depth of the newest action the story tells.""" + db = SessionLocal() + try: + return ( + db.query(models.Action.depth) + .filter(models.Action.adventure_id == client.adv_id, + models.Action.live.is_(True)) + .order_by(models.Action.depth.desc()) + .first()[0] + ) + finally: + db.close() + + +def test_retry_keeps_the_turn_depth(client): """A retry re-runs the same turn, so the world-state clock (which drives - cooldowns) must not advance.""" + cooldowns) must not advance. + + The depth is read from the row rather than the payload. It is a coordinate + on one branch, and the client pages by id, so it is not exposed. + """ ScriptedProvider.replies = ["One.", "Two."] _play(client) - before = _actions(client)[-1]["index"] + before = _live_depth(client) _retry(client) - assert _actions(client)[-1]["index"] == before + assert _live_depth(client) == before def test_export_and_import_round_trips_variants(client): @@ -342,8 +334,19 @@ def test_export_and_import_round_trips_variants(client): assert r.status_code == 201, r.text imported = r.json()["id"] actions = client.get(f"/api/adventures/{imported}").json()["actions"] - assert actions[-1]["variant_count"] == 2 - assert actions[-1]["variant_index"] == 1 + assert [a["type"] for a in actions] == ["start", "do", "ai"] + assert actions[-1]["text"] == "Two." + + # The pager reads 1/1 on the copy, because the import writes no + # `parent_id` and `annotate_takes` groups on it. The attempts are both + # there, at one coordinate, and `GET .../variants` still lists them. This + # is a gap in the import rather than in the drop: `take_count` has been the + # only number the client reads since SP9, and the import has never set the + # column it is derived from. + assert actions[-1]["take_count"] == 1 + variants = client.get( + f"/api/adventures/{imported}/actions/{actions[-1]['id']}/variants").json() + assert [v["text"] for v in variants] == ["One.", "Two."] def test_import_clamps_an_out_of_range_variant_index(client): @@ -357,4 +360,4 @@ def test_import_clamps_an_out_of_range_variant_index(client): r = client.post("/api/adventures/import", json=bundle) assert r.status_code == 201, r.text actions = client.get(f"/api/adventures/{r.json()['id']}").json()["actions"] - assert actions[0]["variant_index"] == 0 + assert actions[0]["take_index"] == 0 diff --git a/backend/tests/test_scenario_art.py b/backend/tests/test_scenario_art.py index 44d8180..5a0ad78 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 @@ -120,11 +113,11 @@ def client(monkeypatch): setup.flush() # A full turn: opening narration, the player's line, then the AI's reply. setup.add_all([ - models.Action(adventure_id=adventure.id, index=0, type="ai", + models.Action(adventure_id=adventure.id, type="ai", text="The door groans open."), - models.Action(adventure_id=adventure.id, index=1, type="do", + models.Action(adventure_id=adventure.id, type="do", text="I draw my sword."), - models.Action(adventure_id=adventure.id, index=2, type="ai", + models.Action(adventure_id=adventure.id, type="ai", text="Steel rings. The\ncorridor answers."), ]) setup.commit() diff --git a/backend/tests/test_scenario_refresh.py b/backend/tests/test_scenario_refresh.py index d5c0d73..40e5dc8 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 @@ -375,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_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..92ecd7f 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 @@ -102,7 +95,7 @@ def test_unpack_rejects_nothing_it_wrote(): def test_the_column_stores_bytes_and_returns_a_dict(db, adventure): value = snapshot(3) action = models.Action( - adventure_id=adventure.id, index=0, type="ai", text="t", + adventure_id=adventure.id, type="ai", text="t", context_snapshot=value, ) db.add(action) @@ -122,7 +115,7 @@ def test_the_column_stores_bytes_and_returns_a_dict(db, adventure): def test_the_column_is_smaller_than_the_json_it_holds(db, adventure): value = snapshot(4, 200_000) action = models.Action( - adventure_id=adventure.id, index=0, type="ai", text="t", + adventure_id=adventure.id, type="ai", text="t", context_snapshot=value, ) db.add(action) @@ -137,7 +130,7 @@ def test_the_column_is_smaller_than_the_json_it_holds(db, adventure): def test_null_stays_null(db, adventure): action = models.Action( - adventure_id=adventure.id, index=0, type="do", text="t", + adventure_id=adventure.id, type="do", text="t", context_snapshot=None, ) db.add(action) @@ -151,7 +144,7 @@ def test_an_unreadable_snapshot_reads_as_none_rather_than_raising(db, adventure) it. The snapshot is a debugging view. The story is what actually matters.""" action = models.Action( - adventure_id=adventure.id, index=0, type="ai", text="t", + adventure_id=adventure.id, type="ai", text="t", context_snapshot={"a": "b"}, ) db.add(action) @@ -184,14 +177,14 @@ def seed_pre_43(db, adventure, count: int = 4) -> dict[int, dict]: ids = [] for i in range(count): action = models.Action( - adventure_id=adventure.id, index=i, type="ai", text=f"t{i}" + adventure_id=adventure.id, type="ai", text=f"t{i}" ) db.add(action) db.flush() ids.append(action.id) # One action with no snapshot at all, which must survive as NULL. plain = models.Action( - adventure_id=adventure.id, index=count, type="do", text="look" + adventure_id=adventure.id, type="do", text="look" ) db.add(plain) db.commit() 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..7a2f45e 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 @@ -40,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): @@ -55,7 +48,7 @@ def _make_adventure(db, script_state): def _add(db, adv, index, type_, text="x", state_after=None): a = models.Action( - adventure_id=adv.id, index=index, type=type_, text=text, + adventure_id=adv.id, type=type_, text=text, state_after=state_after, ) db.add(a) @@ -88,7 +81,7 @@ def test_undo_reverts_state_to_before_the_turn(db): _add(db, adv, 2, "ai", state_after={"gold": 10}) db.commit() - adventures.undo_turn(adv.id, db=db, user=user) + adventures.undo_turn(adv.id, db=db, adventure=adv) assert adv.script_state == {"gold": 0} assert [a.type for a in adv.actions] == ["start"] @@ -102,7 +95,7 @@ def test_undo_of_bare_continue_uses_the_node_in_front(db): _add(db, adv, 1, "ai", state_after={"gold": 5}) db.commit() - adventures.undo_turn(adv.id, db=db, user=user) + adventures.undo_turn(adv.id, db=db, adventure=adv) assert adv.script_state == {"gold": 0} assert [a.type for a in adv.actions] == ["start"] @@ -117,7 +110,7 @@ def test_undo_leaves_state_untouched_when_snapshot_missing(db): _add(db, adv, 2, "ai") _forget_snapshots(db, adv) - adventures.undo_turn(adv.id, db=db, user=user) + adventures.undo_turn(adv.id, db=db, adventure=adv) assert adv.script_state == {"gold": 10} @@ -127,7 +120,7 @@ def test_undo_raises_when_nothing_to_undo(db): _add(db, adv, 0, "start") db.commit() with pytest.raises(HTTPException) as exc: - adventures.undo_turn(adv.id, db=db, user=user) + adventures.undo_turn(adv.id, db=db, adventure=adv) assert exc.value.status_code == 400 @@ -137,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) + adventures.undo_turn(adv.id, db=db, adventure=adv) 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): @@ -158,7 +151,7 @@ def test_undo_prunes_memory_covering_removed_actions(db): db.add_all([covering, keep]) db.commit() - adventures.undo_turn(adv.id, db=db, user=user) # removes indexes 2 & 3 + adventures.undo_turn(adv.id, db=db, adventure=adv) # removes indexes 2 & 3 texts = {m.text for m in adv.memories} assert texts == {"k"} @@ -193,7 +186,7 @@ def test_forget_node_withdraws_only_what_that_node_produced(db): def test_snapshot_outcome_is_an_independent_deep_copy(db): _, adv = _make_adventure(db, {"nested": {"n": 1}}) - node = models.Action(adventure_id=adv.id, index=0, type="ai", text="x") + node = models.Action(adventure_id=adv.id, type="ai", text="x") attempts.snapshot_outcome(adv, node) adv.script_state["nested"]["n"] = 99 assert node.state_after == {"nested": {"n": 1}} # unaffected by later mutation @@ -202,14 +195,14 @@ def test_snapshot_outcome_is_an_independent_deep_copy(db): def test_snapshot_outcome_handles_non_dict(db): _, adv = _make_adventure(db, {}) adv.script_state = None - node = models.Action(adventure_id=adv.id, index=0, type="ai", text="x") + node = models.Action(adventure_id=adv.id, type="ai", text="x") attempts.snapshot_outcome(adv, node) assert node.state_after == {} def test_restore_state_ignores_a_node_with_no_outcome(db): _, adv = _make_adventure(db, {"gold": 7}) - attempts.restore_state(adv, models.Action(adventure_id=adv.id, index=0, type="ai")) + attempts.restore_state(adv, models.Action(adventure_id=adv.id, type="ai")) assert adv.script_state == {"gold": 7} attempts.restore_state(adv, None) assert adv.script_state == {"gold": 7} @@ -228,14 +221,14 @@ 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) + adventures.retry_action(adv.id, request=None, db=db, user=user, adventure=adv) assert adv.script_state == {"gold": 10} # Nothing is written until a replacement actually arrives: the attempt on @@ -243,6 +236,6 @@ def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch): assert [a.type for a in adv.actions] == ["start", "do", "ai"] last = adv.actions[-1] assert last.live is True - assert last.variant_count == 0 + assert len(attempts.group(db, last)) == 1 # no sibling was filed 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 36b7697..0f2b699 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 @@ -92,10 +63,10 @@ def _make_world(monkeypatch, *, seeded_actions: int = 0): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text=OPENING)) + setup.add(models.Action(adventure_id=adv.id, type="start", text=OPENING)) for i in range(seeded_actions): setup.add(models.Action( - adventure_id=adv.id, index=i + 1, + adventure_id=adv.id, type="ai" if i % 2 else "do", text=f"Seeded turn {i}.", )) @@ -109,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) @@ -131,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) @@ -143,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 5300490..6dd505e 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() @@ -71,14 +49,14 @@ def client(monkeypatch): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You begin.")) + setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin.")) setup.commit() adv_id, user_id = adv.id, user.id setup.close() 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) @@ -94,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 47fbe84..b965ff7 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() @@ -68,7 +48,7 @@ def client(monkeypatch): setup.add(adv) setup.flush() setup.add( - models.Action(adventure_id=adv.id, index=0, type="start", text="You enter.") + models.Action(adventure_id=adv.id, type="start", text="You enter.") ) setup.commit() adv_id, user_id = adv.id, user.id @@ -77,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", @@ -96,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 c02e1cb..34c2d9c 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) @@ -79,7 +57,7 @@ def client(monkeypatch): ) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You begin.")) + setup.add(models.Action(adventure_id=adv.id, type="start", text="You begin.")) setup.add(models.AdventureScript( adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, )) @@ -89,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) @@ -105,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_tree_migration.py b/backend/tests/test_tree_migration.py index e775b59..4047bdb 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 @@ -223,7 +216,7 @@ def test_every_action_lands_on_its_adventure_root_branch(pre_tree): def test_depth_is_the_old_index_gaps_included(pre_tree): - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.TREE_BACKFILL_VERSION) assert rows('SELECT "index", depth FROM actions WHERE depth != "index"') == [] depths = [ @@ -295,7 +288,7 @@ def test_the_cursors_become_the_nodes_they_named(pre_tree): """SP3, migration 56. A count of covered actions and a depth become different numbers as soon as the story has a gap in it, and every adventure with a deleted action has one.""" - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION) def marks(title): [row] = rows( @@ -353,7 +346,7 @@ def test_the_branch_clause_index_exists(pre_tree): def test_running_it_again_changes_nothing(pre_tree): - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION) snapshot = ( rows("SELECT id, branch_id, depth FROM actions ORDER BY id"), rows("SELECT id, adventure_id, lineage FROM branches ORDER BY id"), @@ -367,7 +360,7 @@ def test_running_it_again_changes_nothing(pre_tree): # stops the first run, the NULL guards stop the second, and a # migration that only survives because of the stamp is one bad rescue # away from doubling every branch. - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.CURSOR_ANCHOR_VERSION) with engine.begin() as conn: migrations._backfill_tree(conn) migrations._backfill_cursor_anchors(conn) @@ -529,7 +522,7 @@ def test_deleting_the_newest_action_moves_the_head_back(client): db = SessionLocal() try: adventure = db.get(models.Adventure, adventure_id) - extra = models.Action(adventure_id=adventure_id, index=1, type="do", text="Look.") + extra = models.Action(adventure_id=adventure_id, type="do", text="Look.") tree.place_action(db, adventure, extra) db.add(extra) db.commit() @@ -654,7 +647,7 @@ def _attempts(adventure_id) -> list[tuple]: def test_each_attempt_becomes_a_row_at_the_turns_coordinate(pre_split): - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION) assert _attempts(pre_split) == [ (0, "Attempt one.", 0, 3), @@ -676,7 +669,7 @@ def test_the_live_attempt_is_the_one_the_row_was_mirroring(pre_split): """`variant_index` is the only record of which take the player was reading, and it survives as the `live` flag. Guessing "the newest" instead would silently rewrite the story of anyone who had paged back.""" - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION) live = rows( "SELECT text FROM actions WHERE adventure_id = :a AND live = 1 " @@ -686,7 +679,7 @@ def test_the_live_attempt_is_the_one_the_row_was_mirroring(pre_split): def test_the_prompt_stays_on_the_live_attempt_and_nowhere_else(pre_split): - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION) holders = [] for variant_index, snapshot in rows( @@ -703,7 +696,7 @@ def test_the_prompt_stays_on_the_live_attempt_and_nowhere_else(pre_split): def test_each_attempt_keeps_the_outcome_it_produced(pre_split): - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION) parsed = [ (i, json.loads(state), json.loads(world) if world else None) @@ -727,7 +720,7 @@ def test_each_attempt_keeps_the_outcome_it_produced(pre_split): def test_state_after_is_the_state_before_of_the_turn_in_front(pre_split): - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION) after = dict(rows( 'SELECT "index", state_after FROM actions WHERE adventure_id = :a ' @@ -741,11 +734,11 @@ def test_state_after_is_the_state_before_of_the_turn_in_front(pre_split): def test_the_split_survives_being_run_again(pre_split): - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION) snapshot = _attempts(pre_split) before = scalar("SELECT count(*) FROM actions") - migrations.bootstrap(engine) + migrations.bootstrap(engine, through=migrations.SIBLING_SPLIT_VERSION) with engine.begin() as conn: migrations._backfill_state_after(conn) migrations._split_variants_into_siblings(conn) @@ -768,3 +761,36 @@ def test_the_migrated_story_reads_back_as_one_turn(pre_split): assert history.count(adventure) == 4 finally: db.close() + + +# ------------------------------------------------------- SP8: the columns go + +# The columns migrations 66 to 73 drop, paired with the table they sat on. +DROPPED_COLUMNS = [ + ("actions", "index"), + ("actions", "variants"), + ("actions", "variant_index"), + ("actions", "variant_count"), + ("actions", "state_before"), + ("actions", "world_state_before"), + ("adventures", "memory_cursor"), + ("adventures", "summary_cursor"), +] + + +@pytest.mark.parametrize("table,column", DROPPED_COLUMNS) +def test_the_legacy_columns_are_dropped(pre_tree, table, column): + """A pre-tree database that migrates all the way ends without these eight. + + The fixture is a real schema-45 database, so each column starts present and + the `ALTER TABLE` statements have something to remove. A `create_all` + database never has them, and would pass this test without running the + migration at all. + """ + before = {row[1] for row in rows(f"PRAGMA table_info({table})")} + assert column in before, "the fixture must start with the column" + + migrations.bootstrap(engine) + + after = {row[1] for row in rows(f"PRAGMA table_info({table})")} + assert column not in after diff --git a/backend/tests/test_turn_flow_integration.py b/backend/tests/test_turn_flow_integration.py index c5745f4..54bb25f 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() @@ -56,7 +41,7 @@ def client(monkeypatch): adv = models.Adventure(user_id=user.id, title="Cave", script_state={}) setup.add(adv) setup.flush() - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave.")) + setup.add(models.Action(adventure_id=adv.id, type="start", text="You enter a cave.")) setup.add(models.AdventureScript( adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, @@ -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.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 +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 87a195f..d6473d9 100644 --- a/backend/tests/test_worldstate_integration.py +++ b/backend/tests/test_worldstate_integration.py @@ -4,25 +4,17 @@ 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 -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.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) @@ -67,18 +50,19 @@ 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() # "Gwen" in the story text makes her NPC in-scene (matches the "gwen" npc's keys). - setup.add(models.Action(adventure_id=adv.id, index=0, type="start", + setup.add(models.Action(adventure_id=adv.id, type="start", text="You face a goblin. Gwen watches.")) setup.commit() adv_id, user_id = adv.id, user.id setup.close() - monkeypatch.setattr(adventures, "OpenAICompatibleProvider", FakeProvider) + ScriptedProvider.replies = [AI_REPLY] + 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) @@ -95,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..582ba46 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): @@ -96,7 +99,7 @@ adv = models.Adventure( db.add(adv) db.flush() db.add(models.Action( - adventure_id=adv.id, index=0, type="start", + adventure_id=adv.id, type="start", text="The cellar door has been shut since your grandmother died. " "Tonight the lantern is lit and the key is in your hand.")) db.add(models.AdventureScript( @@ -105,7 +108,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..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 = ("", {}) @@ -163,7 +166,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..3ff9e56 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 @@ -336,6 +340,16 @@ def rich_attempts(rng: random.Random, index: int) -> tuple[list[str], int]: return texts, (0 if index % 18 == 1 else count - 1) +def rich_cursor_positions(action_count: int) -> tuple[int, int]: + """Return the memory and summary mark positions for the rich fixture. + + The marks start out as positions because that is the form a pre-SP3 + database holds. `build_fixture` turns them into anchors once the actions + they point at exist. + """ + return max(0, action_count - 8), max(0, action_count - 20) + + def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None: """Adds everything `--rich` contributes apart from the actions themselves. @@ -356,14 +370,10 @@ def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None: adventure.world_state = rich_world_state(schema, args.actions) adventure.script_state = rich_script_state(args.actions) # The post-turn passes do nothing unless summarization is on and the marks - # are past the start. They are written here as positions and translated into - # anchors once the actions exist. See `build_fixture`. A database being - # migrated to SP3 still holds positions, so the fixture carries both forms - # and the two have to agree. + # are past the start. `build_fixture` translates the marks into anchors + # once the actions exist. See `rich_cursor_positions`. adventure.auto_summarize = True adventure.story_summary = RICH_SUMMARY - adventure.memory_cursor = max(0, args.actions - 8) - adventure.summary_cursor = max(0, args.actions - 20) for kind, name, keys, entry in RICH_CARDS: db.add(models.StoryCard( @@ -392,7 +402,7 @@ def add_second_adventure(db, rng: random.Random, user) -> int: db.flush() for i in range(6): action = models.Action( - adventure_id=other.id, index=i, + adventure_id=other.id, type="ai" if i % 2 else "do", text=f"[second adventure] turn {i}. {prose(rng, 200)}", state_after=rich_script_state(i), @@ -500,10 +510,17 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]: NARRATION if is_ai else PLAYER_INPUT ] live = 0 + # The attempts at one turn share that turn's coordinate. Only the + # first one moves the head, so the rest copy the placement of the + # first instead of going through `tree.place_action`, which would + # read each of them as a new turn. This mirrors + # `attempts.add_attempt`, which the fixture cannot call directly + # because it makes the newest attempt live and the fixture needs a + # live attempt that is often not the newest. + first_attempt = None for n, body in enumerate(texts): action = models.Action( adventure_id=adventure.id, - index=i, type="ai" if is_ai else "do", text=body, # The turn's assembled prompt is stored once, on the @@ -521,8 +538,6 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]: # Every third AI turn was retried once, so a turn is # sometimes several rows sharing one coordinate. live=(n == live), - variant_index=n, - variant_count=len(texts) if len(texts) > 1 else 0, # Under `--rich` the value increases with the turn, so an # incorrect rollback shows a wrong number rather than no # value. Otherwise `tree.stamp_outcome` writes the @@ -538,7 +553,13 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]: # sees it. The fixture has to stamp its own nodes, or it would # be the one database in the project whose actions have no # branch. - tree.place_action(db, adventure, action) + if first_attempt is None: + tree.place_action(db, adventure, action) + first_attempt = action + else: + action.branch_id = first_attempt.branch_id + action.depth = first_attempt.depth + action.parent_id = first_attempt.parent_id db.add(action) for i in range(args.memories): @@ -569,8 +590,9 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]: # fixture stamped LATEST never runs a migration, so skipping this # would leave the one database whose marks are only positions. db.flush() - cursors.anchor_at_position(adventure, cursors.MEMORY, adventure.memory_cursor) - cursors.anchor_at_position(adventure, cursors.SUMMARY, adventure.summary_cursor) + memory_position, summary_position = rich_cursor_positions(args.actions) + cursors.anchor_at_position(adventure, cursors.MEMORY, memory_position) + cursors.anchor_at_position(adventure, cursors.SUMMARY, summary_position) add_second_adventure(db, rng, user) _assert_live_variant_invariant(db, adventure.id) @@ -582,7 +604,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 +834,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..6210b89 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 @@ -83,13 +86,13 @@ adv = models.Adventure( db.add(adv) db.flush() db.add(models.Action( - adventure_id=adv.id, index=0, type="start", + adventure_id=adv.id, type="start", text="The cellar door has been shut since your grandmother died.")) 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/docs/self-review.md b/docs/self-review.md index 4750d24..2e09a02 100644 --- a/docs/self-review.md +++ b/docs/self-review.md @@ -125,12 +125,19 @@ break compatibility. This is documented in `engine.py`'s prelude instead. The cl ## Cleanup backlog (reuse / simplification / efficiency / altitude — not bugs, apply later) -- **R1** `frontend/src/pages/Play.jsx:26` + `ScenarioEditor.jsx:19` + `ScriptEditor.jsx:34`: three copies of the debounced-autosave and story-card handlers. Extract a `useDebouncedSave` hook and a shared StoryCardList component. Fixing bugs #15/#16 properly may accomplish this. +- **R1** ~~three copies of the debounced-autosave handler.~~ + **Half applied in phase 17 (2026-08).** All three use + `frontend/src/hooks/useDebouncedSave.js`. A shared StoryCardList component is + still open. - **R2** `backend/seed_demo.py:228`: re-implements create_adventure. Call the router logic instead. - **R3** `backend/app/providers/openai_compatible.py:122`: complete() duplicates _request()'s body building. Add a `stream` param to _request(). -- **R4** `backend/app/routers/adventures.py:476`: six copies of child-resource get+owner-check+404. Extract `get_owned_or_404`. +- **R4** ~~six copies of child-resource get, owner-check, and 404.~~ + **Applied in phase 17 (2026-08).** All 32 handlers take the `current_adventure` + dependency from `routers/adventures/deps.py`. - **R5** `frontend/src/api.js:26`: streamSSE duplicates request()'s error extraction. Extract `throwIfNotOk(resp)`. -- **S1** `backend/app/models.py:210`: `Settings.stream` is dead state (never read). Delete the column and its schema fields. +- **S1** ~~`Settings.stream` is dead state (never read).~~ + **Applied in phase 17 (2026-08).** The column, both schema fields, and migration 65 + drop it. `R4` went with it: the ownership check is the `current_adventure` dependency. - **S2** `frontend/src/pages/Play.jsx:6`: MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo are computed twice. - **E1** `backend/app/context/builder.py:119`: joins and tokenizes the entire adventure history every turn for the trigger window. Walk reversed(actions) until budget instead. - **E2** `backend/app/scripting/pipeline.py:77`: rebuilds full history dicts, JSON, and a blocking commit per script per hook. Build once per hook, slice to HISTORY_WINDOW first, and commit once. diff --git a/frontend/src/hooks/useDebouncedSave.js b/frontend/src/hooks/useDebouncedSave.js new file mode 100644 index 0000000..64f2cf8 --- /dev/null +++ b/frontend/src/hooks/useDebouncedSave.js @@ -0,0 +1,17 @@ +// Delays a save until its field has been quiet for a moment. +// +// Every `key` gets its own timer. One shared timer cancels the pending save of +// whatever was edited before it, so editing two fields inside the same window +// saves only the second one. + +import { useRef } from 'react' + +function useDebouncedSave(delay = 600) { + const timers = useRef(new Map()) + return (key, fn) => { + clearTimeout(timers.current.get(key)) + timers.current.set(key, setTimeout(fn, delay)) + } +} + +export { useDebouncedSave } diff --git a/frontend/src/index.css b/frontend/src/index.css index b606071..f83fac1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,2871 +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. Text may break - mid-word as a last resort, which is what keeps a chip's minimum width - small enough for the flex row to shrink it. */ - max-width: 100%; - overflow-wrap: anywhere; -} -.chg-val { overflow-wrap: anywhere; } -/* Only a signed number is held on one line, so the sign never ends up on a - line of its own. The other values a chip can carry are phrases — a refusal - reason, "no change — at its limit", or the whole new value of a free-text - stat — and holding those together set the chip's minimum width to the width - of the phrase, which is what pushed a chip out of the message on a phone. */ -.chg-num { 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/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 93bd2f9..0000000 --- a/frontend/src/pages/Play.jsx +++ /dev/null @@ -1,2281 +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 signed = !blocked && typeof d === 'number' - const txt = blocked - ? 'no change — at its limit' - : signed ? (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) => ( - - ))} -
-