Let a backup carry a story that went two ways

A bundle had one list and a forked adventure has two stories, so export was
emitting every branch's turns interleaved by index — a mangled story rather
than lost data, and unreachable only because forking has no UI yet.
`ai-dnd-adventure-v2` carries the branches, the depth each one left its
parent at, which attempt at every turn is the story, and what each node
left behind. That last one is not decoration: the after-snapshots are what
a branch switch puts back, and a bundle without them imports a tree nobody
can switch inside.

`app/bundle.py` owns both formats and nothing else knows either. The v1
reader stays — those files are already on people's disks — and it is now
the only place a `variants` array exists anywhere.

The rule the module is built on is that a bundle carries what was chosen
and never what is derived. The head branch, the fork points, the live flags
and the anchors are decisions somebody made. The lineage, the head depth,
the legacy `index` and the variant ordinals are computed from those and are
rebuilt on the way in, because a bundle is a text file anybody can edit and
a derived field shipped beside its source is a chance for the file to
disagree with itself where no read would report it.

`index` is the one that stops being academic here. It agreed with `depth`
until SP5, and this is the first writer that has to fill it for a forked
story, where two branches both hold a node at depth 4. It is allocated one
per turn instead: siblings share it, no two coordinates do.

Everything a hand-edited file can get wrong about the shape of a tree is a
400 raised before the adventure row exists, because a half-applied import
is exactly the failure this phase exists to end — a story that goes quiet.
A file wrong about which attempt is live is corrected rather than refused;
that is an invariant of the database, not of the format.

Measured on the 600-action fixture: 587 kB to 911 kB, and all of the
increase is the outcomes at 489 B a node — the coordinates themselves save
57.5 B a node against the old turn-and-variants shape. Twenty forks add
660 B. 4.3% of the import body cap.

381 tests green, 16 of them new in test_bundle_v2.py. No migration, no
vacuum owed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015H5qiyiR7gtFQaoDphHZ3g
This commit is contained in:
parththakkar106
2026-08-18 19:14:07 +05:30
committed by Parth
co-authored by Claude Opus 5
parent d051501517
commit a7bf47a35e
11 changed files with 1532 additions and 215 deletions
+12 -5
View File
@@ -380,18 +380,25 @@ def test_attempts_module_agrees_with_the_endpoint(client):
db.close()
def test_export_folds_the_group_back_into_one_v1_entry(client):
def test_export_carries_every_attempt_as_its_own_node(client):
"""SP6 changed the answer here, and the reason is the whole of that subphase.
A v1 bundle had one entry per turn and folded the group back into a
`variants` array, because the format had nowhere else to put a second take.
A v2 bundle has coordinates, so an attempt is a node in the file exactly as
it is a node in the database, and `live` says which one is the story.
"""
ScriptedProvider.replies = ["One.", "Two."]
_play(client)
_retry(client)
bundle = client.get(f"/api/adventures/{client.adv_id}/export").json()
ai = [a for a in bundle["actions"] if a["type"] == "ai"]
assert len(ai) == 1, "a v1 bundle carries one entry per turn, not per attempt"
assert [v["text"] for v in ai[0]["variants"]] == ["One.", "Two."]
assert ai[0]["variantIndex"] == 1
assert [(a["text"], a["live"]) for a in ai] == [("One.", False), ("Two.", True)]
assert len({(a["branch"], a["depth"]) for a in ai}) == 1, "one turn, two takes"
assert "variants" not in ai[0], "nothing writes the repeating group any more"
# ...and importing it splits it back out into the rows it describes.
# ...and importing it puts the group back exactly as it stood.
imported = client.post("/api/adventures/import", json=bundle).json()["id"]
rows = _rows(imported)
ai_rows = [a for a in rows if a.type == "ai"]