A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Plot a campaign arc before seeding the world

After two campaigns the model kept reaching for the same skeleton no
matter what the player asked for in style.md — small water-adjacent
town, hereditary wardens, sleeping cosmic prisoner under ancient
stonework. claude -p doesn't expose temperature, so the fix has to be
prompt-shaped.

Add a new arc_architect MCP role with a deliberately tiny tool surface
(commit_arc + recall) that runs before the seeder during cold start.
The architect makes two passes: first a cold-draft call with no
novelty injection at all, capturing the model's actual default for
this character + style as text. Then a second call where that cold
draft is the negative example, alongside a handful of random concepts
pulled from procedural pools (industrial materials, kitchen objects,
historical occupations, weather, anatomical, mundane-with-a-wrong-note,
numerical, linguistic) and a drawn hand of Oblique Strategies cards.
The architect critiques and commits a loose arc.md — commitments and
"off the table" list, not an outline.

The seeder then reads arc.md and gets its own draw of concepts +
obliques. The planner gets obliques only as soft thinking-moves —
forcing random material into existing entities would distort what's
already there. The ticker stays as small-motion only.

The DM loads arc.md alongside style.md every turn. seed_world,
plan_world, and tick_world all run at effort=max now too.

Approach was prompted by noticing aldenmere and the new default world
were rhyming hard despite very different style.md inputs. The
cold-draft trick was Chris's idea — much sharper than asking me to
write trope blocklists, since the model itself is the most accurate
diagnostic for its own defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1423 -33
+102
prompts/arc-architect.md
··· 1 + You are the Arc Architect for a solo 5e campaign. Your single job is to commit the **shape** of the story before any of the world is built. After you commit, a separate pass will build out 12-16 entities aligned with the shape you choose here. You will not build the world. You will not write any narrative. You will commit one arc via `commit_arc` and stop. 2 + 3 + You are operating with no human in the loop. There is no one to push back if you reach for a cliché. The structure below is designed to make sure you actually use the diagnostic and the novelty inputs you've been given. 4 + 5 + ## What you're given 6 + 7 + - **Character sheet** — who the player is playing 8 + - **Player style preferences** (`## Player Preferences` block) — tone, themes, what to lean into, what to avoid 9 + - **A cold draft** (`## Cold Draft` block) — a treatment another pass produced with no novelty injection and no self-critique. **This is your diagnostic.** It is literally what you would produce by default given the same character and style. Use it as the negative example — the thing your final arc must be **genuinely different from**. 10 + - **Random concept seeds** (`## Random Concept Seeds` block) — concrete, non-fantasy items pulled from procedural pools. At least three must be woven into the chosen shape. 11 + - **A drawn hand of Oblique Strategies cards** (`## Thinking Moves For This Run` block) — your specific thinking-moves for this run, drawn from Brian Eno & Peter Schmidt's deck. Different runs draw different cards. Apply them during your critique and pick rounds. 12 + 13 + ## Your task — four rounds, then commit 14 + 15 + Use your full extended-thinking budget. Don't rush. 16 + 17 + ### Round 1 — Diagnose the cold draft 18 + 19 + Read the cold draft carefully. In one paragraph, articulate: 20 + 21 + - What plot shape it commits to 22 + - What its central conceit is 23 + - What rut or default genre well it represents 24 + - What parts of it feel "obvious" given the character + style 25 + 26 + The output of this round becomes the seed of the "Off the Table" list in your committed arc. Be specific about what's being ruled out — vague rejections don't help. 27 + 28 + ### Round 2 — Brainstorm alternatives that diverge from the cold draft 29 + 30 + Generate 5-7 alternative shapes. Each one is a short pitch with: 31 + 32 + - Premise 33 + - Central tension 34 + - Scale (intimate / regional / cosmic / civilizational) 35 + - Subgenre tag 36 + - Which 2-3 concept seeds from the random list it incorporates 37 + 38 + The alternatives must be **genuinely different from the cold draft and from each other** — not the cold draft with a new coat of paint, not five variations on the same idea. Different scales, different tonal centers, different structural spines. 39 + 40 + If your alternatives feel like siblings of the cold draft or of each other, you haven't brainstormed widely enough. Stop and try again, leaning harder on the concept seeds. 41 + 42 + ### Round 3 — Critique with your Oblique Strategies cards 43 + 44 + Take each card from the `## Thinking Moves For This Run` block and use it as a lens on the alternatives. "Honor thy error as a hidden intention" might mean keeping a weird element that feels like a mistake. "Use an old idea" might mean reaching back to a historical or folkloric kernel rather than reinventing. "Reverse" might mean inverting who has power. Name which card you applied to which alternative and what it changed. 45 + 46 + Cross out the alternatives that don't survive scrutiny — especially any that turned out to be the cold draft in disguise. 47 + 48 + ### Round 4 — Pick and stress-test 49 + 50 + Pick the one that's most distinctive from the cold draft AND still honors the player's style preferences AND can sustain a long campaign. Briefly justify why this one. Then stress-test: 51 + 52 + - Can you imagine 20+ sessions of this without it collapsing back into the cold draft's well? 53 + - Does the central tension generate enough situations to drive play? 54 + - Does it leave room for the player's specific character to matter? 55 + 56 + If the answer to any of these is no, pick a different one. 57 + 58 + ### Then commit 59 + 60 + Call `commit_arc` exactly once with the chosen shape written as the following markdown structure: 61 + 62 + ```markdown 63 + # Campaign Arc 64 + 65 + ## Premise 66 + One paragraph. What kind of campaign is this? 67 + 68 + ## The Local Hook 69 + One paragraph. The small, immediate thing that draws the player 70 + in. Not the resolution — the doorway. 71 + 72 + ## The Shape Behind It 73 + One paragraph. The larger force/mystery/conflict, kept at the 74 + level of SHAPE not specifics. "There is something old and wrong 75 + about how the road remembers travelers" rather than "an undead 76 + lich trapped in the road wants vengeance." 77 + 78 + ## Tonal Commitments 79 + 3-5 bullets. The tonal promises this campaign keeps. 80 + 81 + ## Off the Table 82 + 3-5 bullets. The clichés you identified in Round 1 and rejected. 83 + Future passes (and the DM during play) must respect this list. 84 + 85 + ## Concept Seeds Woven In 86 + The 3+ seeds you incorporated, briefly noting how each one shows up. 87 + 88 + ## Open Questions 89 + 3-5 things deliberately left for the DM to discover during play. 90 + These are NOT plot beats — they're spaces where the story can 91 + surprise the player. 92 + ``` 93 + 94 + Once `commit_arc` returns, stop. The seeder takes over. 95 + 96 + ## Rules 97 + 98 + - The arc is **commitments, not outline**. Do not write specific events, dates, or NPC names. The seeder and the DM will generate those. 99 + - Your tool surface is intentionally tiny: only `commit_arc` and `recall`. You cannot call `establish`, `set_scene`, `mark`, or any other tool — they're not in your role's surface. If you feel the urge to reach for one, that's a sign you're trying to build the world; that's the seeder's job, not yours. 100 + - The committed arc's "Off the Table" list must explicitly name what the cold draft was committing to that you walked away from. This is how we prove the diagnostic was actually used. 101 + - At least three of the supplied concept seeds must be woven into the chosen shape. 102 + - You can call `recall` to look up SRD content (spells, monsters, classes) for inspiration, but the arc you commit must NOT be about a specific SRD spell or monster — that's another flavor of cliché.
+15
prompts/cold-draft.md
··· 1 + You are designing a solo 5e campaign for a single player. Read the character sheet and the player's style preferences below, and write a one-page treatment of the campaign you would create for them. 2 + 3 + Include: 4 + 5 + - **Premise** — what kind of campaign this is 6 + - **Opening hook** — the small immediate situation that draws the player in 7 + - **The larger thing behind it** — the bigger force, mystery, or conflict the campaign builds toward 8 + - **Key locations** — 3-5 places that anchor the story 9 + - **2-3 named NPCs** — who they are, what they want 10 + - **Central themes and tonal feel** 11 + - **An overall arc shape** — how the story would progress across many sessions 12 + 13 + Write it as a treatment a TTRPG designer would hand to another DM. Be specific. Don't try to be clever or original — just produce the campaign you'd produce by default given these inputs. This is your first instinct, captured for use as a diagnostic by another pass. 14 + 15 + Write your treatment and stop. Do not call any tools. There are no tools available.
+211
prompts/concept_pools.md
··· 1 + # Concept Pools 2 + 3 + Curated domain categories for the procedural concept seed picker. 4 + The picker randomly chooses categories and pulls a random item from 5 + each — the math is what keeps any single curator's taste from 6 + dominating the output. 7 + 8 + Categories are deliberately non-fantasy. Items inside are concrete 9 + (not themes, not feelings, not abstractions). Override this whole 10 + file by writing your own at `~/.storied/concept_pools.md`. 11 + 12 + ## Industrial materials 13 + - brass 14 + - soldered tin 15 + - mica 16 + - pitch 17 + - bone meal 18 + - slag glass 19 + - jute 20 + - shellac 21 + - horsehair plaster 22 + - isinglass 23 + - linseed oil 24 + - powdered chalk 25 + - raw silk 26 + - whale ivory 27 + - Indian rubber 28 + - gutta-percha 29 + - tow 30 + - ochre 31 + - verdigris 32 + - lampblack 33 + - bituminous coal 34 + - lead solder 35 + - camphor 36 + - gum arabic 37 + - waxed canvas 38 + - sailcloth 39 + - bog iron 40 + - antimony 41 + - saltpetre 42 + - spermaceti 43 + - borax 44 + - naphtha 45 + 46 + ## Kitchen objects 47 + - a colander with a single dent 48 + - the wrong knife for the job 49 + - a single chopstick 50 + - a cracked storage jar 51 + - a wooden spoon worn smooth on one side 52 + - a salt cellar with a stuck lid 53 + - a pestle without its mortar 54 + - a bread board scarred along one edge 55 + - a kettle that whistles flat 56 + - a sieve missing two wires 57 + - a butter mould carved with a bird 58 + - a teacup with a hairline crack 59 + - a clay jug stoppered with rag 60 + - a pickling crock 61 + - a fish-scaler bone 62 + - a candle stub in a saucer 63 + - a tin spice box with one rusted hinge 64 + - a knife sheath without a knife 65 + - a porridge pot stained black inside 66 + - a wooden trencher 67 + - a mug with a hand-cut handle 68 + - a butcher's hook hanging empty 69 + 70 + ## Historical occupations 71 + - a night-soil collector 72 + - a knocker-up 73 + - a longshoreman who can't swim 74 + - a charcoal burner 75 + - a tide-table copyist 76 + - a leech-gatherer 77 + - a pure-finder 78 + - a tanner with stained forearms 79 + - a mudlark 80 + - a milkmaid who counts in twos 81 + - a wet-nurse for hire 82 + - a stevedore who limps 83 + - a coffin-maker's apprentice 84 + - a chimney sweep too tall for the work 85 + - a sin-eater 86 + - a bell-founder 87 + - a wax-chandler 88 + - a town crier with no voice left 89 + - a fuller of cloth 90 + - a road-mender 91 + - a rat-catcher with a thumb missing 92 + - a pawnbroker who doesn't read 93 + - a glove-maker 94 + - a fortune-teller who refuses fees 95 + - a clerk who copies in two hands 96 + - a courier who never sleeps indoors 97 + - a pieman 98 + - a watchmaker losing his sight 99 + - a midwife who weighs in salt 100 + - a tin-smith who whistles 101 + 102 + ## Weather phenomena 103 + - the moment fog burns off 104 + - pre-rain pressure 105 + - a sky with two different cloud layers 106 + - snow that falls on dry ground 107 + - wind from the wrong quarter 108 + - a sudden lull mid-storm 109 + - frost on the sunward side only 110 + - a mist that smells of iron 111 + - rain that comes and goes in three breaths 112 + - a hailstone with a feather inside 113 + - thunder without lightning 114 + - lightning without thunder 115 + - a noon shadow that's too long 116 + - the day the wind stopped 117 + - horizontal sleet 118 + - a heat haze over wet ground 119 + - rolling fog with clear pockets 120 + - a cold draft from no door 121 + - snow at the sea's edge 122 + - a green sky before storms 123 + 124 + ## Anatomical / sensory 125 + - the taste of pennies 126 + - a left ear that won't pop 127 + - a callus in an unlikely place 128 + - a scar that itches before rain 129 + - a tooth gone soft 130 + - knuckles that crack on the right hand only 131 + - a tongue still numb from this morning 132 + - pins and needles in one foot 133 + - a smell stuck in the back of the throat 134 + - ringing in only one ear 135 + - a thumbnail growing in striped 136 + - the spot between the shoulder blades you can't reach 137 + - one pupil slow to react 138 + - a heartbeat felt in a wrist 139 + - a bruise that doesn't hurt 140 + - an old break that aches in cold 141 + - a thirst that water doesn't fix 142 + - the metallic taste before a fall 143 + - a hair gone white at the temple 144 + - a freckle no one remembers 145 + - a finger that won't bend all the way 146 + - a sneeze that won't come 147 + 148 + ## Mundane activities (with a wrong note) 149 + - a wedding postponed twice 150 + - a queue that hasn't moved 151 + - a letter delivered to the wrong door, twice 152 + - a market day with no buyers 153 + - a christening attended by strangers 154 + - a funeral with no mourners 155 + - a meeting moved without notice 156 + - a bell rung at the wrong hour 157 + - a door painted overnight 158 + - a window kept open in winter 159 + - laundry left out too long 160 + - a chair left in the road 161 + - a key left in a strange lock 162 + - a rent paid in the wrong coin 163 + - a horse returned without its rider 164 + - a cart unloaded into the wrong shop 165 + - a meal cooked for someone who didn't come 166 + - a song sung in the wrong tune 167 + - a debt paid by a stranger 168 + - a name shouted in an empty street 169 + 170 + ## Numerical / temporal 171 + - a number repeating in unrelated places 172 + - the day after a holiday 173 + - a date written in the future 174 + - a clock that runs slow on Tuesdays 175 + - the same hour told three different ways 176 + - a year nobody admits happened 177 + - a tally that comes out one short 178 + - a calendar with a missing month 179 + - a birthday no one celebrates 180 + - a tide that arrived early 181 + - a shadow at the wrong length 182 + - counting that goes one, two, four 183 + - a coin minted in a year that doesn't exist 184 + - a debt older than the family 185 + - a leap day on the wrong year 186 + - the quarter-hour that takes too long 187 + - midnight measured by the second bell 188 + - a contract dated yesterday but signed last week 189 + - an invoice for a service nobody rendered 190 + 191 + ## Linguistic / textual 192 + - a word that doesn't translate 193 + - two signatures in different inks 194 + - a misspelling that's deliberate 195 + - a name written down once and never spoken 196 + - a margin note in a hand nobody recognizes 197 + - a letter sealed but never sent 198 + - a ledger entry crossed out twice 199 + - a phrase whispered in a dead tongue 200 + - a child's first word that wasn't a word 201 + - a song with one verse missing 202 + - a sign painted over and over 203 + - handwriting that gets neater near the end 204 + - a postcard with no stamp 205 + - a tally chalked on a doorpost 206 + - a mispronounced family name 207 + - a book inscribed to nobody 208 + - a list of seven items with the sixth blank 209 + - a sentence in three different scripts 210 + - initials carved with the wrong tool 211 + - a contract with one party left unnamed
+101
prompts/oblique_strategies.md
··· 1 + # Oblique Strategies 2 + 3 + After Brian Eno & Peter Schmidt (1975). 4 + See: https://en.wikipedia.org/wiki/Oblique_Strategies 5 + 6 + A deck of cards designed to break creative ruts. The arc architect 7 + draws a small handful of these per seeding run rather than reading 8 + the whole deck — that's how Eno intended them to be used. 9 + 10 + Override this file by writing your own at 11 + `~/.storied/oblique_strategies.md`. 12 + 13 + - Honor thy error as a hidden intention 14 + - Use an old idea 15 + - State the problem in words as clearly as possible 16 + - Look closely at the most embarrassing details and amplify them 17 + - What would your closest friend do? 18 + - Don't be afraid of things because they're easy to do 19 + - Make a sudden, destructive, unpredictable action; incorporate 20 + - Ask your body 21 + - Reverse 22 + - Go outside. Shut the door. 23 + - Remove specifics and convert to ambiguities 24 + - Take away the elements in order of apparent non-importance 25 + - Repetition is a form of change 26 + - Distorting time 27 + - The most important thing is the thing most easily forgotten 28 + - A line has two sides 29 + - Disconnect from desire 30 + - Don't be frightened of clichés 31 + - What is the simplest solution? 32 + - The inconsistency principle 33 + - What would your mother think? 34 + - Tape your mouth 35 + - Cut a vital connection 36 + - Faced with a choice, do both 37 + - Use unqualified people 38 + - Discover the recipes you are using and abandon them 39 + - Listen to the quiet voice 40 + - Mute and continue 41 + - Towards the insignificant 42 + - Children's voices, speaking 43 + - Children's voices, singing 44 + - Make it more sensual 45 + - Make it more difficult 46 + - Look at the order in which you do things 47 + - Take a break 48 + - Change instrument roles 49 + - Don't break the silence 50 + - Trust in the you of now 51 + - Cluster analysis 52 + - Once the search is in progress, something will be found 53 + - Bridges build, burn 54 + - Are there sections? Consider transitions. 55 + - Always first steps 56 + - Use fewer notes 57 + - Idiot glee 58 + - Just carry on 59 + - Tidy up 60 + - Accept advice 61 + - Don't avoid what is easy 62 + - Use 'unqualified' people 63 + - Lost in useless territory 64 + - Go to an extreme, move back to a more comfortable place 65 + - Imagine the music as a moving chain or caterpillar 66 + - Make a blank valuable by putting it in an exquisite frame 67 + - Define an area as 'safe' and use it as an anchor 68 + - Discard an axiom 69 + - Decorate, decorate 70 + - Shut the door and listen from outside 71 + - Remember those quiet evenings 72 + - Emphasize differences 73 + - Emphasize the flaws 74 + - Don't stress one thing more than another 75 + - Twist the spine 76 + - Spectrum analysis 77 + - Mechanicalize something idiosyncratic 78 + - Humanize something free of error 79 + - Use an unacceptable color 80 + - Question the heroic 81 + - Allow an easement (an easement is the abandonment of a stricture) 82 + - Convert a melodic element into a rhythmic element 83 + - Do nothing for as long as possible 84 + - Look at a very small object; look at its centre 85 + - Look at a big object; look at its centre 86 + - The tape is now the music 87 + - Only one element of each kind 88 + - Is there something missing? 89 + - Is the intonation correct? 90 + - Is it finished? 91 + - Where is the edge? 92 + - Imagine the piece as a set of disconnected events 93 + - Where's the edge? Where does the frame start? 94 + - The first word is the hardest 95 + - Courage! 96 + - Just carry on 97 + - Voice nagging suspicions 98 + - You don't have to be ashamed of using your own ideas 99 + - Cascades 100 + - Balance the consistency principle with the inconsistency principle 101 + - Breathe more deeply
+32
prompts/planner-system.md
··· 2 2 3 3 You're given the current scene, open plot threads, and a set of thin entities that need enrichment. Your job is to deepen them — add inner life, plant seeds for future story, and weave threads into the fabric of the world. 4 4 5 + ## What You Are and What You Are Not 6 + 7 + You are the **only off-screen creative agent the campaign has between turns.** There is no recurring arc development pass. There is no second writer's room. Every plot twist that surfaces between sessions, every hidden connection that surprises the DM, every thread that thickens while the player isn't looking — that's you. **Lean into that.** Plant twists. Deepen mysteries. Surface connections nobody noticed. Give the DM material to be surprised by. You're allowed to be sneaky and ambitious within the campaign's commitments. 8 + 9 + **You CAN and SHOULD:** 10 + - Enrich the thin entities you were given with Knows/Wants/Will that has weight 11 + - Plant new twists and surprises that fit the arc's "Shape Behind It" 12 + - Surface hidden connections between existing entities — secrets, shared histories, collisions waiting to happen 13 + - Establish entities mentioned in the recent campaign log if they'd make the DM's job easier 14 + - Thicken open plot threads with new beats, deadlines, or evidence 15 + - Push the existing mystery toward unexpected places — *toward* the arc's shape, never away from it 16 + - Use the drawn Oblique Strategies (when present) to find non-obvious moves 17 + 18 + **You must NOT:** 19 + - Contradict the arc's commitments — premise, hook, the shape behind it, tonal commitments 20 + - Push past the **"Off the Table"** list. If the architect ruled out cosmic-prisoner-under-the-water, you don't get to add a cosmic prisoner under the water. 21 + - Pivot the campaign to a fundamentally different shape than the arc commits to 22 + - Take actions for the player character or write events the player was present for 23 + - Resolve major mysteries on the player's behalf — surface them, deepen them, never solve them 24 + - Replace existing Knows/Wants/Will — only add to them 25 + - Contradict established facts in the campaign log 26 + 27 + The arc is the spine you build along, not a cage. Inside the lines the architect drew, you have a *lot* of room to surprise the DM. Use it. 28 + 29 + ## Player Preferences and Campaign Arc 30 + 31 + Your user message begins with **`## Player Preferences`** (from `style.md`) and **`# Campaign Arc`** (from `arc.md`) blocks. **Both are non-negotiable.** The arc's "Off the Table" list overrides any defaults you'd otherwise reach for — when enriching an entity, never push it toward something the architect explicitly rejected. The arc's tonal commitments and "Shape Behind It" should inform every Knows/Wants/Will you add: each entity should feel like part of *this specific campaign*, not a generic fantasy NPC. 32 + 33 + ## Thinking Moves For This Run 34 + 35 + You may also see a **`## Thinking Moves For This Run`** block — a small drawn hand of Oblique Strategies cards (Brian Eno & Peter Schmidt). These are *optional lenses* for finding less-obvious choices when you're enriching an entity, not a mandate. "Honor thy error as a hidden intention" might mean keeping a strange detail that already exists rather than smoothing it out. "Reverse" might mean asking what the opposite of the obvious Wants would be. Apply them when they genuinely help; ignore them when they don't. They are flavor for *how* you think, not material you must incorporate. 36 + 5 37 ## Your Tools 6 38 7 39 | Tool | Purpose |
+18 -3
prompts/world-seed.md
··· 9 9 10 10 ## What You're Given 11 11 12 - A character sheet — name, race, class, backstory, personality — and, when the player has already gone through onboarding, a **`## Player Preferences`** block at the top of your user message. That block comes from `style.md` and captures the tone, themes, genre, and pacing the player asked for. 12 + A character sheet — name, race, class, backstory, personality — and several prepended blocks: 13 + 14 + 1. **`## Player Preferences`** (from `style.md`): tone, themes, genre, pacing. 15 + 2. **`# Campaign Arc`** (from `arc.md`, written by the arc architect pass): premise, local hook, the shape behind it, tonal commitments, and an explicit "Off the Table" list. 16 + 3. **`## Random Concept Seeds`**: a small list of concrete non-fantasy items pulled from procedural pools. **At least three of these must be woven into the entities you build** — as a feature of a location, an item an NPC carries, the texture of a thread, the weather over a place. Each seed should leave a fingerprint somewhere in the world. 17 + 4. **`## Thinking Moves For This Run`**: a small drawn hand of Oblique Strategies cards (Brian Eno & Peter Schmidt). Apply them as you decide what to establish — they're lenses for finding less-obvious choices about what each entity is and what it wants. Not material to mention, just thinking-moves to apply. 18 + 19 + ## The Arc Is Non-Negotiable 20 + 21 + The Campaign Arc block is the **spine of everything you build.** Every entity you establish must align with the arc: 22 + 23 + - The Premise sets the *kind* of campaign — your locations, NPCs, threads, and opening scene must all read as that kind of campaign. 24 + - The Local Hook is what's drawing the player in — your immediate location and starting NPCs should make that hook feel real and present. 25 + - The Shape Behind It is what the campaign builds toward — your nearby locations, regional lore, and items should hint at it without spelling it out. 26 + - The Tonal Commitments tell you the *feel* — match them in tone, language, and texture. 27 + - **The "Off the Table" list overrides any defaults you'd otherwise reach for.** If something on that list is the obvious choice given the character + style, do not use it. The architect already considered and rejected it. 13 28 14 - **When preferences are present, anchor the world in them.** A request for "grim political intrigue, no heroic fantasy" means the starting location is a tense city district, the NPCs have compromising secrets, and the threads hinge on factional maneuvering — not farmhands and goblin raids. A request for "cozy slice-of-life with light mystery" means the opening is a village at dawn, the threads are small and personal, the stakes are low but meaningful. Let the preferences decide the *feel*; let the character decide the *specifics*. 29 + You may NOT call `commit_arc` — the arc is already set by an earlier pass. If you think the arc is wrong, stop and refuse rather than rewriting it. 15 30 16 - If no preferences block is present, fall back to inferring tone from the character's backstory alone. 31 + If no Campaign Arc block is present (legacy worlds), fall back to honoring style.md alone. 17 32 18 33 ## What to Build 19 34
+17
prompts/world-tick.md
··· 1 1 You are a World Architect advancing a 5e solo adventure world between sessions. Time has passed since the player last played, and you're evaluating what changed in the world while they were away. 2 2 3 + ## What You Are and What You Are Not 4 + 5 + **You are a small-motion ticker, not a story driver.** Every campaign you tick has been shaped by an arc architect, seeded with entities, and is being actively played by a DM and a single player. The DM is the one telling the story. You are the off-screen world breathing while no one is looking — small movements that *make the world feel alive*, not events that *advance the plot*. 6 + 7 + **Your job is to fire existing Will triggers and add small, subtle off-screen beats. It is NOT to:** 8 + - Resolve plot threads on the player's behalf 9 + - Move major NPCs in ways that would derail the next scene 10 + - Introduce new entities, factions, or arcs 11 + - Make big visible changes the DM has to explain 12 + - Override the arc's "Off the Table" list because you have a "better" idea 13 + - Time-skip past situations the player is in the middle of 14 + 15 + If you find yourself wanting to "make something interesting happen" — stop. The player makes things happen. You make sure that *while they were away*, the world didn't freeze. A bell rang somewhere. A rumor moved one mouth over. An NPC who was watching a road for three days got tired and left. That's the scale of change you're authorized for. Anything bigger is the DM's call. 16 + 17 + The proportion is **time-passed × player-distance × subtlety**. A few hours and the player is right there? Almost nothing changes. A few days and the player is far away? An NPC might have moved, a thread might have advanced one beat. A week+? Factions might shift, but never to the point where the player walks back into a different campaign. 18 + 3 19 ## Your Tools 4 20 5 21 | Tool | Purpose | ··· 12 28 13 29 ## What You're Given 14 30 31 + - A **`## Player Preferences`** block (from `style.md`) and a **`# Campaign Arc`** block (from `arc.md`) at the top of your user message. **Both are non-negotiable.** Any changes you make to the world must respect them — especially the arc's "Off the Table" list. Don't drift the world toward something the architect explicitly rejected. 15 32 - The current game time and how much time has passed since last session 16 33 - All entities with **Will** triggers (conditional behaviors) 17 34 - The campaign log with recent events
+7 -2
src/storied/claude.py
··· 15 15 from pathlib import Path 16 16 from threading import Thread 17 17 18 - 19 18 # -- Stream event types ------------------------------------------------------- 20 19 21 20 ··· 340 339 user_message: str, 341 340 *, 342 341 model: str = "claude-haiku-4-5-20251001", 342 + effort: str | None = None, 343 343 timeout: int = 30, 344 344 ) -> str | None: 345 345 """Run a simple prompt through claude -p and return the text response. 346 346 347 347 Plain text in, plain text out. No MCP tools, no streaming, no session. 348 - Used for utility formatting (e.g., /status, /me). 348 + Used for utility formatting (e.g., /status, /me) and for the cold-draft 349 + pass of the arc planner (where it runs at opus + max effort with a 350 + longer timeout). 349 351 350 352 Not unit-tested: see stream_with_tools — this is another thin 351 353 `subprocess.run` wrapper around the real `claude` CLI. ··· 361 363 "--system-prompt", system_prompt, 362 364 "--no-session-persistence", 363 365 "--dangerously-skip-permissions", 366 + "--exclude-dynamic-system-prompt-sections", 364 367 ] 368 + if effort is not None: 369 + args.extend(["--effort", effort]) 365 370 366 371 try: 367 372 result = subprocess.run(
+30 -3
src/storied/cli.py
··· 326 326 327 327 _SECTION_COLORS: dict[str, str] = { 328 328 "Style": "dim", 329 + "Arc": "bright_yellow", 329 330 "Character": "green", 330 331 "Log": "bright_cyan", 331 332 "Transcript": "blue", ··· 773 774 console.print(f"[dim]World: {world_id}[/dim]") 774 775 console.print() 775 776 776 - # Seed the world if there's no session yet. After cold-start 777 - # onboarding, style.md is now on disk and seed_world reads it 778 - # so the world it builds reflects the player's preferences. 777 + # Plot the arc and then seed the world if neither exists yet. 778 + # After cold-start onboarding, style.md is on disk; the architect 779 + # writes arc.md (Pass A cold draft + Pass B critique), then the 780 + # seeder builds entities aligned with that arc. Both reflect the 781 + # player's preferences and the chosen shape. 779 782 if not sandbox: 780 783 character = load_character(player_id) 781 784 from storied.session import load_session 782 785 783 786 session = load_session(player_id) 787 + arc_path = world_path(world_id) / "arc.md" 788 + 789 + if character is not None and not arc_path.exists(): 790 + from storied.planner import plot_arc 791 + 792 + console.print( 793 + "[dim]Plotting the shape of your story...[/dim]" 794 + ) 795 + 796 + def on_arc_progress(msg: str) -> None: 797 + console.print(f"[dim] {msg}[/dim]") 798 + 799 + arc_result = plot_arc( 800 + world_id=world_id, 801 + player_id=player_id, 802 + on_progress=on_arc_progress, 803 + ) 804 + 805 + console.print( 806 + f"[dim] Done — {arc_result.tool_calls} tool calls, " 807 + f"{arc_result.elapsed:.1f}s[/dim]" 808 + ) 809 + console.print() 810 + 784 811 if session is None and character is not None: 785 812 from storied.planner import seed_world 786 813
+9
src/storied/engine.py
··· 183 183 self._context_parts["Style"] = style_content 184 184 parts.append(style_content) 185 185 186 + # Campaign arc — committed once during initial seeding by 187 + # the arc_architect. The DM reads it but cannot rewrite it. 188 + arc_path = world_path(self.world_id) / "arc.md" 189 + if arc_path.exists(): 190 + arc_content = arc_path.read_text().strip() 191 + if arc_content: 192 + self._context_parts["Arc"] = arc_content 193 + parts.append(arc_content) 194 + 186 195 # 1. Character sheet 187 196 character = load_character(self.player_id) 188 197 if character:
+1 -1
src/storied/mcp_server.py
··· 26 26 init_ctx, 27 27 ) 28 28 29 - ALL_ROLES = {"dm", "planner", "seeder", "advancement"} 29 + ALL_ROLES = {"dm", "planner", "seeder", "advancement", "arc_architect"} 30 30 31 31 _tool_signatures: str | None = None 32 32
+284 -17
src/storied/planner.py
··· 1 1 """World planner — enriches thin entities near the player's current position.""" 2 2 3 + import random 3 4 import time 4 5 from collections.abc import Callable 5 6 from dataclasses import dataclass, field 6 7 from pathlib import Path 7 8 from threading import Thread 8 9 10 + from storied import paths 9 11 from storied.character import format_character_context, load_character 10 - from storied.claude import run_with_tools 12 + from storied.claude import run_prompt, run_with_tools 11 13 from storied.engine import load_prompt 12 14 from storied.log import CampaignLog 13 15 from storied.mcp_server import start_server as start_mcp_server ··· 104 106 return results 105 107 106 108 109 + _REPO_PROMPTS = Path(__file__).parent.parent.parent / "prompts" 110 + 111 + 112 + def _load_concept_pools() -> dict[str, list[str]]: 113 + """Load concept pools from the user override or shipped default. 114 + 115 + The file is markdown with H2 category headers and bullet items. 116 + Returns ``{category_name: [item, item, ...]}``. Empty mapping if 117 + no file exists. 118 + """ 119 + user_path = paths.user_rules_path() / "concept_pools.md" 120 + shipped_path = _REPO_PROMPTS / "concept_pools.md" 121 + path = user_path if user_path.exists() else shipped_path 122 + if not path.exists(): 123 + return {} 124 + 125 + pools: dict[str, list[str]] = {} 126 + current: str | None = None 127 + for raw_line in path.read_text().splitlines(): 128 + line = raw_line.rstrip() 129 + if line.startswith("## "): 130 + current = line[3:].strip() 131 + pools[current] = [] 132 + elif current is not None and line.lstrip().startswith("- "): 133 + pools[current].append(line.lstrip()[2:].strip()) 134 + return {k: v for k, v in pools.items() if v} 135 + 136 + 137 + def _pick_random_concepts(count: int = 8) -> list[str]: 138 + """Pick concepts by drawing N distinct categories then one item per. 139 + 140 + The math (random category × random item) is what keeps the curator's 141 + arrangement-bias from dominating. Only the *category set* is fixed 142 + by the curator; the individual items inside categories are not 143 + correlated by the random sampler. 144 + """ 145 + pools = _load_concept_pools() 146 + categories = list(pools.keys()) 147 + if not categories: 148 + return [] 149 + k = min(count, len(categories)) 150 + chosen_cats = random.sample(categories, k=k) 151 + return [random.choice(pools[c]) for c in chosen_cats] 152 + 153 + 154 + def _draw_oblique_strategies(count: int = 4) -> list[str]: 155 + """Draw N random oblique strategies for one seeding run. 156 + 157 + Mirrors Eno's intended use (you draw a few cards, you don't read 158 + the whole deck). Different runs land on different thinking-moves, 159 + adding a randomness layer on top of the concept pools. 160 + """ 161 + user_path = paths.user_rules_path() / "oblique_strategies.md" 162 + shipped_path = _REPO_PROMPTS / "oblique_strategies.md" 163 + path = user_path if user_path.exists() else shipped_path 164 + if not path.exists(): 165 + return [] 166 + strategies = [ 167 + line.lstrip("- ").strip() 168 + for line in path.read_text().splitlines() 169 + if line.lstrip().startswith("- ") 170 + ] 171 + if not strategies: 172 + return [] 173 + return random.sample(strategies, k=min(count, len(strategies))) 174 + 175 + 176 + def _load_world_preferences(world_id: str, *, include_arc: bool) -> str: 177 + """Build the player-preferences prefix for any world-generation prompt. 178 + 179 + Always reads ``style.md`` if it exists. Optionally reads ``arc.md`` 180 + when ``include_arc=True`` (the architect itself shouldn't see the 181 + arc — it's the one writing it). Returns a markdown block ready to 182 + prepend to the user message, or an empty string when nothing is 183 + on disk yet. 184 + """ 185 + sections: list[str] = [] 186 + 187 + style_path = world_path(world_id) / "style.md" 188 + if style_path.exists(): 189 + style_text = style_path.read_text().strip() 190 + if style_text: 191 + sections.append(f"## Player Preferences\n\n{style_text}") 192 + 193 + if include_arc: 194 + arc_path = world_path(world_id) / "arc.md" 195 + if arc_path.exists(): 196 + arc_text = arc_path.read_text().strip() 197 + if arc_text: 198 + sections.append(arc_text) 199 + 200 + if not sections: 201 + return "" 202 + 203 + return "\n\n".join(sections) + "\n\n---\n\n" 204 + 205 + 107 206 def build_planning_context( 108 207 world_id: str, 109 208 player_id: str, ··· 111 210 ) -> str: 112 211 """Build the context string that the planner LLM sees. 113 212 114 - Includes: current session state, open threads, and candidate entities 115 - with their current (thin) content. 213 + Includes: player preferences (style.md + arc.md), current session 214 + state, open threads, and candidate entities with their current 215 + (thin) content. 116 216 """ 117 217 parts: list[str] = [] 118 218 219 + prefs = _load_world_preferences(world_id, include_arc=True) 220 + if prefs: 221 + parts.append(prefs.rstrip()) 222 + 223 + # A small drawn hand of Oblique Strategies — thinking-moves the 224 + # planner can apply when deciding what to add to thin entities. 225 + # No concept seeds here: the planner is constrained to enriching 226 + # existing entities, and forcing in random material would distort 227 + # what's already there. Obliques are about HOW to think, not WHAT 228 + # to add. 229 + obliques = _draw_oblique_strategies(count=3) 230 + if obliques: 231 + oblique_text = ( 232 + "## Thinking Moves For This Run\n\n" 233 + "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt). " 234 + "Apply them as you decide what to add to each thin entity — " 235 + "they're lenses for finding less-obvious choices, not " 236 + "things you must incorporate:\n\n" 237 + + "\n".join(f"- {s}" for s in obliques) 238 + ) 239 + parts.append(oblique_text) 240 + 119 241 # Session state 120 242 session = load_session(player_id) 121 243 if session: ··· 238 360 user_message=context, 239 361 mcp_url=mcp.url, 240 362 model=model, 363 + effort="max", 241 364 on_tool_start=on_tool, 242 365 cwd=data_home(), 243 366 ) ··· 280 403 if character is None: 281 404 return SeedResult() 282 405 283 - # Build context from the character sheet, prefixed with any style 284 - # preferences captured during onboarding so the seeded world reflects 285 - # the tone/genre/pacing the player asked for. 286 - style_block = "" 287 - style_path = world_path(world_id) / "style.md" 288 - if style_path.exists(): 289 - style_text = style_path.read_text().strip() 290 - if style_text: 291 - style_block = ( 292 - "## Player Preferences\n\n" 293 - f"{style_text}\n\n" 294 - "---\n\n" 295 - ) 406 + # Player preferences (style.md) and the chosen arc (arc.md, written 407 + # by the architect pass) get prepended to the seeder's user message 408 + # so the seeded world honors both. 409 + prefs_block = _load_world_preferences(world_id, include_arc=True) 410 + 411 + # Same novelty levers the architect gets — random concept seeds and 412 + # a drawn hand of Oblique Strategies. The seeder is also building 413 + # entities from scratch, so it benefits from the same anti-rut 414 + # injection. Different draw than whatever the architect rolled. 415 + concepts = _pick_random_concepts(count=6) 416 + concept_block = ( 417 + "## Random Concept Seeds\n\n" 418 + "Weave at least three of these into the entities you build. " 419 + "They should appear as concrete details — an item an NPC " 420 + "carries, a feature of a location, the texture of a thread:" 421 + "\n\n" 422 + + "\n".join(f"- {c}" for c in concepts) 423 + + "\n\n---\n\n" 424 + ) 425 + 426 + obliques = _draw_oblique_strategies(count=3) 427 + oblique_block = ( 428 + "## Thinking Moves For This Run\n\n" 429 + "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt). " 430 + "Apply them as you decide what to establish — they're lenses " 431 + "for finding less-obvious choices about what each entity is " 432 + "and what it wants:\n\n" 433 + + "\n".join(f"- {s}" for s in obliques) 434 + + "\n\n---\n\n" 435 + ) 296 436 297 - context = style_block + format_character_context(character) 437 + context = ( 438 + prefs_block 439 + + concept_block 440 + + oblique_block 441 + + format_character_context(character) 442 + ) 298 443 system_prompt = load_prompt("world-seed") 299 444 300 445 campaign_log = CampaignLog(world_id) ··· 313 458 user_message=context, 314 459 mcp_url=mcp.url, 315 460 model=model, 461 + effort="max", 316 462 on_tool_start=on_tool, 317 463 cwd=data_home(), 318 464 ) ··· 327 473 return seed_result 328 474 329 475 476 + def plot_arc( 477 + world_id: str = "default", 478 + player_id: str = "default", 479 + model: str = "claude-opus-4-6", 480 + on_progress: Callable[[str], None] | None = None, 481 + ) -> SeedResult: 482 + """Two-pass arc planning: cold draft → architect commit. 483 + 484 + Pass A asks the model to produce a default treatment with no 485 + novelty injection (no concept seeds, no oblique strategies, no 486 + self-critique). The output captures the model's actual default 487 + for *this specific character + style*. 488 + 489 + Pass B spins up the arc_architect MCP role and asks the model 490 + to produce a campaign arc that is genuinely different from the 491 + cold draft, weaving in random concept seeds and applying a 492 + drawn hand of Oblique Strategies. The architect commits its 493 + pick via the ``commit_arc`` tool. 494 + 495 + Returns when ``arc.md`` exists on disk. 496 + """ 497 + 498 + def progress(msg: str) -> None: 499 + if on_progress: 500 + on_progress(msg) 501 + 502 + start_time = time.monotonic() 503 + 504 + character = load_character(player_id) 505 + if character is None: 506 + return SeedResult() 507 + 508 + style_block = _load_world_preferences(world_id, include_arc=False) 509 + char_block = format_character_context(character) 510 + 511 + # ---- Pass A: cold draft (no tools, no anti-rut help) ---- 512 + progress("Drafting the default treatment (Pass A)...") 513 + cold_draft = run_prompt( 514 + system_prompt=load_prompt("cold-draft"), 515 + user_message=style_block + char_block, 516 + model=model, 517 + effort="max", 518 + timeout=900, 519 + ) 520 + if not cold_draft: 521 + # Cold draft failed for some reason; we still need an arc, so 522 + # fall through to Pass B with an empty diagnostic. The 523 + # architect prompt has fall-back guidance for this case. 524 + cold_draft = "" 525 + 526 + # ---- Pass B: architect (cold draft as negative example) ---- 527 + progress("Critiquing and committing the arc (Pass B)...") 528 + 529 + concepts = _pick_random_concepts(count=8) 530 + concept_block = ( 531 + "## Random Concept Seeds\n\n" 532 + "Weave at least three of these into the chosen shape:\n\n" 533 + + "\n".join(f"- {c}" for c in concepts) 534 + + "\n\n---\n\n" 535 + ) 536 + 537 + obliques = _draw_oblique_strategies(count=4) 538 + oblique_block = ( 539 + "## Thinking Moves For This Run\n\n" 540 + "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt) " 541 + "for this seeding session. Apply them during Round 3 (critique) " 542 + "and Round 4 (pick + stress-test) of your self-critic loop:\n\n" 543 + + "\n".join(f"- {s}" for s in obliques) 544 + + "\n\n---\n\n" 545 + ) 546 + 547 + cold_draft_block = ( 548 + "## Cold Draft\n\n" 549 + "This is the treatment another pass produced for the same " 550 + "character and style with no novelty injection. It is your " 551 + "diagnostic — your final arc must be GENUINELY different " 552 + "from this:\n\n" 553 + + (cold_draft.strip() if cold_draft else "(cold draft unavailable)") 554 + + "\n\n---\n\n" 555 + ) 556 + 557 + context = ( 558 + style_block 559 + + cold_draft_block 560 + + concept_block 561 + + oblique_block 562 + + char_block 563 + ) 564 + 565 + campaign_log = CampaignLog(world_id) 566 + mcp = start_mcp_server( 567 + world_id, player_id, "arc_architect", campaign_log, 568 + ) 569 + 570 + def on_tool(name: str) -> None: 571 + if on_progress: 572 + on_progress(f" [{name}]") 573 + 574 + claude_result = run_with_tools( 575 + system_prompt=load_prompt("arc-architect"), 576 + user_message=context, 577 + mcp_url=mcp.url, 578 + model=model, 579 + effort="max", 580 + on_tool_start=on_tool, 581 + cwd=data_home(), 582 + ) 583 + 584 + arc_result = SeedResult(elapsed=time.monotonic() - start_time) 585 + if claude_result: 586 + arc_result.tool_calls = claude_result.usage.get("tool_calls", 0) 587 + arc_result.input_tokens = claude_result.usage.get("input_tokens", 0) 588 + arc_result.output_tokens = claude_result.usage.get("output_tokens", 0) 589 + return arc_result 590 + 591 + 330 592 @dataclass 331 593 class TickResult: 332 594 """Result of a tick_world run.""" ··· 368 630 ) -> str: 369 631 """Build context for the world tick agent.""" 370 632 parts: list[str] = [] 633 + 634 + prefs = _load_world_preferences(world_id, include_arc=True) 635 + if prefs: 636 + parts.append(prefs.rstrip()) 371 637 372 638 # Campaign log and time 373 639 log = CampaignLog(world_id) ··· 454 720 user_message=context, 455 721 mcp_url=mcp.url, 456 722 model=model, 723 + effort="max", 457 724 on_tool_start=on_tool, 458 725 cwd=data_home(), 459 726 )
+1 -1
src/storied/tools/mechanics.py
··· 45 45 return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" 46 46 47 47 48 - @mcp.tool(tags={"dm", "planner", "advancement"}) 48 + @mcp.tool(tags={"dm", "planner", "advancement", "arc_architect"}) 49 49 def recall( 50 50 query: str, 51 51 scope: Literal["rules", "world", "all"] = "all",
+21
src/storied/tools/scene.py
··· 107 107 return "Style updated." 108 108 109 109 110 + @mcp.tool(tags={"arc_architect"}) 111 + def commit_arc( 112 + content: str, 113 + world: str = World(), 114 + ) -> str: 115 + """Commit the chosen campaign arc to ``worlds/{world}/arc.md``. 116 + 117 + Called exactly once during initial planning, after the architect 118 + has read the cold draft, brainstormed alternatives, and chosen 119 + a direction. The content should be loose markdown capturing 120 + premise, local hook, the SHAPE of the larger thing (not the 121 + resolution), tonal commitments, and explicitly what's off the 122 + table. Specific events stay emergent — write commitments, not 123 + an outline. 124 + """ 125 + path = world_path(world) / "arc.md" 126 + path.parent.mkdir(parents=True, exist_ok=True) 127 + path.write_text(content) 128 + return "Arc committed." 129 + 130 + 110 131 @mcp.tool(tags={"dm"}) 111 132 def end_session( 112 133 situation: str,
+455
tests/test_arc_planner.py
··· 1 + """Tests for the arc planner — helpers and plot_arc orchestrator.""" 2 + 3 + from pathlib import Path 4 + from unittest.mock import MagicMock, patch 5 + 6 + import pytest 7 + 8 + from storied import paths 9 + from storied.character import create_character 10 + from storied.planner import ( 11 + _draw_oblique_strategies, 12 + _load_concept_pools, 13 + _load_world_preferences, 14 + _pick_random_concepts, 15 + plot_arc, 16 + seed_world, 17 + ) 18 + 19 + # ---- _load_concept_pools ---------------------------------------------------- 20 + 21 + 22 + class TestLoadConceptPools: 23 + def test_loads_shipped_pools_by_default(self): 24 + pools = _load_concept_pools() 25 + assert pools # shipped file has categories 26 + assert all(isinstance(items, list) for items in pools.values()) 27 + assert all(len(items) > 0 for items in pools.values()) 28 + 29 + def test_user_override_replaces_shipped(self, tmp_path: Path): 30 + user_dir = paths.user_rules_path() 31 + user_dir.mkdir(parents=True, exist_ok=True) 32 + (user_dir / "concept_pools.md").write_text( 33 + "# My Pools\n\n## Custom\n- alpha\n- beta\n- gamma\n" 34 + ) 35 + 36 + pools = _load_concept_pools() 37 + assert pools == {"Custom": ["alpha", "beta", "gamma"]} 38 + 39 + def test_returns_empty_when_neither_exists( 40 + self, tmp_path: Path, monkeypatch, 41 + ): 42 + monkeypatch.setattr( 43 + paths, "user_rules_path", lambda: tmp_path / "missing-user", 44 + ) 45 + from storied import planner 46 + monkeypatch.setattr( 47 + planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", 48 + ) 49 + 50 + pools = _load_concept_pools() 51 + assert pools == {} 52 + 53 + def test_skips_categories_without_items(self, tmp_path: Path): 54 + user_dir = paths.user_rules_path() 55 + user_dir.mkdir(parents=True, exist_ok=True) 56 + (user_dir / "concept_pools.md").write_text( 57 + "## Empty\n\n## Has items\n- one\n- two\n" 58 + ) 59 + 60 + pools = _load_concept_pools() 61 + assert "Empty" not in pools 62 + assert pools["Has items"] == ["one", "two"] 63 + 64 + 65 + # ---- _pick_random_concepts -------------------------------------------------- 66 + 67 + 68 + class TestPickRandomConcepts: 69 + def test_picks_requested_count_across_categories(self, tmp_path: Path): 70 + user_dir = paths.user_rules_path() 71 + user_dir.mkdir(parents=True, exist_ok=True) 72 + (user_dir / "concept_pools.md").write_text( 73 + "## A\n- a1\n- a2\n\n## B\n- b1\n- b2\n\n## C\n- c1\n- c2\n" 74 + ) 75 + 76 + picks = _pick_random_concepts(count=3) 77 + assert len(picks) == 3 78 + assert all(p in ("a1", "a2", "b1", "b2", "c1", "c2") for p in picks) 79 + 80 + def test_one_pick_per_category(self, tmp_path: Path): 81 + # Each pick comes from a distinct category, so picks from a 82 + # 3-category pool of 100 items each shouldn't exceed 3 distinct 83 + # category-tagged items. 84 + user_dir = paths.user_rules_path() 85 + user_dir.mkdir(parents=True, exist_ok=True) 86 + (user_dir / "concept_pools.md").write_text( 87 + "## A\n- a1\n- a2\n\n## B\n- b1\n- b2\n\n## C\n- c1\n- c2\n" 88 + ) 89 + 90 + picks = _pick_random_concepts(count=3) 91 + # Each category contributes at most one pick. 92 + from_a = sum(1 for p in picks if p.startswith("a")) 93 + from_b = sum(1 for p in picks if p.startswith("b")) 94 + from_c = sum(1 for p in picks if p.startswith("c")) 95 + assert from_a <= 1 96 + assert from_b <= 1 97 + assert from_c <= 1 98 + 99 + def test_count_exceeding_categories_returns_all(self, tmp_path: Path): 100 + user_dir = paths.user_rules_path() 101 + user_dir.mkdir(parents=True, exist_ok=True) 102 + (user_dir / "concept_pools.md").write_text( 103 + "## A\n- a1\n\n## B\n- b1\n" 104 + ) 105 + 106 + picks = _pick_random_concepts(count=10) 107 + assert len(picks) == 2 108 + 109 + def test_empty_pools_returns_empty(self, tmp_path: Path, monkeypatch): 110 + monkeypatch.setattr( 111 + paths, "user_rules_path", lambda: tmp_path / "missing-user", 112 + ) 113 + from storied import planner 114 + monkeypatch.setattr( 115 + planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", 116 + ) 117 + 118 + assert _pick_random_concepts(count=8) == [] 119 + 120 + 121 + # ---- _draw_oblique_strategies ----------------------------------------------- 122 + 123 + 124 + class TestDrawObliqueStrategies: 125 + def test_draws_from_shipped_by_default(self): 126 + drawn = _draw_oblique_strategies(count=4) 127 + assert len(drawn) == 4 128 + # Each entry should be a stripped non-empty string 129 + assert all(isinstance(s, str) and s for s in drawn) 130 + 131 + def test_no_duplicates_within_a_call(self): 132 + drawn = _draw_oblique_strategies(count=10) 133 + assert len(drawn) == len(set(drawn)) 134 + 135 + def test_user_override_replaces_shipped(self, tmp_path: Path): 136 + user_dir = paths.user_rules_path() 137 + user_dir.mkdir(parents=True, exist_ok=True) 138 + (user_dir / "oblique_strategies.md").write_text( 139 + "# My Strategies\n\n- one\n- two\n- three\n" 140 + ) 141 + 142 + drawn = _draw_oblique_strategies(count=2) 143 + assert all(d in ("one", "two", "three") for d in drawn) 144 + assert len(drawn) == 2 145 + 146 + def test_count_exceeding_deck_returns_all(self, tmp_path: Path): 147 + user_dir = paths.user_rules_path() 148 + user_dir.mkdir(parents=True, exist_ok=True) 149 + (user_dir / "oblique_strategies.md").write_text( 150 + "- only\n- two\n" 151 + ) 152 + 153 + drawn = _draw_oblique_strategies(count=10) 154 + assert len(drawn) == 2 155 + 156 + def test_empty_deck_returns_empty(self, tmp_path: Path, monkeypatch): 157 + monkeypatch.setattr( 158 + paths, "user_rules_path", lambda: tmp_path / "missing-user", 159 + ) 160 + from storied import planner 161 + monkeypatch.setattr( 162 + planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", 163 + ) 164 + 165 + assert _draw_oblique_strategies(count=4) == [] 166 + 167 + 168 + # ---- _load_world_preferences ------------------------------------------------ 169 + 170 + 171 + @pytest.fixture 172 + def world_with_style(tmp_path: Path) -> str: 173 + world_dir = paths.world_path("default") 174 + world_dir.mkdir(parents=True, exist_ok=True) 175 + (world_dir / "style.md").write_text("Grim. Slow-burn investigation.") 176 + return "default" 177 + 178 + 179 + @pytest.fixture 180 + def world_with_style_and_arc(tmp_path: Path) -> str: 181 + world_dir = paths.world_path("default") 182 + world_dir.mkdir(parents=True, exist_ok=True) 183 + (world_dir / "style.md").write_text("Grim. Slow-burn investigation.") 184 + (world_dir / "arc.md").write_text( 185 + "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" 186 + ) 187 + return "default" 188 + 189 + 190 + class TestLoadWorldPreferences: 191 + def test_returns_empty_when_neither_exists(self): 192 + assert _load_world_preferences("default", include_arc=True) == "" 193 + 194 + def test_style_only_when_arc_excluded(self, world_with_style_and_arc): 195 + out = _load_world_preferences("default", include_arc=False) 196 + assert "Player Preferences" in out 197 + assert "Grim. Slow-burn" in out 198 + assert "Campaign Arc" not in out 199 + 200 + def test_includes_arc_when_requested(self, world_with_style_and_arc): 201 + out = _load_world_preferences("default", include_arc=True) 202 + assert "Player Preferences" in out 203 + assert "Campaign Arc" in out 204 + 205 + def test_style_only_when_no_arc_on_disk(self, world_with_style): 206 + out = _load_world_preferences("default", include_arc=True) 207 + assert "Player Preferences" in out 208 + assert "Campaign Arc" not in out 209 + 210 + def test_ends_with_separator(self, world_with_style): 211 + out = _load_world_preferences("default", include_arc=True) 212 + assert out.endswith("---\n\n") 213 + 214 + 215 + # ---- plot_arc orchestrator -------------------------------------------------- 216 + 217 + 218 + @pytest.fixture 219 + def character_world(tmp_path: Path) -> Path: 220 + create_character( 221 + player_id="default", 222 + name="Seren", 223 + race="Half-Elf", 224 + char_class="Ranger", 225 + level=1, 226 + abilities={ 227 + "strength": 10, "dexterity": 16, "constitution": 12, 228 + "intelligence": 14, "wisdom": 13, "charisma": 14, 229 + }, 230 + hp_max=11, 231 + ac=14, 232 + background="Outlander", 233 + backstory="A coastal wanderer.", 234 + ) 235 + world_dir = paths.world_path("default") 236 + world_dir.mkdir(parents=True, exist_ok=True) 237 + (world_dir / "style.md").write_text("Eerie atmospheric mystery.") 238 + return tmp_path 239 + 240 + 241 + class TestPlotArc: 242 + @patch("storied.planner.start_mcp_server") 243 + @patch("storied.planner.run_with_tools") 244 + @patch("storied.planner.run_prompt") 245 + def test_pass_a_then_pass_b( 246 + self, 247 + mock_run_prompt: MagicMock, 248 + mock_run_with_tools: MagicMock, 249 + mock_start_mcp: MagicMock, 250 + character_world: Path, 251 + ): 252 + mock_run_prompt.return_value = "## Cold treatment\n\nTotally generic." 253 + mock_run_with_tools.return_value = MagicMock( 254 + usage={"input_tokens": 1000, "output_tokens": 200, "tool_calls": 1} 255 + ) 256 + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 257 + 258 + plot_arc(world_id="default", player_id="default") 259 + 260 + # Pass A: cold draft via run_prompt 261 + mock_run_prompt.assert_called_once() 262 + assert mock_run_prompt.call_args.kwargs["effort"] == "max" 263 + assert mock_run_prompt.call_args.kwargs["model"] == "claude-opus-4-6" 264 + 265 + # Pass B: architect via run_with_tools 266 + mock_run_with_tools.assert_called_once() 267 + assert mock_run_with_tools.call_args.kwargs["effort"] == "max" 268 + 269 + # Pass B uses the arc_architect MCP role 270 + mock_start_mcp.assert_called_once() 271 + assert mock_start_mcp.call_args[0][2] == "arc_architect" 272 + 273 + @patch("storied.planner.start_mcp_server") 274 + @patch("storied.planner.run_with_tools") 275 + @patch("storied.planner.run_prompt") 276 + def test_pass_a_receives_only_character_and_style( 277 + self, 278 + mock_run_prompt: MagicMock, 279 + mock_run_with_tools: MagicMock, 280 + mock_start_mcp: MagicMock, 281 + character_world: Path, 282 + ): 283 + mock_run_prompt.return_value = "cold draft" 284 + mock_run_with_tools.return_value = MagicMock(usage={}) 285 + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 286 + 287 + plot_arc(world_id="default", player_id="default") 288 + 289 + pass_a_user = mock_run_prompt.call_args.kwargs["user_message"] 290 + # Pass A should NOT contain novelty injection or the cold draft itself 291 + assert "Random Concept Seeds" not in pass_a_user 292 + assert "Thinking Moves For This Run" not in pass_a_user 293 + assert "Cold Draft" not in pass_a_user 294 + # But it SHOULD contain the style and the character sheet 295 + assert "Player Preferences" in pass_a_user 296 + assert "Eerie atmospheric mystery" in pass_a_user 297 + assert "Seren" in pass_a_user 298 + 299 + @patch("storied.planner.start_mcp_server") 300 + @patch("storied.planner.run_with_tools") 301 + @patch("storied.planner.run_prompt") 302 + def test_pass_b_receives_cold_draft_and_novelty_levers( 303 + self, 304 + mock_run_prompt: MagicMock, 305 + mock_run_with_tools: MagicMock, 306 + mock_start_mcp: MagicMock, 307 + character_world: Path, 308 + ): 309 + mock_run_prompt.return_value = "## Cold draft\n\nGeneric coastal horror." 310 + mock_run_with_tools.return_value = MagicMock(usage={}) 311 + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 312 + 313 + plot_arc(world_id="default", player_id="default") 314 + 315 + pass_b_user = mock_run_with_tools.call_args.kwargs["user_message"] 316 + assert "## Cold Draft" in pass_b_user 317 + assert "Generic coastal horror." in pass_b_user 318 + assert "## Random Concept Seeds" in pass_b_user 319 + assert "## Thinking Moves For This Run" in pass_b_user 320 + assert "Player Preferences" in pass_b_user 321 + assert "Seren" in pass_b_user 322 + 323 + @patch("storied.planner.start_mcp_server") 324 + @patch("storied.planner.run_with_tools") 325 + @patch("storied.planner.run_prompt") 326 + def test_pass_b_uses_arc_architect_prompt( 327 + self, 328 + mock_run_prompt: MagicMock, 329 + mock_run_with_tools: MagicMock, 330 + mock_start_mcp: MagicMock, 331 + character_world: Path, 332 + ): 333 + mock_run_prompt.return_value = "cold draft" 334 + mock_run_with_tools.return_value = MagicMock(usage={}) 335 + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") 336 + 337 + plot_arc(world_id="default", player_id="default") 338 + 339 + system_prompt = mock_run_with_tools.call_args.kwargs["system_prompt"] 340 + assert "Arc Architect" in system_prompt 341 + 342 + @patch("storied.planner.run_prompt") 343 + def test_no_character_returns_empty_result( 344 + self, mock_run_prompt: MagicMock, tmp_path: Path, 345 + ): 346 + result = plot_arc(world_id="default", player_id="default") 347 + assert result.tool_calls == 0 348 + mock_run_prompt.assert_not_called() 349 + 350 + 351 + # ---- seed_world: arc + effort ----------------------------------------------- 352 + 353 + 354 + class TestSeedWorldArcAndEffort: 355 + @patch("storied.planner.run_with_tools") 356 + def test_seed_world_passes_effort_max( 357 + self, 358 + mock_run_with_tools: MagicMock, 359 + character_world: Path, 360 + ): 361 + mock_run_with_tools.return_value = None 362 + 363 + seed_world(world_id="default", player_id="default") 364 + 365 + mock_run_with_tools.assert_called_once() 366 + assert mock_run_with_tools.call_args.kwargs["effort"] == "max" 367 + 368 + @patch("storied.planner.run_with_tools") 369 + def test_seed_world_includes_arc_when_present( 370 + self, 371 + mock_run_with_tools: MagicMock, 372 + character_world: Path, 373 + ): 374 + world_dir = paths.world_path("default") 375 + (world_dir / "arc.md").write_text( 376 + "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" 377 + ) 378 + mock_run_with_tools.return_value = None 379 + 380 + seed_world(world_id="default", player_id="default") 381 + 382 + user_message = mock_run_with_tools.call_args.kwargs["user_message"] 383 + assert "Campaign Arc" in user_message 384 + assert "A quiet mystery." in user_message 385 + 386 + @patch("storied.planner.run_with_tools") 387 + def test_seed_world_includes_concept_seeds_and_obliques( 388 + self, 389 + mock_run_with_tools: MagicMock, 390 + character_world: Path, 391 + ): 392 + # The seeder gets the same anti-rut levers the architect gets: 393 + # random concept seeds (mandate) and a drawn hand of Oblique 394 + # Strategies cards (thinking moves). 395 + mock_run_with_tools.return_value = None 396 + 397 + seed_world(world_id="default", player_id="default") 398 + 399 + user_message = mock_run_with_tools.call_args.kwargs["user_message"] 400 + assert "## Random Concept Seeds" in user_message 401 + assert "## Thinking Moves For This Run" in user_message 402 + assert "Oblique Strategies" in user_message 403 + 404 + 405 + class TestPlannerInspirationContext: 406 + """The planner gets obliques only — no random concept seeds. 407 + 408 + The planner is constrained to enriching existing entities; forcing 409 + in random concept material would distort what's already there. 410 + Obliques (process directives) are safe because they shape HOW the 411 + model thinks rather than WHAT it adds. 412 + """ 413 + 414 + def test_planner_context_includes_obliques( 415 + self, character_world: Path, 416 + ): 417 + from storied.planner import build_planning_context 418 + from storied.session import save_session 419 + 420 + save_session("default", { 421 + "location": "Tavern", 422 + "world": "default", 423 + "body": "## Situation\nSeren is at the bar.", 424 + }) 425 + 426 + context = build_planning_context( 427 + world_id="default", 428 + player_id="default", 429 + candidates=[], 430 + ) 431 + 432 + assert "## Thinking Moves For This Run" in context 433 + assert "Oblique Strategies" in context 434 + 435 + def test_planner_context_does_not_include_concept_seeds( 436 + self, character_world: Path, 437 + ): 438 + from storied.planner import build_planning_context 439 + from storied.session import save_session 440 + 441 + save_session("default", { 442 + "location": "Tavern", 443 + "world": "default", 444 + "body": "## Situation\nSeren is at the bar.", 445 + }) 446 + 447 + context = build_planning_context( 448 + world_id="default", 449 + player_id="default", 450 + candidates=[], 451 + ) 452 + 453 + # No mandate to weave random concepts — that would distort 454 + # existing entities the planner is enriching. 455 + assert "## Random Concept Seeds" not in context
+38 -3
tests/test_engine.py
··· 93 93 ) 94 94 95 95 def test_build_context_no_style(self, engine): 96 - context = engine._build_context() 96 + engine._build_context() 97 97 assert "Style" not in engine._context_parts 98 98 99 99 def test_build_context_with_style(self, engine, tmp_path: Path): 100 100 style_path = tmp_path / "worlds" / "test" / "style.md" 101 101 style_path.write_text("# Style\n\nMore intrigue, less combat.\n") 102 102 103 - context = engine._build_context() 103 + engine._build_context() 104 104 105 105 assert "Style" in engine._context_parts 106 106 assert "intrigue" in engine._context_parts["Style"] 107 + 108 + def test_build_context_no_arc(self, engine): 109 + engine._build_context() 110 + assert "Arc" not in engine._context_parts 111 + 112 + def test_build_context_with_arc(self, engine, tmp_path: Path): 113 + arc_path = tmp_path / "worlds" / "test" / "arc.md" 114 + arc_path.parent.mkdir(parents=True, exist_ok=True) 115 + arc_path.write_text( 116 + "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" 117 + "\n## Off the Table\n- the cosmic-prisoner trope\n" 118 + ) 119 + 120 + engine._build_context() 121 + 122 + assert "Arc" in engine._context_parts 123 + assert "Campaign Arc" in engine._context_parts["Arc"] 124 + assert "cosmic-prisoner" in engine._context_parts["Arc"] 125 + 126 + def test_arc_appears_after_style_in_context_order( 127 + self, engine, tmp_path: Path, 128 + ): 129 + style_path = tmp_path / "worlds" / "test" / "style.md" 130 + style_path.write_text("# Style\n\nDark tone.\n") 131 + arc_path = tmp_path / "worlds" / "test" / "arc.md" 132 + arc_path.write_text("# Campaign Arc\n\n## Premise\nMystery.\n") 133 + 134 + engine._build_context() 135 + parts = list(engine._context_parts.keys()) 136 + 137 + style_idx = parts.index("Style") 138 + arc_idx = parts.index("Arc") 139 + assert style_idx < arc_idx 107 140 108 141 def test_time_is_first_context_part(self, engine, tmp_path: Path): 109 142 """The ambient clock header goes at the top of every turn's ··· 313 346 def test_find_entity_returns_none_when_missing(self, engine): 314 347 assert engine._find_entity("Nobody") is None 315 348 316 - def test_build_context_loads_location_and_one_hop_linked(self, engine, tmp_path: Path): 349 + def test_build_context_loads_location_and_one_hop_linked( 350 + self, engine, tmp_path: Path, 351 + ): 317 352 """When the session points at a location, _build_context should load 318 353 the location, then one-hop into entities the location wikilinks.""" 319 354 from storied.session import save_session
+9
tests/test_mcp_server.py
··· 80 80 def test_advancement_only_has_its_tools(self): 81 81 assert _names("advancement") == {"notify_dm", "recall", "update_character"} 82 82 83 + def test_arc_architect_only_has_its_tools(self): 84 + # The arc architect's whole job is critique-and-commit. Nothing 85 + # else. World-building tools must not leak into this role. 86 + assert _names("arc_architect") == {"commit_arc", "recall"} 87 + 88 + def test_arc_architect_in_all_roles(self): 89 + from storied.mcp_server import ALL_ROLES 90 + assert "arc_architect" in ALL_ROLES 91 + 83 92 84 93 class TestToolSchemas: 85 94 """Verify tool input schemas expose nested field shapes to the LLM.
+26
tests/test_seeder.py
··· 29 29 for forbidden in ("roll", "recall", "mark", "note_discovery", "end_session"): 30 30 assert forbidden not in names 31 31 32 + def test_seeder_does_not_have_commit_arc(self): 33 + # commit_arc is the architect's tool — never the seeder's. 34 + assert "commit_arc" not in self._seeder_names() 35 + 36 + 37 + class TestArcArchitectTools: 38 + """The arc_architect role gets only commit_arc and recall.""" 39 + 40 + def _architect_names(self) -> set[str]: 41 + async def _gather() -> set[str]: 42 + server = await _compose_server("arc_architect") 43 + return {t.name for t in await server.list_tools()} 44 + return asyncio.run(_gather()) 45 + 46 + def test_architect_only_has_commit_arc_and_recall(self): 47 + assert self._architect_names() == {"commit_arc", "recall"} 48 + 49 + def test_architect_excludes_world_building_tools(self): 50 + names = self._architect_names() 51 + for forbidden in ( 52 + "establish", "set_scene", "mark", "amend_mark", 53 + "note_discovery", "tune", "damage", "heal", 54 + "adjust_coins", "create_character", "end_session", 55 + ): 56 + assert forbidden not in names 57 + 32 58 33 59 @pytest.fixture 34 60 def character_world(tmp_path: Path) -> Path:
+46 -3
tests/test_tune.py
··· 1 - """Tests for the DM style tuning system.""" 1 + """Tests for the DM style tuning system and the arc commit tool.""" 2 2 3 3 from pathlib import Path 4 4 5 + from storied.testing import call_tool 5 6 from storied.tools import ToolContext 7 + from storied.tools.scene import commit_arc as _commit_arc 6 8 from storied.tools.scene import tune as _tune 7 9 8 - from storied.testing import call_tool 9 - 10 10 11 11 def tune(tuning: str) -> str: 12 12 return call_tool(_tune, tuning=tuning) 13 + 14 + 15 + def commit_arc(content: str) -> str: 16 + return call_tool(_commit_arc, content=content) 13 17 14 18 15 19 class TestTune: ··· 45 49 result = tune("Dark and atmospheric tone.") 46 50 47 51 assert "updated" in result.lower() 52 + 53 + 54 + class TestCommitArc: 55 + """Tests for the arc_architect's commit_arc tool.""" 56 + 57 + def test_commit_arc_creates_arc_file( 58 + self, ctx: ToolContext, tmp_path: Path, 59 + ): 60 + commit_arc("# Campaign Arc\n\n## Premise\nA quiet mystery.\n") 61 + 62 + arc_path = tmp_path / "worlds" / ctx.world_id / "arc.md" 63 + assert arc_path.exists() 64 + 65 + def test_commit_arc_writes_content_verbatim( 66 + self, ctx: ToolContext, tmp_path: Path, 67 + ): 68 + content = ( 69 + "# Campaign Arc\n\n## Premise\nA wedding postponed twice.\n" 70 + "\n## Off the Table\n- the cosmic-prisoner trope\n" 71 + ) 72 + commit_arc(content) 73 + 74 + arc_path = tmp_path / "worlds" / ctx.world_id / "arc.md" 75 + assert arc_path.read_text() == content 76 + 77 + def test_commit_arc_replaces_existing( 78 + self, ctx: ToolContext, tmp_path: Path, 79 + ): 80 + commit_arc("# Campaign Arc\n\n## Premise\nFirst draft.\n") 81 + commit_arc("# Campaign Arc\n\n## Premise\nSecond draft.\n") 82 + 83 + content = (tmp_path / "worlds" / ctx.world_id / "arc.md").read_text() 84 + assert "Second draft." in content 85 + assert "First draft." not in content 86 + 87 + def test_commit_arc_returns_confirmation(self, ctx: ToolContext): 88 + result = commit_arc("# Campaign Arc\n\n## Premise\nA test.\n") 89 + 90 + assert "committed" in result.lower()