Merge pull request #16 from parththakkar106/refactor-17

Phase 17: split the four largest files, remove duplication, and drop the legacy columns
This commit is contained in:
Parth
2026-08-30 19:01:40 +05:30
committed by GitHub
116 changed files with 10095 additions and 9404 deletions
+6
View File
@@ -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
+12
View File
@@ -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")
+15 -37
View File
@@ -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:
+20 -47
View File
@@ -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)),
)
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
+1 -1
View File
@@ -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),
)
-24
View File
@@ -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,
+129 -7
View File
@@ -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)
+9 -54
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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.<name>`. 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",
]
+105
View File
@@ -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)
+259
View File
@@ -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)
@@ -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
+334
View File
@@ -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)
+42
View File
@@ -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)
@@ -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
+116
View File
@@ -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()
+139
View File
@@ -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)
+168
View File
@@ -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,
)
+195
View File
@@ -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
@@ -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:<id>`, plus one card
per NPC defined in its `stat_schema`, keyed `npc:<key>`. 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)
+116
View File
@@ -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
+388
View File
@@ -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)
+418
View File
@@ -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.<name>` 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,
)
+1 -1
View File
@@ -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"])
+2 -11
View File
@@ -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
+30
View File
@@ -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})
+11 -12
View File
@@ -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:
+23 -13
View File
@@ -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",
+407
View File
@@ -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.<id>.<stat>`, 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.<name>` 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.<id>` 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.<stat>` and `player.<stat>`.
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.<id>.<stat>`. 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.<stat>, "
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
}
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
-939
View File
@@ -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.<stat>", '
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
'"milestones.<id>": 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.<stat>, "
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
})
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.<name>` 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.<id>` 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.<stat> / player.<stat>
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.<npcId>.<stat>`. 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.<stat>, "
f"world.<stat>, npc.<id>.<stat>, flags.<name> or milestones.<id>.",
})
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.<id>.<stat>.
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.<id>`, 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.<id>): " + "; ".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)
+165
View File
@@ -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.<stat>", '
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
'"milestones.<id>": 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(), {}
+167
View File
@@ -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.<id>.<stat>.
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.<id>`, 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.<id>): " + "; ".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)
+198
View File
@@ -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
+1 -1
View File
@@ -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.
+57
View File
@@ -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
+41
View File
@@ -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)
-9
View File
@@ -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
+25 -22
View File
@@ -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
-9
View File
@@ -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
+18 -38
View File
@@ -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."
+7 -14
View File
@@ -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]
+6 -31
View File
@@ -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()
+4 -26
View File
@@ -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)
+14 -48
View File
@@ -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()
-9
View File
@@ -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
+7 -30
View File
@@ -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"
+24 -52
View File
@@ -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()
-7
View File
@@ -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
@@ -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
+1 -8
View File
@@ -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),
+2 -13
View File
@@ -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)
+2 -9
View File
@@ -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()
-10
View File
@@ -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",
+1 -10
View File
@@ -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
+8 -18
View File
@@ -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)
-9
View File
@@ -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
+3 -9
View File
@@ -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()
@@ -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
+49 -46
View File
@@ -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
+3 -10
View File
@@ -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()
+2 -11
View File
@@ -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)
-7
View File
@@ -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
+6 -13
View File
@@ -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()
-7
View File
@@ -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
+19 -26
View File
@@ -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)
+7 -36
View File
@@ -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)
+4 -26
View File
@@ -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)
+4 -24
View File
@@ -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)
+5 -27
View File
@@ -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)
+45 -19
View File
@@ -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
+7 -21
View File
@@ -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)
+8 -24
View File
@@ -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)
+5 -2
View File
@@ -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
+4 -1
View File
@@ -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
+36 -14
View File
@@ -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.
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.
+5 -2
View File
@@ -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
+10 -3
View File
@@ -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.
+17
View File
@@ -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 }
+28 -2871
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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'
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
// The confirmation dialog for copying a scenario's current content.
//
// The dialog shows exactly what the update changes, and collects any
// `${Placeholder}` answers the adventure has no stored value for. An adventure
// started before those were saved has none, and so does one whose author added
// a placeholder since. The update overwrites plot text and scenario-derived
// cards, so nothing happens until you press Update.
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { FIELD_LABELS, clip } from './format'
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 <body>: 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(
<div className="modal-overlay" onClick={onCancel}>
<form className="modal refresh-modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h2>Update from scenario</h2>
{!plan.has_changes ? (
<p className="modal-hint">
This adventure already matches “{plan.scenario_title}”. Nothing to update.
</p>
) : (
<>
<p className="modal-hint">
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 <b>overwritten</b>, including any edits you made here.
</p>
<div className="refresh-changes">
{fields.map(([field, diff]) => (
<div key={field} className="refresh-change">
<b>{FIELD_LABELS[field] || field}</b>
<div className="dim refresh-old">− {clip(diff.old)}</div>
<div className="refresh-new">+ {clip(diff.new)}</div>
</div>
))}
{added.length > 0 && (
<div className="refresh-change">Story cards added: <b>{added.join(', ')}</b></div>
)}
{updated.length > 0 && (
<div className="refresh-change">Story cards overwritten: <b>{updated.join(', ')}</b></div>
)}
{removed.length > 0 && (
<div className="refresh-change">Story cards removed: <b>{removed.join(', ')}</b></div>
)}
{world.added?.length > 0 && (
<div className="refresh-change">
New stats (at their starting value): <b>{world.added.join(', ')}</b>
</div>
)}
{world.removed?.length > 0 && (
<div className="refresh-change">Stats removed: <b>{world.removed.join(', ')}</b></div>
)}
</div>
{(plan.placeholders_needed || []).length > 0 && (
<>
<p className="modal-hint">
This scenario asks a few questions, and this adventure has no saved
answers for them. They'll be remembered from now on.
</p>
{plan.placeholders_needed.map((name) => (
<label key={name} className="field">
<span className="label">{name}</span>
<input type="text" value={values[name]}
onChange={(e) => setValues({ ...values, [name]: e.target.value })} />
</label>
))}
</>
)}
</>
)}
<div className="modal-buttons">
<button type="button" onClick={onCancel}>{plan.has_changes ? 'Cancel' : 'Close'}</button>
{plan.has_changes && (
<button type="submit" className="primary" disabled={busy}>
{busy ? 'Updating…' : 'Update'}
</button>
)}
</div>
</form>
</div>,
document.body,
)
}
export { RefreshModal }
+88
View File
@@ -0,0 +1,88 @@
// The pager that steps between the attempts at one coordinate: ‹ 2/4 ›, and
// nothing else.
//
// SP7 shipped chips instead, because a chip could also offer "take this path"
// where a pager can only step. Driving it by hand said otherwise. The chip
// meant two things depending on where you were standing: a real switch at the
// tip, or a preview that needed a second button above it. Two meanings in one
// control is what made the tree unusable.
//
// So the pager is back, and stepping is all it does. Stepping tells the server
// nothing, because reading a take is not a decision. You decide by writing
// below a take, and that is where the branch is created (SP9, `after_id`).
//
// One step does reach 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 it 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 ends there.
import { useEffect, useState } from 'react'
import { api } from '../../api'
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 (
<div className="take-pager">
<button type="button" disabled={busy || loading || current === 0}
onClick={() => step(-1)} title="The take before this one" aria-label="Previous take">‹</button>
<span className="take-count" aria-live="polite">{current + 1}/{count}</span>
<button type="button" disabled={busy || loading || current === count - 1}
onClick={() => step(1)} title="The take after this one" aria-label="Next take">›</button>
{preview && !preview.written && (
<span className="take-note">write below to keep this one</span>
)}
</div>
)
}
export { TakePager }
@@ -0,0 +1,99 @@
// The left drawer: the script state an adventure's scripts read and write.
//
// `StateTree` and `StateValue` render a value of any shape, because script
// state is whatever the scripts put there.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
// Renders one script-state value. A primitive renders inline, typed and
// colored. An object or an array renders as a collapsible indented tree, and
// the render recurses, so deep state shows its structure rather than one flat
// JSON blob.
function StateValue({ value, depth = 0 }) {
if (value === null || value === undefined) return <span className="jv-null">null</span>
if (typeof value === 'boolean') return <span className="jv-bool">{String(value)}</span>
if (typeof value === 'number') return <span className="jv-number">{value}</span>
if (typeof value === 'string') return <span className="jv-string">{value}</span>
if (Array.isArray(value)) return <StateTree entries={value.map((v, i) => [i, v])} empty="[ ]" depth={depth} />
if (typeof value === 'object') return <StateTree entries={Object.entries(value)} empty="{ }" depth={depth} />
return <span className="jv-string">{String(value)}</span>
}
// 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 <span className="jv-empty">{empty}</span>
return (
<div className="jv-tree">
<button className="jv-toggle" onClick={() => setOpen((o) => !o)}>
{open ? '▾' : '▸'} {entries.length} {entries.length === 1 ? 'item' : 'items'}
</button>
{open && (
<ul className="jv-children">
{entries.map(([k, v]) => (
<li key={k}>
<span className="jv-key">{k}</span>
<StateValue value={v} depth={depth + 1} />
</li>
))}
</ul>
)}
</div>
)
}
// 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 (
<div className={`status-drawer ${open ? 'open' : ''}`}>
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
title="Script state variables">
{open ? '‹' : '›'}<span className="status-toggle-label">State</span>
</button>
{open && (
<div className="status-body">
<div className="side-panel-header">
<h2>Script State</h2>
<button onClick={load} title="Refresh">↻</button>
</div>
{failed ? (
<div className="empty">Couldn’t load state.</div>
) : entries.length === 0 ? (
<div className="empty">
No variables yet. Scripts that use <code>state</code> will appear here after a turn.
</div>
) : (
<ul className="status-vars">
{entries.map(([k, v]) => (
<li key={k}>
<span className="status-key">{k}</span>
<div className="status-val"><StateValue value={v} /></div>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
export { StatusDrawer }
@@ -0,0 +1,295 @@
// The right drawer: the RPG world state, and the form that edits it.
//
// Editing builds a flat draft keyed by path, so a nested value can be edited
// without rebuilding the tree on every keystroke. `buildWorldStateDraft` makes
// it and `sliceDraft` reads one section back out.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
import { npcInitials } from '../../../components'
// Returns the word label for a value, read from the stat def's bands. This
// mirrors `worldstate.band_label` on the server.
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 (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
</div>
{isText ? (
<input className="ws-edit-input ws-edit-text" type="text" value={value ?? ''}
onChange={(e) => onChange(e.target.value)} />
) : (
<input className="ws-edit-input ws-edit-num" type="number" value={value ?? 0}
min={typeof def?.min === 'number' ? def.min : undefined}
max={typeof def?.max === 'number' ? def.max : undefined}
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))} />
)}
</div>
)
}
if (isText) {
const text = typeof value === 'string' ? value : (def?.initial ?? '')
return (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
<span className="ws-stat-val">{text || '(unset)'}</span>
</div>
</div>
)
}
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 (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
<span className="ws-stat-val">
{val}{typeof max === 'number' ? `/${max}` : ''}
{label ? <span className="ws-band"> · {label}</span> : null}
</span>
</div>
{pct != null && (
<div className="ws-bar"><div className="ws-bar-fill" style={{ width: `${pct}%` }} /></div>
)}
</div>
)
}
// `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 ? <div className="ws-none">No tracked stats.</div> : null
}
return (
<div className={nested ? 'ws-stats' : 'ws-group'}>
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
{entries.map(([name, def]) => (
<StatRow key={name} name={name} def={def}
value={editing ? draft?.[name] : values?.[name]}
editing={editing}
onChange={editing ? (v) => onEdit(name, v) : undefined} />
))}
</div>
)
}
// 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 (
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
title="RPG world state">
{open ? '‹' : '›'}<span className="status-toggle-label">World</span>
</button>
{open && (
<div className="status-body">
<div className="side-panel-header">
<h2>World State</h2>
{editing ? (
<div className="ws-edit-actions">
<button onClick={cancelEdit} disabled={saving} title="Discard changes">Cancel</button>
<button onClick={saveEdit} disabled={saving} title="Save overrides">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
) : (
<div className="ws-edit-actions">
<button onClick={startEdit} title="Edit current values">✎</button>
<button onClick={load} title="Refresh">↻</button>
</div>
)}
</div>
{failed ? (
<div className="empty">Couldn’t load world state.</div>
) : (
<>
<StatGroup title={null} defs={schema.world} values={state.world}
editing={editing} draft={sliceDraft(draft, 'world')}
onEdit={(name, v) => setPath(`world.${name}`, v)} />
<StatGroup title="You" defs={schema.player} values={state.player}
editing={editing} draft={sliceDraft(draft, 'player')}
onEdit={(name, v) => setPath(`player.${name}`, v)} />
{npcs.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Cast</h3>
{npcs.map(([id, def]) => (
<div key={id} className="ws-npc">
<div className="ws-npc-head" title={def.desc || undefined}>
<span className="ws-npc-avatar" aria-hidden="true">
{npcInitials(def.name, id)}
</span>
<span className="ws-npc-name">{def.name || id}</span>
</div>
<StatGroup defs={def.stats} values={npcState[id]}
editing={editing} draft={sliceDraft(draft, `npc.${id}`)}
onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)}
nested />
</div>
))}
</div>
)}
{flags.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Flags</h3>
<ul className="ws-flags">
{flags.map(([fid, def]) => {
const on = editing ? !!draft?.[`flags.${fid}`] : (flagState[fid] ?? !!def.initial)
return (
<li key={fid} title={def.desc || undefined}>
{editing ? (
<label className="ws-flag-edit">
<input type="checkbox" checked={on}
onChange={(e) => setPath(`flags.${fid}`, e.target.checked)} />
<span className="ws-flag-name">{fid}</span>
</label>
) : (
<>
<span className={`ws-flag-dot ${on ? 'on' : ''}`} />
<span className="ws-flag-name">{fid}</span>
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
</>
)}
</li>
)
})}
</ul>
</div>
)}
{milestones.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Milestones</h3>
<ul className="ws-milestones">
{milestones.map(([mid, def]) => {
const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached
return (
<li key={mid} className={done ? 'done' : ''}>
{editing ? (
<label className="ws-flag-edit">
<input type="checkbox" checked={done}
onChange={(e) => setPath(`milestones.${mid}`, e.target.checked)} />
<span>{def.desc || mid}</span>
</label>
) : (
<>
<span className="ws-check">{done ? '☑' : '☐'}</span>
{def.desc || mid}
</>
)}
</li>
)
})}
</ul>
</div>
)}
</>
)}
</div>
)}
</div>
)
}
export { WorldStateDrawer }
+67
View File
@@ -0,0 +1,67 @@
// Labels and formatting shared by the panels that report on a turn.
//
// `SECTION_COLORS` and `SECTION_FALLBACK` stay private. Read a color through
// `sectionColor`, so an unknown section gets the fallback rather than
// `undefined`.
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)')
}
export { SECTION_LABELS, sectionColor, pctLabel, FIELD_LABELS, clip }
+783
View File
@@ -0,0 +1,783 @@
/* The Play screen.
*
* This file holds the page component and the two helpers only it uses. The
* panels, drawers, and reports it renders are separate modules, listed in the
* imports below.
*
* The page component still owns all of the session state. Lifting it into a
* `usePlaySession` hook waits for Stage 5 of `plan/17-refactor.md`, which adds
* a frontend test runner. Moving eighteen `useState` calls and seven
* `useEffect` calls is a rewrite rather than a move, and nothing would catch a
* mistake in it today.
*/
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../../api'
import { AutoTextarea } from '../../components'
import { StateChangeChips } from './reports'
import { TakePager } from './TakePager'
import { StatusDrawer } from './drawers/StatusDrawer'
import { WorldStateDrawer } from './drawers/WorldStateDrawer'
import { BranchPanel } from './panels/BranchPanel'
import { InsightsPanel } from './panels/InsightsPanel'
import { MemoryPanel } from './panels/MemoryPanel'
import { PlotPanel } from './panels/PlotPanel'
import { ScriptsPanel } from './panels/ScriptsPanel'
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
? <b key={match.index}>{match[1]}</b>
: <i key={match.index}>{match[2]}</i>)
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 (
<details className="reasoning" open={streaming || undefined}>
<summary>💭 Reasoning{streaming ? '…' : ''}</summary>
<div className="reasoning-text">{text}</div>
</details>
)
}
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 (
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
{/* 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. */}
<WorldStateDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
<StatusDrawer advId={id} refreshKey={`${actions.length}:${stateKey}`} />
<div className="page play-page">
<div className="page-header">
<h1>{adventure.title}</h1>
<div className="panel-toggles">
<button className={panel === 'plot' ? 'active' : ''}
onClick={() => setPanel(panel === 'plot' ? null : 'plot')}>Plot</button>
<button className={panel === 'memory' ? 'active' : ''}
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'scripts' ? 'active' : ''}
onClick={() => setPanel(panel === 'scripts' ? null : 'scripts')}>Scripts</button>
<button className={panel === 'branches' ? 'active' : ''}
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
<button className={panel === 'insights' ? 'active' : ''}
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
Insights
</button>
</div>
</div>
<div className="story">
{actions.length === 0 && streaming === null && (
<div className="empty">A blank page. Type something below to begin your story.</div>
)}
{/* 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 && (
<div className="story-earlier">
{loadingOlder ? (
<span className="dim">Turning back the pages…</span>
) : (
<button type="button" onClick={loadOlder}>
{Math.max(total - actions.length, 0)} earlier
{total - actions.length === 1 ? ' moment' : ' moments'}
</button>
)}
</div>
)}
{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 ? (
<div key={action.id} className="action-edit">
<AutoTextarea
autoFocus
value={editing.text}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
/>
<div className="edit-buttons">
<button className="primary" onClick={saveEdit}>Save</button>
<button onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
) : (
<Fragment key={action.id}>
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
<ReasoningBlock text={shown ? shown.reasoning : action.reasoning} />
{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 && (
<StateChangeChips changes={action.world_changes} />
)}
{/* 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. */}
<TakePager
advId={id}
action={action}
busy={busy}
// The pinned take counts as the one on screen, so the
// ordinal matches the words above it.
preview={shown}
takesKey={takesKey}
onPreview={setPreview}
onSwitchedBranch={adoptWindow}
onError={(message) => setToast({ text: message, isError: true })}
/>
{!busy && (
<span className="action-tools">
{action.type === 'ai' && (
<button title="View the exact prompt that produced this"
onClick={() => inspect(action.id)}>🔍</button>
)}
{/* 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. */}
<button title="Edit"
onClick={() => setEditing(previewing
? { id: previewing.takeId, text: previewing.text, take: true }
: { id: action.id, text: action.text })}>✎</button>
{/* 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' && (
<button
title={action.type === 'ai'
? 'Another take on this turn'
: 'Say this differently, and keep both'}
onClick={() => (action.type === 'ai'
? addTake(action.id, '')
: setEditing({
id: action.id,
text: previewing ? previewing.text : action.text,
fork: true,
}))}
>⑂</button>
)}
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
</span>
)}
</div>
</Fragment>
)
})}
{waitingForFirstToken && (
<div className="thinking" role="status">
<i /><i /><i />
<span>Weaving</span>
</div>
)}
{streaming !== null && !waitingForFirstToken && (
<div className="action streaming">
<ReasoningBlock text={reasoningStream} streaming />
{renderEmphasis(streaming)}
<span className="cursor">▋</span>
</div>
)}
<div ref={storyEndRef} />
</div>
<div className="play-controls">
<div className="turn-buttons">
<button onClick={() => send('continue')} disabled={busy}>Continue ▸</button>
<button onClick={retry} disabled={busy || !lastIsAi} title="Ctrl+R">↻ Retry</button>
<button onClick={undo} disabled={busy || !canUndo} title="Ctrl+Z">↶ Undo</button>
</div>
<div className="input-bar">
<div className="mode-select">
{MODES.map((m) => (
<button key={m} className={mode === m ? 'active' : ''}
onClick={() => setMode(m)} disabled={busy}>
{m[0].toUpperCase() + m.slice(1)}
</button>
))}
</div>
<textarea
ref={inputRef}
rows={1}
value={input}
disabled={busy}
placeholder={
mode === 'do' ? 'What do you do?'
: mode === 'say' ? 'What do you say?'
: 'What happens next?'
}
onChange={(e) => setInput(e.target.value)}
// Enter sends; Shift+Enter inserts a newline.
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !busy) { e.preventDefault(); send() }
}}
/>
{busy ? (
<button className="danger" onClick={stopGeneration}>■ Stop</button>
) : (
<button className="primary" onClick={() => send()}>Send</button>
)}
</div>
</div>
</div>
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', scripts: 'Scripts', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'plot' ? (
<PlotPanel adventure={adventure} setAdventure={setAdventure}
onWorldStateChanged={() => setStateKey((k) => k + 1)} />
) : panel === 'memory' ? (
<MemoryPanel adventure={adventure} setAdventure={setAdventure}
// The bank is adventure-wide, so a switch does not change it —
// but deleting a branch deletes the memories that hung off it,
// and that happens without a turn being played.
refreshKey={`${actions.length}:${stateKey}`} />
) : panel === 'scripts' ? (
<ScriptsPanel advId={id} />
) : panel === 'branches' ? (
<BranchPanel
advId={id}
// Not `actions.length` alone. A fork taken from the story column
// replaces one window with another of the same size, so the
// length is unchanged and the panel would go on showing a tree
// with one branch in it while the story is being read on a
// second. `stateKey` is bumped by adoptWindow, which is exactly
// the two operations that move the head.
refreshKey={`${actions.length}:${stateKey}`}
onSwitched={adoptWindow}
onTreeChanged={() => setStateKey((k) => k + 1)}
onError={(message) => setToast({ text: message, isError: true })}
/>
) : (
// Insights is the prompt as it would be sent *now*, which is built
// from the story on the current path — so of everything on this
// screen it is the panel a branch switch changes most completely.
<InsightsPanel advId={id} inspectActionId={inspectActionId}
onClearInspect={() => setInspectActionId(null)}
refreshKey={`${actions.length}:${stateKey}`} />
)}
</div>
)}
{toast && (
<div className={`play-toast ${toast.isError ? '' : 'ok'}`}>
{toast.text}
{toast.isError && (
<button style={{ marginLeft: 12 }} onClick={retry}>Retry</button>
)}
<button style={{ marginLeft: 8 }} onClick={() => setToast(null)}>✕</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,175 @@
// The branch panel: every line the story has taken, and the map of them.
//
// One request draws the whole panel. `fork_depth` says where a branch leaves
// its parent and `depth` says where it currently ends, so the shape is two
// numbers a row rather than a walk.
//
// Delete belongs here rather than in a later subphase because nothing prunes
// the tree on its own. This panel is the first place you can make a fork, so
// it has to be the first place you can unmake one.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { BranchMap } from '../../../BranchMap'
import { branchLabel, headLineage, orderBranches } from '../../../branches'
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 <div className="panel-empty">Couldn’t read the branches — {failed}</div>
if (!branches) return <div className="panel-empty">Reading the tree…</div>
const lineage = headLineage(branches)
return (
<div className="branch-panel">
{/* 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. */}
<button type="button" className="branch-map-open" onClick={() => setMapOpen(true)}>
⌗ See the tree
</button>
{branches.length === 1 && (
<p className="branch-intro">
One thread so far. Retry a turn, then take an attempt the story moved
past — that is what makes a second one.
</p>
)}
<div className="branch-list">
{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 (
<div key={branch.id} className={`branch-row ${branch.is_head ? 'here' : ''}`}
style={{ marginLeft: indent * 12 }}>
<div className="branch-head">
<span className="branch-glyph" aria-hidden="true">
{branch.parent_branch_id === null ? '●' : '└'}
</span>
{isRenaming ? (
<input
className="branch-rename"
autoFocus
maxLength={80}
value={renaming.text}
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') saveName(branch)
if (e.key === 'Escape') setRenaming(null)
}}
/>
) : (
<span className="branch-name">{branchLabel(branch)}</span>
)}
{branch.is_head && <span className="branch-here">reading</span>}
</div>
<div className="branch-meta">
{branch.own_actions} of its own
{branch.parent_branch_id !== null && ` · forked at moment ${branch.fork_depth + 1}`}
{` · ends at ${branch.depth + 1}`}
</div>
{isConfirming ? (
<div className="branch-confirm">
<span>Delete this branch and everything forked from it?</span>
<button type="button" className="danger" disabled={busy}
onClick={() => remove(branch)}>Delete</button>
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
</div>
) : (
<div className="branch-tools">
{!branch.is_head && (
<button type="button" disabled={busy} onClick={() => switchTo(branch)}>Switch</button>
)}
{isRenaming ? (
<>
<button type="button" disabled={busy} onClick={() => saveName(branch)}>Save</button>
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
</>
) : (
<button type="button" disabled={busy}
onClick={() => setRenaming({ id: branch.id, text: branch.name || '' })}>
Rename
</button>
)}
{/* The root holds the turns every other branch borrows, and
the server refuses it — so it is not offered. */}
{branch.parent_branch_id !== null && (
<button type="button" className="danger" disabled={busy || loadBearing}
title={loadBearing
? 'The line you are reading is built on this one. Switch away first.'
: undefined}
onClick={() => setConfirming(branch.id)}>Delete</button>
)}
</div>
)}
</div>
)
})}
</div>
{mapOpen && (
<BranchMap
branches={branches}
busyId={busyId}
onSwitch={switchTo}
onRename={renameTo}
onDelete={removeBranch}
onClose={() => setMapOpen(false)}
/>
)}
</div>
)
}
export { BranchPanel }
@@ -0,0 +1,121 @@
// The insights panel: what the last turn sent, and what it cost.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { SECTION_LABELS, pctLabel, sectionColor } from '../format'
import { CacheReport, ScriptReport, TokenBreakdown, WorldStateReport } from '../reports'
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 <div className="empty">{error}</div>
if (!report) return <div className="empty">Loading…</div>
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 (
<div className="insights">
<div className="insights-meta">
{inspectActionId ? (
<div className="insights-mode">
Snapshot of a past turn
<button onClick={onClearInspect} style={{ marginLeft: 8 }}>Back to next turn</button>
</div>
) : (
<div className="insights-mode">What will be sent on the next turn</div>
)}
<div className={`token-total ${overBudget ? 'over' : ''}`}>
{tokens.total} / {tokens.budget} tokens
</div>
<TokenBreakdown
sections={sections}
tokens={tokens}
used={sectionTotal}
onJump={jumpToSection}
/>
<div className="insights-history">
History: {history.included} of {history.total} actions in context
{history.total > history.included && ' (older history trimmed)'}
{history.oldest_truncated && ' — oldest entry cut mid-text'}
</div>
<CacheReport usage={report.usage} />
{cards.length > 0 && (
<div className="insights-cards">
{cards.map((c, i) => (
<div key={i} className={c.included ? '' : 'dropped'}>
▸ <b>{c.name || '(unnamed card)'}</b> triggered on “{c.keyword}”
{!c.included && ' — dropped (over card budget)'}
</div>
))}
</div>
)}
{report.memories && (
<div className="insights-cards">
{report.memories.error && (
<div className="dropped">⚠ Memory bank: {report.memories.error}</div>
)}
{report.memories.used?.map((m, i) => (
<div key={i}>
▸ memory retrieved ({m.pinned ? 'pinned' : `similarity ${m.similarity.toFixed(2)}`}):
{' '}{m.text.length > 90 ? m.text.slice(0, 90) + '…' : m.text}
</div>
))}
{!report.memories.error && report.memories.used?.length === 0 && (
<div className="dim">Memory bank on — no memories retrieved.</div>
)}
</div>
)}
</div>
{sections.map((s, i) => (
<div
key={i}
id={`ctx-sec-${i}`}
className={`ctx-section ctx-${s.label}`}
style={{ borderLeftColor: sectionColor(s.label) }}
>
<div className="ctx-header" style={{ color: sectionColor(s.label) }}>
<span>{SECTION_LABELS[s.label] || s.label}</span>
<span>
{s.tokens} tok
{sectionTotal > 0 && ` · ${pctLabel((s.tokens / sectionTotal) * 100)}`}
</span>
</div>
<pre>{s.text}</pre>
</div>
))}
<ScriptReport script={report.script} />
<WorldStateReport worldState={report.world_state} />
{report.raw_output && (
<div className="ctx-section">
<div className="ctx-header">
<span>Raw AI output (before state block was stripped)</span>
</div>
<pre>{report.raw_output}</pre>
</div>
)}
</div>
)
}
export { InsightsPanel }
@@ -0,0 +1,151 @@
// The memory bank panel: the entries retrieval can pull into a prompt.
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../../api'
import { AutoTextarea } from '../../../components'
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 (
<div className={`memory-row ${memory.forgotten ? 'forgotten' : ''}`}>
{editText !== null ? (
<div className="action-edit" style={{ margin: 0 }}>
<AutoTextarea autoFocus value={editText} rows={3}
onChange={(e) => setEditText(e.target.value)} />
<div className="edit-buttons">
<button className="primary" onClick={save}>Save</button>
<button onClick={() => setEditText(null)}>Cancel</button>
</div>
</div>
) : (
<>
<div className="memory-text">{memory.text}</div>
<div className="memory-meta">
{memory.pinned && <span className="memory-badge">📌 pinned</span>}
{memory.forgotten && <span className="memory-badge">forgotten</span>}
{!memory.embedded && !memory.forgotten && (
<span className="memory-badge" title="Embedded on the next turn">not embedded yet</span>
)}
{memory.source_start != null && (
<span className="dim">actions {memory.source_start}–{memory.source_end}</span>
)}
<span className="dim">used {memory.use_count}×</span>
<span className="action-tools">
<button title={memory.pinned ? 'Unpin' : 'Pin (always included in context)'}
onClick={() => onChange({ pinned: !memory.pinned })}>📌</button>
{memory.forgotten && (
<button title="Restore this memory"
onClick={() => onChange({ forgotten: false })}>↩</button>
)}
<button title="Edit" onClick={() => setEditText(memory.text)}>✎</button>
<button title="Delete" onClick={onDelete}>✕</button>
</span>
</div>
</>
)}
</div>
)
}
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 (
<div>
<label className="script-attach">
<input type="checkbox" checked={adventure.auto_summarize}
onChange={(e) => setFlag('auto_summarize', e.target.checked)} />
<span>Auto-summarization</span>
<span className="dim">memories every 6 actions, Story Summary every 15</span>
</label>
<label className="script-attach">
<input type="checkbox" checked={adventure.memory_bank_enabled}
onChange={(e) => setFlag('memory_bank_enabled', e.target.checked)} />
<span>Memory Bank</span>
<span className="dim">retrieve relevant memories into context (needs an embedding model)</span>
</label>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Memories {memories && `(${active.length})`}</h3>
<button onClick={load}>Refresh</button>
</div>
{!memories && <div className="empty" style={{ padding: '12px 0' }}>Loading…</div>}
{memories && active.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>
No memories yet. They are generated automatically as you play (from action 12
onward), or add one below.
</div>
)}
{active.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
<div className="input-bar" style={{ marginTop: 10 }}>
<input type="text" value={newText} placeholder="Add a memory manually…"
onChange={(e) => setNewText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
<button onClick={add}>+ Add</button>
</div>
{forgotten.length > 0 && (
<>
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Forgotten ({forgotten.length})</h3>
</div>
{forgotten.map((m) => (
<MemoryRow key={m.id} memory={m}
onChange={(data) => change(m, data)} onDelete={() => remove(m)} />
))}
</>
)}
</div>
)
}
export { MemoryPanel }
@@ -0,0 +1,137 @@
// The plot panel: the adventure's own copy of the scenario text and cards.
import { useState } from 'react'
import { api } from '../../../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../../../components'
import { useDebouncedSave } from '../../../hooks/useDebouncedSave'
import { RefreshModal } from '../RefreshModal'
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)
const debounceSave = useDebouncedSave()
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 (
<div>
{adventure.scenario_id != null && (
<div className="plot-source">
<span className="dim">
Copied from its scenario when the adventure began; later scenario edits don't
reach it on their own.
</span>
<button className="linklike" onClick={openRefresh} disabled={planning}
title="Replace this adventure's plot text and scenario story cards with the scenario's current content">
{planning ? 'Checking…' : '⟳ Update from scenario'}
</button>
</div>
)}
{plan && (
<RefreshModal plan={plan} onConfirm={applyRefresh} onCancel={() => setPlan(null)} />
)}
<Field label="Plot Essentials (Memory)" value={adventure.memory}
onChange={(v) => setField('memory', v)} textarea
placeholder="Key facts the AI should always remember." />
<Field label="Author's Note" value={adventure.authors_note}
onChange={(v) => setField('authors_note', v)} textarea rows={2}
placeholder="Style/theme guidance, injected near the end of context." />
<Field label="AI Instructions" value={adventure.ai_instructions}
onChange={(v) => setField('ai_instructions', v)} textarea rows={2}
placeholder="Behavioral guidance for the model." />
<Field label="Story Summary" value={adventure.story_summary}
onChange={(v) => 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." />
<div className="page-header" style={{ marginTop: 18 }}>
<h3 style={{ margin: 0 }}>Story Cards</h3>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={exportCards} disabled={adventure.story_cards.length === 0}>Export</button>
<button onClick={importCards}>Import</button>
<button onClick={addCard}>+ Add</button>
</div>
</div>
{adventure.story_cards.length === 0 && (
<div className="empty" style={{ padding: '12px 0' }}>No story cards yet.</div>
)}
{adventure.story_cards.map((card) => (
<StoryCardRow key={card.id} card={card}
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
))}
</div>
)
}
export { PlotPanel }
@@ -0,0 +1,116 @@
// The scripts panel: this adventure's copies of the library scripts.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
import { downloadJSON } from '../../../components'
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 <div className="empty">Loading…</div>
if (scripts.length === 0) {
return (
<div className="empty">
No scripts on this adventure. Attach scripts to a scenario before starting an
adventure from it.
</div>
)
}
return (
<div>
{scripts.map((s) => {
const hooks = SCRIPT_HOOKS.filter(([, f]) => (s[f] || '').trim())
const open = openId === s.id
return (
<div key={s.id} className="adv-script">
<div className="adv-script-head">
<label className="script-attach" style={{ margin: 0 }}>
<input type="checkbox" checked={s.enabled} onChange={() => toggle(s)} />
<span>{s.name}</span>
</label>
<div style={{ display: 'flex', gap: 12, flexShrink: 0 }}>
{s.out_of_date && (
<button
className="linklike"
title="Replace this adventure's copy with the latest from your script library"
disabled={syncingId === s.id}
onClick={() => sync(s)}
>
{syncingId === s.id ? 'Syncing…' : '⟳ Sync from library'}
</button>
)}
<button className="linklike" onClick={() => setOpenId(open ? null : s.id)}>
{open ? 'Hide code' : 'View code'}
</button>
<button className="linklike" onClick={() => download(s)}>Download</button>
</div>
</div>
{s.description && <div className="dim adv-script-desc">{s.description}</div>}
{open && (
<div className="adv-script-code">
{hooks.length === 0 ? (
<div className="empty">This script has no code.</div>
) : (
hooks.map(([label, f]) => (
<div key={f}>
<div className="adv-script-hook">{label}</div>
<pre>{s[f]}</pre>
</div>
))
)}
</div>
)}
</div>
)
})}
</div>
)
}
export { ScriptsPanel }
+247
View File
@@ -0,0 +1,247 @@
// What a turn did, rendered five ways: world-state chips, the world-state
// report, the script report, the cache report, and the token breakdown.
//
// These read a turn's result and render it. None of them fetch.
import { useState } from 'react'
import { SECTION_LABELS, pctLabel, sectionColor } from './format'
// 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 (
<div className="turn-changes">
{changes.map((c, i) => {
if (c.kind === 'flag') {
return <span key={i} className="chg chg-flag">{nice(c.label)}: <span className="chg-val">{c.on ? 'on' : 'off'}</span></span>
}
if (c.kind === 'milestone') {
return <span key={i} className="chg chg-ms">✓ {nice(c.label)}</span>
}
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 (
<span key={i} className="chg chg-refused" title={c.fix || `The story asked to change this and the rules refused: ${why}.`}>
{nice(c.label)} <span className="chg-val">refused — {why}</span>
</span>
)
}
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 (
<span key={i} className={`chg chg-stat chg-${dir}`} title={title}>
{nice(c.label)} <span className={signed ? 'chg-val chg-num' : 'chg-val'}>{txt}</span>
{c.clamped && !blocked ? <span className="chg-limited"> (limited)</span> : null}
</span>
)
})}
</div>
)
}
// 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 (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>World State</span></div>
<div className="dim" style={{ fontSize: '0.82rem' }}>No changes this turn.</div>
</div>
)
}
return (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>World State changes</span></div>
<ul className="ws-report">
{paths.map((p) => (
<li key={p}>
<code>{p}</code> <span className="ws-delta">{String(delta[p])}</span>
{clamped.has(p) && <span className="ws-flag"> clamped</span>}
</li>
))}
{rejected.map((r, i) => (
<li key={`r${i}`} className="ws-rejected">
<code>{r.path}</code> rejected — {r.reason}
</li>
))}
</ul>
</div>
)
}
function ScriptReport({ script }) {
if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) {
return null
}
return (
<div className="script-report">
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>Scripts</span></div>
{script.errors?.map((e, i) => <div key={i} className="script-error">⚠ {e}</div>)}
{script.logs?.length > 0 && (
<pre className="script-logs">{script.logs.join('\n')}</pre>
)}
{script.context_changed && (
<>
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context before script</span>
</div>
<pre>{script.context_before}</pre>
</div>
<div className="ctx-section" style={{ borderLeftColor: sectionColor('script_context') }}>
<div className="ctx-header" style={{ color: sectionColor('script_context') }}>
<span>Context after script (sent to AI)</span>
</div>
<pre>{script.context_after}</pre>
</div>
</>
)}
</div>
)
}
// 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 (
<div className="insights-history">
Prompt cache: {cached} of {prompt} prompt tokens read from cache ({pct}%)
{usage.cost != null && ` · cost $${Number(usage.cost).toFixed(5)}`}
</div>
)
}
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 (
<>
<div
className={`token-split ${tokens.total > budget ? 'over' : ''}`}
role="img"
aria-label={`Context breakdown: ${ranked.map(describe).join(', ')}`}
>
{ranked.map((s) => (
<div
key={s.i}
className={`token-seg ${hovered !== null && hovered !== s.i ? 'faded' : ''}`}
style={{ width: `${(s.tokens / scale) * 100}%`, background: sectionColor(s.label) }}
title={describe(s)}
onMouseEnter={() => setHovered(s.i)}
onMouseLeave={() => setHovered(null)}
/>
))}
{free > 0 && (
<div
className="token-seg token-seg-free"
style={{ width: `${(free / scale) * 100}%` }}
title={`${free} tokens of budget unused`}
/>
)}
</div>
<div className="token-legend">
{shown.map((s) => (
<button
key={s.i}
type="button"
className={`token-legend-item ${hovered !== null && hovered !== s.i ? 'faded' : ''}`}
title={`${describe(s)} — click to jump to this section`}
onMouseEnter={() => setHovered(s.i)}
onMouseLeave={() => setHovered(null)}
onClick={() => onJump(s.i)}
>
<span className="token-swatch" style={{ background: sectionColor(s.label) }} />
<span className="token-legend-name">{SECTION_LABELS[s.label] || s.label}</span>
<span className="token-legend-pct">{pctLabel(s.pct)}</span>
<span className="token-legend-tok">{s.tokens}</span>
</button>
))}
{rest.length > 0 && (
<button type="button" className="token-legend-more" onClick={() => setExpanded(true)}>
+ {rest.length} smaller {rest.length === 1 ? 'section' : 'sections'} ({pctLabel(restPct)})
</button>
)}
{expanded && ranked.length > LEGEND_VISIBLE && (
<button type="button" className="token-legend-more" onClick={() => setExpanded(false)}>
Show less
</button>
)}
</div>
</>
)
}
export { StateChangeChips, WorldStateReport, ScriptReport, CacheReport, TokenBreakdown }
+3 -8
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
import ArtPicker from '../ArtPicker'
import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
@@ -18,13 +19,7 @@ export default function ScenarioEditor() {
const [parsedSchema, setParsedSchema] = useState(null)
const [schemaView, setSchemaView] = useState('form') // 'form' | 'json'
const toast = useToast()
// 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 debounceSave = useDebouncedSave()
useEffect(() => {
api.getScenario(id).then((s) => {
+5 -5
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { oneDark } from '@codemirror/theme-one-dark'
import { api } from '../api'
import { Field, downloadJSON } from '../components'
import { useDebouncedSave } from '../hooks/useDebouncedSave'
const SLOTS = [
{ key: 'library_js', label: 'Library', hint: 'Shared code prepended to all three hooks.' },
@@ -19,7 +20,7 @@ export default function ScriptEditor() {
const [script, setScript] = useState(null)
const [slot, setSlot] = useState('input_js')
const [status, setStatus] = useState('')
const saveTimer = useRef(null)
const debounceSave = useDebouncedSave()
// Test-run state
const [testHook, setTestHook] = useState('input')
@@ -33,12 +34,11 @@ export default function ScriptEditor() {
const setField = (field, value) => {
setScript((prev) => ({ ...prev, [field]: value }))
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
debounceSave(field, async () => {
await api.updateScript(id, { [field]: value })
setStatus('Saved')
setTimeout(() => setStatus(''), 1500)
}, 600)
})
}
const runTest = async () => {
+287
View File
@@ -0,0 +1,287 @@
/* ---------- Visit analytics (owner dashboard) ---------- */
/* Wider than the reading pages: this one is a grid of small figures, not
prose, and 960px puts two charts and eight tiles into a column. */
.an-page { max-width: 1180px; }
.an-ranges { display: flex; flex-wrap: wrap; gap: 6px; }
/* Four across, not auto-fit: there are eight tiles, and letting them flow
leaves a single orphan on the second row at most widths. */
.an-tiles {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
@media (max-width: 900px) {
.an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.an-tile {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
}
.an-tile-value {
font-family: var(--font-display);
font-size: 1.7rem;
line-height: 1.1;
color: var(--accent-bright);
}
.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; }
.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }
.an-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.an-card {
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px 14px;
margin-bottom: 16px;
}
.an-grid .an-card { margin-bottom: 0; }
.an-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.an-card-head h2 {
margin: 0;
font-family: var(--font-display);
font-size: 0.95rem;
letter-spacing: 0.06em;
color: var(--accent);
}
.an-note { font-size: 0.72rem; color: var(--text-dim); }
.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; }
.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); }
.an-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; }
/* ----- Day columns ----- */
.an-plot { position: relative; height: 170px; margin-top: 12px; }
.an-gridline {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed var(--border);
pointer-events: none;
}
.an-gridline span {
position: absolute;
right: 0;
top: -0.62em;
font-size: 0.68rem;
color: var(--text-dim);
background: var(--bg-panel);
padding: 0 4px;
/* The dashed rule belongs behind the bars; its value does not. Columns are
positioned, so without this the tallest bar swallows the label. */
z-index: 3;
}
.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; }
.an-column {
position: relative;
flex: 1 1 0;
min-width: 0;
height: 100%;
display: flex;
align-items: flex-end;
border-radius: 4px 4px 0 0;
outline: none;
}
.an-column.hot { background: rgba(212, 169, 78, 0.07); }
.an-stack {
width: 100%;
/* Capped so a 7-day range draws bars and not slabs; the column around it
stays full width, so the hover target does not shrink with the mark. */
max-width: 44px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
/* A 2px gap of surface between stacked segments: the boundary reads as a
boundary without a border, which would eat a thin bar entirely. */
gap: 2px;
}
.an-bar { width: 100%; transition: filter 0.15s; }
.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; }
.an-column.hot .an-bar { filter: brightness(1.2); }
.an-tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 10px;
background: var(--bg-panel);
border: 1px solid var(--border-bright);
border-radius: 8px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55);
font-size: 0.74rem;
white-space: nowrap;
pointer-events: none;
z-index: 4;
}
.an-tip strong { font-weight: 600; color: var(--accent-bright); }
.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); }
.an-axis { display: flex; gap: 2px; margin-top: 7px; }
.an-axis span {
flex: 1 1 0;
min-width: 0;
font-size: 0.68rem;
color: var(--text-dim);
text-align: center;
white-space: nowrap;
}
.an-axis span:first-child { text-align: left; }
.an-axis span:last-child { text-align: right; }
.an-overlay-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-style: italic;
font-size: 0.85rem;
pointer-events: none;
}
/* ----- Funnel ----- */
.an-funnel { display: flex; flex-direction: column; gap: 10px; }
.an-funnel-row {
display: grid;
grid-template-columns: 168px 1fr 96px;
align-items: center;
gap: 12px;
}
.an-funnel-label { font-size: 0.84rem; }
.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; }
.an-funnel-bar {
height: 100%;
min-width: 2px;
background: var(--chart-1);
border-radius: 7px;
transition: width 0.4s ease;
}
.an-funnel-value {
text-align: right;
font-size: 0.86rem;
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 8px;
}
.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); }
/* ----- Ranked lists ----- */
.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.an-list li {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 10px;
border-radius: 6px;
font-size: 0.84rem;
}
/* The bar sits behind the row rather than beside it, so a long label keeps the
full width and the magnitude is still visible at a glance. */
.an-list-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: rgba(181, 138, 48, 0.2);
border-radius: 6px;
}
.an-list-label {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.an-list-value { position: relative; color: var(--text-dim); flex: none; }
/* ----- Tabs, and the access log table ----- */
.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
.an-tabs button {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 8px 14px;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.82rem;
letter-spacing: 0.06em;
cursor: pointer;
}
.an-tabs button:hover { color: var(--text); }
.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
.an-log-head { flex-wrap: wrap; row-gap: 10px; }
.an-log-search { max-width: 260px; }
/* The table is the one thing here with a minimum width — six columns of real
data don't compress. It scrolls inside its own box so the page never does. */
.an-table-scroll { overflow-x: auto; }
.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; }
.an-table th {
text-align: left;
font-weight: 500;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
padding: 0 10px 8px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.an-table tr:last-child td { border-bottom: none; }
.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); }
/* A failed attempt is the row you are scanning for; it gets the only colour in
the table, on a border rather than the text, which stays readable. */
.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); }
.an-table tr.failed td:nth-child(3) { color: var(--danger); }
.an-cell-dim { color: var(--text-dim); }
.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; }
.an-tag {
margin-left: 8px;
padding: 1px 6px;
border: 1px solid var(--border-bright);
border-radius: 10px;
font-size: 0.66rem;
color: var(--text-dim);
}
.an-more { display: flex; justify-content: center; padding-top: 14px; }
.an-footnote {
color: var(--text-dim);
font-size: 0.74rem;
line-height: 1.6;
margin: 22px 0 0;
max-width: 70ch;
}
+145
View File
@@ -0,0 +1,145 @@
/* ---------- Auth (log in / sign up) ---------- */
.auth-modal { width: min(400px, calc(100vw - 40px)); text-align: center; padding-top: 22px; }
.auth-crest {
font-size: 1.1rem;
color: var(--accent-dim);
letter-spacing: 0.3em;
margin-bottom: 6px;
}
.auth-modal h2 { font-size: 1.35rem; }
.auth-modal .auth-hint { margin-bottom: 16px; line-height: 1.45; }
/* Fields go back to left-aligned — only the header is centred. */
.auth-modal label.field { text-align: left; }
.auth-tabs {
display: flex;
gap: 4px;
padding: 4px;
margin-bottom: 18px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 10px;
}
.auth-tab {
flex: 1;
padding: 7px 0;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
font-family: var(--font-display);
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.auth-tab:hover { color: var(--text); background: rgba(212, 169, 78, 0.06); }
.auth-tab.active {
background: var(--bg-panel);
border-color: var(--accent-dim);
color: var(--accent-bright);
}
.auth-password { display: flex; gap: 8px; }
.auth-password input { flex: 1; min-width: 0; }
.auth-reveal {
flex: none;
padding: 0 12px;
font-size: 0.78rem;
color: var(--text-dim);
}
.auth-reveal:hover { color: var(--accent-bright); }
.auth-help {
display: block;
margin-top: 6px;
font-size: 0.76rem;
color: var(--text-dim);
}
.auth-error {
margin-top: 4px;
padding: 9px 12px;
border: 1px solid rgba(208, 101, 101, 0.45);
border-radius: 8px;
background: rgba(208, 101, 101, 0.09);
color: var(--danger);
font-size: 0.85rem;
text-align: left;
white-space: pre-wrap;
}
/* "Update from scenario" — a wider modal, since it lists a diff. */
.refresh-modal { width: min(620px, calc(100vw - 40px)); }
.refresh-changes {
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
font-size: 0.86rem;
}
.refresh-change + .refresh-change {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; }
.refresh-old { text-decoration: line-through; }
.refresh-new { color: var(--accent-bright); }
/* The button sits above the plot fields it would overwrite. */
.plot-source {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
}
.plot-source .linklike { flex-shrink: 0; }
/* ---------- Debug log (Settings) ---------- */
.debug-entry {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.debug-entry-head {
display: flex;
align-items: baseline;
gap: 12px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.86rem;
background: var(--bg-input);
}
.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
.debug-status {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border-bright);
color: var(--text-dim);
}
.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
.debug-status.error { color: var(--danger); border-color: var(--danger); }
.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
.debug-entry-body pre {
margin: 2px 0 8px;
padding: 8px 10px;
background: var(--bg-input);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, monospace;
font-size: 0.78rem;
max-height: 300px;
overflow-y: auto;
}
+25
View File
@@ -0,0 +1,25 @@
/* ---------- Play screen error/success banner ----------
Persistent (no auto-dismiss) and can carry a Retry action, so it stays
separate from the transient global toasts further down this file. */
.play-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-panel-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--danger);
color: var(--text);
padding: 11px 20px;
border-radius: 10px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
animation: toast-in 0.25s ease both;
}
.play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
+11
View File
@@ -0,0 +1,11 @@
/* ---------- Scrollbars ---------- */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border-bright);
border-radius: 5px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }

Some files were not shown because too many files have changed in this diff Show More