Migrations 66 to 73 drop `actions.index`, `variants`, `variant_index`, `variant_count`, `state_before`, and `world_state_before`, plus `adventures.memory_cursor` and `summary_cursor`. `index` is a keyword in SQLite, so migration 71 quotes it. Nothing outside the migrations read these. `models.py`, `schemas.py`, and `ACTION_LIST_COLUMNS` lose the same eight fields, `Adventure.actions` orders by `id`, and `attempts.renumber`, `context.history.max_action_index`, and `nodes.next_index` are deleted. Two changes keep the migration replayable on a `create_all` database: - `_split_variants_into_siblings` wrote through the live ORM table, so it stopped compiling once migration 66 removed five of its columns. It now writes through `_ACTIONS_AT_60`, a frozen `Table` with its own `MetaData`. - Five data passes read columns these migrations drop. Each now calls `_has_columns` and returns early when the columns are absent. `bootstrap` takes a `through` version so a migration test can stop at the schema it asserts on. 555 tests pass, up from 549. Eight of the new cases assert each column is gone after a real schema-45 database migrates all the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0198qDK3gmgSo7EtQ4GTPqqK
140 lines
5.4 KiB
Python
140 lines
5.4 KiB
Python
"""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)
|