Files
interactive-story/backend/tools/shots_fixture.py
T
parththakkar106andClaude Opus 5 40d2555f84 Say what the app is now, everywhere it is published
The README, the project page and the engineering guide all describe a linear
story. The tree shipped two days ago. Every published surface is a phase
behind, and the guide is not merely behind — it is wrong in a way that costs a
reader time.

Its 2.2 was "Two coordinate systems, and the bug class they create", and it
explained the codebase through position_of_index, note_action_removed and
settled_story_actions. All three were deleted in SP3. 2.3 explained retry
through Action.variants and state_before. Somebody reading either would go
looking for machinery that is not there, which is worse than a gap.

So 2.2 is now "The story is a tree", written at the depth 1.2 and 1.3 are
written at: the seven bugs that turned out to be one bug, the lineage clause
and the two properties that make fork count free, why takes group by parent_id
rather than by coordinate, cursors becoming anchors, and a closing list of what
the design is honest about. 2.3 is rewritten around state_after and takes, and
1.1 and 1.5 follow, because the pipeline no longer snapshots before the call
and the memory bank no longer holds an action back.

The numbers were simply old: 151 tests where there are 440, 37 migrations where
there are 64, twelve phases where there are fourteen. They appear in four
places across the README, the project page's stat tiles and the guide's results
table. The measured branch cost — 103 B, and 1.007x the page load of the same
story flat — is added beside the egress and turn-cost figures it belongs with,
since it is the number that answers "what does branching cost me".

Three screenshots, on a new tools/shots_fixture.py: the Bandit Camp demo driven
through eight written turns with written deltas, three discarded takes forked
onto branches of their own, one off a branch so the map has to nest. Same
reason tree_fixture.py is committed — the shots have to be reproducible and the
frontend still has no test runner. play-world-state.jpg is reshot because it
predates the entire tree UI; the map and the branches panel are new.

Note for next time: docs/guide.html is hand-written, not generated from the
Markdown, so every guide edit is two edits in two vocabularies. Both files were
checked for tag balance and both pages rendered locally before this landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfMCsN1KBLsTqMkj5hSgrY
2026-08-20 04:19:53 +05:30

313 lines
13 KiB
Python

"""The story the README screenshots are taken of.
`tools.tree_fixture` builds a tree with a shape worth drawing, on a scenario
with one stat, because a map only cares about the shape. A screenshot cares
about everything else: the world-state rail wants a scenario with bands, flags,
milestones and a named cast, and the story wants prose somebody would read.
So this drives the seeded **Bandit Camp** demo — the same scenario the older
images were shot on, so the set stays one product — through eight turns of
written prose and written deltas, and forks three discarded takes onto branches
of their own — one of them off a branch, so the map has to nest. It leaves the reader on the first telling, on a
turn that has a second take, so one screen shows the world state, the take
pager and the branch rail at once.
cd backend
.venv/Scripts/python.exe -m tools.shots_fixture /tmp/shots.db
AIDND_DB_PATH=/tmp/shots.db .venv/Scripts/python.exe \
-m uvicorn app.main:app --port 8010
Then open http://127.0.0.1:8010/play/1. The SPA is served out of
`frontend/dist`, so run `npm run build` first if it is stale.
No LLM is called: the provider is scripted, and every delta below is the one
the referee is being shown clamping.
"""
import os
import sys
sys.path.insert(0, os.getcwd())
OUT = sys.argv[1] if len(sys.argv) > 1 else "shotsfixture.db"
if os.path.exists(OUT):
os.remove(OUT)
os.environ["AIDND_DB_PATH"] = OUT
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
os.environ.pop("AIDND_MULTI_USER", None)
import json # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import text # noqa: E402
from app import auth, limits, models # noqa: E402
from app.database import SessionLocal # noqa: E402
from app.database import engine # noqa: E402
from app.main import app # noqa: E402
from app.migrations import LATEST_VERSION # noqa: E402
from app.routers import adventures # noqa: E402
SCENARIO_TITLE = "[Demo] The Bandit Camp (RPG world state)"
# (what the player typed, what the model said, what it claimed changed).
#
# The deltas are chosen so the rail has something to show at every level: a
# counter that only climbs, two flags that flip both ways, a text stat that is
# rewritten rather than added to, a milestone that sticks, and one delta the
# per-turn cap has to cut down (-60 hp against a max_delta_per_turn of 35).
TURNS = [
(
"Signal Gwen to circle left, and keep my eyes on the fire.",
"Gwen goes without a sound, low along the ditch, and the fire keeps its "
"own counsel. Two bedrolls, not three. Whoever owns the third is awake "
"somewhere in the dark, and that is the one worth knowing about.",
{"npc.gwen.trust": 8},
),
(
"Cut the horse line so they scatter through the camp.",
"The rope parts under the knife and eleven hands of frightened horse go "
"through the fire pit sideways. Somebody shouts your name for a thing "
"you have not done yet. The camp is awake now, all of it at once.",
{"flags.alarm_raised": True, "flags.player_hidden": False,
"npc.bandit_leader.aggression": 20},
),
(
"Get behind the wagon before anyone finds the ditch.",
"You make the wagon's shadow with a spear-length to spare and put your "
"back against a wheel that has not turned in a season. Gwen's arrow "
"answers from the treeline — once, and then not again, which is her way "
"of saying she is fine and busy.",
{"flags.player_hidden": True, "player.mana": -6},
),
(
"Wait for the leader to pass, then take him from behind.",
"You wait a beat too long. He turns for a noise Gwen is making and walks "
"onto the knife himself, and it is the least honourable thing you have "
"ever done well. He is dead before the surprise finishes crossing his "
"face.",
{"npc.bandit_leader.health": -120, "npc.gwen.trust": -10,
"milestones.camp_cleared": True},
),
(
"Fall back to Gwen and let her finish it.",
"You give ground the way she taught you, keeping him square to the "
"treeline, and the arrow takes him through the shoulder blade at "
"eleven paces. He sits down in the ash of his own fire and does not "
"get up. The camp goes quiet in pieces.",
{"npc.bandit_leader.health": -70, "npc.gwen.trust": 15,
"milestones.camp_cleared": True, "flags.alarm_raised": False},
),
(
"Search the wagon for the strongbox.",
"It is under the false floor, of course it is, and it is heavier than "
"the caravan master implied. The lock has been worked at by somebody "
"patient and unsuccessful. You take the dead man's coat as well; yours "
"is one long tear from armpit to hip.",
{"milestones.strongbox_found": True,
"player.outfit": "a bandit's tarred coat over torn leather, strongbox under one arm"},
),
(
"Bind my side before we move.",
"Gwen does it, badly and fast, with a strip off the same coat. \"You "
"went in alone,\" she says, in the voice she uses when she has decided "
"not to have the argument. The bleeding stops. The rest of it does not.",
{"player.hp": 18, "npc.gwen.trust": -5},
),
(
"Take the north road while it's still dark.",
"You are two miles out when the sky starts telling on you, and the camp "
"behind is only smoke by then. Gwen walks ahead where she can see the "
"road, which means she is still angry, which means she is still here.",
{"world.day": 1, "milestones.gwen_survives": True, "player.mana": 9},
),
]
# The takes the story did not keep. Each is retried at the turn of the same
# index below, and then forked onto a branch of its own — a discarded take is
# the only thing a fork can be made of.
RETAKES = {
3: (
"He passes close enough that you can smell the tar on his coat, and the "
"knife goes in under the arm where the plate is not. He is not a man who "
"goes down for it. He turns inside the blow and opens your side with the "
"back-swing, and the two of you come apart bleeding.",
{"player.hp": -60, "npc.bandit_leader.health": -45,
"npc.bandit_leader.aggression": 25},
),
6: (
"You tell her you will do it yourself and she lets you, which is worse "
"than the argument. The knot is bad. You will feel it in the morning, "
"and she will watch you feel it and say nothing at all.",
{"player.hp": 9, "npc.gwen.trust": -18},
),
}
class ScriptedProvider:
"""Serves whatever the driver loaded, so a retry can differ from the take
it replaces — which is the whole thing being photographed."""
next_reply = ("", {})
def __init__(self, *a, **k):
pass
async def generate(self, parts, *, temperature, max_tokens):
prose, delta = ScriptedProvider.next_reply
block = f"\n```state\n{json.dumps(delta)}\n```" if delta else ""
yield ("text", prose + block)
adventures.OpenAICompatibleProvider = ScriptedProvider
auth.resolve_provider_config = lambda s: auth.ProviderConfig(
"http://fake", "k", "test-model", False)
limits.rate_limit = lambda *a, **k: None
limits.check_row_cap = lambda *a, **k: None
# `bootstrap()` runs at import and seeds the public demo scenarios, so the
# Bandit Camp is already here — the point of shooting on it is that it is the
# scenario a visitor actually meets.
client = TestClient(app)
db = SessionLocal()
user = models.User(is_guest=False, email=None)
db.add(user)
db.flush()
db.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model"))
scenario = db.query(models.Scenario).filter(
models.Scenario.title == SCENARIO_TITLE).one()
db.commit()
scenario_id = scenario.id
db.close()
r = client.post("/api/adventures", json={
"scenario_id": scenario_id,
"title": "The Bandit Camp",
"placeholders": {},
})
assert r.status_code == 201, r.text
adv_id = r.json()["id"]
base = f"/api/adventures/{adv_id}"
def play(i):
"""One turn of the written story."""
player, prose, delta = TURNS[i]
ScriptedProvider.next_reply = (prose, delta)
r = client.post(f"{base}/actions", json={"type": "do", "text": player})
assert r.status_code == 200, r.text
def retake(i):
"""Retry turn `i` with the other reply, and hand back the take it left."""
ScriptedProvider.next_reply = RETAKES[i]
return retry_last()
def retry_last():
"""Retry whatever is at the tip, with whatever reply is loaded, and hand
back the take the story just walked away from — the only thing a fork can
be made of."""
before = live_ai_ids()
r = client.post(f"{base}/retry")
assert r.status_code == 200, r.text
dead = [a for a in all_ai() if not a.live and a.id in before]
assert dead, "retry left no discarded take"
return dead[-1].id
def all_ai():
db = SessionLocal()
out = db.query(models.Action).filter(
models.Action.adventure_id == adv_id,
models.Action.type == "ai").order_by(models.Action.id).all()
db.expunge_all()
db.close()
return out
def live_ai_ids():
return [a.id for a in all_ai() if a.live]
def branches():
return client.get(f"{base}/branches").json()
def name(branch_id, label):
r = client.patch(f"{base}/branches/{branch_id}", json={"name": label})
assert r.status_code == 200, r.text
# ---- the first telling, with the knife-in-the-back turn retried ----
for i in range(4):
play(i)
knife = retake(3)
for i in range(4, 7):
play(i)
binding = retake(6)
play(7)
root = branches()[0]["id"]
name(root, "The quiet way in")
# ---- the take where he died easy, given a line of its own ----
r = client.post(f"{base}/actions/{knife}/fork")
assert r.status_code == 200, r.text
ScriptedProvider.next_reply = (
"The camp finds him before you have finished wiping the knife, and there "
"is nothing quiet about the next four minutes.", {"flags.alarm_raised": True,
"player.hp": -22})
r = client.post(f"{base}/actions", json={"type": "do", "text": "Take his horn and blow it."})
assert r.status_code == 200, r.text
name([b for b in branches() if b["is_head"]][0]["id"], "Loud, and early")
# ---- and a line that left that one again, so the map has to nest ----
#
# Forking the take at the tip only switches to it — the attempts there are
# still leaves nobody has built on. So the story is moved one turn past it
# first, and only then is the take it walked away from worth a branch.
ScriptedProvider.next_reply = (
"Nobody comes. The horn was the wrong horn, or the camp has been empty of "
"anyone who cares since before you got here.", {"npc.gwen.trust": -4})
horn = retry_last()
ScriptedProvider.next_reply = (
"The tents are as empty as the sound was. Somebody left in a hurry and did "
"not take the good rope.", {"flags.player_hidden": True})
r = client.post(f"{base}/actions", json={"type": "do", "text": "Search the tents."})
assert r.status_code == 200, r.text
r = client.post(f"{base}/actions/{horn}/fork")
assert r.status_code == 200, r.text
ScriptedProvider.next_reply = (
"They come at the sound the way water finds a crack, and you spend the next "
"hour learning the camp by running through it.",
{"player.hp": -28, "npc.bandit_leader.aggression": 15})
r = client.post(f"{base}/actions", json={"type": "do", "text": "Run for the treeline."})
assert r.status_code == 200, r.text
name([b for b in branches() if b["is_head"]][0]["id"], "Through the camp")
# ---- and the argument that was never had ----
client.post(f"{base}/branches/{root}/switch")
r = client.post(f"{base}/actions/{binding}/fork")
assert r.status_code == 200, r.text
ScriptedProvider.next_reply = (
"She lets it go, and the road out is quieter for it than either of you "
"wanted.", {"npc.gwen.trust": -6})
r = client.post(f"{base}/actions", json={"type": "do", "text": "Say nothing and walk."})
assert r.status_code == 200, r.text
name([b for b in branches() if b["is_head"]][0]["id"], "Said nothing")
# Leave the reader on the first telling, standing on the turn that has two
# takes — the one screen that shows the rail, the pager and the branches.
client.post(f"{base}/branches/{root}/switch")
with engine.begin() as conn:
conn.execute(text(f"PRAGMA user_version = {LATEST_VERSION}"))
for b in branches():
print(f" branch {b['id']}: name={b['name']!r} parent={b['parent_branch_id']} "
f"fork_depth={b['fork_depth']} depth={b['depth']} own={b['own_actions']} "
f"head={b['is_head']}")
print(f"fixture written: {OUT} (adventure {adv_id})")