Group a turn's takes by their parent, not by where they sit
SP7 shipped a tree nobody could use. Driving it by hand found three things, and the schema is what the third one needs. A take forked onto its own branch leaves the coordinate its siblings are still at. `attempts.group` filtered on (branch_id, depth), so that take read as the only one of its turn -- 1/1 where the player is owed 1/3, with the other takes unreachable from the line they were taken on. Nesting has the same shape from the other side: takes under C1 and takes under C2 share a depth, and only the parent says a pager under C2 reads 2/2 rather than counting C1's three too. So `actions.parent_id`, and `group` keys on it. The alternative -- pointing a branch's fork at a node instead of a depth, so a promoted take never moves -- was rejected: `lineage` exists so a read is an OR-clause per branch rather than a walk up parent pointers, and moving the fork point changes path resolution itself, which drags in cursors, memory depths and both bundle formats. This column is read to group takes and for nothing else. One indexed lookup, never a walk, and no read of the story changes. Two writers had to learn it. `add_attempt` places a sibling by hand rather than through `place_action`, and copies the parent, because a take belongs to the turn it is a take *of*. `place_action` resolves it from the path for a genuinely new node, which is the honest default. And one reader had to be kept out of it. `delete_turn` meant "every attempt at this coordinate"; the group spans branches now, so undoing a turn would have deleted a take that another branch is telling. `attempts.on_branch` scopes it back. 409 tests, unchanged from main and green -- a linear story has one take per parent, so none of this is reachable until a second one exists. The migration adds one column, backfills the linear case, and leaves NULL where it cannot honestly place a row; `group` falls back to the coordinate there, which is the rule those rows were written under. One `VACUUM FULL actions;` owed after deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Dvvqn9ZDR4ixeFPHNbww7
This commit is contained in:
committed by
Parth
co-authored by
Claude Opus 5
parent
e692780f08
commit
6fa213f6db
+52
-4
@@ -50,23 +50,66 @@ ATTEMPT_KEYS = ("world_state", "script", "raw_output")
|
||||
def group(db: Session, action: models.Action) -> list[models.Action]:
|
||||
"""Every attempt at `action`'s turn, oldest first.
|
||||
|
||||
A node with no branch is a pre-tree row that no path contains; it is its
|
||||
own only attempt, and saying so here saves every caller a special case.
|
||||
Keyed on the **parent**, not on the coordinate (SP9). The two agree right up
|
||||
until a take is forked onto its own branch: it keeps its parent but leaves
|
||||
the (branch, depth) its siblings are still at, so a coordinate would report
|
||||
it as the only take of its turn — `1/1` where the player is owed `1/3`.
|
||||
|
||||
The parent also gets the nesting right without being asked. Takes under C1
|
||||
and takes under C2 share a depth and, until one of them forks, a branch;
|
||||
only the parent separates them, which is what makes a pager under C2 read
|
||||
`2/2` instead of counting C1's three as well.
|
||||
|
||||
Two fallbacks, both meaning "this row predates the key being asked about":
|
||||
a node with no branch is a pre-tree row no path contains, and a node with no
|
||||
parent is a pre-SP9 row the backfill could not place. Both are their own
|
||||
only attempt under the rule they were written with.
|
||||
"""
|
||||
if action.branch_id is None or action.depth is None:
|
||||
return [action]
|
||||
if action.parent_id is None:
|
||||
# Pre-SP9, and the coordinate is the key those rows were written under.
|
||||
# Root nodes land here too and are genuinely alone: nothing is a take of
|
||||
# the opening of a story.
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == action.adventure_id,
|
||||
models.Action.branch_id == action.branch_id,
|
||||
models.Action.depth == action.depth,
|
||||
models.Action.parent_id.is_(None),
|
||||
)
|
||||
.order_by(models.Action.variant_index, models.Action.id)
|
||||
.all()
|
||||
)
|
||||
return (
|
||||
db.query(models.Action)
|
||||
.filter(
|
||||
models.Action.adventure_id == action.adventure_id,
|
||||
models.Action.branch_id == action.branch_id,
|
||||
models.Action.depth == action.depth,
|
||||
models.Action.parent_id == action.parent_id,
|
||||
)
|
||||
.order_by(models.Action.variant_index, models.Action.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def on_branch(rows: list[models.Action], node: models.Action) -> list[models.Action]:
|
||||
"""The takes in `rows` that sit on `node`'s own branch.
|
||||
|
||||
`group` answers "which takes are of this turn", and since SP9 that spans
|
||||
branches — a take forked onto its own line is still a take of the same turn,
|
||||
which is the whole point of keying on the parent.
|
||||
|
||||
Deleting is the one caller that must not follow it there. A take on another
|
||||
branch is reachable through that branch and belongs to the story somebody is
|
||||
telling on it; removing it because a turn was undone over here would delete
|
||||
a line nobody asked about. Same parent *and* same branch is the coordinate,
|
||||
which is what "every attempt at this turn" meant before a fork could move
|
||||
one out of it.
|
||||
"""
|
||||
return [row for row in rows if row.branch_id == node.branch_id]
|
||||
|
||||
|
||||
def live_in(rows: list[models.Action]) -> models.Action | None:
|
||||
for row in rows:
|
||||
if row.live:
|
||||
@@ -149,6 +192,11 @@ def add_attempt(
|
||||
"""
|
||||
replacement.branch_id = previous.branch_id
|
||||
replacement.depth = previous.depth
|
||||
# Copied, never resolved from the path: a take belongs to the turn it is a
|
||||
# take *of*, and that is what `group` keys on. Resolving it here would ask
|
||||
# what is live one depth back, which is the same node right now and stops
|
||||
# being once this turn is forked away from.
|
||||
replacement.parent_id = previous.parent_id
|
||||
replacement.live = True
|
||||
# The end of the group, not one past `previous` — which is only the same
|
||||
# thing when `previous` is the newest take. Switch a three-take turn back to
|
||||
|
||||
Reference in New Issue
Block a user