diff --git a/prompts/dm-system.md b/prompts/dm-system.md index 8e28ebf..46d8e4a 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -66,6 +66,99 @@ Use markdown formatting to enhance readability: Don't over-describe. One vivid detail beats three adequate ones. Trust the player's imagination. +## Display Blocks + +You have special fenced block types that render as distinct panels in the terminal. **Use them generously** — they make the world feel tangible. Draw a map when the player walks into a new place. Show the sign on the tavern door. Sketch the dagger they just looted. These visual moments are what players remember. + +### Maps — ` ```map Title` + +Use for spatial layouts: rooms, buildings, cities, regions. The terminal renders these in a bordered panel. + +```map The Rusty Anchor — Ground Floor +┌──────────────┬───────────┐ +│ COMMON │ KITCHEN │ +│ ROOM [B]│ │ +│ [T] [T] ───┤ [F] │ +│ │ │ +│ [☆] [T] ├───────────┤ +│ ═════ │ STORAGE │ +└─────═════────┴───────────┘ + ☆ You T Table B Bar F Fireplace +``` + +**Drawing guidelines:** +- Check the Display Layout section in your context for exact column widths +- Use Unicode freely: box-drawing (┌┐└┘─│├┤┬┴┼═║╔╗╚╝), blocks (█▓▒░), arrows (→←↑↓), symbols (●○◆★☆⚔⛪🏠) +- Legend below the map +- Mark the player's position with ☆ +- Scale to the situation: + - **Room**: furniture, doors, objects, cover + - **Building**: rooms, corridors, stairs + - **City**: districts, landmarks, gates, major roads + - **Region**: towns, roads, terrain, rivers + +**When to draw maps:** +- When the player enters a significant new location +- When spatial layout matters (combat, exploration, chase) +- When the player asks to see the area +- When you `establish` a location, include a map in the description + +For important locations, also `establish` the map as a `maps` entity so it persists across sessions. + +### Asides — ` ```aside Title` + +Use for documents the character reads: letters, signs, inscriptions, wanted posters, journal entries, prophecies. + +```aside Notice on the Tavern Door +WANTED — Mira Ashvale +For questioning in connection with +the disappearance of Merchant Aldric. +50 gp reward. See Constable Harrik. +``` + +Asides render in a softer panel in the right column. Use them whenever the character encounters written text in the world — it makes the moment feel distinct from narration. Keep titles concise (e.g., "House Rules" not "Notice Posted on the Staircase Post") — long titles force the panel wider. + +**Good opportunities for asides:** +- Tavern menus, chalkboards, house rules +- Wanted posters, bounty boards, job listings +- Letters, notes, journal entries the character finds +- Signs, inscriptions, carved warnings +- Prophecies, riddles, magical runes +- Shop inventories, price lists +- Grave markers, plaques, dedications + +### Items — ` ```item Title` + +Use for significant items: magical weapons, artifacts, potions, treasures. Draw the item in Unicode art with labeled parts — blade, hilt, gem, etc. The terminal renders these in a magenta-bordered panel. + +```item The Tide's Tooth — Curved Dagger + . + / + / ≈ ≈ + | ≈ ≈ ≈ blade shifts + | ≈ ≈ ≈ between steel + | ≈ ≈ and seawater + | ≈ ≈ + ┌─────┐ + │░░░░░│ crossguard: barnacle-crusted bronze + └──┬──┘ + ◆◆◆◆◆◆ grip: sharkskin wound with + ◆◆◆◆◆◆ salt-stained cord + ┌──┴──┐ + │ ●● │ pommel: a smooth black sea stone + │●●●●│ (always cold, always wet) + └─────┘ +``` + +Use item blocks when: +- The player finds or examines a notable item +- A magical item is identified or its properties are revealed +- A quest-relevant object is discovered +- A shopkeeper displays their wares +- The player inspects loot after combat + +Draw the item with labeled parts — a sword has a blade, crossguard, grip, pommel. A potion has a bottle shape, liquid color, stopper. A ring has a band, setting, gem. Let the art tell the story of the item's history and nature. + ## Narrative Rhythm Vary your storytelling pace to match the moment: @@ -274,11 +367,14 @@ Use `establish` after introducing anything significant: - **Locations** - anywhere the player visits or will return to - **Items** - magical items, plot-relevant objects, interesting equipment - **Threads** - situations in motion that might develop +- **Maps** - spatial layouts worth persisting (use `entity_type="maps"`) **Give names to everyone.** Don't introduce "a guard" - introduce "Mara, a guard". Then establish her. Named NPCs create a living world. **Anchor entities to locations.** Use wikilinks to connect NPCs to where they belong. Don't say "works at the city jail" - say "works at [[Greyhaven City Jail]]" or "a jailer in [[Greyhaven]]". This creates a connected world where relationships are explicit and traceable. +**Make locations visual.** When you establish a location, include a ` ```map` block in its description. When you establish an item, include a ` ```item` block. When a location has signage, menus, or posted notices, include those as ` ```aside` blocks. These display blocks persist with the entity and bring it to life when it's recalled later. + Think: What does this entity know that isn't obvious? What is its nature? What might it do if...? Where do they belong? Example: diff --git a/prompts/planner-system.md b/prompts/planner-system.md index e22680c..b1ed7b0 100644 --- a/prompts/planner-system.md +++ b/prompts/planner-system.md @@ -43,6 +43,8 @@ When enriching an existing entity, you may reference entities that don't exist y - 1-2 Knows/Wants/Will items - A location via wikilink +**Make locations visual.** When establishing or enriching a location, include a ` ```map` block in the description showing its layout. Include ` ```aside` blocks for any signage, posted menus, or notices that would be visible. For notable items, include ` ```item` blocks with Unicode art. These display blocks persist with the entity and render as visual panels when the DM presents them to the player. + Prefer enriching the thin entities you were given over inventing new ones. ## Rules diff --git a/prompts/world-seed.md b/prompts/world-seed.md index 51eb5f1..e4bb973 100644 --- a/prompts/world-seed.md +++ b/prompts/world-seed.md @@ -60,6 +60,15 @@ Use these for the `entity_type` parameter: - `items` — notable objects, artifacts, equipment - `threads` — plot hooks and ongoing storylines - `lore` — history, legends, world facts +- `maps` — spatial layouts worth persisting + +## Visual Details + +When establishing entities, include display blocks in the description to make them visually rich when the DM presents them to the player: + +- **Locations**: Include a ` ```map` block showing the layout. Include ` ```aside` blocks for visible signage, posted menus, house rules, notices, or inscriptions. +- **Items**: Include a ` ```item` block with Unicode art showing the item's appearance and labeled parts. +- **Maps**: For regional or city-scale maps, establish them as `entity_type="maps"` so they persist separately. ## Order of Operations diff --git a/pyproject.toml b/pyproject.toml index e705545..5fb6cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,12 @@ packages = ["src/storied"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +addopts = [ + "--cov=src/storied", + "--cov=tests", + "--cov-report=term-missing:skip-covered", + "--cov-branch", +] [tool.mypy] python_version = "3.12" @@ -57,5 +63,4 @@ source = ["src/storied"] branch = true [tool.coverage.report] -# Quick iteration over strict coverage -# fail_under = 100 +fail_under = 48 diff --git a/src/storied/cli.py b/src/storied/cli.py index c58e98a..6e4d717 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -193,8 +193,10 @@ def cmd_play(args: argparse.Namespace) -> int: """Start an interactive DM session.""" import atexit import readline + import shutil + import tempfile - from rich.console import Console, Group + from rich.console import Console from rich.live import Live from rich.markdown import Markdown from rich.panel import Panel @@ -227,34 +229,55 @@ def cmd_play(args: argparse.Namespace) -> int: console = Console() world_id = args.world if args.world else "default" player_id = "default" + sandbox = getattr(args, "sandbox", False) - # Check if character exists - character = load_character(player_id) - creation_mode = character is None + # Sandbox mode: throwaway session in a temp directory + sandbox_dir: Path | None = None + if sandbox: + sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) + (sandbox_dir / "worlds" / world_id).mkdir(parents=True) + (sandbox_dir / "players" / player_id).mkdir(parents=True) - 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: + base_path = sandbox_dir if sandbox else None + creation_mode = False + + if sandbox: 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", + "[bold]Storied Sandbox[/bold]\n" + "No character, no world — just you and the DM.\n" + "Type [cyan]Ctrl+D[/cyan] to quit.", + title="Sandbox", + border_style="cyan", )) prompt_name = "dm-system" - - console.print(f"[dim]World: {world_id}[/dim]") + else: + # 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}{' (sandbox)' if sandbox else ''}[/dim]") console.print() # Seed the world if the character exists but no session yet - if not creation_mode: + if not creation_mode and not sandbox: from storied.session import load_session session = load_session(player_id) @@ -283,18 +306,19 @@ def cmd_play(args: argparse.Namespace) -> int: player_id=player_id, prompt_name=prompt_name, transcript_path=transcript_path, + base_path=base_path, ) engine.debug = args.debug # Background ticker for mid-session world advancement ticker = None - if not creation_mode: + if not creation_mode and not sandbox: from storied.planner import BackgroundTicker ticker = BackgroundTicker( world_id=world_id, player_id=player_id, - base_path=Path.cwd(), + base_path=base_path or Path.cwd(), ) # Kick off initial tick in background ticker.maybe_tick(engine._campaign_log) @@ -304,24 +328,15 @@ def cmd_play(args: argparse.Namespace) -> int: 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).""" - renderables = [] - prev_type = None - for part_type, content in parts: - if part_type == "tool": - if prev_type == "text": - renderables.append(Text("")) # Blank line before tools - renderables.append(Text(content, style="dim")) - elif part_type == "text" and content.strip(): - if prev_type == "tool": - renderables.append(Text("")) # Blank line after tools - renderables.append(Markdown(content)) - prev_type = part_type - return Group(*renderables) + from storied.display import StreamClassifier, build_display # Kick off the conversation with appropriate first message - if creation_mode: + if sandbox: + first_message = ( + "[Sandbox session — no character, no world. The player wants to " + "experiment freely. Jump straight into whatever they ask.]" + ) + elif creation_mode: first_message = "Let's create a character!" else: first_message = "[Session starting]" @@ -335,15 +350,16 @@ def cmd_play(args: argparse.Namespace) -> int: else: try: console.print(Rule(style="dim blue")) - if creation_mode: + if creation_mode or sandbox: 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: + if sandbox: + console.print("[cyan]Sandbox session ended.[/cyan]") + elif not creation_mode: console.print("[dim]Saving session...[/dim]") try: save_msg = "I need to quit now. Please save the game." @@ -468,19 +484,17 @@ def cmd_play(args: argparse.Namespace) -> int: try: console.print(Rule(style="dim blue")) console.print() # Blank line before DM response - parts: list[tuple[str, str]] = [] # (type, content) in order + classifier = StreamClassifier() - with Live(console=console, refresh_per_second=10) as live: + with Live(console=console, refresh_per_second=10, vertical_overflow="visible") as live: for chunk in engine.stream_action(action): if chunk.startswith("\n[") or chunk.startswith("Rolled "): - parts.append(("tool", chunk.strip())) + classifier.feed_tool(chunk) else: - # Append to last text part, or create new one - if parts and parts[-1][0] == "text": - parts[-1] = ("text", parts[-1][1] + chunk) - else: - parts.append(("text", chunk)) - live.update(build_display(parts)) + classifier.feed(chunk) + live.update(build_display(classifier.parts, console.width)) + classifier.flush() + live.update(build_display(classifier.parts, console.width)) console.print() @@ -525,8 +539,9 @@ def cmd_play(args: argparse.Namespace) -> int: except KeyboardInterrupt: console.print("\n[red][Interrupted][/red]") - # Ask DM to save on interrupt - skip if in creation mode - if not creation_mode: + if sandbox: + console.print("[cyan]Sandbox session ended.[/cyan]") + elif not creation_mode: console.print("[dim]Saving session...[/dim]") try: save_msg = "I need to quit now. Please save the game." @@ -542,8 +557,9 @@ def cmd_play(args: argparse.Namespace) -> int: except KeyboardInterrupt: console.print() - # Outer interrupt - skip save in creation mode - if not creation_mode: + if sandbox: + console.print("[cyan]Sandbox session ended.[/cyan]") + elif not creation_mode: console.print("[dim]Saving session...[/dim]") try: save_msg = "I need to quit now. Please save the game." @@ -555,6 +571,9 @@ def cmd_play(args: argparse.Namespace) -> int: ) else: console.print("[yellow]Farewell![/yellow]") + finally: + if sandbox_dir and sandbox_dir.exists(): + shutil.rmtree(sandbox_dir, ignore_errors=True) return 0 @@ -741,6 +760,11 @@ def build_parser() -> argparse.ArgumentParser: "--transcript", "-t", help="Path to write full debug transcript (JSONL format)", ) + play_parser.add_argument( + "--sandbox", "-s", + action="store_true", + help="Throwaway session — no character, no world state", + ) play_parser.set_defaults(func=cmd_play) # reset command diff --git a/src/storied/display.py b/src/storied/display.py new file mode 100644 index 0000000..e3f3f81 --- /dev/null +++ b/src/storied/display.py @@ -0,0 +1,179 @@ +"""Display rendering for DM output — stream classification and column layout.""" + +import re + +from rich import box as rich_box +from rich.align import Align +from rich.console import Group +from rich.markdown import Markdown +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +BLOCK_STYLES: dict[str, tuple[str, rich_box.Box]] = { + "map": ("green", rich_box.HEAVY), + "aside": ("yellow dim", rich_box.ROUNDED), + "item": ("magenta", rich_box.DOUBLE), +} + +_FENCE_RE = re.compile(r"^```(map|aside|item)\s*(.*)") + + +class StreamClassifier: + """Line-oriented state machine for classifying DM output chunks. + + Splits streaming text into typed parts: plain text, tool notifications, + and fenced display blocks (```map, ```aside, ```item). + """ + + def __init__(self) -> None: + self.parts: list[tuple] = [] + self._partial: str = "" + self._block: dict | None = None + + def feed_tool(self, chunk: str) -> None: + """Add a tool notification chunk directly.""" + self.parts.append(("tool", chunk.strip())) + + def feed(self, chunk: str) -> None: + """Process a narrative text chunk through the state machine.""" + text = self._partial + chunk + *lines, self._partial = text.split("\n") + + for line in lines: + self._process_line(line) + + def flush(self) -> None: + """Flush any remaining partial line at end of stream.""" + if self._partial: + self._process_line(self._partial) + self._partial = "" + + def _process_line(self, line: str) -> None: + if self._block is not None: + if line.strip() == "```": + # Remove live preview if present + if self.parts and self.parts[-1][0] == "block_preview": + self.parts.pop() + # Closing fence — finalize block + self.parts.append(( + "block", self._block["kind"], + self._block["title"], + "\n".join(self._block["lines"]), + )) + self._block = None + else: + self._block["lines"].append(line) + self._update_live_block() + else: + m = _FENCE_RE.match(line) + if m: + self._block = { + "kind": m.group(1), + "title": m.group(2).strip(), + "lines": [], + } + else: + self._append_text(line) + + def _append_text(self, line: str) -> None: + if self.parts and self.parts[-1][0] == "text": + self.parts[-1] = ("text", self.parts[-1][1] + "\n" + line) + else: + self.parts.append(("text", line)) + + def _update_live_block(self) -> None: + """Update or append an in-progress block part for live preview.""" + preview = ( + "block", self._block["kind"], + self._block["title"], + "\n".join(self._block["lines"]), + ) + if self.parts and self.parts[-1][0] == "block_preview": + self.parts[-1] = ("block_preview", *preview[1:]) + else: + self.parts.append(("block_preview", *preview[1:])) + + +def make_panel(kind: str, title: str, content: str) -> Panel: + """Create a styled Panel for a display block.""" + border_style, border_box = BLOCK_STYLES.get(kind, ("white", rich_box.ROUNDED)) + return Panel( + content, title=title or None, + border_style=border_style, box=border_box, + padding=(0, 1), expand=False, + ) + + +def build_display(parts: list[tuple], console_width: int) -> Group | Text: + """Build display as a continuous 2/3 + 1/3 column layout. + + Text and tool notifications go in the left column. Narrow blocks + (fitting in 1/3 of terminal) go in the right column. Wide blocks + break the grid and render centered full-width. Text always renders + in the left 2/3 for consistent, comfortable reading width. + """ + sections: list = [] + grid: Table | None = None + left_parts: list = [] + right_panel: Panel | None = None + prev_type: str | None = None + narrow_max = console_width // 3 + + def new_grid() -> Table: + g = Table.grid(padding=(0, 2)) + g.add_column(ratio=2) + g.add_column(ratio=1, min_width=narrow_max) + return g + + def close_row() -> None: + nonlocal right_panel, grid + if not left_parts and right_panel is None: + return + if grid is None: + grid = new_grid() + left = Group(*left_parts) if left_parts else Text("") + grid.add_row(left, right_panel or Text("")) + left_parts.clear() + right_panel = None + + def close_grid() -> None: + nonlocal grid + close_row() + if grid is not None and grid.row_count > 0: + sections.append(grid) + grid = None + + for part in parts: + part_type = part[0] + + if part_type == "tool": + if prev_type == "text": + left_parts.append(Text("")) + left_parts.append(Text(part[1], style="dim")) + + elif part_type == "text" and part[1].strip(): + if prev_type in ("tool", "block", "block_preview"): + left_parts.append(Text("")) + left_parts.append(Markdown(part[1])) + + elif part_type in ("block", "block_preview"): + _, kind, title, content = part + content_width = max( + (len(line) for line in content.splitlines()), default=0, + ) + panel_width = content_width + 4 + panel = make_panel(kind, title, content) + + if panel_width <= narrow_max: + if right_panel is not None: + close_row() + right_panel = panel + else: + close_grid() + sections.append(Align.center(panel)) + + prev_type = part_type + + close_grid() + return Group(*sections) if sections else Text("") diff --git a/src/storied/engine.py b/src/storied/engine.py index 2393c55..3493c20 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -1,6 +1,7 @@ """DM Engine - drives claude -p for running 5e sessions.""" import json +import os import re from collections.abc import Iterator from datetime import UTC, datetime @@ -221,6 +222,23 @@ class DMEngine: parts.append(entity_context) loaded_names.add(name) + # Display layout info so the DM knows its column sizes + term_width = os.get_terminal_size().columns + right_col = term_width // 3 + left_col = term_width - right_col + right_content = right_col - 4 # panel border + padding + layout = ( + "## Display Layout\n\n" + f"Terminal: {term_width} chars wide. " + f"Text column: ~{left_col} chars. " + f"Side column: ~{right_col} chars.\n" + f"Display blocks under ~{right_content} chars wide " + "inset in the right column. Wider blocks render centered full-width.\n" + "Keep aside/item titles concise — long titles force the panel wider." + ) + self._context_parts["Layout"] = layout + parts.append(layout) + return "\n\n---\n\n".join(parts) def _load_player_knowledge(self) -> str | None: diff --git a/tests/test_character.py b/tests/test_character.py index ba443f2..ca00e8d 100644 --- a/tests/test_character.py +++ b/tests/test_character.py @@ -1,4 +1,4 @@ -"""Tests for character loading, saving, and updates.""" +"""Tests for character loading, saving, updates, and display formatting.""" from pathlib import Path @@ -6,6 +6,9 @@ import pytest from storied.character import ( create_character, + format_character_context, + format_sheet, + format_status, load_character, parse_character, save_character, @@ -250,3 +253,168 @@ class TestHPClamping: char = load_character("test-player", player_base) assert char["hp"]["current"] == 0 + + +# ── Display formatting ─────────────────────────────────────────────────── + + +@pytest.fixture +def rich_character() -> dict: + """A character dict with all fields populated for display tests.""" + return { + "name": "Mira Ashvale", + "race": "Human", + "class": "Rogue", + "level": 3, + "background": "Criminal", + "hp": {"current": 20, "max": 24}, + "ac": 16, + "speed": 30, + "abilities": { + "strength": 11, + "dexterity": 18, + "constitution": 14, + "intelligence": 15, + "wisdom": 14, + "charisma": 18, + }, + "purse": {"cp": 40, "sp": 20, "ep": 0, "gp": 93, "pp": 0}, + "body": ( + "## Proficiencies\n" + "Armor: Light.\n\n" + "## Features\n" + "- Sneak Attack 2d6\n" + "- Cunning Action\n\n" + "## Equipment\n" + "- Thieves' tools\n" + "- Dagger\n\n" + "## Backstory\n" + "A sharp-tongued grifter." + ), + } + + +class TestFormatStatus: + """Tests for compact /status display.""" + + def test_identity_line(self, rich_character: dict): + result = format_status(rich_character) + assert "**Mira Ashvale**" in result + assert "Human Rogue 3" in result + assert "(Criminal)" in result + + def test_vitals(self, rich_character: dict): + result = format_status(rich_character) + assert "HP 20/24" in result + assert "AC 16" in result + assert "Speed 30 ft" in result + + def test_abilities(self, rich_character: dict): + result = format_status(rich_character) + assert "STR 11 (+0)" in result + assert "DEX 18 (+4)" in result + assert "CHA 18 (+4)" in result + + def test_purse_nonzero_only(self, rich_character: dict): + result = format_status(rich_character) + assert "93 gp" in result + assert "20 sp" in result + assert "40 cp" in result + assert "ep" not in result + assert "pp" not in result + + def test_equipment_one_liner(self, rich_character: dict): + result = format_status(rich_character) + assert "Thieves' tools" in result + assert "Dagger" in result + + def test_equipment_excluded(self, rich_character: dict): + result = format_status(rich_character, include_equipment=False) + assert "Thieves' tools" not in result + + def test_no_background(self): + data = { + "name": "Test", "race": "Elf", "class": "Wizard", "level": 1, + "hp": {"current": 6, "max": 6}, "ac": 12, "speed": 30, + "abilities": {}, "body": "", + } + result = format_status(data) + assert "(" not in result + + def test_legacy_gold_field(self): + data = { + "name": "Old", "race": "Human", "class": "Fighter", "level": 1, + "hp": 10, "ac": 14, "speed": 30, "gold": 50, "body": "", + } + result = format_status(data) + assert "50 gp" in result + + def test_empty_purse(self): + data = { + "name": "Broke", "race": "Human", "class": "Rogue", "level": 1, + "hp": {"current": 5, "max": 5}, "ac": 10, "speed": 30, + "purse": {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, + "body": "", + } + result = format_status(data) + assert "empty" in result + + +class TestFormatSheet: + """Tests for full /me display.""" + + def test_includes_status_header(self, rich_character: dict): + result = format_sheet(rich_character) + assert "**Mira Ashvale**" in result + assert "HP 20/24" in result + + def test_includes_all_sections(self, rich_character: dict): + result = format_sheet(rich_character) + assert "**Proficiencies**" in result + assert "**Features**" in result + assert "**Equipment**" in result + assert "**Backstory**" in result + + def test_equipment_as_list(self, rich_character: dict): + result = format_sheet(rich_character) + assert "- Thieves' tools" in result + assert "- Dagger" in result + + def test_skips_empty_sections(self): + data = { + "name": "Sparse", "race": "Human", "class": "Fighter", "level": 1, + "hp": {"current": 10, "max": 10}, "ac": 14, "speed": 30, + "body": "## Features\n- Tough\n\n## Notes\n", + } + result = format_sheet(data) + assert "**Features**" in result + assert "**Notes**" not in result + + def test_no_equipment_one_liner(self, rich_character: dict): + result = format_sheet(rich_character) + equipment_one_liners = [ + l for l in result.splitlines() if l.startswith("**Equipment:**") + ] + assert len(equipment_one_liners) == 0 + + +class TestFormatCharacterContext: + """Tests for system prompt character context.""" + + def test_includes_name_and_class(self, rich_character: dict): + result = format_character_context(rich_character) + assert "Mira Ashvale" in result + assert "Rogue" in result + + def test_includes_abilities_with_modifiers(self, rich_character: dict): + result = format_character_context(rich_character) + assert "STR 11 (+0)" in result + assert "DEX 18 (+4)" in result + + def test_includes_purse(self, rich_character: dict): + result = format_character_context(rich_character) + assert "93 gp" in result + + def test_includes_body(self, rich_character: dict): + result = format_character_context(rich_character) + assert "Sneak Attack" in result diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..c3affa5 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,305 @@ +"""Tests for the display module — stream classification and column layout.""" + +import pytest +from rich.align import Align +from rich.console import Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from storied.display import StreamClassifier, build_display, make_panel + + +# ── StreamClassifier ───────────────────────────────────────────────────── + + +class TestStreamClassifierText: + """Plain text accumulation.""" + + def test_single_line(self): + c = StreamClassifier() + c.feed("Hello world\n") + assert c.parts == [("text", "Hello world")] + + def test_multiple_lines_merge(self): + c = StreamClassifier() + c.feed("Line one\nLine two\n") + assert c.parts == [("text", "Line one\nLine two")] + + def test_chunked_delivery(self): + c = StreamClassifier() + c.feed("Hello ") + c.feed("world\n") + assert c.parts == [("text", "Hello world")] + + def test_flush_partial_line(self): + c = StreamClassifier() + c.feed("No trailing newline") + assert len(c.parts) == 0 + c.flush() + assert c.parts == [("text", "No trailing newline")] + + def test_empty_lines_preserved(self): + c = StreamClassifier() + c.feed("Before\n\nAfter\n") + assert c.parts == [("text", "Before\n\nAfter")] + + +class TestStreamClassifierBlocks: + """Fenced display block detection.""" + + def test_map_block(self): + c = StreamClassifier() + c.feed("Before\n```map Tavern\n+-+\n|X|\n+-+\n```\nAfter\n") + assert c.parts[0] == ("text", "Before") + assert c.parts[1] == ("block", "map", "Tavern", "+-+\n|X|\n+-+") + assert c.parts[2] == ("text", "After") + + def test_aside_block(self): + c = StreamClassifier() + c.feed("```aside A Letter\nDear friend,\nCome quickly.\n```\n") + assert c.parts[0] == ("block", "aside", "A Letter", "Dear friend,\nCome quickly.") + + def test_item_block(self): + c = StreamClassifier() + c.feed("```item Magic Sword\n/|\\\n | \n```\n") + assert c.parts[0] == ("block", "item", "Magic Sword", "/|\\\n | ") + + def test_block_without_title(self): + c = StreamClassifier() + c.feed("```map\n+-+\n```\n") + assert c.parts[0] == ("block", "map", "", "+-+") + + def test_regular_code_block_passthrough(self): + c = StreamClassifier() + c.feed("```python\nprint('hi')\n```\n") + assert c.parts[0][0] == "text" + assert "python" in c.parts[0][1] + + def test_chunked_block_delivery(self): + c = StreamClassifier() + c.feed("```map Room") + c.feed("\n+-+\n|") + c.feed("X|\n+-+\n```\nDone\n") + assert c.parts[0] == ("block", "map", "Room", "+-+\n|X|\n+-+") + assert c.parts[1] == ("text", "Done") + + def test_block_preview_during_streaming(self): + c = StreamClassifier() + c.feed("```map Room\nline1\n") + previews = [p for p in c.parts if p[0] == "block_preview"] + assert len(previews) == 1 + assert previews[0][1] == "map" + + def test_preview_replaced_by_final_block(self): + c = StreamClassifier() + c.feed("```map Room\nline1\nline2\n```\n") + assert not any(p[0] == "block_preview" for p in c.parts) + assert c.parts[0] == ("block", "map", "Room", "line1\nline2") + + def test_backticks_inside_block_not_closing(self): + c = StreamClassifier() + c.feed("```map Room\n`code`\n```\n") + assert c.parts[0] == ("block", "map", "Room", "`code`") + + +class TestStreamClassifierTools: + """Tool notification handling.""" + + def test_tool_via_feed_tool(self): + c = StreamClassifier() + c.feed_tool("\n[Rolling...]\n") + assert c.parts == [("tool", "[Rolling...]")] + + def test_tool_interleaved_with_text(self): + c = StreamClassifier() + c.feed("Some text\n") + c.feed_tool("\n[Rolling...]\n") + c.feed("More text\n") + assert c.parts[0] == ("text", "Some text") + assert c.parts[1] == ("tool", "[Rolling...]") + assert c.parts[2] == ("text", "More text") + + +class TestStreamClassifierMixed: + """Complex mixed content scenarios.""" + + def test_text_block_text(self): + c = StreamClassifier() + c.feed("Intro\n```aside Note\nHello\n```\nOutro\n") + assert len(c.parts) == 3 + assert c.parts[0][0] == "text" + assert c.parts[1][0] == "block" + assert c.parts[2][0] == "text" + + def test_multiple_blocks(self): + c = StreamClassifier() + c.feed("```map A\n+-+\n```\n```aside B\nHi\n```\n") + assert c.parts[0] == ("block", "map", "A", "+-+") + assert c.parts[1] == ("block", "aside", "B", "Hi") + + def test_tool_between_blocks(self): + c = StreamClassifier() + c.feed("Text\n") + c.feed_tool("\n[Setting scene...]\n") + c.feed("```map Room\n+-+\n```\n") + assert c.parts[0] == ("text", "Text") + assert c.parts[1] == ("tool", "[Setting scene...]") + assert c.parts[2] == ("block", "map", "Room", "+-+") + + +# ── make_panel ─────────────────────────────────────────────────────────── + + +class TestMakePanel: + """Panel creation with styled borders.""" + + def test_map_panel(self): + panel = make_panel("map", "Room", "+-+") + assert isinstance(panel, Panel) + assert str(panel.title) == "Room" + + def test_aside_panel(self): + panel = make_panel("aside", "Note", "Hello") + assert isinstance(panel, Panel) + + def test_item_panel(self): + panel = make_panel("item", "Sword", "X") + assert isinstance(panel, Panel) + + def test_no_title(self): + panel = make_panel("map", "", "+-+") + assert panel.title is None + + def test_expand_false(self): + panel = make_panel("map", "Room", "+-+") + assert panel.expand is False + + +# ── build_display ──────────────────────────────────────────────────────── + + +WIDTH = 120 +NARROW_MAX = WIDTH // 3 # 40 + + +@pytest.fixture +def narrow_block() -> tuple: + """A block narrow enough to fit in the right column (< 40 chars).""" + return ("block", "aside", "Note", "Short content") + + +@pytest.fixture +def wide_block() -> tuple: + """A block too wide for the right column (> 40 chars).""" + return ("block", "map", "City", "x" * 50) + + +class TestBuildDisplayTextOnly: + """Text-only responses should use the 2/3 column grid.""" + + def test_returns_group(self): + parts = [("text", "Hello world")] + result = build_display(parts, WIDTH) + assert isinstance(result, Group) + + def test_single_text_creates_grid(self): + parts = [("text", "Hello world")] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + assert len(grids) == 1 + + def test_empty_parts_returns_text(self): + result = build_display([], WIDTH) + assert isinstance(result, Text) + + +class TestBuildDisplayNarrowBlocks: + """Narrow blocks should pair with text in the right column.""" + + def test_text_plus_narrow_block_single_grid(self, narrow_block: tuple): + parts = [("text", "Narrative"), narrow_block] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + assert len(grids) == 1 + assert grids[0].row_count == 1 + + def test_text_flows_alongside_block(self, narrow_block: tuple): + parts = [ + ("text", "First paragraph"), + narrow_block, + ("text", "Second paragraph"), + ] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + # Both text parts should be in the same row as the block + assert len(grids) == 1 + assert grids[0].row_count == 1 + + def test_second_block_closes_row(self): + parts = [ + ("text", "Intro"), + ("block", "aside", "A", "First"), + ("block", "aside", "B", "Second"), + ("text", "Outro"), + ] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + assert len(grids) == 1 + assert grids[0].row_count == 2 + + +class TestBuildDisplayWideBlocks: + """Wide blocks should render centered, breaking the grid.""" + + def test_wide_block_centered(self, wide_block: tuple): + parts = [("text", "Before"), wide_block, ("text", "After")] + result = build_display(parts, WIDTH) + aligns = [r for r in result.renderables if isinstance(r, Align)] + assert len(aligns) == 1 + + def test_wide_block_splits_grids(self, wide_block: tuple): + parts = [("text", "Before"), wide_block, ("text", "After")] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + assert len(grids) == 2 # one before, one after the wide block + + +class TestBuildDisplayTools: + """Tool notifications go in the left column.""" + + def test_tool_in_grid(self): + parts = [ + ("text", "Narrative"), + ("tool", "[Rolling...]"), + ("text", "Result"), + ] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + assert len(grids) == 1 + assert grids[0].row_count == 1 + + def test_tool_with_block(self, narrow_block: tuple): + parts = [ + ("text", "Narrative"), + ("tool", "[Rolling...]"), + narrow_block, + ("text", "After"), + ] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + assert len(grids) == 1 + + +class TestBuildDisplayBlockPreview: + """In-progress blocks should render like finalized blocks.""" + + def test_preview_treated_as_block(self): + parts = [ + ("text", "Narrative"), + ("block_preview", "aside", "Note", "In progress..."), + ] + result = build_display(parts, WIDTH) + grids = [r for r in result.renderables if isinstance(r, Table)] + assert len(grids) == 1 + assert grids[0].row_count == 1 diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..716d74e --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,231 @@ +"""Tests for session state management.""" + +from pathlib import Path + +import pytest + +from storied.session import ( + extract_wiki_links, + format_session_context, + load_session, + name_to_slug, + parse_session, + resolve_wiki_link, + save_session, + update_session, +) + + +@pytest.fixture +def session_base(tmp_path: Path) -> Path: + """Create a base directory with player structure.""" + (tmp_path / "players" / "test-player").mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def saved_session(session_base: Path) -> dict: + """Create and save a session with situation and threads.""" + data = { + "location": "rusty-anchor", + "body": ( + "## Situation\n" + "At the tavern, talking to Vera.\n\n" + "## Present\n" + "- [[Vera Blackwater]]\n" + "- [[Henrik]]\n\n" + "## Open Threads\n" + "- Investigate the warehouse\n" + "- Find the missing merchant" + ), + } + save_session("test-player", data, session_base) + return load_session("test-player", session_base) + + +# ── Parsing ────────────────────────────────────────────────────────────── + + +class TestParseSession: + def test_with_frontmatter(self): + content = "---\nlocation: tavern\n---\n\n## Situation\nAt the bar." + result = parse_session(content) + assert result["location"] == "tavern" + assert "Situation" in result["body"] + + def test_without_frontmatter(self): + result = parse_session("Just notes") + assert result["body"] == "Just notes" + + def test_empty_frontmatter(self): + result = parse_session("---\n---\n\nBody here") + assert result["body"] == "Body here" + + +# ── Load / Save ────────────────────────────────────────────────────────── + + +class TestLoadSaveSession: + def test_save_and_load(self, session_base: Path): + save_session("test-player", {"location": "docks", "body": "At the docks."}, session_base) + loaded = load_session("test-player", session_base) + assert loaded["location"] == "docks" + assert "At the docks" in loaded["body"] + + def test_load_nonexistent(self, session_base: Path): + assert load_session("nobody", session_base) is None + + def test_save_adds_timestamp(self, session_base: Path): + save_session("test-player", {"body": ""}, session_base) + loaded = load_session("test-player", session_base) + assert "updated" in loaded + + +# ── Updates ────────────────────────────────────────────────────────────── + + +class TestUpdateSession: + def test_update_situation(self, session_base: Path, saved_session: dict): + result = update_session( + "test-player", + {"situation": "Escaped to the harbor."}, + session_base, + ) + assert "Updated situation" in result + loaded = load_session("test-player", session_base) + assert "Escaped to the harbor" in loaded["body"] + + def test_update_threads_from_list(self, session_base: Path, saved_session: dict): + update_session( + "test-player", + {"threads": ["New thread one", "New thread two"]}, + session_base, + ) + loaded = load_session("test-player", session_base) + assert "- New thread one" in loaded["body"] + assert "- New thread two" in loaded["body"] + + def test_update_present_from_list(self, session_base: Path, saved_session: dict): + update_session( + "test-player", + {"present": ["[[Captain Harrik]]"]}, + session_base, + ) + loaded = load_session("test-player", session_base) + assert "Captain Harrik" in loaded["body"] + + def test_update_location(self, session_base: Path, saved_session: dict): + result = update_session( + "test-player", + {"location": "harbor"}, + session_base, + ) + assert "location = harbor" in result + loaded = load_session("test-player", session_base) + assert loaded["location"] == "harbor" + + def test_creates_session_if_missing(self, session_base: Path): + update_session( + "test-player", + {"situation": "Starting fresh."}, + session_base, + ) + loaded = load_session("test-player", session_base) + assert "Starting fresh" in loaded["body"] + + def test_no_changes(self, session_base: Path, saved_session: dict): + result = update_session("test-player", {}, session_base) + assert "No changes" in result + + def test_appends_new_section(self, session_base: Path): + save_session("test-player", {"body": ""}, session_base) + update_session( + "test-player", + {"situation": "Brand new situation."}, + session_base, + ) + loaded = load_session("test-player", session_base) + assert "## Situation" in loaded["body"] + assert "Brand new situation" in loaded["body"] + + +# ── Wiki links ─────────────────────────────────────────────────────────── + + +class TestExtractWikiLinks: + def test_single_link(self): + assert extract_wiki_links("Talk to [[Vera]]") == ["Vera"] + + def test_multiple_links(self): + result = extract_wiki_links("[[Vera]] and [[Henrik]] at [[The Rusty Anchor]]") + assert result == ["Vera", "Henrik", "The Rusty Anchor"] + + def test_no_links(self): + assert extract_wiki_links("No links here") == [] + + def test_empty_string(self): + assert extract_wiki_links("") == [] + + +class TestResolveWikiLink: + def test_finds_npc(self, tmp_path: Path): + npc_dir = tmp_path / "worlds" / "default" / "npcs" + npc_dir.mkdir(parents=True) + (npc_dir / "Vera Blackwater.md").write_text("---\nname: Vera\n---\n") + result = resolve_wiki_link("Vera Blackwater", "default", tmp_path) + assert result is not None + assert result.name == "Vera Blackwater.md" + + def test_finds_location(self, tmp_path: Path): + loc_dir = tmp_path / "worlds" / "default" / "locations" + loc_dir.mkdir(parents=True) + (loc_dir / "The Rusty Anchor.md").write_text("---\nname: The Rusty Anchor\n---\n") + result = resolve_wiki_link("The Rusty Anchor", "default", tmp_path) + assert result is not None + + def test_not_found(self, tmp_path: Path): + (tmp_path / "worlds" / "default").mkdir(parents=True) + assert resolve_wiki_link("Nobody", "default", tmp_path) is None + + def test_priority_order(self, tmp_path: Path): + """NPCs are checked before locations.""" + for entity_type in ("npcs", "locations"): + d = tmp_path / "worlds" / "default" / entity_type + d.mkdir(parents=True) + (d / "Ambiguous.md").write_text(f"---\ntype: {entity_type}\n---\n") + result = resolve_wiki_link("Ambiguous", "default", tmp_path) + assert "npcs" in str(result) + + +# ── Slugification ──────────────────────────────────────────────────────── + + +class TestNameToSlug: + def test_simple_name(self): + assert name_to_slug("Vera Blackwater") == "vera-blackwater" + + def test_special_characters(self): + assert name_to_slug("The Rusty Anchor!") == "the-rusty-anchor" + + def test_multiple_spaces(self): + assert name_to_slug("Captain Harrik") == "captain-harrik" + + +# ── Context formatting ─────────────────────────────────────────────────── + + +class TestFormatSessionContext: + def test_includes_location(self): + data = {"location": "harbor", "body": ""} + result = format_session_context(data) + assert "harbor" in result + + def test_includes_body(self): + data = {"body": "## Situation\nAt the docks."} + result = format_session_context(data) + assert "At the docks" in result + + def test_no_location(self): + data = {"body": "Just a body"} + result = format_session_context(data) + assert "Location" not in result