diff --git a/prompts/character-creation.md b/prompts/character-creation.md new file mode 100644 --- /dev/null +++ b/prompts/character-creation.md @@ -0,0 +1,45 @@ +You are helping a player create a new D&D 5e character for a solo adventure. + +## Your Role + +Guide the player through character creation conversationally. Don't present it as a form - have a natural back-and-forth where you learn about who they want to play. + +## The Flow + +Start by asking what kind of hero they imagine. Let their answer guide you: + +- If they have a clear concept ("a sneaky halfling thief"), help them build toward it +- If they're unsure, ask questions to discover what appeals to them +- If they want to explore options, briefly describe what's available + +## Key Decisions + +Work through these naturally (not necessarily in order): + +1. **Concept**: Who is this person? What's their deal? +2. **Race**: Human, Elf, Dwarf, Halfling, etc. Look up races for traits. +3. **Class**: Fighter, Wizard, Rogue, etc. Look up classes for features. +4. **Background**: Acolyte, Criminal, Soldier, etc. Provides skills and flavor. +5. **Ability Scores**: Roll 4d6kh3 six times, let them assign scores. +6. **Starting Equipment**: Based on class and background. +7. **Details**: Name, personality, bonds, flaws - whatever feels right. + +## Ability Scores + +Roll ability scores using the standard method (4d6, drop lowest, six times). Let the player assign the results to abilities as they choose based on their concept. + +## Looking Things Up + +Use `lookup_rule` freely to check: +- Racial traits (darkvision, resistances, etc.) +- Class features (hit dice, proficiencies, starting abilities) +- Background features (skills, tools, equipment) +- Spell lists if they're a caster + +Get the details right - this character will be with them for a while. + +## Finalizing + +Once you have everything, call `create_character` with the full character data. This saves their character sheet and you can transition to the adventure. + +Don't rush. Character creation is part of the fun. Let them explore, change their mind, and discover who they want to play. diff --git a/prompts/dm-system.md b/prompts/dm-system.md --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -1,5 +1,17 @@ You are an expert D&D 5e Dungeon Master running a solo adventure. +## Starting a Session + +When you see `[Session starting]`, begin with a brief "previously on..." recap: + +- Remind them where they are and what's happening +- Mention any open threads or objectives +- Set the scene to re-immerse them in the moment + +Keep it short - 2-4 sentences. Then smoothly transition into the current situation and invite action. The session state and campaign log below have everything you need. + +If this is a brand new adventure (no session state exists), skip the recap and simply begin the story with an opening scene. + ## Output Format You're running in a terminal with markdown support. Use formatting to enhance readability: @@ -7,7 +19,27 @@ - **Bold** for emphasis, names, or important terms - *Italics* for character speech or internal thoughts - Horizontal rules (---) to separate scenes -Keep responses focused and paced well - this is interactive fiction, not a novel. End on moments that invite player action. +End on moments that invite player action. + +## Narrative Rhythm + +Vary your storytelling pace to match the moment: + +**Slower, more epic** - For dramatic reveals, tense confrontations, or emotional beats: +- Longer, more atmospheric descriptions +- Let tension build before resolution +- Include sensory details and internal reactions +- Make skill checks feel like meaningful moments in the story + +**Faster, beat-by-beat** - For action sequences, rapid exploration, or when momentum matters: +- Shorter, punchier responses +- Present choices quickly +- Keep the energy moving +- Roll dice freely without belaboring outcomes + +Read the player's energy. If they're giving short, action-oriented inputs, match that tempo. If they're exploring or engaging deeply with a scene, slow down and breathe life into it. + +Not every encounter needs the same weight. A routine check at the city gates is different from standing before the dragon's lair. ## Core Principle: Real Mechanics @@ -108,7 +140,12 @@ ## Player Agency - Ask what the player wants to attempt, then determine if a roll is needed - Failures create complications, not dead ends -- The world is reactive and consistent + +**Balance "yes, and..." with world integrity.** Sometimes the answer is "yes, and here's what happens." Sometimes it's "you can try, but..." with real consequences. And occasionally it's "that won't work because..." when the world's logic demands it. + +The goal isn't to block the player, but to make the world feel real. A locked door stays locked without the key or a good roll. The guard captain won't be bribed with 5 gold. The dragon doesn't negotiate with level 1 adventurers. + +When you do push back, make it interesting - offer alternatives, hint at other approaches, or let failure open unexpected doors. The constraint itself becomes part of the story. ## Character Management @@ -161,6 +198,22 @@ - `[[Vera Blackwater]]` - links to the NPC file - `[[The Rusty Anchor]]` - links to the location file This helps load relevant context automatically when resuming sessions. + +## Ending Sessions + +When the player indicates they want to stop playing (saying "quit", "exit", "I need to go", "let's stop here", etc.), use the `end_session` tool: + +1. Give a brief, satisfying farewell that feels like a natural pause +2. Call `end_session` with a situation summary for next time +3. Include any open threads the player is pursuing + +Example: +``` +end_session( + situation="Kira has just descended into the Gloomwater Caves, torch in hand, following the trail of missing villagers. She found drag marks in the mud leading deeper.", + threads=["Find the missing villagers", "The merchant's mysterious cargo - 50gp reward"] +) +``` ## Duration Guidelines diff --git a/src/storied/character.py b/src/storied/character.py --- a/src/storied/character.py +++ b/src/storied/character.py @@ -150,6 +150,82 @@ return new_body +def create_character( + player_id: str, + name: str, + race: str, + char_class: str, + level: int, + abilities: dict[str, int], + hp_max: int, + ac: int, + background: str | None = None, + speed: int = 30, + gold: int = 0, + equipment: list[str] | None = None, + features: list[str] | None = None, + proficiencies: str | None = None, + backstory: str | None = None, + base_path: Path | None = None, +) -> str: + """Create a new character from scratch. + + Args: + player_id: Player identifier + name: Character name + race: Character race (e.g., "Human", "Elf") + char_class: Character class (e.g., "Fighter", "Wizard") + level: Starting level (usually 1) + abilities: Dict of ability scores {"strength": 15, "dexterity": 14, ...} + hp_max: Maximum HP + ac: Armor class + background: Background (e.g., "Soldier", "Acolyte") + speed: Movement speed in feet + gold: Starting gold + equipment: List of equipment items + features: List of class/racial features + proficiencies: Proficiency description + backstory: Character backstory + base_path: Base path for players directory + + Returns: + Confirmation message + """ + # Build frontmatter + data = { + "name": name, + "race": race, + "class": char_class, + "level": level, + "background": background, + "hp": {"current": hp_max, "max": hp_max}, + "ac": ac, + "speed": speed, + "abilities": abilities, + "gold": gold, + } + + # Build body sections + body_parts = [] + + if proficiencies: + body_parts.append(f"## Proficiencies\n{proficiencies}") + + if features: + body_parts.append("## Features\n" + "\n".join(f"- {f}" for f in features)) + + if equipment: + body_parts.append("## Equipment\n" + "\n".join(f"- {e}" for e in equipment)) + + if backstory: + body_parts.append(f"## Backstory\n{backstory}") + + data["body"] = "\n\n".join(body_parts) + + save_character(player_id, data, base_path) + return f"Created character '{name}' - a level {level} {race} {char_class}!" + + def format_character_context(data: dict) -> str: """Format character data for inclusion in the system prompt. diff --git a/src/storied/cli.py b/src/storied/cli.py --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -124,6 +124,51 @@ log("Done!") return 0 +def cmd_reset(args: argparse.Namespace) -> int: + """Reset player and world state to start fresh.""" + import shutil + + base_path = Path.cwd() + player_id = args.player or "default" + world_id = args.world or "default" + + player_dir = base_path / "players" / player_id + world_dir = base_path / "worlds" / world_id + + # Check what exists + has_player = player_dir.exists() + has_world = world_dir.exists() + + if not has_player and not has_world: + print("Nothing to reset.") + return 0 + + # Show what will be deleted + print("This will delete:") + if has_player: + print(f" - {player_dir.relative_to(base_path)}/") + if has_world: + print(f" - {world_dir.relative_to(base_path)}/") + print() + + if not args.force: + confirm = input("Are you sure? [y/N] ") + if confirm.lower() != "y": + print("Cancelled.") + return 0 + + # Delete + if has_player: + shutil.rmtree(player_dir) + print(f"Deleted {player_dir.relative_to(base_path)}/") + if has_world: + shutil.rmtree(world_dir) + print(f"Deleted {world_dir.relative_to(base_path)}/") + + print("Reset complete. Ready for a fresh start!") + return 0 + + def cmd_play(args: argparse.Namespace) -> int: """Start an interactive DM session.""" import atexit @@ -133,8 +178,10 @@ from rich.console import Console, Group from rich.live import Live from rich.markdown import Markdown from rich.panel import Panel + from rich.rule import Rule from rich.text import Text + from storied.character import load_character from storied.engine import DMEngine # Set up readline history @@ -148,18 +195,39 @@ atexit.register(readline.write_history_file, history_file) console = Console() world_id = args.world if args.world else "default" + player_id = "default" - console.print(Panel.fit( - "[bold]Welcome to Storied![/bold]\n" - "Type [cyan]quit[/cyan] or [cyan]exit[/cyan] to end the session.\n" - "Type [cyan]/context[/cyan] to see token usage.", - title="Storied", - border_style="green", - )) + # Check if character exists + character = load_character(player_id) + creation_mode = character is None + + if creation_mode: + console.print(Panel.fit( + "[bold]Welcome to Storied![/bold]\n" + "Let's create your character!", + title="Character Creation", + border_style="yellow", + )) + prompt_name = "character-creation" + else: + console.print(Panel.fit( + "[bold]Welcome to Storied![/bold]\n" + "Let the DM know when you're ready to quit.\n" + "Type [cyan]/context[/cyan] to see token usage.", + title="Storied", + border_style="green", + )) + prompt_name = "dm-system" + console.print(f"[dim]World: {world_id}[/dim]") console.print() - engine = DMEngine(world_id=world_id) + engine = DMEngine(world_id=world_id, player_id=player_id, prompt_name=prompt_name) + + # If in creation mode, start the conversation + if creation_mode: + console.print("[dim]The DM will guide you through character creation...[/dim]") + console.print() def build_display(parts: list[tuple[str, str]]) -> Group: """Build display from ordered parts (type, content).""" @@ -177,21 +245,45 @@ renderables.append(Markdown(content)) prev_type = part_type return Group(*renderables) + # Kick off the conversation with appropriate first message + if creation_mode: + first_message = "Let's create a character!" + else: + first_message = "[Session starting]" + try: while True: - try: - game_time = engine.get_current_time() - action = input(f"[{game_time}] > ") - except EOFError: - print() - break - - if not action.strip(): - continue + # Get player input (or use first_message to kick off creation) + if first_message: + action = first_message + first_message = None + else: + try: + console.print(Rule(style="dim blue")) + if creation_mode: + action = input("> ") + else: + game_time = engine.get_current_time() + action = input(f"[{game_time}] > ") + except EOFError: + console.print() + # Ask DM to save on EOF (Ctrl+D) - skip if in creation mode + if not creation_mode: + console.print("[dim]Saving session...[/dim]") + try: + save_msg = "I need to quit now. Please save the game." + list(engine.stream_action(save_msg)) + except Exception: + pass + console.print( + "[yellow]Session saved. Farewell, adventurer![/yellow]" + ) + else: + console.print("[yellow]Farewell![/yellow]") + break - if action.strip().lower() in ("quit", "exit"): - console.print("[yellow]Farewell, adventurer![/yellow]") - break + if not action.strip(): + continue # Handle /context command if action.strip().lower() == "/context": @@ -266,6 +358,7 @@ console.print() continue try: + console.print(Rule(style="dim blue")) console.print() # Blank line before DM response parts: list[tuple[str, str]] = [] # (type, content) in order @@ -282,12 +375,56 @@ parts.append(("text", chunk)) live.update(build_display(parts)) console.print() + + # Check if session ended (player quit gracefully) + if engine.session_ended: + console.print( + "[yellow]Session saved. Farewell, adventurer![/yellow]" + ) + break + + # Check if character was just created + if creation_mode and load_character(player_id) is not None: + console.print() + console.print(Panel.fit( + "[bold green]Character created![/bold green]\n" + "Run [cyan]storied play[/cyan] again to begin your adventure.", + border_style="green", + )) + break + except KeyboardInterrupt: console.print("\n[red][Interrupted][/red]") - continue + # Ask DM to save on interrupt - skip if in creation mode + if not creation_mode: + console.print("[dim]Saving session...[/dim]") + try: + save_msg = "I need to quit now. Please save the game." + list(engine.stream_action(save_msg)) + except Exception: + pass + console.print( + "[yellow]Session saved. Farewell, adventurer![/yellow]" + ) + else: + console.print("[yellow]Farewell![/yellow]") + break except KeyboardInterrupt: - console.print("\n[yellow]Farewell, adventurer![/yellow]") + console.print() + # Outer interrupt - skip save in creation mode + if not creation_mode: + console.print("[dim]Saving session...[/dim]") + try: + save_msg = "I need to quit now. Please save the game." + list(engine.stream_action(save_msg)) + except Exception: + pass + console.print( + "[yellow]Session saved. Farewell, adventurer![/yellow]" + ) + else: + console.print("[yellow]Farewell![/yellow]") return 0 @@ -361,6 +498,23 @@ "--world", "-w", help="World ID to use for world-specific content", ) play_parser.set_defaults(func=cmd_play) + + # reset command + reset_parser = subparsers.add_parser("reset", help="Reset player and world state") + reset_parser.add_argument( + "--world", "-w", + help="World ID to reset (default: default)", + ) + reset_parser.add_argument( + "--player", "-p", + help="Player ID to reset (default: default)", + ) + reset_parser.add_argument( + "--force", "-f", + action="store_true", + help="Skip confirmation prompt", + ) + reset_parser.set_defaults(func=cmd_reset) return parser diff --git a/src/storied/engine.py b/src/storied/engine.py --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -34,6 +34,7 @@ world_id: str = "default", player_id: str = "default", base_path: Path | None = None, model: str = "claude-sonnet-4-20250514", + prompt_name: str = "dm-system", ): """Initialize the DM engine. @@ -42,6 +43,7 @@ world_id: World ID for world-specific content (default: "default") player_id: Player identifier for character loading (default: "default") base_path: Base path for content resolution (defaults to cwd) model: Claude model to use + prompt_name: System prompt to use (default: "dm-system", or "character-creation") """ self.client = anthropic.Anthropic() self.model = model @@ -55,11 +57,15 @@ self.last_usage: dict = {"input_tokens": 0, "output_tokens": 0} self.total_input_tokens: int = 0 self.total_output_tokens: int = 0 + # Session end flag (set when end_session tool is called) + self.session_ended: bool = False + # Campaign log for time tracking self._campaign_log = CampaignLog(self.player_id, self.base_path) # Build system prompt with full context - self._base_prompt = load_prompt("dm-system") + self._prompt_name = prompt_name + self._base_prompt = load_prompt(prompt_name) self._context_parts: dict[str, str] = {} self.system_prompt = self._base_prompt + "\n\n" + self._build_context() @@ -318,6 +324,9 @@ elif tool_use["name"] == "query_world": yield f"\n[Checking world: {tool_use['input'].get('query', '?')}...]\n" elif tool_use["name"] == "update_character": yield "\n[Updating character sheet...]\n" + elif tool_use["name"] == "create_character": + name = tool_use["input"].get("name", "character") + yield f"\n[Creating {name}...]\n" elif tool_use["name"] == "update_session": yield "\n[Updating session state...]\n" elif tool_use["name"] == "save_to_world": @@ -327,6 +336,8 @@ elif tool_use["name"] == "log_event": event = tool_use["input"].get("event", "?") duration = tool_use["input"].get("duration", "?") yield f"\n[Logging: {event} ({duration})]...\n" + elif tool_use["name"] == "end_session": + yield "\n[Saving session...]...\n" result = execute_tool( tool_use["name"], @@ -336,6 +347,11 @@ player_id=self.player_id, base_path=self.base_path, campaign_log=self._campaign_log, ) + + # Check if session ended + if result == "SESSION_ENDED": + self.session_ended = True + result = "Session saved. Farewell!" # Show dice roll results immediately if tool_use["name"] == "roll_dice": diff --git a/src/storied/tools.py b/src/storied/tools.py --- a/src/storied/tools.py +++ b/src/storied/tools.py @@ -8,6 +8,7 @@ from pathlib import Path import yaml +from storied.character import create_character as char_create from storied.character import update_character as char_update from storied.content import ContentResolver from storied.dice import roll as dice_roll @@ -157,6 +158,70 @@ """ return char_update(player_id, updates, base_path) +def create_character( + name: str, + race: str, + char_class: str, + level: int, + abilities: dict[str, int], + hp_max: int, + ac: int, + background: str | None = None, + speed: int = 30, + gold: int = 0, + equipment: list[str] | None = None, + features: list[str] | None = None, + proficiencies: str | None = None, + backstory: str | None = None, + player_id: str = "default", + base_path: Path | None = None, +) -> str: + """Create a new player character and save to disk. + + Call this when character creation is complete. Include all the mechanical + details needed to play: abilities, HP, AC, equipment, and features. + + Args: + name: Character name + race: Race (e.g., "Human", "High Elf", "Hill Dwarf") + char_class: Class (e.g., "Fighter", "Wizard", "Rogue") + level: Starting level (usually 1) + abilities: All six ability scores as a dict: + {"strength": 15, "dexterity": 14, "constitution": 13, + "intelligence": 12, "wisdom": 10, "charisma": 8} + hp_max: Maximum hit points + ac: Armor class + background: Background (e.g., "Soldier", "Sage", "Criminal") + speed: Movement speed in feet (default 30) + gold: Starting gold pieces + equipment: List of equipment items + features: List of racial and class features + proficiencies: Description of proficiencies (armor, weapons, tools, saves, skills) + backstory: Character backstory and personality + + Returns: + Confirmation message + """ + return char_create( + player_id=player_id, + name=name, + race=race, + char_class=char_class, + level=level, + abilities=abilities, + hp_max=hp_max, + ac=ac, + background=background, + speed=speed, + gold=gold, + equipment=equipment, + features=features, + proficiencies=proficiencies, + backstory=backstory, + base_path=base_path, + ) + + def update_session( situation: str | None = None, location: str | None = None, @@ -311,6 +376,37 @@ anchor = log.append_entry(event, duration, advance_time, tags) return f"Logged: {anchor} | {event} | {duration}" +def end_session( + situation: str, + threads: list[str] | None = None, + player_id: str = "default", + base_path: Path | None = None, +) -> str: + """End the current session, saving the game state for next time. + + Call this when the player indicates they want to stop playing. This saves + the current situation so the next session can resume smoothly. + + Before calling, give a brief farewell and summary of what happened this session. + + Args: + situation: Summary of the current state of affairs for the next session. + Write as if briefing a DM who will pick up where you left off. + threads: Open plot threads or objectives to carry forward + player_id: Player identifier + base_path: Base path for players directory + + Returns: + Confirmation that session was saved + """ + updates = {"situation": situation} + if threads is not None: + updates["threads"] = threads + + session_update(player_id, updates, base_path) + return "SESSION_ENDED" + + # Tool definitions for the Anthropic API TOOL_DEFINITIONS = [ { @@ -399,6 +495,41 @@ "required": ["updates"], }, }, { + "name": "create_character", + "description": create_character.__doc__, + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Character name"}, + "race": {"type": "string", "description": "Race (e.g., 'Human', 'High Elf')"}, + "char_class": {"type": "string", "description": "Class (e.g., 'Fighter', 'Wizard')"}, + "level": {"type": "integer", "description": "Starting level (usually 1)"}, + "abilities": { + "type": "object", + "description": "All six ability scores: strength, dexterity, constitution, intelligence, wisdom, charisma", + }, + "hp_max": {"type": "integer", "description": "Maximum hit points"}, + "ac": {"type": "integer", "description": "Armor class"}, + "background": {"type": "string", "description": "Background (e.g., 'Soldier', 'Sage')"}, + "speed": {"type": "integer", "description": "Movement speed in feet"}, + "gold": {"type": "integer", "description": "Starting gold pieces"}, + "equipment": { + "type": "array", + "items": {"type": "string"}, + "description": "List of equipment items", + }, + "features": { + "type": "array", + "items": {"type": "string"}, + "description": "List of racial and class features", + }, + "proficiencies": {"type": "string", "description": "Proficiency description"}, + "backstory": {"type": "string", "description": "Character backstory and personality"}, + }, + "required": ["name", "race", "char_class", "level", "abilities", "hp_max", "ac"], + }, + }, + { "name": "update_session", "description": update_session.__doc__, "input_schema": { @@ -481,6 +612,25 @@ }, "required": ["event", "duration"], }, }, + { + "name": "end_session", + "description": end_session.__doc__, + "input_schema": { + "type": "object", + "properties": { + "situation": { + "type": "string", + "description": "Summary of current state for the next session", + }, + "threads": { + "type": "array", + "items": {"type": "string"}, + "description": "Open plot threads or objectives to carry forward", + }, + }, + "required": ["situation"], + }, + }, ] @@ -539,6 +689,26 @@ player_id=player_id, base_path=base_path, ) + elif tool_name == "create_character": + return create_character( + name=tool_input["name"], + race=tool_input["race"], + char_class=tool_input["char_class"], + level=tool_input["level"], + abilities=tool_input["abilities"], + hp_max=tool_input["hp_max"], + ac=tool_input["ac"], + background=tool_input.get("background"), + speed=tool_input.get("speed", 30), + gold=tool_input.get("gold", 0), + equipment=tool_input.get("equipment"), + features=tool_input.get("features"), + proficiencies=tool_input.get("proficiencies"), + backstory=tool_input.get("backstory"), + player_id=player_id, + base_path=base_path, + ) + elif tool_name == "update_session": return update_session( situation=tool_input.get("situation"), @@ -566,6 +736,14 @@ duration=tool_input["duration"], advance_time=tool_input.get("advance_time", True), tags=tool_input.get("tags"), campaign_log=campaign_log, + player_id=player_id, + base_path=base_path, + ) + + elif tool_name == "end_session": + return end_session( + situation=tool_input["situation"], + threads=tool_input.get("threads"), player_id=player_id, base_path=base_path, )