Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.
This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:
visible active transcript position == stored head == authoritative state
Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)
A narrator edit no longer rewrites a row. It returns to the state before the
turn, takes the reader's exact text as the accepted narration, re-derives the
state that text implies, and becomes a new active continuation — while the
original narration keeps its words, its live flag and its whole future as
retained history. At the tip the correction is another take; with story below
it, it forks. No new history machinery: this is the existing fork/take/head
path with the reader's text in place of a generated reply. The §14A refusal
is therefore gone for narrator turns, and remains only for player input.
Pre-M5 positions
Migration 88 backfills the empty narrative document onto every action written
before M5, and a missing snapshot now restores the empty document instead of
leaving the previous position's state standing. Restoring to an old Save
Point no longer leaves a later position's entities and facts on screen.
Narrator context
Replayed history carries prose only; the machine-readable block is no longer
reconstructed into past turns, where it contradicted the authoritative state
in the same prompt. A fact withdrawn by a manual correction is now named as
no longer true, with the reader's reason, rather than silently dropped.
Also
- state_changes joins the action-list bulk read, removing one query per row.
- Extraction takes only the application's own protocol payload: an ordinary
```json or ```python block in a story survives, and a mangled proposal
still does not reach the reader.
Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
178 lines
7.2 KiB
Python
178 lines
7.2 KiB
Python
"""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 head, 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,
|
|
# M5: `world_delta`'s counterpart, and listed for exactly the reason stated
|
|
# above it. `ActionOut.state_summary` reads it for every row on the page, so
|
|
# leaving it out of the bulk read cost one lazy load per action — 51 rows
|
|
# bought 53 queries (M5 review, Finding 2). It holds one turn's accepted
|
|
# events and its summary lines, the same order of size as `world_delta`, not
|
|
# the deferred snapshot.
|
|
models.Action.state_changes,
|
|
# 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,
|
|
can_undo=head.can_undo(db, adventure),
|
|
can_redo=head.can_redo(db, adventure),
|
|
)
|