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
300 lines
14 KiB
Python
300 lines
14 KiB
Python
"""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, head, memorybank, models, narrative, schemas, tree
|
|
from ...context import cursors, lineage
|
|
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,
|
|
# Every page carries them, not just the newest window: the client reads
|
|
# the flags off whichever page arrived last, and scrolling up must not
|
|
# be able to grey out a Redo that is still available (M3).
|
|
can_undo=head.can_undo(db, adventure),
|
|
can_redo=head.can_redo(db, adventure),
|
|
)
|
|
|
|
|
|
@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")
|
|
# A narrator turn the story is currently telling is corrected through the
|
|
# §§14-15 path, which forks. A take the story is *not* telling is a
|
|
# different thing: it has no continuation of its own — keeping one is what
|
|
# forking is for — so correcting its words cannot contradict anything, and
|
|
# it stays the plain in-place edit it has always been.
|
|
if action.type == "ai" and lineage.path_of(db, adventure).contains(action):
|
|
return _edit_narration(db, adventure, action, payload.text)
|
|
# A player's own words. Editing one rewrites this row and re-evaluates
|
|
# nothing after it, which is what makes it a correction rather than a new
|
|
# continuation. That is safe while everything descending from the row is on
|
|
# screen, and unsafe the moment something descends from it that is not — an
|
|
# undone future, or a line a divergence left behind. The reader cannot see
|
|
# that story, so they cannot see what their correction has just contradicted
|
|
# (M3, `STORY-BRANCH-SEMANTICS.md` §13).
|
|
if head.displaced_history_under(db, adventure, action):
|
|
raise HTTPException(
|
|
400,
|
|
"This turn has a later story that is not on screen — undone, or "
|
|
"left behind by a new continuation. Editing it here would change "
|
|
"the words that story was written from. Redo to bring it back "
|
|
"first, or play the turn again to start a new line from here.",
|
|
)
|
|
turns.acquire_turn_lock(adventure_id)
|
|
try:
|
|
action.text = payload.text
|
|
db.commit()
|
|
finally:
|
|
turns._active_turns.discard(adventure_id)
|
|
db.refresh(action)
|
|
return action
|
|
|
|
|
|
def _edit_narration(
|
|
db: Session, adventure: models.Adventure, action: models.Action, text: str
|
|
) -> models.Action:
|
|
"""Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15.
|
|
|
|
A narrator edit is not a rewrite of a row. It is a continuation written from
|
|
the same place the original was written from, using the reader's words
|
|
instead of the model's. §15 lists what that has to mean, and each clause
|
|
maps to a step below:
|
|
|
|
1. return to the state immediately before the edited narration — the
|
|
preceding node's snapshot, one row read;
|
|
2. treat the edited text as the accepted narrator output — it is stored
|
|
verbatim, with only the protocol block stripped, and no model is called;
|
|
3. re-evaluate the state that output implies — the normal M5 extraction and
|
|
validation path, run against that starting state;
|
|
4. create a new active continuation — a new node, and the head on it;
|
|
5. retain the original narration and its future as disposable history —
|
|
nothing on the old line is written to at all.
|
|
|
|
The M5 review found the previous implementation failing 3-5 together: it
|
|
edited the row in place and rewound the campaign's live state to that
|
|
position while the head stayed at the tip, so the reader saw a full
|
|
transcript over a state document describing an earlier moment, and the
|
|
snapshots below the edit still described prose that no longer existed
|
|
(Finding 1). Forking is what fixes it, and no new machinery is needed to
|
|
fork — this function is the ⑂ path from `takes.py` with the reader's text in
|
|
place of a generated one.
|
|
|
|
Two shapes, chosen by whether anything was written after the turn:
|
|
|
|
at the tip the attempts of the turn are still leaves, so the
|
|
correction joins them as a sibling take and the
|
|
original is retained beside it in the pager;
|
|
|
|
anything below the story after the turn was written as a
|
|
continuation of the words that are there now, so it
|
|
keeps them: the correction leaves the path just
|
|
before the turn and the old line keeps its node, its
|
|
future, and its live flag.
|
|
|
|
The §14A refusal is gone from this path, and this is what replaces it. It
|
|
refused an in-place edit under an off-screen future because the edit would
|
|
silently change the words that story was written from. Nothing is changed
|
|
now — the off-screen future keeps the exact narration it descends from — so
|
|
the case that had to be refused is simply handled.
|
|
"""
|
|
if action.depth is None:
|
|
raise HTTPException(400, "That turn is not on the story you are reading.")
|
|
|
|
turns.acquire_turn_lock(adventure.id)
|
|
try:
|
|
# §15.2. The reader's words are the narration; a block they pasted in is
|
|
# protocol and is stripped before storage, exactly as a model's is.
|
|
prose, parsed, raw_block = narrative.extract.split(text)
|
|
# §15.1. Not the campaign's current state — the state this turn was
|
|
# played from. One row read, not a replay (ADR 012).
|
|
before = attempts.preceding(db, adventure, action)
|
|
starting_state = (
|
|
narrative.model.normalize(before.narrative_state_after)
|
|
if before is not None and isinstance(before.narrative_state_after, dict)
|
|
else narrative.model.empty()
|
|
)
|
|
|
|
corrected = models.Action(
|
|
adventure_id=adventure.id,
|
|
type="ai",
|
|
text=prose,
|
|
# No model was called, so there is no prompt to show for this node.
|
|
# In the sibling case the turn's assembled prompt moves to whichever
|
|
# attempt is live, which is what the Insights viewer reads; in the
|
|
# forked case the original keeps it, because the original is still
|
|
# the live node of its own line.
|
|
context_snapshot=None,
|
|
)
|
|
|
|
tip = db_tip(db, adventure)
|
|
# A turn the head rests on is not a leaf while a retained future
|
|
# descends from it, and `db_tip` reads the capped path and cannot see
|
|
# that future. Ask the head module as well (M3).
|
|
at_the_tip = (
|
|
tip is not None
|
|
and tip.id == action.id
|
|
and not head.behind_tip(db, adventure)
|
|
)
|
|
|
|
if at_the_tip:
|
|
# §15.4-5 as a take. The original stays at this coordinate as a
|
|
# prior attempt, reachable through the pager, and the correction
|
|
# becomes the one the story tells.
|
|
attempts.hand_over_the_prompt(action, corrected)
|
|
attempts.add_attempt(db, adventure, action, corrected)
|
|
db.add(corrected)
|
|
# The words at this coordinate changed, so anything derived from
|
|
# them no longer describes the story.
|
|
memorybank.forget_node(db, adventure, action)
|
|
cursors.rewind_all(adventure, action.branch_id, action.depth - 1)
|
|
db.flush()
|
|
else:
|
|
# §15.4-5 as a branch. Nothing on the departed line is written to:
|
|
# the original node keeps its text, its live flag and every turn
|
|
# that was played after it.
|
|
departed = lineage.branch_of(db, adventure)
|
|
tree.branch_at(db, adventure, action.depth - 1)
|
|
if departed is not None:
|
|
head.mark_superseded(departed, action.depth - 1)
|
|
tree.place_action(db, adventure, corrected)
|
|
db.add(corrected)
|
|
db.flush()
|
|
|
|
# §15.3. The same validation path a generated turn takes, so a hand
|
|
# -typed event is no more trusted than a model's: the allowlist, the
|
|
# schema, the references and the canon all still apply.
|
|
review = narrative.validate.review(
|
|
parsed if parsed is not None else {"events": []},
|
|
starting_state,
|
|
narrative.store.canon_of(adventure),
|
|
)
|
|
# `record` writes the events and the provenance. Its returned document
|
|
# applies them to the campaign's *current* state, which is not what an
|
|
# edit derives from, so the document this node leaves behind is computed
|
|
# from the turn's own starting point below.
|
|
narrative.store.record(
|
|
db, adventure,
|
|
review=review,
|
|
raw_block=raw_block,
|
|
parsed=parsed,
|
|
action=corrected,
|
|
branch_id=corrected.branch_id,
|
|
depth=corrected.depth,
|
|
source="narrator_edit",
|
|
)
|
|
new_state = narrative.apply.apply_events(
|
|
starting_state, review.accepted,
|
|
branch_id=corrected.branch_id, depth=corrected.depth,
|
|
source="narrator_edit",
|
|
)
|
|
corrected.state_changes = {
|
|
"accepted": review.accepted,
|
|
"rejected": [r.as_dict() for r in review.rejected],
|
|
"summary": narrative.apply.diff(starting_state, new_state),
|
|
}
|
|
# The head is on the corrected node, so the campaign's live state is
|
|
# what that node leaves behind, and the node's own snapshot is the same
|
|
# document. That equality is the invariant the review found broken:
|
|
# visible position == head == authoritative state.
|
|
narrative.store.set_current(adventure, new_state)
|
|
attempts.snapshot_outcome(adventure, corrected)
|
|
adventure.updated_at = models.utcnow()
|
|
db.commit()
|
|
finally:
|
|
turns._active_turns.discard(adventure.id)
|
|
db.refresh(corrected)
|
|
return corrected
|
|
|
|
|
|
@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.
|
|
was_at = adventure.head_depth
|
|
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)
|
|
# `refresh_head` recomputes the tip, which since M3 is not the head. A
|
|
# story sitting behind its retained tip must not be dragged forward to
|
|
# the tip by an unrelated delete — that would silently Redo it. Keep the
|
|
# head where the reader left it, unless the delete took the ground out
|
|
# from under it, in which case the new tip is as far as it can stay.
|
|
if was_at < adventure.head_depth:
|
|
adventure.head_depth = was_at
|
|
# 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)
|