From ff195103c0077a5ae4796eaed6e2d018ec343895 Mon Sep 17 00:00:00 2001 From: Chris Guidry Date: Thu, 26 Mar 2026 19:26:06 -0400 Subject: [PATCH] Add display blocks, sandbox mode, column layout, and test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI was pretty monotonous — everything rendered as flat markdown. Now the DM can use fenced blocks (```map, ```aside, ```item) that render as styled Rich Panels in a 2/3 + 1/3 column layout. Maps get heavy green borders, asides get soft rounded yellow, items get double magenta. Narrow blocks float in the right column alongside narrative text, wide blocks (big city maps) center full-width. Text always stays in the 2/3 column for comfortable reading width. A StreamClassifier state machine handles the streaming — it detects fenced block boundaries as chunks arrive, shows live previews while blocks are being drawn, and classifies everything into typed parts that build_display() arranges into the grid layout. The DM now knows its column widths (read dynamically from the terminal each turn) so it can size blocks to fit the right column. All three prompts (dm-system, planner, world-seed) encourage using display blocks generously when establishing and enriching entities. Also adds `storied play --sandbox` for throwaway sessions — no character, no world state, temp directory cleaned up on exit. Helpful for iterating on display stuff and also just fun to mess around in. Extracted display logic from cli.py into display.py so it's testable. Added pytest-cov reporting (branch coverage, term-missing, 48% threshold) and tests for display, character formatting, and session management. 216 tests at 51% coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- prompts/dm-system.md | 96 ++++++++++++ prompts/planner-system.md | 2 + prompts/world-seed.md | 9 ++ pyproject.toml | 9 +- src/storied/cli.py | 134 ++++++++++------- src/storied/display.py | 179 ++++++++++++++++++++++ src/storied/engine.py | 18 +++ tests/test_character.py | 170 ++++++++++++++++++++- tests/test_display.py | 305 ++++++++++++++++++++++++++++++++++++++ tests/test_session.py | 231 +++++++++++++++++++++++++++++ 10 files changed, 1095 insertions(+), 58 deletions(-) create mode 100644 src/storied/display.py create mode 100644 tests/test_display.py create mode 100644 tests/test_session.py 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 -- 2.51.2