diff --git a/prompts/arc-architect.md b/prompts/arc-architect.md new file mode 100644 index 0000000..b015f24 --- /dev/null +++ b/prompts/arc-architect.md @@ -0,0 +1,102 @@ +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. + +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. + +## What you're given + +- **Character sheet** — who the player is playing +- **Player style preferences** (`## Player Preferences` block) — tone, themes, what to lean into, what to avoid +- **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**. +- **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. +- **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. + +## Your task — four rounds, then commit + +Use your full extended-thinking budget. Don't rush. + +### Round 1 — Diagnose the cold draft + +Read the cold draft carefully. In one paragraph, articulate: + +- What plot shape it commits to +- What its central conceit is +- What rut or default genre well it represents +- What parts of it feel "obvious" given the character + style + +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. + +### Round 2 — Brainstorm alternatives that diverge from the cold draft + +Generate 5-7 alternative shapes. Each one is a short pitch with: + +- Premise +- Central tension +- Scale (intimate / regional / cosmic / civilizational) +- Subgenre tag +- Which 2-3 concept seeds from the random list it incorporates + +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. + +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. + +### Round 3 — Critique with your Oblique Strategies cards + +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. + +Cross out the alternatives that don't survive scrutiny — especially any that turned out to be the cold draft in disguise. + +### Round 4 — Pick and stress-test + +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: + +- Can you imagine 20+ sessions of this without it collapsing back into the cold draft's well? +- Does the central tension generate enough situations to drive play? +- Does it leave room for the player's specific character to matter? + +If the answer to any of these is no, pick a different one. + +### Then commit + +Call `commit_arc` exactly once with the chosen shape written as the following markdown structure: + +```markdown +# Campaign Arc + +## Premise +One paragraph. What kind of campaign is this? + +## The Local Hook +One paragraph. The small, immediate thing that draws the player +in. Not the resolution — the doorway. + +## The Shape Behind It +One paragraph. The larger force/mystery/conflict, kept at the +level of SHAPE not specifics. "There is something old and wrong +about how the road remembers travelers" rather than "an undead +lich trapped in the road wants vengeance." + +## Tonal Commitments +3-5 bullets. The tonal promises this campaign keeps. + +## Off the Table +3-5 bullets. The clichés you identified in Round 1 and rejected. +Future passes (and the DM during play) must respect this list. + +## Concept Seeds Woven In +The 3+ seeds you incorporated, briefly noting how each one shows up. + +## Open Questions +3-5 things deliberately left for the DM to discover during play. +These are NOT plot beats — they're spaces where the story can +surprise the player. +``` + +Once `commit_arc` returns, stop. The seeder takes over. + +## Rules + +- The arc is **commitments, not outline**. Do not write specific events, dates, or NPC names. The seeder and the DM will generate those. +- 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. +- 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. +- At least three of the supplied concept seeds must be woven into the chosen shape. +- 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é. diff --git a/prompts/cold-draft.md b/prompts/cold-draft.md new file mode 100644 index 0000000..5b67db3 --- /dev/null +++ b/prompts/cold-draft.md @@ -0,0 +1,15 @@ +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. + +Include: + +- **Premise** — what kind of campaign this is +- **Opening hook** — the small immediate situation that draws the player in +- **The larger thing behind it** — the bigger force, mystery, or conflict the campaign builds toward +- **Key locations** — 3-5 places that anchor the story +- **2-3 named NPCs** — who they are, what they want +- **Central themes and tonal feel** +- **An overall arc shape** — how the story would progress across many sessions + +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. + +Write your treatment and stop. Do not call any tools. There are no tools available. diff --git a/prompts/concept_pools.md b/prompts/concept_pools.md new file mode 100644 index 0000000..9c0d555 --- /dev/null +++ b/prompts/concept_pools.md @@ -0,0 +1,211 @@ +# Concept Pools + +Curated domain categories for the procedural concept seed picker. +The picker randomly chooses categories and pulls a random item from +each — the math is what keeps any single curator's taste from +dominating the output. + +Categories are deliberately non-fantasy. Items inside are concrete +(not themes, not feelings, not abstractions). Override this whole +file by writing your own at `~/.storied/concept_pools.md`. + +## Industrial materials +- brass +- soldered tin +- mica +- pitch +- bone meal +- slag glass +- jute +- shellac +- horsehair plaster +- isinglass +- linseed oil +- powdered chalk +- raw silk +- whale ivory +- Indian rubber +- gutta-percha +- tow +- ochre +- verdigris +- lampblack +- bituminous coal +- lead solder +- camphor +- gum arabic +- waxed canvas +- sailcloth +- bog iron +- antimony +- saltpetre +- spermaceti +- borax +- naphtha + +## Kitchen objects +- a colander with a single dent +- the wrong knife for the job +- a single chopstick +- a cracked storage jar +- a wooden spoon worn smooth on one side +- a salt cellar with a stuck lid +- a pestle without its mortar +- a bread board scarred along one edge +- a kettle that whistles flat +- a sieve missing two wires +- a butter mould carved with a bird +- a teacup with a hairline crack +- a clay jug stoppered with rag +- a pickling crock +- a fish-scaler bone +- a candle stub in a saucer +- a tin spice box with one rusted hinge +- a knife sheath without a knife +- a porridge pot stained black inside +- a wooden trencher +- a mug with a hand-cut handle +- a butcher's hook hanging empty + +## Historical occupations +- a night-soil collector +- a knocker-up +- a longshoreman who can't swim +- a charcoal burner +- a tide-table copyist +- a leech-gatherer +- a pure-finder +- a tanner with stained forearms +- a mudlark +- a milkmaid who counts in twos +- a wet-nurse for hire +- a stevedore who limps +- a coffin-maker's apprentice +- a chimney sweep too tall for the work +- a sin-eater +- a bell-founder +- a wax-chandler +- a town crier with no voice left +- a fuller of cloth +- a road-mender +- a rat-catcher with a thumb missing +- a pawnbroker who doesn't read +- a glove-maker +- a fortune-teller who refuses fees +- a clerk who copies in two hands +- a courier who never sleeps indoors +- a pieman +- a watchmaker losing his sight +- a midwife who weighs in salt +- a tin-smith who whistles + +## Weather phenomena +- the moment fog burns off +- pre-rain pressure +- a sky with two different cloud layers +- snow that falls on dry ground +- wind from the wrong quarter +- a sudden lull mid-storm +- frost on the sunward side only +- a mist that smells of iron +- rain that comes and goes in three breaths +- a hailstone with a feather inside +- thunder without lightning +- lightning without thunder +- a noon shadow that's too long +- the day the wind stopped +- horizontal sleet +- a heat haze over wet ground +- rolling fog with clear pockets +- a cold draft from no door +- snow at the sea's edge +- a green sky before storms + +## Anatomical / sensory +- the taste of pennies +- a left ear that won't pop +- a callus in an unlikely place +- a scar that itches before rain +- a tooth gone soft +- knuckles that crack on the right hand only +- a tongue still numb from this morning +- pins and needles in one foot +- a smell stuck in the back of the throat +- ringing in only one ear +- a thumbnail growing in striped +- the spot between the shoulder blades you can't reach +- one pupil slow to react +- a heartbeat felt in a wrist +- a bruise that doesn't hurt +- an old break that aches in cold +- a thirst that water doesn't fix +- the metallic taste before a fall +- a hair gone white at the temple +- a freckle no one remembers +- a finger that won't bend all the way +- a sneeze that won't come + +## Mundane activities (with a wrong note) +- a wedding postponed twice +- a queue that hasn't moved +- a letter delivered to the wrong door, twice +- a market day with no buyers +- a christening attended by strangers +- a funeral with no mourners +- a meeting moved without notice +- a bell rung at the wrong hour +- a door painted overnight +- a window kept open in winter +- laundry left out too long +- a chair left in the road +- a key left in a strange lock +- a rent paid in the wrong coin +- a horse returned without its rider +- a cart unloaded into the wrong shop +- a meal cooked for someone who didn't come +- a song sung in the wrong tune +- a debt paid by a stranger +- a name shouted in an empty street + +## Numerical / temporal +- a number repeating in unrelated places +- the day after a holiday +- a date written in the future +- a clock that runs slow on Tuesdays +- the same hour told three different ways +- a year nobody admits happened +- a tally that comes out one short +- a calendar with a missing month +- a birthday no one celebrates +- a tide that arrived early +- a shadow at the wrong length +- counting that goes one, two, four +- a coin minted in a year that doesn't exist +- a debt older than the family +- a leap day on the wrong year +- the quarter-hour that takes too long +- midnight measured by the second bell +- a contract dated yesterday but signed last week +- an invoice for a service nobody rendered + +## Linguistic / textual +- a word that doesn't translate +- two signatures in different inks +- a misspelling that's deliberate +- a name written down once and never spoken +- a margin note in a hand nobody recognizes +- a letter sealed but never sent +- a ledger entry crossed out twice +- a phrase whispered in a dead tongue +- a child's first word that wasn't a word +- a song with one verse missing +- a sign painted over and over +- handwriting that gets neater near the end +- a postcard with no stamp +- a tally chalked on a doorpost +- a mispronounced family name +- a book inscribed to nobody +- a list of seven items with the sixth blank +- a sentence in three different scripts +- initials carved with the wrong tool +- a contract with one party left unnamed diff --git a/prompts/oblique_strategies.md b/prompts/oblique_strategies.md new file mode 100644 index 0000000..dad61e8 --- /dev/null +++ b/prompts/oblique_strategies.md @@ -0,0 +1,101 @@ +# Oblique Strategies + +After Brian Eno & Peter Schmidt (1975). +See: https://en.wikipedia.org/wiki/Oblique_Strategies + +A deck of cards designed to break creative ruts. The arc architect +draws a small handful of these per seeding run rather than reading +the whole deck — that's how Eno intended them to be used. + +Override this file by writing your own at +`~/.storied/oblique_strategies.md`. + +- Honor thy error as a hidden intention +- Use an old idea +- State the problem in words as clearly as possible +- Look closely at the most embarrassing details and amplify them +- What would your closest friend do? +- Don't be afraid of things because they're easy to do +- Make a sudden, destructive, unpredictable action; incorporate +- Ask your body +- Reverse +- Go outside. Shut the door. +- Remove specifics and convert to ambiguities +- Take away the elements in order of apparent non-importance +- Repetition is a form of change +- Distorting time +- The most important thing is the thing most easily forgotten +- A line has two sides +- Disconnect from desire +- Don't be frightened of clichés +- What is the simplest solution? +- The inconsistency principle +- What would your mother think? +- Tape your mouth +- Cut a vital connection +- Faced with a choice, do both +- Use unqualified people +- Discover the recipes you are using and abandon them +- Listen to the quiet voice +- Mute and continue +- Towards the insignificant +- Children's voices, speaking +- Children's voices, singing +- Make it more sensual +- Make it more difficult +- Look at the order in which you do things +- Take a break +- Change instrument roles +- Don't break the silence +- Trust in the you of now +- Cluster analysis +- Once the search is in progress, something will be found +- Bridges build, burn +- Are there sections? Consider transitions. +- Always first steps +- Use fewer notes +- Idiot glee +- Just carry on +- Tidy up +- Accept advice +- Don't avoid what is easy +- Use 'unqualified' people +- Lost in useless territory +- Go to an extreme, move back to a more comfortable place +- Imagine the music as a moving chain or caterpillar +- Make a blank valuable by putting it in an exquisite frame +- Define an area as 'safe' and use it as an anchor +- Discard an axiom +- Decorate, decorate +- Shut the door and listen from outside +- Remember those quiet evenings +- Emphasize differences +- Emphasize the flaws +- Don't stress one thing more than another +- Twist the spine +- Spectrum analysis +- Mechanicalize something idiosyncratic +- Humanize something free of error +- Use an unacceptable color +- Question the heroic +- Allow an easement (an easement is the abandonment of a stricture) +- Convert a melodic element into a rhythmic element +- Do nothing for as long as possible +- Look at a very small object; look at its centre +- Look at a big object; look at its centre +- The tape is now the music +- Only one element of each kind +- Is there something missing? +- Is the intonation correct? +- Is it finished? +- Where is the edge? +- Imagine the piece as a set of disconnected events +- Where's the edge? Where does the frame start? +- The first word is the hardest +- Courage! +- Just carry on +- Voice nagging suspicions +- You don't have to be ashamed of using your own ideas +- Cascades +- Balance the consistency principle with the inconsistency principle +- Breathe more deeply diff --git a/prompts/planner-system.md b/prompts/planner-system.md index dff4e5a..d375444 100644 --- a/prompts/planner-system.md +++ b/prompts/planner-system.md @@ -2,6 +2,38 @@ You are a World Architect for a 5e solo adventure. You prepare the world so the 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. +## What You Are and What You Are Not + +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. + +**You CAN and SHOULD:** +- Enrich the thin entities you were given with Knows/Wants/Will that has weight +- Plant new twists and surprises that fit the arc's "Shape Behind It" +- Surface hidden connections between existing entities — secrets, shared histories, collisions waiting to happen +- Establish entities mentioned in the recent campaign log if they'd make the DM's job easier +- Thicken open plot threads with new beats, deadlines, or evidence +- Push the existing mystery toward unexpected places — *toward* the arc's shape, never away from it +- Use the drawn Oblique Strategies (when present) to find non-obvious moves + +**You must NOT:** +- Contradict the arc's commitments — premise, hook, the shape behind it, tonal commitments +- 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. +- Pivot the campaign to a fundamentally different shape than the arc commits to +- Take actions for the player character or write events the player was present for +- Resolve major mysteries on the player's behalf — surface them, deepen them, never solve them +- Replace existing Knows/Wants/Will — only add to them +- Contradict established facts in the campaign log + +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. + +## Player Preferences and Campaign Arc + +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. + +## Thinking Moves For This Run + +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. + ## Your Tools | Tool | Purpose | diff --git a/prompts/world-seed.md b/prompts/world-seed.md index d9115d7..8033c84 100644 --- a/prompts/world-seed.md +++ b/prompts/world-seed.md @@ -9,11 +9,26 @@ You are a World Architect for a solo 5e adventure. You're building the opening w ## What You're Given -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. +A character sheet — name, race, class, backstory, personality — and several prepended blocks: -**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*. +1. **`## Player Preferences`** (from `style.md`): tone, themes, genre, pacing. +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. +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. +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. -If no preferences block is present, fall back to inferring tone from the character's backstory alone. +## The Arc Is Non-Negotiable + +The Campaign Arc block is the **spine of everything you build.** Every entity you establish must align with the arc: + +- The Premise sets the *kind* of campaign — your locations, NPCs, threads, and opening scene must all read as that kind of campaign. +- The Local Hook is what's drawing the player in — your immediate location and starting NPCs should make that hook feel real and present. +- 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. +- The Tonal Commitments tell you the *feel* — match them in tone, language, and texture. +- **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. + +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. + +If no Campaign Arc block is present (legacy worlds), fall back to honoring style.md alone. ## What to Build diff --git a/prompts/world-tick.md b/prompts/world-tick.md index 19166d1..bc0a5ea 100644 --- a/prompts/world-tick.md +++ b/prompts/world-tick.md @@ -1,5 +1,21 @@ 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. +## What You Are and What You Are Not + +**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*. + +**Your job is to fire existing Will triggers and add small, subtle off-screen beats. It is NOT to:** +- Resolve plot threads on the player's behalf +- Move major NPCs in ways that would derail the next scene +- Introduce new entities, factions, or arcs +- Make big visible changes the DM has to explain +- Override the arc's "Off the Table" list because you have a "better" idea +- Time-skip past situations the player is in the middle of + +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. + +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. + ## Your Tools | Tool | Purpose | @@ -12,6 +28,7 @@ You are a World Architect advancing a 5e solo adventure world between sessions. ## What You're Given +- 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. - The current game time and how much time has passed since last session - All entities with **Will** triggers (conditional behaviors) - The campaign log with recent events diff --git a/src/storied/claude.py b/src/storied/claude.py index 25293d3..5a6eed0 100644 --- a/src/storied/claude.py +++ b/src/storied/claude.py @@ -15,7 +15,6 @@ from dataclasses import dataclass, field from pathlib import Path from threading import Thread - # -- Stream event types ------------------------------------------------------- @@ -340,12 +339,15 @@ def run_prompt( # pragma: no cover user_message: str, *, model: str = "claude-haiku-4-5-20251001", + effort: str | None = None, timeout: int = 30, ) -> str | None: """Run a simple prompt through claude -p and return the text response. Plain text in, plain text out. No MCP tools, no streaming, no session. - Used for utility formatting (e.g., /status, /me). + Used for utility formatting (e.g., /status, /me) and for the cold-draft + pass of the arc planner (where it runs at opus + max effort with a + longer timeout). Not unit-tested: see stream_with_tools — this is another thin `subprocess.run` wrapper around the real `claude` CLI. @@ -361,7 +363,10 @@ def run_prompt( # pragma: no cover "--system-prompt", system_prompt, "--no-session-persistence", "--dangerously-skip-permissions", + "--exclude-dynamic-system-prompt-sections", ] + if effort is not None: + args.extend(["--effort", effort]) try: result = subprocess.run( diff --git a/src/storied/cli.py b/src/storied/cli.py index 3a23820..055f935 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -326,6 +326,7 @@ def _run_engine_loop( _SECTION_COLORS: dict[str, str] = { "Style": "dim", + "Arc": "bright_yellow", "Character": "green", "Log": "bright_cyan", "Transcript": "blue", @@ -773,14 +774,40 @@ def cmd_play(args: argparse.Namespace) -> int: console.print(f"[dim]World: {world_id}[/dim]") console.print() - # Seed the world if there's no session yet. After cold-start - # onboarding, style.md is now on disk and seed_world reads it - # so the world it builds reflects the player's preferences. + # Plot the arc and then seed the world if neither exists yet. + # After cold-start onboarding, style.md is on disk; the architect + # writes arc.md (Pass A cold draft + Pass B critique), then the + # seeder builds entities aligned with that arc. Both reflect the + # player's preferences and the chosen shape. if not sandbox: character = load_character(player_id) from storied.session import load_session session = load_session(player_id) + arc_path = world_path(world_id) / "arc.md" + + if character is not None and not arc_path.exists(): + from storied.planner import plot_arc + + console.print( + "[dim]Plotting the shape of your story...[/dim]" + ) + + def on_arc_progress(msg: str) -> None: + console.print(f"[dim] {msg}[/dim]") + + arc_result = plot_arc( + world_id=world_id, + player_id=player_id, + on_progress=on_arc_progress, + ) + + console.print( + f"[dim] Done — {arc_result.tool_calls} tool calls, " + f"{arc_result.elapsed:.1f}s[/dim]" + ) + console.print() + if session is None and character is not None: from storied.planner import seed_world diff --git a/src/storied/engine.py b/src/storied/engine.py index ab5992f..7cc1254 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -183,6 +183,15 @@ class DMEngine: self._context_parts["Style"] = style_content parts.append(style_content) + # Campaign arc — committed once during initial seeding by + # the arc_architect. The DM reads it but cannot rewrite it. + arc_path = world_path(self.world_id) / "arc.md" + if arc_path.exists(): + arc_content = arc_path.read_text().strip() + if arc_content: + self._context_parts["Arc"] = arc_content + parts.append(arc_content) + # 1. Character sheet character = load_character(self.player_id) if character: diff --git a/src/storied/mcp_server.py b/src/storied/mcp_server.py index 9ee37d6..79f75e7 100644 --- a/src/storied/mcp_server.py +++ b/src/storied/mcp_server.py @@ -26,7 +26,7 @@ from storied.tools._context import ( init_ctx, ) -ALL_ROLES = {"dm", "planner", "seeder", "advancement"} +ALL_ROLES = {"dm", "planner", "seeder", "advancement", "arc_architect"} _tool_signatures: str | None = None diff --git a/src/storied/planner.py b/src/storied/planner.py index bd1dce3..df59dce 100644 --- a/src/storied/planner.py +++ b/src/storied/planner.py @@ -1,13 +1,15 @@ """World planner — enriches thin entities near the player's current position.""" +import random import time from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from threading import Thread +from storied import paths from storied.character import format_character_context, load_character -from storied.claude import run_with_tools +from storied.claude import run_prompt, run_with_tools from storied.engine import load_prompt from storied.log import CampaignLog from storied.mcp_server import start_server as start_mcp_server @@ -104,6 +106,103 @@ def find_nearby_entities( return results +_REPO_PROMPTS = Path(__file__).parent.parent.parent / "prompts" + + +def _load_concept_pools() -> dict[str, list[str]]: + """Load concept pools from the user override or shipped default. + + The file is markdown with H2 category headers and bullet items. + Returns ``{category_name: [item, item, ...]}``. Empty mapping if + no file exists. + """ + user_path = paths.user_rules_path() / "concept_pools.md" + shipped_path = _REPO_PROMPTS / "concept_pools.md" + path = user_path if user_path.exists() else shipped_path + if not path.exists(): + return {} + + pools: dict[str, list[str]] = {} + current: str | None = None + for raw_line in path.read_text().splitlines(): + line = raw_line.rstrip() + if line.startswith("## "): + current = line[3:].strip() + pools[current] = [] + elif current is not None and line.lstrip().startswith("- "): + pools[current].append(line.lstrip()[2:].strip()) + return {k: v for k, v in pools.items() if v} + + +def _pick_random_concepts(count: int = 8) -> list[str]: + """Pick concepts by drawing N distinct categories then one item per. + + The math (random category × random item) is what keeps the curator's + arrangement-bias from dominating. Only the *category set* is fixed + by the curator; the individual items inside categories are not + correlated by the random sampler. + """ + pools = _load_concept_pools() + categories = list(pools.keys()) + if not categories: + return [] + k = min(count, len(categories)) + chosen_cats = random.sample(categories, k=k) + return [random.choice(pools[c]) for c in chosen_cats] + + +def _draw_oblique_strategies(count: int = 4) -> list[str]: + """Draw N random oblique strategies for one seeding run. + + Mirrors Eno's intended use (you draw a few cards, you don't read + the whole deck). Different runs land on different thinking-moves, + adding a randomness layer on top of the concept pools. + """ + user_path = paths.user_rules_path() / "oblique_strategies.md" + shipped_path = _REPO_PROMPTS / "oblique_strategies.md" + path = user_path if user_path.exists() else shipped_path + if not path.exists(): + return [] + strategies = [ + line.lstrip("- ").strip() + for line in path.read_text().splitlines() + if line.lstrip().startswith("- ") + ] + if not strategies: + return [] + return random.sample(strategies, k=min(count, len(strategies))) + + +def _load_world_preferences(world_id: str, *, include_arc: bool) -> str: + """Build the player-preferences prefix for any world-generation prompt. + + Always reads ``style.md`` if it exists. Optionally reads ``arc.md`` + when ``include_arc=True`` (the architect itself shouldn't see the + arc — it's the one writing it). Returns a markdown block ready to + prepend to the user message, or an empty string when nothing is + on disk yet. + """ + sections: list[str] = [] + + style_path = world_path(world_id) / "style.md" + if style_path.exists(): + style_text = style_path.read_text().strip() + if style_text: + sections.append(f"## Player Preferences\n\n{style_text}") + + if include_arc: + arc_path = world_path(world_id) / "arc.md" + if arc_path.exists(): + arc_text = arc_path.read_text().strip() + if arc_text: + sections.append(arc_text) + + if not sections: + return "" + + return "\n\n".join(sections) + "\n\n---\n\n" + + def build_planning_context( world_id: str, player_id: str, @@ -111,11 +210,34 @@ def build_planning_context( ) -> str: """Build the context string that the planner LLM sees. - Includes: current session state, open threads, and candidate entities - with their current (thin) content. + Includes: player preferences (style.md + arc.md), current session + state, open threads, and candidate entities with their current + (thin) content. """ parts: list[str] = [] + prefs = _load_world_preferences(world_id, include_arc=True) + if prefs: + parts.append(prefs.rstrip()) + + # A small drawn hand of Oblique Strategies — thinking-moves the + # planner can apply when deciding what to add to thin entities. + # No concept seeds here: the planner is constrained to enriching + # existing entities, and forcing in random material would distort + # what's already there. Obliques are about HOW to think, not WHAT + # to add. + obliques = _draw_oblique_strategies(count=3) + if obliques: + oblique_text = ( + "## Thinking Moves For This Run\n\n" + "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt). " + "Apply them as you decide what to add to each thin entity — " + "they're lenses for finding less-obvious choices, not " + "things you must incorporate:\n\n" + + "\n".join(f"- {s}" for s in obliques) + ) + parts.append(oblique_text) + # Session state session = load_session(player_id) if session: @@ -238,6 +360,7 @@ def plan_world( user_message=context, mcp_url=mcp.url, model=model, + effort="max", on_tool_start=on_tool, cwd=data_home(), ) @@ -280,21 +403,43 @@ def seed_world( if character is None: return SeedResult() - # Build context from the character sheet, prefixed with any style - # preferences captured during onboarding so the seeded world reflects - # the tone/genre/pacing the player asked for. - style_block = "" - style_path = world_path(world_id) / "style.md" - if style_path.exists(): - style_text = style_path.read_text().strip() - if style_text: - style_block = ( - "## Player Preferences\n\n" - f"{style_text}\n\n" - "---\n\n" - ) + # Player preferences (style.md) and the chosen arc (arc.md, written + # by the architect pass) get prepended to the seeder's user message + # so the seeded world honors both. + prefs_block = _load_world_preferences(world_id, include_arc=True) + + # Same novelty levers the architect gets — random concept seeds and + # a drawn hand of Oblique Strategies. The seeder is also building + # entities from scratch, so it benefits from the same anti-rut + # injection. Different draw than whatever the architect rolled. + concepts = _pick_random_concepts(count=6) + concept_block = ( + "## Random Concept Seeds\n\n" + "Weave at least three of these into the entities you build. " + "They should appear as concrete details — an item an NPC " + "carries, a feature of a location, the texture of a thread:" + "\n\n" + + "\n".join(f"- {c}" for c in concepts) + + "\n\n---\n\n" + ) - context = style_block + format_character_context(character) + obliques = _draw_oblique_strategies(count=3) + oblique_block = ( + "## Thinking Moves For This Run\n\n" + "Drawn 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:\n\n" + + "\n".join(f"- {s}" for s in obliques) + + "\n\n---\n\n" + ) + + context = ( + prefs_block + + concept_block + + oblique_block + + format_character_context(character) + ) system_prompt = load_prompt("world-seed") campaign_log = CampaignLog(world_id) @@ -313,6 +458,7 @@ def seed_world( user_message=context, mcp_url=mcp.url, model=model, + effort="max", on_tool_start=on_tool, cwd=data_home(), ) @@ -327,6 +473,122 @@ def seed_world( return seed_result +def plot_arc( + world_id: str = "default", + player_id: str = "default", + model: str = "claude-opus-4-6", + on_progress: Callable[[str], None] | None = None, +) -> SeedResult: + """Two-pass arc planning: cold draft → architect commit. + + Pass A asks the model to produce a default treatment with no + novelty injection (no concept seeds, no oblique strategies, no + self-critique). The output captures the model's actual default + for *this specific character + style*. + + Pass B spins up the arc_architect MCP role and asks the model + to produce a campaign arc that is genuinely different from the + cold draft, weaving in random concept seeds and applying a + drawn hand of Oblique Strategies. The architect commits its + pick via the ``commit_arc`` tool. + + Returns when ``arc.md`` exists on disk. + """ + + def progress(msg: str) -> None: + if on_progress: + on_progress(msg) + + start_time = time.monotonic() + + character = load_character(player_id) + if character is None: + return SeedResult() + + style_block = _load_world_preferences(world_id, include_arc=False) + char_block = format_character_context(character) + + # ---- Pass A: cold draft (no tools, no anti-rut help) ---- + progress("Drafting the default treatment (Pass A)...") + cold_draft = run_prompt( + system_prompt=load_prompt("cold-draft"), + user_message=style_block + char_block, + model=model, + effort="max", + timeout=900, + ) + if not cold_draft: + # Cold draft failed for some reason; we still need an arc, so + # fall through to Pass B with an empty diagnostic. The + # architect prompt has fall-back guidance for this case. + cold_draft = "" + + # ---- Pass B: architect (cold draft as negative example) ---- + progress("Critiquing and committing the arc (Pass B)...") + + concepts = _pick_random_concepts(count=8) + concept_block = ( + "## Random Concept Seeds\n\n" + "Weave at least three of these into the chosen shape:\n\n" + + "\n".join(f"- {c}" for c in concepts) + + "\n\n---\n\n" + ) + + obliques = _draw_oblique_strategies(count=4) + oblique_block = ( + "## Thinking Moves For This Run\n\n" + "Drawn Oblique Strategies cards (Brian Eno & Peter Schmidt) " + "for this seeding session. Apply them during Round 3 (critique) " + "and Round 4 (pick + stress-test) of your self-critic loop:\n\n" + + "\n".join(f"- {s}" for s in obliques) + + "\n\n---\n\n" + ) + + cold_draft_block = ( + "## Cold Draft\n\n" + "This is the treatment another pass produced for the same " + "character and style with no novelty injection. It is your " + "diagnostic — your final arc must be GENUINELY different " + "from this:\n\n" + + (cold_draft.strip() if cold_draft else "(cold draft unavailable)") + + "\n\n---\n\n" + ) + + context = ( + style_block + + cold_draft_block + + concept_block + + oblique_block + + char_block + ) + + campaign_log = CampaignLog(world_id) + mcp = start_mcp_server( + world_id, player_id, "arc_architect", campaign_log, + ) + + def on_tool(name: str) -> None: + if on_progress: + on_progress(f" [{name}]") + + claude_result = run_with_tools( + system_prompt=load_prompt("arc-architect"), + user_message=context, + mcp_url=mcp.url, + model=model, + effort="max", + on_tool_start=on_tool, + cwd=data_home(), + ) + + arc_result = SeedResult(elapsed=time.monotonic() - start_time) + if claude_result: + arc_result.tool_calls = claude_result.usage.get("tool_calls", 0) + arc_result.input_tokens = claude_result.usage.get("input_tokens", 0) + arc_result.output_tokens = claude_result.usage.get("output_tokens", 0) + return arc_result + + @dataclass class TickResult: """Result of a tick_world run.""" @@ -369,6 +631,10 @@ def build_tick_context( """Build context for the world tick agent.""" parts: list[str] = [] + prefs = _load_world_preferences(world_id, include_arc=True) + if prefs: + parts.append(prefs.rstrip()) + # Campaign log and time log = CampaignLog(world_id) current_time = log.get_current_time() @@ -454,6 +720,7 @@ def tick_world( # pragma: no cover user_message=context, mcp_url=mcp.url, model=model, + effort="max", on_tool_start=on_tool, cwd=data_home(), ) diff --git a/src/storied/tools/mechanics.py b/src/storied/tools/mechanics.py index 9c3dd5e..956a965 100644 --- a/src/storied/tools/mechanics.py +++ b/src/storied/tools/mechanics.py @@ -45,7 +45,7 @@ def roll(notation: str, reason: str) -> str: return f"Rolled {result['notation']}: [{rolls_str}] = {result['total']}" -@mcp.tool(tags={"dm", "planner", "advancement"}) +@mcp.tool(tags={"dm", "planner", "advancement", "arc_architect"}) def recall( query: str, scope: Literal["rules", "world", "all"] = "all", diff --git a/src/storied/tools/scene.py b/src/storied/tools/scene.py index c16f7d7..0cc9bb7 100644 --- a/src/storied/tools/scene.py +++ b/src/storied/tools/scene.py @@ -107,6 +107,27 @@ def tune( return "Style updated." +@mcp.tool(tags={"arc_architect"}) +def commit_arc( + content: str, + world: str = World(), +) -> str: + """Commit the chosen campaign arc to ``worlds/{world}/arc.md``. + + Called exactly once during initial planning, after the architect + has read the cold draft, brainstormed alternatives, and chosen + a direction. The content should be loose markdown capturing + premise, local hook, the SHAPE of the larger thing (not the + resolution), tonal commitments, and explicitly what's off the + table. Specific events stay emergent — write commitments, not + an outline. + """ + path = world_path(world) / "arc.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return "Arc committed." + + @mcp.tool(tags={"dm"}) def end_session( situation: str, diff --git a/tests/test_arc_planner.py b/tests/test_arc_planner.py new file mode 100644 index 0000000..1b3442d --- /dev/null +++ b/tests/test_arc_planner.py @@ -0,0 +1,455 @@ +"""Tests for the arc planner — helpers and plot_arc orchestrator.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from storied import paths +from storied.character import create_character +from storied.planner import ( + _draw_oblique_strategies, + _load_concept_pools, + _load_world_preferences, + _pick_random_concepts, + plot_arc, + seed_world, +) + +# ---- _load_concept_pools ---------------------------------------------------- + + +class TestLoadConceptPools: + def test_loads_shipped_pools_by_default(self): + pools = _load_concept_pools() + assert pools # shipped file has categories + assert all(isinstance(items, list) for items in pools.values()) + assert all(len(items) > 0 for items in pools.values()) + + def test_user_override_replaces_shipped(self, tmp_path: Path): + user_dir = paths.user_rules_path() + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "concept_pools.md").write_text( + "# My Pools\n\n## Custom\n- alpha\n- beta\n- gamma\n" + ) + + pools = _load_concept_pools() + assert pools == {"Custom": ["alpha", "beta", "gamma"]} + + def test_returns_empty_when_neither_exists( + self, tmp_path: Path, monkeypatch, + ): + monkeypatch.setattr( + paths, "user_rules_path", lambda: tmp_path / "missing-user", + ) + from storied import planner + monkeypatch.setattr( + planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", + ) + + pools = _load_concept_pools() + assert pools == {} + + def test_skips_categories_without_items(self, tmp_path: Path): + user_dir = paths.user_rules_path() + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "concept_pools.md").write_text( + "## Empty\n\n## Has items\n- one\n- two\n" + ) + + pools = _load_concept_pools() + assert "Empty" not in pools + assert pools["Has items"] == ["one", "two"] + + +# ---- _pick_random_concepts -------------------------------------------------- + + +class TestPickRandomConcepts: + def test_picks_requested_count_across_categories(self, tmp_path: Path): + user_dir = paths.user_rules_path() + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "concept_pools.md").write_text( + "## A\n- a1\n- a2\n\n## B\n- b1\n- b2\n\n## C\n- c1\n- c2\n" + ) + + picks = _pick_random_concepts(count=3) + assert len(picks) == 3 + assert all(p in ("a1", "a2", "b1", "b2", "c1", "c2") for p in picks) + + def test_one_pick_per_category(self, tmp_path: Path): + # Each pick comes from a distinct category, so picks from a + # 3-category pool of 100 items each shouldn't exceed 3 distinct + # category-tagged items. + user_dir = paths.user_rules_path() + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "concept_pools.md").write_text( + "## A\n- a1\n- a2\n\n## B\n- b1\n- b2\n\n## C\n- c1\n- c2\n" + ) + + picks = _pick_random_concepts(count=3) + # Each category contributes at most one pick. + from_a = sum(1 for p in picks if p.startswith("a")) + from_b = sum(1 for p in picks if p.startswith("b")) + from_c = sum(1 for p in picks if p.startswith("c")) + assert from_a <= 1 + assert from_b <= 1 + assert from_c <= 1 + + def test_count_exceeding_categories_returns_all(self, tmp_path: Path): + user_dir = paths.user_rules_path() + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "concept_pools.md").write_text( + "## A\n- a1\n\n## B\n- b1\n" + ) + + picks = _pick_random_concepts(count=10) + assert len(picks) == 2 + + def test_empty_pools_returns_empty(self, tmp_path: Path, monkeypatch): + monkeypatch.setattr( + paths, "user_rules_path", lambda: tmp_path / "missing-user", + ) + from storied import planner + monkeypatch.setattr( + planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", + ) + + assert _pick_random_concepts(count=8) == [] + + +# ---- _draw_oblique_strategies ----------------------------------------------- + + +class TestDrawObliqueStrategies: + def test_draws_from_shipped_by_default(self): + drawn = _draw_oblique_strategies(count=4) + assert len(drawn) == 4 + # Each entry should be a stripped non-empty string + assert all(isinstance(s, str) and s for s in drawn) + + def test_no_duplicates_within_a_call(self): + drawn = _draw_oblique_strategies(count=10) + assert len(drawn) == len(set(drawn)) + + def test_user_override_replaces_shipped(self, tmp_path: Path): + user_dir = paths.user_rules_path() + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "oblique_strategies.md").write_text( + "# My Strategies\n\n- one\n- two\n- three\n" + ) + + drawn = _draw_oblique_strategies(count=2) + assert all(d in ("one", "two", "three") for d in drawn) + assert len(drawn) == 2 + + def test_count_exceeding_deck_returns_all(self, tmp_path: Path): + user_dir = paths.user_rules_path() + user_dir.mkdir(parents=True, exist_ok=True) + (user_dir / "oblique_strategies.md").write_text( + "- only\n- two\n" + ) + + drawn = _draw_oblique_strategies(count=10) + assert len(drawn) == 2 + + def test_empty_deck_returns_empty(self, tmp_path: Path, monkeypatch): + monkeypatch.setattr( + paths, "user_rules_path", lambda: tmp_path / "missing-user", + ) + from storied import planner + monkeypatch.setattr( + planner, "_REPO_PROMPTS", tmp_path / "missing-shipped", + ) + + assert _draw_oblique_strategies(count=4) == [] + + +# ---- _load_world_preferences ------------------------------------------------ + + +@pytest.fixture +def world_with_style(tmp_path: Path) -> str: + world_dir = paths.world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + (world_dir / "style.md").write_text("Grim. Slow-burn investigation.") + return "default" + + +@pytest.fixture +def world_with_style_and_arc(tmp_path: Path) -> str: + world_dir = paths.world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + (world_dir / "style.md").write_text("Grim. Slow-burn investigation.") + (world_dir / "arc.md").write_text( + "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" + ) + return "default" + + +class TestLoadWorldPreferences: + def test_returns_empty_when_neither_exists(self): + assert _load_world_preferences("default", include_arc=True) == "" + + def test_style_only_when_arc_excluded(self, world_with_style_and_arc): + out = _load_world_preferences("default", include_arc=False) + assert "Player Preferences" in out + assert "Grim. Slow-burn" in out + assert "Campaign Arc" not in out + + def test_includes_arc_when_requested(self, world_with_style_and_arc): + out = _load_world_preferences("default", include_arc=True) + assert "Player Preferences" in out + assert "Campaign Arc" in out + + def test_style_only_when_no_arc_on_disk(self, world_with_style): + out = _load_world_preferences("default", include_arc=True) + assert "Player Preferences" in out + assert "Campaign Arc" not in out + + def test_ends_with_separator(self, world_with_style): + out = _load_world_preferences("default", include_arc=True) + assert out.endswith("---\n\n") + + +# ---- plot_arc orchestrator -------------------------------------------------- + + +@pytest.fixture +def character_world(tmp_path: Path) -> Path: + create_character( + player_id="default", + name="Seren", + race="Half-Elf", + char_class="Ranger", + level=1, + abilities={ + "strength": 10, "dexterity": 16, "constitution": 12, + "intelligence": 14, "wisdom": 13, "charisma": 14, + }, + hp_max=11, + ac=14, + background="Outlander", + backstory="A coastal wanderer.", + ) + world_dir = paths.world_path("default") + world_dir.mkdir(parents=True, exist_ok=True) + (world_dir / "style.md").write_text("Eerie atmospheric mystery.") + return tmp_path + + +class TestPlotArc: + @patch("storied.planner.start_mcp_server") + @patch("storied.planner.run_with_tools") + @patch("storied.planner.run_prompt") + def test_pass_a_then_pass_b( + self, + mock_run_prompt: MagicMock, + mock_run_with_tools: MagicMock, + mock_start_mcp: MagicMock, + character_world: Path, + ): + mock_run_prompt.return_value = "## Cold treatment\n\nTotally generic." + mock_run_with_tools.return_value = MagicMock( + usage={"input_tokens": 1000, "output_tokens": 200, "tool_calls": 1} + ) + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") + + plot_arc(world_id="default", player_id="default") + + # Pass A: cold draft via run_prompt + mock_run_prompt.assert_called_once() + assert mock_run_prompt.call_args.kwargs["effort"] == "max" + assert mock_run_prompt.call_args.kwargs["model"] == "claude-opus-4-6" + + # Pass B: architect via run_with_tools + mock_run_with_tools.assert_called_once() + assert mock_run_with_tools.call_args.kwargs["effort"] == "max" + + # Pass B uses the arc_architect MCP role + mock_start_mcp.assert_called_once() + assert mock_start_mcp.call_args[0][2] == "arc_architect" + + @patch("storied.planner.start_mcp_server") + @patch("storied.planner.run_with_tools") + @patch("storied.planner.run_prompt") + def test_pass_a_receives_only_character_and_style( + self, + mock_run_prompt: MagicMock, + mock_run_with_tools: MagicMock, + mock_start_mcp: MagicMock, + character_world: Path, + ): + mock_run_prompt.return_value = "cold draft" + mock_run_with_tools.return_value = MagicMock(usage={}) + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") + + plot_arc(world_id="default", player_id="default") + + pass_a_user = mock_run_prompt.call_args.kwargs["user_message"] + # Pass A should NOT contain novelty injection or the cold draft itself + assert "Random Concept Seeds" not in pass_a_user + assert "Thinking Moves For This Run" not in pass_a_user + assert "Cold Draft" not in pass_a_user + # But it SHOULD contain the style and the character sheet + assert "Player Preferences" in pass_a_user + assert "Eerie atmospheric mystery" in pass_a_user + assert "Seren" in pass_a_user + + @patch("storied.planner.start_mcp_server") + @patch("storied.planner.run_with_tools") + @patch("storied.planner.run_prompt") + def test_pass_b_receives_cold_draft_and_novelty_levers( + self, + mock_run_prompt: MagicMock, + mock_run_with_tools: MagicMock, + mock_start_mcp: MagicMock, + character_world: Path, + ): + mock_run_prompt.return_value = "## Cold draft\n\nGeneric coastal horror." + mock_run_with_tools.return_value = MagicMock(usage={}) + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") + + plot_arc(world_id="default", player_id="default") + + pass_b_user = mock_run_with_tools.call_args.kwargs["user_message"] + assert "## Cold Draft" in pass_b_user + assert "Generic coastal horror." in pass_b_user + assert "## Random Concept Seeds" in pass_b_user + assert "## Thinking Moves For This Run" in pass_b_user + assert "Player Preferences" in pass_b_user + assert "Seren" in pass_b_user + + @patch("storied.planner.start_mcp_server") + @patch("storied.planner.run_with_tools") + @patch("storied.planner.run_prompt") + def test_pass_b_uses_arc_architect_prompt( + self, + mock_run_prompt: MagicMock, + mock_run_with_tools: MagicMock, + mock_start_mcp: MagicMock, + character_world: Path, + ): + mock_run_prompt.return_value = "cold draft" + mock_run_with_tools.return_value = MagicMock(usage={}) + mock_start_mcp.return_value = MagicMock(url="http://localhost:0/sse") + + plot_arc(world_id="default", player_id="default") + + system_prompt = mock_run_with_tools.call_args.kwargs["system_prompt"] + assert "Arc Architect" in system_prompt + + @patch("storied.planner.run_prompt") + def test_no_character_returns_empty_result( + self, mock_run_prompt: MagicMock, tmp_path: Path, + ): + result = plot_arc(world_id="default", player_id="default") + assert result.tool_calls == 0 + mock_run_prompt.assert_not_called() + + +# ---- seed_world: arc + effort ----------------------------------------------- + + +class TestSeedWorldArcAndEffort: + @patch("storied.planner.run_with_tools") + def test_seed_world_passes_effort_max( + self, + mock_run_with_tools: MagicMock, + character_world: Path, + ): + mock_run_with_tools.return_value = None + + seed_world(world_id="default", player_id="default") + + mock_run_with_tools.assert_called_once() + assert mock_run_with_tools.call_args.kwargs["effort"] == "max" + + @patch("storied.planner.run_with_tools") + def test_seed_world_includes_arc_when_present( + self, + mock_run_with_tools: MagicMock, + character_world: Path, + ): + world_dir = paths.world_path("default") + (world_dir / "arc.md").write_text( + "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" + ) + mock_run_with_tools.return_value = None + + seed_world(world_id="default", player_id="default") + + user_message = mock_run_with_tools.call_args.kwargs["user_message"] + assert "Campaign Arc" in user_message + assert "A quiet mystery." in user_message + + @patch("storied.planner.run_with_tools") + def test_seed_world_includes_concept_seeds_and_obliques( + self, + mock_run_with_tools: MagicMock, + character_world: Path, + ): + # The seeder gets the same anti-rut levers the architect gets: + # random concept seeds (mandate) and a drawn hand of Oblique + # Strategies cards (thinking moves). + mock_run_with_tools.return_value = None + + seed_world(world_id="default", player_id="default") + + user_message = mock_run_with_tools.call_args.kwargs["user_message"] + assert "## Random Concept Seeds" in user_message + assert "## Thinking Moves For This Run" in user_message + assert "Oblique Strategies" in user_message + + +class TestPlannerInspirationContext: + """The planner gets obliques only — no random concept seeds. + + The planner is constrained to enriching existing entities; forcing + in random concept material would distort what's already there. + Obliques (process directives) are safe because they shape HOW the + model thinks rather than WHAT it adds. + """ + + def test_planner_context_includes_obliques( + self, character_world: Path, + ): + from storied.planner import build_planning_context + from storied.session import save_session + + save_session("default", { + "location": "Tavern", + "world": "default", + "body": "## Situation\nSeren is at the bar.", + }) + + context = build_planning_context( + world_id="default", + player_id="default", + candidates=[], + ) + + assert "## Thinking Moves For This Run" in context + assert "Oblique Strategies" in context + + def test_planner_context_does_not_include_concept_seeds( + self, character_world: Path, + ): + from storied.planner import build_planning_context + from storied.session import save_session + + save_session("default", { + "location": "Tavern", + "world": "default", + "body": "## Situation\nSeren is at the bar.", + }) + + context = build_planning_context( + world_id="default", + player_id="default", + candidates=[], + ) + + # No mandate to weave random concepts — that would distort + # existing entities the planner is enriching. + assert "## Random Concept Seeds" not in context diff --git a/tests/test_engine.py b/tests/test_engine.py index fb8d7e5..1f1d30d 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -93,18 +93,51 @@ class TestDMEngineContext: ) def test_build_context_no_style(self, engine): - context = engine._build_context() + engine._build_context() assert "Style" not in engine._context_parts def test_build_context_with_style(self, engine, tmp_path: Path): style_path = tmp_path / "worlds" / "test" / "style.md" style_path.write_text("# Style\n\nMore intrigue, less combat.\n") - context = engine._build_context() + engine._build_context() assert "Style" in engine._context_parts assert "intrigue" in engine._context_parts["Style"] + def test_build_context_no_arc(self, engine): + engine._build_context() + assert "Arc" not in engine._context_parts + + def test_build_context_with_arc(self, engine, tmp_path: Path): + arc_path = tmp_path / "worlds" / "test" / "arc.md" + arc_path.parent.mkdir(parents=True, exist_ok=True) + arc_path.write_text( + "# Campaign Arc\n\n## Premise\nA quiet mystery.\n" + "\n## Off the Table\n- the cosmic-prisoner trope\n" + ) + + engine._build_context() + + assert "Arc" in engine._context_parts + assert "Campaign Arc" in engine._context_parts["Arc"] + assert "cosmic-prisoner" in engine._context_parts["Arc"] + + def test_arc_appears_after_style_in_context_order( + self, engine, tmp_path: Path, + ): + style_path = tmp_path / "worlds" / "test" / "style.md" + style_path.write_text("# Style\n\nDark tone.\n") + arc_path = tmp_path / "worlds" / "test" / "arc.md" + arc_path.write_text("# Campaign Arc\n\n## Premise\nMystery.\n") + + engine._build_context() + parts = list(engine._context_parts.keys()) + + style_idx = parts.index("Style") + arc_idx = parts.index("Arc") + assert style_idx < arc_idx + def test_time_is_first_context_part(self, engine, tmp_path: Path): """The ambient clock header goes at the top of every turn's context so the DM can't miss it. Style comes immediately after.""" @@ -313,7 +346,9 @@ class TestDMEngineContext: def test_find_entity_returns_none_when_missing(self, engine): assert engine._find_entity("Nobody") is None - def test_build_context_loads_location_and_one_hop_linked(self, engine, tmp_path: Path): + def test_build_context_loads_location_and_one_hop_linked( + self, engine, tmp_path: Path, + ): """When the session points at a location, _build_context should load the location, then one-hop into entities the location wikilinks.""" from storied.session import save_session diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index f3d6954..2e306a3 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -80,6 +80,15 @@ class TestPerRoleComposition: def test_advancement_only_has_its_tools(self): assert _names("advancement") == {"notify_dm", "recall", "update_character"} + def test_arc_architect_only_has_its_tools(self): + # The arc architect's whole job is critique-and-commit. Nothing + # else. World-building tools must not leak into this role. + assert _names("arc_architect") == {"commit_arc", "recall"} + + def test_arc_architect_in_all_roles(self): + from storied.mcp_server import ALL_ROLES + assert "arc_architect" in ALL_ROLES + class TestToolSchemas: """Verify tool input schemas expose nested field shapes to the LLM. diff --git a/tests/test_seeder.py b/tests/test_seeder.py index 38e273f..c309062 100644 --- a/tests/test_seeder.py +++ b/tests/test_seeder.py @@ -29,6 +29,32 @@ class TestSeederTools: for forbidden in ("roll", "recall", "mark", "note_discovery", "end_session"): assert forbidden not in names + def test_seeder_does_not_have_commit_arc(self): + # commit_arc is the architect's tool — never the seeder's. + assert "commit_arc" not in self._seeder_names() + + +class TestArcArchitectTools: + """The arc_architect role gets only commit_arc and recall.""" + + def _architect_names(self) -> set[str]: + async def _gather() -> set[str]: + server = await _compose_server("arc_architect") + return {t.name for t in await server.list_tools()} + return asyncio.run(_gather()) + + def test_architect_only_has_commit_arc_and_recall(self): + assert self._architect_names() == {"commit_arc", "recall"} + + def test_architect_excludes_world_building_tools(self): + names = self._architect_names() + for forbidden in ( + "establish", "set_scene", "mark", "amend_mark", + "note_discovery", "tune", "damage", "heal", + "adjust_coins", "create_character", "end_session", + ): + assert forbidden not in names + @pytest.fixture def character_world(tmp_path: Path) -> Path: diff --git a/tests/test_tune.py b/tests/test_tune.py index 6a63d06..cb24cd1 100644 --- a/tests/test_tune.py +++ b/tests/test_tune.py @@ -1,17 +1,21 @@ -"""Tests for the DM style tuning system.""" +"""Tests for the DM style tuning system and the arc commit tool.""" from pathlib import Path +from storied.testing import call_tool from storied.tools import ToolContext +from storied.tools.scene import commit_arc as _commit_arc from storied.tools.scene import tune as _tune -from storied.testing import call_tool - def tune(tuning: str) -> str: return call_tool(_tune, tuning=tuning) +def commit_arc(content: str) -> str: + return call_tool(_commit_arc, content=content) + + class TestTune: """Tests for the tune tool.""" @@ -45,3 +49,42 @@ class TestTune: result = tune("Dark and atmospheric tone.") assert "updated" in result.lower() + + +class TestCommitArc: + """Tests for the arc_architect's commit_arc tool.""" + + def test_commit_arc_creates_arc_file( + self, ctx: ToolContext, tmp_path: Path, + ): + commit_arc("# Campaign Arc\n\n## Premise\nA quiet mystery.\n") + + arc_path = tmp_path / "worlds" / ctx.world_id / "arc.md" + assert arc_path.exists() + + def test_commit_arc_writes_content_verbatim( + self, ctx: ToolContext, tmp_path: Path, + ): + content = ( + "# Campaign Arc\n\n## Premise\nA wedding postponed twice.\n" + "\n## Off the Table\n- the cosmic-prisoner trope\n" + ) + commit_arc(content) + + arc_path = tmp_path / "worlds" / ctx.world_id / "arc.md" + assert arc_path.read_text() == content + + def test_commit_arc_replaces_existing( + self, ctx: ToolContext, tmp_path: Path, + ): + commit_arc("# Campaign Arc\n\n## Premise\nFirst draft.\n") + commit_arc("# Campaign Arc\n\n## Premise\nSecond draft.\n") + + content = (tmp_path / "worlds" / ctx.world_id / "arc.md").read_text() + assert "Second draft." in content + assert "First draft." not in content + + def test_commit_arc_returns_confirmation(self, ctx: ToolContext): + result = commit_arc("# Campaign Arc\n\n## Premise\nA test.\n") + + assert "committed" in result.lower()