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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+21
-48
@@ -14,8 +14,8 @@ Version 2 carries the tree. It holds three things version 1 could not, and each
|
||||
one is required:
|
||||
|
||||
* The branches, because a forked adventure is two stories and a flat list holds
|
||||
one. A version 1 export interleaved them by `index`, which read as a garbled
|
||||
story rather than as lost data.
|
||||
one. A version 1 export interleaved them by turn number, which read as a
|
||||
garbled story rather than as lost data.
|
||||
* `live`, because a coordinate can hold several attempts at one turn and exactly
|
||||
one of them is the story.
|
||||
* Both after-snapshots, because they are what a branch switch and an undo
|
||||
@@ -26,22 +26,14 @@ one is required:
|
||||
|
||||
A bundle carries what was chosen, never what is derived. The head branch, the
|
||||
fork points, the live flags, and the anchors are decisions somebody made, so
|
||||
they are in the file. `lineage`, the head depth, `index`, and the variant
|
||||
ordinals are all computed from those, and the import recomputes them:
|
||||
they are in the file. `lineage` and the head depth are computed from those, and
|
||||
the import recomputes them:
|
||||
|
||||
* `lineage` is a cache of `parent` plus `fork_depth`. Shipping it as well would
|
||||
put a second source of truth for one fact into a file anyone can hand-edit,
|
||||
and the two could then disagree without any read reporting it.
|
||||
* The head depth is the tip of the head branch, which is a fact about the nodes
|
||||
that arrived with it.
|
||||
* `index` is the legacy column SP8 drops. Its one remaining job is to give the
|
||||
next row a number nothing else holds, which is a fact about the adventure
|
||||
rather than about a path, so `depth` cannot serve: two branches each have a
|
||||
node at depth 4. The import allocates one index per turn instead, which keeps
|
||||
`max_action_index` correct and keeps siblings sharing an index the way SP4
|
||||
leaves them.
|
||||
* `attempts.renumber` maintains the variant ordinals, and it is the only place
|
||||
allowed to.
|
||||
|
||||
Every hand-editable coordinate is therefore checked before a row is written, in
|
||||
`plan`, rather than repaired afterwards. An import that fails partway leaves an
|
||||
@@ -98,8 +90,7 @@ def export(db: Session, adventure: models.Adventure) -> dict:
|
||||
undefer(models.Action.world_state_after),
|
||||
)
|
||||
.order_by(
|
||||
models.Action.branch_id, models.Action.depth,
|
||||
models.Action.variant_index, models.Action.id,
|
||||
models.Action.branch_id, models.Action.depth, models.Action.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
@@ -168,8 +159,7 @@ def _exported_branch(branch: models.Branch, local: dict[int, int]) -> dict:
|
||||
def _exported_node(action: models.Action, local: dict[int, int]) -> dict:
|
||||
node = {
|
||||
"branch": _local(action.branch_id, local),
|
||||
# A pre-tree row's depth is the number `index` already holds.
|
||||
"depth": action.depth if action.depth is not None else action.index,
|
||||
"depth": action.depth,
|
||||
"live": bool(action.live),
|
||||
"type": action.type,
|
||||
"text": action.text,
|
||||
@@ -496,24 +486,17 @@ def _write_nodes(
|
||||
) -> None:
|
||||
"""Writes the nodes, grouped into the turns they are attempts at.
|
||||
|
||||
Two values are allocated here rather than read from the file. `index` is
|
||||
issued once per turn, in the order the bundle lists them, so siblings share
|
||||
one index and no two coordinates do. `max_action_index` needs that to keep
|
||||
issuing numbers nothing holds. Exactly one attempt in each group is also made
|
||||
live, because a file can name none or several, and a turn with no live node
|
||||
disappears from the story.
|
||||
One value is decided here rather than read from the file. Exactly one
|
||||
attempt in each group is made live, because a file can name none or several,
|
||||
and a turn with no live node disappears from the story.
|
||||
"""
|
||||
groups: dict[tuple[int, int], list[models.Action]] = {}
|
||||
indices: dict[tuple[int, int], int] = {}
|
||||
for spec in specs:
|
||||
key = (spec["branch"], spec["depth"])
|
||||
if key not in indices:
|
||||
indices[key] = len(indices)
|
||||
action = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
branch_id=ids[spec["branch"]],
|
||||
depth=spec["depth"],
|
||||
index=indices[key],
|
||||
type=spec["type"],
|
||||
text=spec["text"],
|
||||
reasoning=spec["reasoning"],
|
||||
@@ -531,7 +514,6 @@ def _write_nodes(
|
||||
live = next((row for row in rows if row.live), rows[0])
|
||||
for row in rows:
|
||||
row.live = row is live
|
||||
attempts.renumber(rows)
|
||||
|
||||
|
||||
def _write_memories(
|
||||
@@ -605,31 +587,22 @@ def _write_anchors(
|
||||
cursor.anchor(adventure, ids[branch] if branch is not None else None, depth)
|
||||
|
||||
|
||||
def settle(db: Session, adventure: models.Adventure, story: dict) -> None:
|
||||
"""Aligns the two coordinate systems, once the nodes exist.
|
||||
def settle(adventure: models.Adventure, story: dict) -> None:
|
||||
"""Resolves a version 1 bundle's counts into anchors, once the nodes exist.
|
||||
|
||||
The anchors and the legacy counts describe the same boundary in different
|
||||
terms, and each version of the bundle carries one of them. A version 1 file
|
||||
carries the count, so the anchor is found by counting that far along the
|
||||
story. A version 2 file carries the anchor, so the count is read back from
|
||||
it. The legacy columns are written either way, because a rolled-back build
|
||||
reads them.
|
||||
A version 1 file records how far the memories and the summary have read as
|
||||
a count, so the anchor is found by counting that far along the story. A
|
||||
version 2 file carries the anchor itself, which `_write_anchors` has
|
||||
already stored, so there is nothing left to do.
|
||||
|
||||
The caller runs this after the flush, because both directions need the
|
||||
actions to be queryable.
|
||||
The caller runs this after the flush, because counting needs the actions to
|
||||
be queryable.
|
||||
"""
|
||||
positions = story["positions"]
|
||||
if positions is None:
|
||||
return
|
||||
for cursor in cursors.ALL:
|
||||
if positions is not None:
|
||||
setattr(adventure, f"{cursor.name}_cursor", positions[cursor.name])
|
||||
cursors.anchor_at_position(adventure, cursor, positions[cursor.name])
|
||||
else:
|
||||
setattr(
|
||||
adventure, f"{cursor.name}_cursor",
|
||||
cursors.position_of(adventure, cursor.depth(db, adventure)),
|
||||
)
|
||||
|
||||
|
||||
cursors.anchor_at_position(adventure, cursor, positions[cursor.name])
|
||||
|
||||
|
||||
def materialize(
|
||||
@@ -691,7 +664,7 @@ def materialize(
|
||||
write(db, adventure, story)
|
||||
db.flush()
|
||||
db.expire(adventure, ["actions"])
|
||||
settle(db, adventure, story)
|
||||
settle(adventure, story)
|
||||
return adventure
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(), {}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -196,6 +196,10 @@ class FakeProvider:
|
||||
time.
|
||||
"""
|
||||
|
||||
# The turn engine records the cost of the call, so a stand-in provider has
|
||||
# to carry this attribute even when it never calls anything.
|
||||
last_usage = None
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
@@ -336,6 +340,16 @@ def rich_attempts(rng: random.Random, index: int) -> tuple[list[str], int]:
|
||||
return texts, (0 if index % 18 == 1 else count - 1)
|
||||
|
||||
|
||||
def rich_cursor_positions(action_count: int) -> tuple[int, int]:
|
||||
"""Return the memory and summary mark positions for the rich fixture.
|
||||
|
||||
The marks start out as positions because that is the form a pre-SP3
|
||||
database holds. `build_fixture` turns them into anchors once the actions
|
||||
they point at exist.
|
||||
"""
|
||||
return max(0, action_count - 8), max(0, action_count - 20)
|
||||
|
||||
|
||||
def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None:
|
||||
"""Adds everything `--rich` contributes apart from the actions themselves.
|
||||
|
||||
@@ -356,14 +370,10 @@ def add_rich_extras(db, args, rng: random.Random, user, adventure) -> None:
|
||||
adventure.world_state = rich_world_state(schema, args.actions)
|
||||
adventure.script_state = rich_script_state(args.actions)
|
||||
# The post-turn passes do nothing unless summarization is on and the marks
|
||||
# are past the start. They are written here as positions and translated into
|
||||
# anchors once the actions exist. See `build_fixture`. A database being
|
||||
# migrated to SP3 still holds positions, so the fixture carries both forms
|
||||
# and the two have to agree.
|
||||
# are past the start. `build_fixture` translates the marks into anchors
|
||||
# once the actions exist. See `rich_cursor_positions`.
|
||||
adventure.auto_summarize = True
|
||||
adventure.story_summary = RICH_SUMMARY
|
||||
adventure.memory_cursor = max(0, args.actions - 8)
|
||||
adventure.summary_cursor = max(0, args.actions - 20)
|
||||
|
||||
for kind, name, keys, entry in RICH_CARDS:
|
||||
db.add(models.StoryCard(
|
||||
@@ -392,7 +402,7 @@ def add_second_adventure(db, rng: random.Random, user) -> int:
|
||||
db.flush()
|
||||
for i in range(6):
|
||||
action = models.Action(
|
||||
adventure_id=other.id, index=i,
|
||||
adventure_id=other.id,
|
||||
type="ai" if i % 2 else "do",
|
||||
text=f"[second adventure] turn {i}. {prose(rng, 200)}",
|
||||
state_after=rich_script_state(i),
|
||||
@@ -500,10 +510,17 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
NARRATION if is_ai else PLAYER_INPUT
|
||||
]
|
||||
live = 0
|
||||
# The attempts at one turn share that turn's coordinate. Only the
|
||||
# first one moves the head, so the rest copy the placement of the
|
||||
# first instead of going through `tree.place_action`, which would
|
||||
# read each of them as a new turn. This mirrors
|
||||
# `attempts.add_attempt`, which the fixture cannot call directly
|
||||
# because it makes the newest attempt live and the fixture needs a
|
||||
# live attempt that is often not the newest.
|
||||
first_attempt = None
|
||||
for n, body in enumerate(texts):
|
||||
action = models.Action(
|
||||
adventure_id=adventure.id,
|
||||
index=i,
|
||||
type="ai" if is_ai else "do",
|
||||
text=body,
|
||||
# The turn's assembled prompt is stored once, on the
|
||||
@@ -521,8 +538,6 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
# Every third AI turn was retried once, so a turn is
|
||||
# sometimes several rows sharing one coordinate.
|
||||
live=(n == live),
|
||||
variant_index=n,
|
||||
variant_count=len(texts) if len(texts) > 1 else 0,
|
||||
# Under `--rich` the value increases with the turn, so an
|
||||
# incorrect rollback shows a wrong number rather than no
|
||||
# value. Otherwise `tree.stamp_outcome` writes the
|
||||
@@ -538,7 +553,13 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
# sees it. The fixture has to stamp its own nodes, or it would
|
||||
# be the one database in the project whose actions have no
|
||||
# branch.
|
||||
tree.place_action(db, adventure, action)
|
||||
if first_attempt is None:
|
||||
tree.place_action(db, adventure, action)
|
||||
first_attempt = action
|
||||
else:
|
||||
action.branch_id = first_attempt.branch_id
|
||||
action.depth = first_attempt.depth
|
||||
action.parent_id = first_attempt.parent_id
|
||||
db.add(action)
|
||||
|
||||
for i in range(args.memories):
|
||||
@@ -569,8 +590,9 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
# fixture stamped LATEST never runs a migration, so skipping this
|
||||
# would leave the one database whose marks are only positions.
|
||||
db.flush()
|
||||
cursors.anchor_at_position(adventure, cursors.MEMORY, adventure.memory_cursor)
|
||||
cursors.anchor_at_position(adventure, cursors.SUMMARY, adventure.summary_cursor)
|
||||
memory_position, summary_position = rich_cursor_positions(args.actions)
|
||||
cursors.anchor_at_position(adventure, cursors.MEMORY, memory_position)
|
||||
cursors.anchor_at_position(adventure, cursors.SUMMARY, summary_position)
|
||||
add_second_adventure(db, rng, user)
|
||||
_assert_live_variant_invariant(db, adventure.id)
|
||||
|
||||
@@ -582,7 +604,7 @@ def build_fixture(args, rng: random.Random) -> tuple[int, int]:
|
||||
|
||||
def install_fakes(user_id: int, rng: random.Random) -> None:
|
||||
embeddings = FakeEmbeddings(rng)
|
||||
adventures.OpenAICompatibleProvider = FakeProvider
|
||||
adventures.turns.OpenAICompatibleProvider = FakeProvider
|
||||
memorybank.embedding_provider = lambda settings: embeddings
|
||||
memorybank.summary_provider = lambda settings: FakeProvider()
|
||||
auth.resolve_provider_config = lambda s, **k: auth.ProviderConfig(
|
||||
@@ -812,7 +834,7 @@ def main(argv=None) -> int:
|
||||
print(f"{'total across all shapes':<44}{kb(sum(s.total.fetched for s in meter.scopes)):>16}")
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
adventures._active_turns.clear()
|
||||
adventures.turns._active_turns.clear()
|
||||
|
||||
# After the shapes, not before: make_bootable() writes, and the meter is
|
||||
# still attached until the report above is rendered.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user