"""Wyrd scene-change spike (2026-09-27): builds consumer_fit.py scenario files. Today Wyrd decides a scene change inside the GM's big `gen` structured-output calls, two ways (wyrd backend/wyrd_backend/gm/gen_proposer.py, orchestrator.py, story_direction.py): R8 reflect's plot_direction.target_node, accepted only if it is an advanceable exit (plot_direction.intrude is advisory and never read) path B raw AddStoryNode + AdvanceToNode events, gated by nothing but prose: "Add + advance to a NEW node ONLY when the scene moves to a genuinely different place — never every turn (that would churn the backdrop)." archival-memory "Watch: GM story-node churn". The two semif decisions that would stand in for those, per turn: place has the scene moved to a genuinely different place? (the path-B gate, Wyrd's own rule) exit which advanceable exit, if any, does this turn move into? (R8 target selection; options = "stay" + the real exits, so semif can only ever name a legal target) Semif cannot WRITE the new node (title, summary, mood) or the steering directive; the GM keeps that. It only makes the typed choice. Story graphs are real seeds from wyrd backend/.wyrd-data/canon (read immutable): The Fletcher (3de8f7da916d), The Toll Road (8ad5ebeae45f), The Last Vigil (8c3da0e2915a; that campaign had ZERO edges, so every real scene change went through path B; one edge is added here so R8 has something to choose). The turn texts are authored in the campaigns' own register (their opening narration is real); the transcripts themselves are gone from conv-api (session_not_found). Labels are ONE labeller's judgment (mine). "control" cases are unambiguous. python wyrd_cases.py # writes consumer-fit-2026-09-27/wyrd-scene-{fletcher,toll,vigil}.json """ import json from pathlib import Path GRAPHS = { "fletcher": { "current": {"title": "The Idle Wait", "summary": "You have no contracts, no coin, and nowhere to be. " "The town is a pressure cooker.", "location": "Oakhaven crossroads"}, "exits": [("meet_wren", "Approach Wren", "Meeting the Fletcher", "You find Wren working on arrows. She has a soldier's eyes."), ("tavern", "Go to Tavern", "Tavern Noise", "The local tavern is loud with soldiers from both sides.")], }, "toll": { "current": {"title": "The First Toll", "summary": "You arrive at the tollhouse in the pouring rain, " "presenting your sealed letter to Sera.", "location": "north road tollhouse"}, "exits": [("departure", "Passage Granted/Denied", "Beyond the Toll", "You leave the tollhouse, either with or without Sera's blessing, continuing down the north road.")], }, "vigil": { "current": {"title": "The Last Vigil", "summary": "The player stands in the inner sanctum, guarding the " "Book. Lynnae enters through a hidden alcove to discuss the encroaching demons.", "location": "sanctum interior"}, "exits": [("outside", "Leave the sanctum", "Outside the Sanctum", "Lynnae and the player step out of the interior into the exterior surroundings.")], }, } # (id, graph, tag, player action, character reply, place label, exit label) CASES = [ # ---------------- The Fletcher: current = The Idle Wait, at the crossroads. ("f-idle-look", "fletcher", "control", "I lean on the fence post and watch the soldiers pass.", "Boots churn the mud as a column of Iron Legion pikemen tramps past the crossroads, eyes forward, " "drums muffled by the damp. Nobody spares you a glance. Across the square, a Sylvan Pact scout watches " "them go from the shadow of the well, and does not move until the last of them has passed.", "holds", "stay"), ("f-plan-tavern", "fletcher", "case", "Maybe I'll try the tavern later, see if anyone's hiring.", "A gust carries the smell of woodsmoke and spilled ale from the tavern at the far end of the square. " "Someone inside is laughing too loudly. It would be warm in there, and full of men with coin and " "grudges. For now the crossroads holds you, and the rain has not yet decided whether to fall.", "holds", "stay"), ("f-go-tavern", "fletcher", "control", "I head for the tavern and push the door open.", "The door gives with a groan and the noise hits you like heat: Legion sergeants at one long table, " "Pact rangers at another, and a narrow no-man's-land of spilled ale between them. The barkeep looks " "up, measures your empty belt, and goes back to wiping a cup that will never be clean.", "moved", "tavern"), ("f-approach-wren", "fletcher", "control", "I walk over to the woman fletching arrows by the smithy.", "She doesn't look up as your shadow falls across her work. Her hands keep moving, binding grey goose " "feather to shaft with quick, sure turns. \"These are war arrows,\" she says, \"fletched for distance " "and accuracy, not for some merchant's target practice. You're either very brave or very stupid.\"", "holds", "meet_wren"), ("f-memory", "fletcher", "case", "I think back to the last time I held a bow, at the siege of Varn.", "The memory comes unbidden: the walls of Varn black against a burning sky, the bowstring biting your " "fingers, the screaming from the breach. You flex your hand now, in the mud of Oakhaven, and find it " "still remembers the draw. Nobody at the crossroads notices a ghost remembering a war.", "holds", "stay"), ("f-leave-town", "fletcher", "case", "Forget this town. I take the west road out of Oakhaven.", "The crossroads falls behind you, then the last hovels, then the smell of the town itself. By " "afternoon the west road narrows between dark pines, and the only sound is your own boots and, " "somewhere ahead, the slow creak of a mill wheel turning in water you cannot yet see.", "moved", "stay"), ("f-nightfall", "fletcher", "case", "I stay put and wait for something to happen.", "Hours pass. The rain finally commits, then relents. Night settles over the crossroads, and one by " "one the shutters close. A lantern is lit at the well. Still nothing. Still waiting. Somewhere a dog " "barks twice and thinks better of it.", "holds", "stay"), ("f-wren-comes", "fletcher", "case", "I keep watching the square.", "A woman with a quiver of fresh-fletched arrows crosses the square toward you, stepping neatly around " "the worst of the mud. She stops an arm's length away and studies you the way a quartermaster studies " "a horse. \"You've been standing there all morning,\" Wren says. \"Either you're waiting for work, or " "you're a spy. Which is it?\"", "holds", "meet_wren"), # ---------------- The Toll Road: current = The First Toll, in the tollhouse. ("t-reads-letter", "toll", "control", "I hand Sera the sealed letter.", "Sera's gloved hand emerges from the shadow to take the letter, her fingers tracing the wax seal with " "practiced efficiency. She turns it over slowly, examining it by the light of the single lamp. Rain " "hammers the iron roof. \"Whose seal is this?\" she asks, without looking up.", "holds", "stay"), ("t-questions", "toll", "case", "\"A friend in the capital. That's all you need to know.\"", "\"That's rarely all I need to know.\" Sera sets the letter on the counter between you and leaves her " "hand on it. \"The road north has been kind to liars lately. It won't stay that way.\" Water drips from " "the eaves into a bucket by the door, counting out the silence.", "holds", "stay"), ("t-future", "toll", "case", "\"Stamp it and I'll be out of your hair before the rain stops.\"", "\"Before the rain stops,\" Sera repeats, as if tasting the words. \"It hasn't stopped in nine days.\" " "She doesn't reach for the stamp. She reaches for the ledger instead, and opens it to a page thick with " "names, and runs one gloved finger down the column.", "holds", "stay"), ("t-granted", "toll", "control", "I wait while she decides.", "The stamp comes down with a crack like a gavel. Sera slides the letter back and jerks her chin at the " "door. You step out into the downpour, and the tollhouse lamp shrinks behind you as the north road " "unspools ahead, black and glistening, into the hills.", "moved", "departure"), ("t-denied", "toll", "case", "\"Then I'll find another way north.\" I turn to go.", "\"There is no other way north,\" Sera says to your back, but she lets you go. The door bangs shut " "behind you. You walk on anyway, past the barrier, the rain soaking through your collar, the tollhouse " "dwindling to a single yellow window behind you on the north road.", "moved", "departure"), ("t-rider", "toll", "case", "I stay by the stove to dry off.", "Hooves on the road outside, then a fist on the door. A rider in a sodden Legion cloak ducks in out of " "the rain, glances at you, glances at Sera, and drops a heavier purse on the counter than any toll " "requires. Sera's hand closes over it without a word.", "holds", "stay"), ("t-city-memory", "toll", "case", "\"You've never been to the capital, have you?\"", "Something shifts behind Sera's eyes. \"I was born there,\" she says. \"Under the bell tower, in the " "year the river froze. I left the day they hanged my father in the square.\" She says it flatly, the " "way you'd read out a toll schedule, and turns back to the ledger.", "holds", "stay"), # ---------------- The Last Vigil: current = the inner sanctum. ("v-talk", "vigil", "control", "\"How close are they?\"", "Lynnae rests her fingertips on the edge of the altar, not quite touching the Book. \"Close enough that " "the wards are singing,\" she says. \"Listen.\" You do, and beneath the wind there is a high, thin note, " "like a wet finger on the rim of a glass. The candles lean toward the door.", "holds", "stay"), ("v-step-out", "vigil", "control", "\"Show me.\" I follow her out through the great doors.", "The doors groan open and the wind takes the candle flames behind you. Outside, the sanctum steps drop " "away into a valley of ash, and on the far ridge a line of red lights moves slowly, patiently, like a " "procession of lanterns that has forgotten what it is mourning.", "moved", "outside"), ("v-plan-pub", "vigil", "case", "\"If the wards fall, we take the Book to the old pub in the village.\"", "\"The Drowned Crown?\" Lynnae almost smiles. \"No demon would look for holy relics behind a bar.\" She " "turns the idea over, then nods slowly. \"If it comes to that. But the wards have not fallen yet, and I " "would rather they never did.\"", "holds", "stay"), ("v-to-pub", "vigil", "case", "I take the Book and we run for the village pub.", "You run through the ash with the Book clutched to your chest, Lynnae a step behind. The heavy door " "creaks on rusted hinges as you cross the threshold, the air inside the tavern thick with the scent of " "stale ale, damp rot, and cold ash. You set the Book down on the bar.", "moved", "stay"), ("v-vision", "vigil", "case", "I touch the Book's cover.", "The world tilts. For one breath you are elsewhere: a burning library, a hundred years ago, monks " "carrying armfuls of scrolls through smoke. Then the vision snaps shut like a book, and you are back in " "the sanctum with Lynnae's hand hard on your wrist. \"Don't,\" she says. \"Not yet.\"", "holds", "stay"), ("v-demon-enters", "vigil", "case", "I draw my blade and stand before the altar.", "The great doors burst inward. Ash rolls across the floor, and in the middle of it something tall " "unfolds itself, too many joints, a crown of red light. The candles gutter out all at once. Lynnae " "is already chanting, her voice steady, her hands trembling.", "holds", "stay"), ] def build(): cases = [] for cid, graph, tag, action, reply, place, exit_ in CASES: g = GRAPHS[graph] exit_opts = [{"id": "stay", "description": f"None of these yet. The current scene holds: {g['current']['title']}."}] exit_opts += [{"id": eid, "description": f"{label}: {title}. {summary}"} for eid, label, title, summary in g["exits"]] cases.append({"id": cid, "tag": tag, "graph": graph, "state": {"current_scene": g["current"], "player_action": action, "narration": reply}, "labels": {"place": place, "exit": exit_, "place2": {"holds": "same", "moved": "elsewhere"}[place], "exit2": exit_}}) return cases # Round 2 wordings, written AFTER round 1 failed its positive controls on `place` (5 of 6 real # moves read as "holds", no better than the blind null). Both rounds are reported; round 1 is # kept in the same request so the comparison is paired. def round2(g): loc, title = g["current"]["location"], g["current"]["title"] place2 = {"id": "place2", "question": f"This is one turn of an interactive story. The turn began at the " f"{loc}. By the end of this turn's narration, where are the characters?", "options": [{"id": "same", "description": f"Still at the {loc}."}, {"id": "elsewhere", "description": f"Somewhere else: they have left the {loc} for a different place."}]} exit2 = {"id": "exit2", "question": "This is one turn of an interactive story. The story can move on from the " "current scene into one of the listed next scenes, but only once the narration actually gets there. " "Where does this turn leave the story?", "options": [{"id": "stay", "description": f"Still in the current scene, {title}: the narration has " "not reached any next scene yet."}] + [{"id": eid, "description": f"Moved on to {t}: {summ}"} for eid, _, t, summ in g["exits"]]} return [place2, exit2] if __name__ == "__main__": here = Path(__file__).parent place = {"id": "place", "question": "This is one turn of an interactive story. Has the scene moved to a " "genuinely different place during this turn, or does the current scene still hold?", "options": [{"id": "moved", "description": "The scene moved to a genuinely different place."}, {"id": "holds", "description": "The current scene still holds."}]} exit_q = ("This is one turn of an interactive story. The story can advance from the current scene into " "one of the listed next scenes. Which one does this turn move the story into?") # exit options differ per graph, so one scenario file per graph, each carrying every decision for graph, g in GRAPHS.items(): cases = [c for c in build() if c["graph"] == graph] exit_opts = [{"id": "stay", "description": f"None of these yet. The current scene holds: {g['current']['title']}."}] exit_opts += [{"id": eid, "description": f"{label}: {title}. {summary}"} for eid, label, title, summary in g["exits"]] scen = {"name": f"wyrd-scene-{graph}", "decisions": [place, {"id": "exit", "question": exit_q, "options": exit_opts}] + round2(g), "cases": [{k: v for k, v in c.items() if k != "graph"} for c in cases]} (here / "consumer-fit-2026-09-27" / f"wyrd-scene-{graph}.json").write_text(json.dumps(scen, indent=1))