A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

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

Add display blocks, sandbox mode, column layout, and test coverage

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) <noreply@anthropic.com>

+1094 -57
+96
prompts/dm-system.md
··· 66 66 67 67 Don't over-describe. One vivid detail beats three adequate ones. Trust the player's imagination. 68 68 69 + ## Display Blocks 70 + 71 + 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. 72 + 73 + ### Maps — ` ```map Title` 74 + 75 + Use for spatial layouts: rooms, buildings, cities, regions. The terminal renders these in a bordered panel. 76 + 77 + ```map The Rusty Anchor — Ground Floor 78 + ┌──────────────┬───────────┐ 79 + │ COMMON │ KITCHEN │ 80 + │ ROOM [B]│ │ 81 + │ [T] [T] ───┤ [F] │ 82 + │ │ │ 83 + │ [☆] [T] ├───────────┤ 84 + │ ═════ │ STORAGE │ 85 + └─────═════────┴───────────┘ 86 + ☆ You T Table B Bar F Fireplace 87 + ``` 88 + 89 + **Drawing guidelines:** 90 + - Check the Display Layout section in your context for exact column widths 91 + - Use Unicode freely: box-drawing (┌┐└┘─│├┤┬┴┼═║╔╗╚╝), blocks (█▓▒░), arrows (→←↑↓), symbols (●○◆★☆⚔⛪🏠) 92 + - Legend below the map 93 + - Mark the player's position with ☆ 94 + - Scale to the situation: 95 + - **Room**: furniture, doors, objects, cover 96 + - **Building**: rooms, corridors, stairs 97 + - **City**: districts, landmarks, gates, major roads 98 + - **Region**: towns, roads, terrain, rivers 99 + 100 + **When to draw maps:** 101 + - When the player enters a significant new location 102 + - When spatial layout matters (combat, exploration, chase) 103 + - When the player asks to see the area 104 + - When you `establish` a location, include a map in the description 105 + 106 + For important locations, also `establish` the map as a `maps` entity so it persists across sessions. 107 + 108 + ### Asides — ` ```aside Title` 109 + 110 + Use for documents the character reads: letters, signs, inscriptions, wanted posters, journal entries, prophecies. 111 + 112 + ```aside Notice on the Tavern Door 113 + WANTED — Mira Ashvale 114 + For questioning in connection with 115 + the disappearance of Merchant Aldric. 116 + 50 gp reward. See Constable Harrik. 117 + ``` 118 + 119 + 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. 120 + 121 + **Good opportunities for asides:** 122 + - Tavern menus, chalkboards, house rules 123 + - Wanted posters, bounty boards, job listings 124 + - Letters, notes, journal entries the character finds 125 + - Signs, inscriptions, carved warnings 126 + - Prophecies, riddles, magical runes 127 + - Shop inventories, price lists 128 + - Grave markers, plaques, dedications 129 + 130 + ### Items — ` ```item Title` 131 + 132 + 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. 133 + 134 + ```item The Tide's Tooth — Curved Dagger 135 + . 136 + / 137 + / ≈ ≈ 138 + | ≈ ≈ ≈ blade shifts 139 + | ≈ ≈ ≈ between steel 140 + | ≈ ≈ and seawater 141 + | ≈ ≈ 142 + ┌─────┐ 143 + │░░░░░│ crossguard: barnacle-crusted bronze 144 + └──┬──┘ 145 + ◆◆◆◆◆◆ grip: sharkskin wound with 146 + ◆◆◆◆◆◆ salt-stained cord 147 + ┌──┴──┐ 148 + │ ●● │ pommel: a smooth black sea stone 149 + │●●●●│ (always cold, always wet) 150 + └─────┘ 151 + ``` 152 + 153 + Use item blocks when: 154 + - The player finds or examines a notable item 155 + - A magical item is identified or its properties are revealed 156 + - A quest-relevant object is discovered 157 + - A shopkeeper displays their wares 158 + - The player inspects loot after combat 159 + 160 + 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. 161 + 69 162 ## Narrative Rhythm 70 163 71 164 Vary your storytelling pace to match the moment: ··· 274 367 - **Locations** - anywhere the player visits or will return to 275 368 - **Items** - magical items, plot-relevant objects, interesting equipment 276 369 - **Threads** - situations in motion that might develop 370 + - **Maps** - spatial layouts worth persisting (use `entity_type="maps"`) 277 371 278 372 **Give names to everyone.** Don't introduce "a guard" - introduce "Mara, a guard". Then establish her. Named NPCs create a living world. 279 373 280 374 **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. 375 + 376 + **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. 281 377 282 378 Think: What does this entity know that isn't obvious? What is its nature? What might it do if...? Where do they belong? 283 379
+2
prompts/planner-system.md
··· 43 43 - 1-2 Knows/Wants/Will items 44 44 - A location via wikilink 45 45 46 + **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. 47 + 46 48 Prefer enriching the thin entities you were given over inventing new ones. 47 49 48 50 ## Rules
+9
prompts/world-seed.md
··· 60 60 - `items` — notable objects, artifacts, equipment 61 61 - `threads` — plot hooks and ongoing storylines 62 62 - `lore` — history, legends, world facts 63 + - `maps` — spatial layouts worth persisting 64 + 65 + ## Visual Details 66 + 67 + When establishing entities, include display blocks in the description to make them visually rich when the DM presents them to the player: 68 + 69 + - **Locations**: Include a ` ```map` block showing the layout. Include ` ```aside` blocks for visible signage, posted menus, house rules, notices, or inscriptions. 70 + - **Items**: Include a ` ```item` block with Unicode art showing the item's appearance and labeled parts. 71 + - **Maps**: For regional or city-scale maps, establish them as `entity_type="maps"` so they persist separately. 63 72 64 73 ## Order of Operations 65 74
+7 -2
pyproject.toml
··· 40 40 [tool.pytest.ini_options] 41 41 testpaths = ["tests"] 42 42 pythonpath = ["src"] 43 + addopts = [ 44 + "--cov=src/storied", 45 + "--cov=tests", 46 + "--cov-report=term-missing:skip-covered", 47 + "--cov-branch", 48 + ] 43 49 44 50 [tool.mypy] 45 51 python_version = "3.12" ··· 57 63 branch = true 58 64 59 65 [tool.coverage.report] 60 - # Quick iteration over strict coverage 61 - # fail_under = 100 66 + fail_under = 48
+78 -54
src/storied/cli.py
··· 193 193 """Start an interactive DM session.""" 194 194 import atexit 195 195 import readline 196 + import shutil 197 + import tempfile 196 198 197 - from rich.console import Console, Group 199 + from rich.console import Console 198 200 from rich.live import Live 199 201 from rich.markdown import Markdown 200 202 from rich.panel import Panel ··· 227 229 console = Console() 228 230 world_id = args.world if args.world else "default" 229 231 player_id = "default" 232 + sandbox = getattr(args, "sandbox", False) 233 + 234 + # Sandbox mode: throwaway session in a temp directory 235 + sandbox_dir: Path | None = None 236 + if sandbox: 237 + sandbox_dir = Path(tempfile.mkdtemp(prefix="storied-sandbox-")) 238 + (sandbox_dir / "worlds" / world_id).mkdir(parents=True) 239 + (sandbox_dir / "players" / player_id).mkdir(parents=True) 230 240 231 - # Check if character exists 232 - character = load_character(player_id) 233 - creation_mode = character is None 241 + base_path = sandbox_dir if sandbox else None 242 + creation_mode = False 234 243 235 - if creation_mode: 244 + if sandbox: 236 245 console.print(Panel.fit( 237 - "[bold]Welcome to Storied![/bold]\n" 238 - "Let's create your character!", 239 - title="Character Creation", 240 - border_style="yellow", 241 - )) 242 - prompt_name = "character-creation" 243 - else: 244 - console.print(Panel.fit( 245 - "[bold]Welcome to Storied![/bold]\n" 246 - "Let the DM know when you're ready to quit.\n" 247 - "Type [cyan]/context[/cyan] to see token usage.", 248 - title="Storied", 249 - border_style="green", 246 + "[bold]Storied Sandbox[/bold]\n" 247 + "No character, no world — just you and the DM.\n" 248 + "Type [cyan]Ctrl+D[/cyan] to quit.", 249 + title="Sandbox", 250 + border_style="cyan", 250 251 )) 251 252 prompt_name = "dm-system" 253 + else: 254 + # Check if character exists 255 + character = load_character(player_id) 256 + creation_mode = character is None 252 257 253 - console.print(f"[dim]World: {world_id}[/dim]") 258 + if creation_mode: 259 + console.print(Panel.fit( 260 + "[bold]Welcome to Storied![/bold]\n" 261 + "Let's create your character!", 262 + title="Character Creation", 263 + border_style="yellow", 264 + )) 265 + prompt_name = "character-creation" 266 + else: 267 + console.print(Panel.fit( 268 + "[bold]Welcome to Storied![/bold]\n" 269 + "Let the DM know when you're ready to quit.\n" 270 + "Type [cyan]/context[/cyan] to see token usage.", 271 + title="Storied", 272 + border_style="green", 273 + )) 274 + prompt_name = "dm-system" 275 + 276 + console.print(f"[dim]World: {world_id}{' (sandbox)' if sandbox else ''}[/dim]") 254 277 console.print() 255 278 256 279 # Seed the world if the character exists but no session yet 257 - if not creation_mode: 280 + if not creation_mode and not sandbox: 258 281 from storied.session import load_session 259 282 260 283 session = load_session(player_id) ··· 283 306 player_id=player_id, 284 307 prompt_name=prompt_name, 285 308 transcript_path=transcript_path, 309 + base_path=base_path, 286 310 ) 287 311 engine.debug = args.debug 288 312 289 313 # Background ticker for mid-session world advancement 290 314 ticker = None 291 - if not creation_mode: 315 + if not creation_mode and not sandbox: 292 316 from storied.planner import BackgroundTicker 293 317 294 318 ticker = BackgroundTicker( 295 319 world_id=world_id, 296 320 player_id=player_id, 297 - base_path=Path.cwd(), 321 + base_path=base_path or Path.cwd(), 298 322 ) 299 323 # Kick off initial tick in background 300 324 ticker.maybe_tick(engine._campaign_log) ··· 304 328 console.print("[dim]The DM will guide you through character creation...[/dim]") 305 329 console.print() 306 330 307 - def build_display(parts: list[tuple[str, str]]) -> Group: 308 - """Build display from ordered parts (type, content).""" 309 - renderables = [] 310 - prev_type = None 311 - for part_type, content in parts: 312 - if part_type == "tool": 313 - if prev_type == "text": 314 - renderables.append(Text("")) # Blank line before tools 315 - renderables.append(Text(content, style="dim")) 316 - elif part_type == "text" and content.strip(): 317 - if prev_type == "tool": 318 - renderables.append(Text("")) # Blank line after tools 319 - renderables.append(Markdown(content)) 320 - prev_type = part_type 321 - return Group(*renderables) 331 + from storied.display import StreamClassifier, build_display 322 332 323 333 # Kick off the conversation with appropriate first message 324 - if creation_mode: 334 + if sandbox: 335 + first_message = ( 336 + "[Sandbox session — no character, no world. The player wants to " 337 + "experiment freely. Jump straight into whatever they ask.]" 338 + ) 339 + elif creation_mode: 325 340 first_message = "Let's create a character!" 326 341 else: 327 342 first_message = "[Session starting]" ··· 335 350 else: 336 351 try: 337 352 console.print(Rule(style="dim blue")) 338 - if creation_mode: 353 + if creation_mode or sandbox: 339 354 action = input("> ") 340 355 else: 341 356 game_time = engine.get_current_time() 342 357 action = input(f"[{game_time}] > ") 343 358 except EOFError: 344 359 console.print() 345 - # Ask DM to save on EOF (Ctrl+D) - skip if in creation mode 346 - if not creation_mode: 360 + if sandbox: 361 + console.print("[cyan]Sandbox session ended.[/cyan]") 362 + elif not creation_mode: 347 363 console.print("[dim]Saving session...[/dim]") 348 364 try: 349 365 save_msg = "I need to quit now. Please save the game." ··· 468 484 try: 469 485 console.print(Rule(style="dim blue")) 470 486 console.print() # Blank line before DM response 471 - parts: list[tuple[str, str]] = [] # (type, content) in order 487 + classifier = StreamClassifier() 472 488 473 - with Live(console=console, refresh_per_second=10) as live: 489 + with Live(console=console, refresh_per_second=10, vertical_overflow="visible") as live: 474 490 for chunk in engine.stream_action(action): 475 491 if chunk.startswith("\n[") or chunk.startswith("Rolled "): 476 - parts.append(("tool", chunk.strip())) 492 + classifier.feed_tool(chunk) 477 493 else: 478 - # Append to last text part, or create new one 479 - if parts and parts[-1][0] == "text": 480 - parts[-1] = ("text", parts[-1][1] + chunk) 481 - else: 482 - parts.append(("text", chunk)) 483 - live.update(build_display(parts)) 494 + classifier.feed(chunk) 495 + live.update(build_display(classifier.parts, console.width)) 496 + classifier.flush() 497 + live.update(build_display(classifier.parts, console.width)) 484 498 485 499 console.print() 486 500 ··· 525 539 526 540 except KeyboardInterrupt: 527 541 console.print("\n[red][Interrupted][/red]") 528 - # Ask DM to save on interrupt - skip if in creation mode 529 - if not creation_mode: 542 + if sandbox: 543 + console.print("[cyan]Sandbox session ended.[/cyan]") 544 + elif not creation_mode: 530 545 console.print("[dim]Saving session...[/dim]") 531 546 try: 532 547 save_msg = "I need to quit now. Please save the game." ··· 542 557 543 558 except KeyboardInterrupt: 544 559 console.print() 545 - # Outer interrupt - skip save in creation mode 546 - if not creation_mode: 560 + if sandbox: 561 + console.print("[cyan]Sandbox session ended.[/cyan]") 562 + elif not creation_mode: 547 563 console.print("[dim]Saving session...[/dim]") 548 564 try: 549 565 save_msg = "I need to quit now. Please save the game." ··· 555 571 ) 556 572 else: 557 573 console.print("[yellow]Farewell![/yellow]") 574 + finally: 575 + if sandbox_dir and sandbox_dir.exists(): 576 + shutil.rmtree(sandbox_dir, ignore_errors=True) 558 577 559 578 return 0 560 579 ··· 740 759 play_parser.add_argument( 741 760 "--transcript", "-t", 742 761 help="Path to write full debug transcript (JSONL format)", 762 + ) 763 + play_parser.add_argument( 764 + "--sandbox", "-s", 765 + action="store_true", 766 + help="Throwaway session — no character, no world state", 743 767 ) 744 768 play_parser.set_defaults(func=cmd_play) 745 769
+179
src/storied/display.py
··· 1 + """Display rendering for DM output — stream classification and column layout.""" 2 + 3 + import re 4 + 5 + from rich import box as rich_box 6 + from rich.align import Align 7 + from rich.console import Group 8 + from rich.markdown import Markdown 9 + from rich.panel import Panel 10 + from rich.table import Table 11 + from rich.text import Text 12 + 13 + BLOCK_STYLES: dict[str, tuple[str, rich_box.Box]] = { 14 + "map": ("green", rich_box.HEAVY), 15 + "aside": ("yellow dim", rich_box.ROUNDED), 16 + "item": ("magenta", rich_box.DOUBLE), 17 + } 18 + 19 + _FENCE_RE = re.compile(r"^```(map|aside|item)\s*(.*)") 20 + 21 + 22 + class StreamClassifier: 23 + """Line-oriented state machine for classifying DM output chunks. 24 + 25 + Splits streaming text into typed parts: plain text, tool notifications, 26 + and fenced display blocks (```map, ```aside, ```item). 27 + """ 28 + 29 + def __init__(self) -> None: 30 + self.parts: list[tuple] = [] 31 + self._partial: str = "" 32 + self._block: dict | None = None 33 + 34 + def feed_tool(self, chunk: str) -> None: 35 + """Add a tool notification chunk directly.""" 36 + self.parts.append(("tool", chunk.strip())) 37 + 38 + def feed(self, chunk: str) -> None: 39 + """Process a narrative text chunk through the state machine.""" 40 + text = self._partial + chunk 41 + *lines, self._partial = text.split("\n") 42 + 43 + for line in lines: 44 + self._process_line(line) 45 + 46 + def flush(self) -> None: 47 + """Flush any remaining partial line at end of stream.""" 48 + if self._partial: 49 + self._process_line(self._partial) 50 + self._partial = "" 51 + 52 + def _process_line(self, line: str) -> None: 53 + if self._block is not None: 54 + if line.strip() == "```": 55 + # Remove live preview if present 56 + if self.parts and self.parts[-1][0] == "block_preview": 57 + self.parts.pop() 58 + # Closing fence — finalize block 59 + self.parts.append(( 60 + "block", self._block["kind"], 61 + self._block["title"], 62 + "\n".join(self._block["lines"]), 63 + )) 64 + self._block = None 65 + else: 66 + self._block["lines"].append(line) 67 + self._update_live_block() 68 + else: 69 + m = _FENCE_RE.match(line) 70 + if m: 71 + self._block = { 72 + "kind": m.group(1), 73 + "title": m.group(2).strip(), 74 + "lines": [], 75 + } 76 + else: 77 + self._append_text(line) 78 + 79 + def _append_text(self, line: str) -> None: 80 + if self.parts and self.parts[-1][0] == "text": 81 + self.parts[-1] = ("text", self.parts[-1][1] + "\n" + line) 82 + else: 83 + self.parts.append(("text", line)) 84 + 85 + def _update_live_block(self) -> None: 86 + """Update or append an in-progress block part for live preview.""" 87 + preview = ( 88 + "block", self._block["kind"], 89 + self._block["title"], 90 + "\n".join(self._block["lines"]), 91 + ) 92 + if self.parts and self.parts[-1][0] == "block_preview": 93 + self.parts[-1] = ("block_preview", *preview[1:]) 94 + else: 95 + self.parts.append(("block_preview", *preview[1:])) 96 + 97 + 98 + def make_panel(kind: str, title: str, content: str) -> Panel: 99 + """Create a styled Panel for a display block.""" 100 + border_style, border_box = BLOCK_STYLES.get(kind, ("white", rich_box.ROUNDED)) 101 + return Panel( 102 + content, title=title or None, 103 + border_style=border_style, box=border_box, 104 + padding=(0, 1), expand=False, 105 + ) 106 + 107 + 108 + def build_display(parts: list[tuple], console_width: int) -> Group | Text: 109 + """Build display as a continuous 2/3 + 1/3 column layout. 110 + 111 + Text and tool notifications go in the left column. Narrow blocks 112 + (fitting in 1/3 of terminal) go in the right column. Wide blocks 113 + break the grid and render centered full-width. Text always renders 114 + in the left 2/3 for consistent, comfortable reading width. 115 + """ 116 + sections: list = [] 117 + grid: Table | None = None 118 + left_parts: list = [] 119 + right_panel: Panel | None = None 120 + prev_type: str | None = None 121 + narrow_max = console_width // 3 122 + 123 + def new_grid() -> Table: 124 + g = Table.grid(padding=(0, 2)) 125 + g.add_column(ratio=2) 126 + g.add_column(ratio=1, min_width=narrow_max) 127 + return g 128 + 129 + def close_row() -> None: 130 + nonlocal right_panel, grid 131 + if not left_parts and right_panel is None: 132 + return 133 + if grid is None: 134 + grid = new_grid() 135 + left = Group(*left_parts) if left_parts else Text("") 136 + grid.add_row(left, right_panel or Text("")) 137 + left_parts.clear() 138 + right_panel = None 139 + 140 + def close_grid() -> None: 141 + nonlocal grid 142 + close_row() 143 + if grid is not None and grid.row_count > 0: 144 + sections.append(grid) 145 + grid = None 146 + 147 + for part in parts: 148 + part_type = part[0] 149 + 150 + if part_type == "tool": 151 + if prev_type == "text": 152 + left_parts.append(Text("")) 153 + left_parts.append(Text(part[1], style="dim")) 154 + 155 + elif part_type == "text" and part[1].strip(): 156 + if prev_type in ("tool", "block", "block_preview"): 157 + left_parts.append(Text("")) 158 + left_parts.append(Markdown(part[1])) 159 + 160 + elif part_type in ("block", "block_preview"): 161 + _, kind, title, content = part 162 + content_width = max( 163 + (len(line) for line in content.splitlines()), default=0, 164 + ) 165 + panel_width = content_width + 4 166 + panel = make_panel(kind, title, content) 167 + 168 + if panel_width <= narrow_max: 169 + if right_panel is not None: 170 + close_row() 171 + right_panel = panel 172 + else: 173 + close_grid() 174 + sections.append(Align.center(panel)) 175 + 176 + prev_type = part_type 177 + 178 + close_grid() 179 + return Group(*sections) if sections else Text("")
+18
src/storied/engine.py
··· 1 1 """DM Engine - drives claude -p for running 5e sessions.""" 2 2 3 3 import json 4 + import os 4 5 import re 5 6 from collections.abc import Iterator 6 7 from datetime import UTC, datetime ··· 220 221 self._context_parts[f"Linked:{name}"] = entity_context 221 222 parts.append(entity_context) 222 223 loaded_names.add(name) 224 + 225 + # Display layout info so the DM knows its column sizes 226 + term_width = os.get_terminal_size().columns 227 + right_col = term_width // 3 228 + left_col = term_width - right_col 229 + right_content = right_col - 4 # panel border + padding 230 + layout = ( 231 + "## Display Layout\n\n" 232 + f"Terminal: {term_width} chars wide. " 233 + f"Text column: ~{left_col} chars. " 234 + f"Side column: ~{right_col} chars.\n" 235 + f"Display blocks under ~{right_content} chars wide " 236 + "inset in the right column. Wider blocks render centered full-width.\n" 237 + "Keep aside/item titles concise — long titles force the panel wider." 238 + ) 239 + self._context_parts["Layout"] = layout 240 + parts.append(layout) 223 241 224 242 return "\n\n---\n\n".join(parts) 225 243
+169 -1
tests/test_character.py
··· 1 - """Tests for character loading, saving, and updates.""" 1 + """Tests for character loading, saving, updates, and display formatting.""" 2 2 3 3 from pathlib import Path 4 4 ··· 6 6 7 7 from storied.character import ( 8 8 create_character, 9 + format_character_context, 10 + format_sheet, 11 + format_status, 9 12 load_character, 10 13 parse_character, 11 14 save_character, ··· 250 253 251 254 char = load_character("test-player", player_base) 252 255 assert char["hp"]["current"] == 0 256 + 257 + 258 + # ── Display formatting ─────────────────────────────────────────────────── 259 + 260 + 261 + @pytest.fixture 262 + def rich_character() -> dict: 263 + """A character dict with all fields populated for display tests.""" 264 + return { 265 + "name": "Mira Ashvale", 266 + "race": "Human", 267 + "class": "Rogue", 268 + "level": 3, 269 + "background": "Criminal", 270 + "hp": {"current": 20, "max": 24}, 271 + "ac": 16, 272 + "speed": 30, 273 + "abilities": { 274 + "strength": 11, 275 + "dexterity": 18, 276 + "constitution": 14, 277 + "intelligence": 15, 278 + "wisdom": 14, 279 + "charisma": 18, 280 + }, 281 + "purse": {"cp": 40, "sp": 20, "ep": 0, "gp": 93, "pp": 0}, 282 + "body": ( 283 + "## Proficiencies\n" 284 + "Armor: Light.\n\n" 285 + "## Features\n" 286 + "- Sneak Attack 2d6\n" 287 + "- Cunning Action\n\n" 288 + "## Equipment\n" 289 + "- Thieves' tools\n" 290 + "- Dagger\n\n" 291 + "## Backstory\n" 292 + "A sharp-tongued grifter." 293 + ), 294 + } 295 + 296 + 297 + class TestFormatStatus: 298 + """Tests for compact /status display.""" 299 + 300 + def test_identity_line(self, rich_character: dict): 301 + result = format_status(rich_character) 302 + assert "**Mira Ashvale**" in result 303 + assert "Human Rogue 3" in result 304 + assert "(Criminal)" in result 305 + 306 + def test_vitals(self, rich_character: dict): 307 + result = format_status(rich_character) 308 + assert "HP 20/24" in result 309 + assert "AC 16" in result 310 + assert "Speed 30 ft" in result 311 + 312 + def test_abilities(self, rich_character: dict): 313 + result = format_status(rich_character) 314 + assert "STR 11 (+0)" in result 315 + assert "DEX 18 (+4)" in result 316 + assert "CHA 18 (+4)" in result 317 + 318 + def test_purse_nonzero_only(self, rich_character: dict): 319 + result = format_status(rich_character) 320 + assert "93 gp" in result 321 + assert "20 sp" in result 322 + assert "40 cp" in result 323 + assert "ep" not in result 324 + assert "pp" not in result 325 + 326 + def test_equipment_one_liner(self, rich_character: dict): 327 + result = format_status(rich_character) 328 + assert "Thieves' tools" in result 329 + assert "Dagger" in result 330 + 331 + def test_equipment_excluded(self, rich_character: dict): 332 + result = format_status(rich_character, include_equipment=False) 333 + assert "Thieves' tools" not in result 334 + 335 + def test_no_background(self): 336 + data = { 337 + "name": "Test", "race": "Elf", "class": "Wizard", "level": 1, 338 + "hp": {"current": 6, "max": 6}, "ac": 12, "speed": 30, 339 + "abilities": {}, "body": "", 340 + } 341 + result = format_status(data) 342 + assert "(" not in result 343 + 344 + def test_legacy_gold_field(self): 345 + data = { 346 + "name": "Old", "race": "Human", "class": "Fighter", "level": 1, 347 + "hp": 10, "ac": 14, "speed": 30, "gold": 50, "body": "", 348 + } 349 + result = format_status(data) 350 + assert "50 gp" in result 351 + 352 + def test_empty_purse(self): 353 + data = { 354 + "name": "Broke", "race": "Human", "class": "Rogue", "level": 1, 355 + "hp": {"current": 5, "max": 5}, "ac": 10, "speed": 30, 356 + "purse": {"cp": 0, "sp": 0, "ep": 0, "gp": 0, "pp": 0}, 357 + "body": "", 358 + } 359 + result = format_status(data) 360 + assert "empty" in result 361 + 362 + 363 + class TestFormatSheet: 364 + """Tests for full /me display.""" 365 + 366 + def test_includes_status_header(self, rich_character: dict): 367 + result = format_sheet(rich_character) 368 + assert "**Mira Ashvale**" in result 369 + assert "HP 20/24" in result 370 + 371 + def test_includes_all_sections(self, rich_character: dict): 372 + result = format_sheet(rich_character) 373 + assert "**Proficiencies**" in result 374 + assert "**Features**" in result 375 + assert "**Equipment**" in result 376 + assert "**Backstory**" in result 377 + 378 + def test_equipment_as_list(self, rich_character: dict): 379 + result = format_sheet(rich_character) 380 + assert "- Thieves' tools" in result 381 + assert "- Dagger" in result 382 + 383 + def test_skips_empty_sections(self): 384 + data = { 385 + "name": "Sparse", "race": "Human", "class": "Fighter", "level": 1, 386 + "hp": {"current": 10, "max": 10}, "ac": 14, "speed": 30, 387 + "body": "## Features\n- Tough\n\n## Notes\n", 388 + } 389 + result = format_sheet(data) 390 + assert "**Features**" in result 391 + assert "**Notes**" not in result 392 + 393 + def test_no_equipment_one_liner(self, rich_character: dict): 394 + result = format_sheet(rich_character) 395 + equipment_one_liners = [ 396 + l for l in result.splitlines() if l.startswith("**Equipment:**") 397 + ] 398 + assert len(equipment_one_liners) == 0 399 + 400 + 401 + class TestFormatCharacterContext: 402 + """Tests for system prompt character context.""" 403 + 404 + def test_includes_name_and_class(self, rich_character: dict): 405 + result = format_character_context(rich_character) 406 + assert "Mira Ashvale" in result 407 + assert "Rogue" in result 408 + 409 + def test_includes_abilities_with_modifiers(self, rich_character: dict): 410 + result = format_character_context(rich_character) 411 + assert "STR 11 (+0)" in result 412 + assert "DEX 18 (+4)" in result 413 + 414 + def test_includes_purse(self, rich_character: dict): 415 + result = format_character_context(rich_character) 416 + assert "93 gp" in result 417 + 418 + def test_includes_body(self, rich_character: dict): 419 + result = format_character_context(rich_character) 420 + assert "Sneak Attack" in result
+305
tests/test_display.py
··· 1 + """Tests for the display module — stream classification and column layout.""" 2 + 3 + import pytest 4 + from rich.align import Align 5 + from rich.console import Group 6 + from rich.panel import Panel 7 + from rich.table import Table 8 + from rich.text import Text 9 + 10 + from storied.display import StreamClassifier, build_display, make_panel 11 + 12 + 13 + # ── StreamClassifier ───────────────────────────────────────────────────── 14 + 15 + 16 + class TestStreamClassifierText: 17 + """Plain text accumulation.""" 18 + 19 + def test_single_line(self): 20 + c = StreamClassifier() 21 + c.feed("Hello world\n") 22 + assert c.parts == [("text", "Hello world")] 23 + 24 + def test_multiple_lines_merge(self): 25 + c = StreamClassifier() 26 + c.feed("Line one\nLine two\n") 27 + assert c.parts == [("text", "Line one\nLine two")] 28 + 29 + def test_chunked_delivery(self): 30 + c = StreamClassifier() 31 + c.feed("Hello ") 32 + c.feed("world\n") 33 + assert c.parts == [("text", "Hello world")] 34 + 35 + def test_flush_partial_line(self): 36 + c = StreamClassifier() 37 + c.feed("No trailing newline") 38 + assert len(c.parts) == 0 39 + c.flush() 40 + assert c.parts == [("text", "No trailing newline")] 41 + 42 + def test_empty_lines_preserved(self): 43 + c = StreamClassifier() 44 + c.feed("Before\n\nAfter\n") 45 + assert c.parts == [("text", "Before\n\nAfter")] 46 + 47 + 48 + class TestStreamClassifierBlocks: 49 + """Fenced display block detection.""" 50 + 51 + def test_map_block(self): 52 + c = StreamClassifier() 53 + c.feed("Before\n```map Tavern\n+-+\n|X|\n+-+\n```\nAfter\n") 54 + assert c.parts[0] == ("text", "Before") 55 + assert c.parts[1] == ("block", "map", "Tavern", "+-+\n|X|\n+-+") 56 + assert c.parts[2] == ("text", "After") 57 + 58 + def test_aside_block(self): 59 + c = StreamClassifier() 60 + c.feed("```aside A Letter\nDear friend,\nCome quickly.\n```\n") 61 + assert c.parts[0] == ("block", "aside", "A Letter", "Dear friend,\nCome quickly.") 62 + 63 + def test_item_block(self): 64 + c = StreamClassifier() 65 + c.feed("```item Magic Sword\n/|\\\n | \n```\n") 66 + assert c.parts[0] == ("block", "item", "Magic Sword", "/|\\\n | ") 67 + 68 + def test_block_without_title(self): 69 + c = StreamClassifier() 70 + c.feed("```map\n+-+\n```\n") 71 + assert c.parts[0] == ("block", "map", "", "+-+") 72 + 73 + def test_regular_code_block_passthrough(self): 74 + c = StreamClassifier() 75 + c.feed("```python\nprint('hi')\n```\n") 76 + assert c.parts[0][0] == "text" 77 + assert "python" in c.parts[0][1] 78 + 79 + def test_chunked_block_delivery(self): 80 + c = StreamClassifier() 81 + c.feed("```map Room") 82 + c.feed("\n+-+\n|") 83 + c.feed("X|\n+-+\n```\nDone\n") 84 + assert c.parts[0] == ("block", "map", "Room", "+-+\n|X|\n+-+") 85 + assert c.parts[1] == ("text", "Done") 86 + 87 + def test_block_preview_during_streaming(self): 88 + c = StreamClassifier() 89 + c.feed("```map Room\nline1\n") 90 + previews = [p for p in c.parts if p[0] == "block_preview"] 91 + assert len(previews) == 1 92 + assert previews[0][1] == "map" 93 + 94 + def test_preview_replaced_by_final_block(self): 95 + c = StreamClassifier() 96 + c.feed("```map Room\nline1\nline2\n```\n") 97 + assert not any(p[0] == "block_preview" for p in c.parts) 98 + assert c.parts[0] == ("block", "map", "Room", "line1\nline2") 99 + 100 + def test_backticks_inside_block_not_closing(self): 101 + c = StreamClassifier() 102 + c.feed("```map Room\n`code`\n```\n") 103 + assert c.parts[0] == ("block", "map", "Room", "`code`") 104 + 105 + 106 + class TestStreamClassifierTools: 107 + """Tool notification handling.""" 108 + 109 + def test_tool_via_feed_tool(self): 110 + c = StreamClassifier() 111 + c.feed_tool("\n[Rolling...]\n") 112 + assert c.parts == [("tool", "[Rolling...]")] 113 + 114 + def test_tool_interleaved_with_text(self): 115 + c = StreamClassifier() 116 + c.feed("Some text\n") 117 + c.feed_tool("\n[Rolling...]\n") 118 + c.feed("More text\n") 119 + assert c.parts[0] == ("text", "Some text") 120 + assert c.parts[1] == ("tool", "[Rolling...]") 121 + assert c.parts[2] == ("text", "More text") 122 + 123 + 124 + class TestStreamClassifierMixed: 125 + """Complex mixed content scenarios.""" 126 + 127 + def test_text_block_text(self): 128 + c = StreamClassifier() 129 + c.feed("Intro\n```aside Note\nHello\n```\nOutro\n") 130 + assert len(c.parts) == 3 131 + assert c.parts[0][0] == "text" 132 + assert c.parts[1][0] == "block" 133 + assert c.parts[2][0] == "text" 134 + 135 + def test_multiple_blocks(self): 136 + c = StreamClassifier() 137 + c.feed("```map A\n+-+\n```\n```aside B\nHi\n```\n") 138 + assert c.parts[0] == ("block", "map", "A", "+-+") 139 + assert c.parts[1] == ("block", "aside", "B", "Hi") 140 + 141 + def test_tool_between_blocks(self): 142 + c = StreamClassifier() 143 + c.feed("Text\n") 144 + c.feed_tool("\n[Setting scene...]\n") 145 + c.feed("```map Room\n+-+\n```\n") 146 + assert c.parts[0] == ("text", "Text") 147 + assert c.parts[1] == ("tool", "[Setting scene...]") 148 + assert c.parts[2] == ("block", "map", "Room", "+-+") 149 + 150 + 151 + # ── make_panel ─────────────────────────────────────────────────────────── 152 + 153 + 154 + class TestMakePanel: 155 + """Panel creation with styled borders.""" 156 + 157 + def test_map_panel(self): 158 + panel = make_panel("map", "Room", "+-+") 159 + assert isinstance(panel, Panel) 160 + assert str(panel.title) == "Room" 161 + 162 + def test_aside_panel(self): 163 + panel = make_panel("aside", "Note", "Hello") 164 + assert isinstance(panel, Panel) 165 + 166 + def test_item_panel(self): 167 + panel = make_panel("item", "Sword", "X") 168 + assert isinstance(panel, Panel) 169 + 170 + def test_no_title(self): 171 + panel = make_panel("map", "", "+-+") 172 + assert panel.title is None 173 + 174 + def test_expand_false(self): 175 + panel = make_panel("map", "Room", "+-+") 176 + assert panel.expand is False 177 + 178 + 179 + # ── build_display ──────────────────────────────────────────────────────── 180 + 181 + 182 + WIDTH = 120 183 + NARROW_MAX = WIDTH // 3 # 40 184 + 185 + 186 + @pytest.fixture 187 + def narrow_block() -> tuple: 188 + """A block narrow enough to fit in the right column (< 40 chars).""" 189 + return ("block", "aside", "Note", "Short content") 190 + 191 + 192 + @pytest.fixture 193 + def wide_block() -> tuple: 194 + """A block too wide for the right column (> 40 chars).""" 195 + return ("block", "map", "City", "x" * 50) 196 + 197 + 198 + class TestBuildDisplayTextOnly: 199 + """Text-only responses should use the 2/3 column grid.""" 200 + 201 + def test_returns_group(self): 202 + parts = [("text", "Hello world")] 203 + result = build_display(parts, WIDTH) 204 + assert isinstance(result, Group) 205 + 206 + def test_single_text_creates_grid(self): 207 + parts = [("text", "Hello world")] 208 + result = build_display(parts, WIDTH) 209 + grids = [r for r in result.renderables if isinstance(r, Table)] 210 + assert len(grids) == 1 211 + 212 + def test_empty_parts_returns_text(self): 213 + result = build_display([], WIDTH) 214 + assert isinstance(result, Text) 215 + 216 + 217 + class TestBuildDisplayNarrowBlocks: 218 + """Narrow blocks should pair with text in the right column.""" 219 + 220 + def test_text_plus_narrow_block_single_grid(self, narrow_block: tuple): 221 + parts = [("text", "Narrative"), narrow_block] 222 + result = build_display(parts, WIDTH) 223 + grids = [r for r in result.renderables if isinstance(r, Table)] 224 + assert len(grids) == 1 225 + assert grids[0].row_count == 1 226 + 227 + def test_text_flows_alongside_block(self, narrow_block: tuple): 228 + parts = [ 229 + ("text", "First paragraph"), 230 + narrow_block, 231 + ("text", "Second paragraph"), 232 + ] 233 + result = build_display(parts, WIDTH) 234 + grids = [r for r in result.renderables if isinstance(r, Table)] 235 + # Both text parts should be in the same row as the block 236 + assert len(grids) == 1 237 + assert grids[0].row_count == 1 238 + 239 + def test_second_block_closes_row(self): 240 + parts = [ 241 + ("text", "Intro"), 242 + ("block", "aside", "A", "First"), 243 + ("block", "aside", "B", "Second"), 244 + ("text", "Outro"), 245 + ] 246 + result = build_display(parts, WIDTH) 247 + grids = [r for r in result.renderables if isinstance(r, Table)] 248 + assert len(grids) == 1 249 + assert grids[0].row_count == 2 250 + 251 + 252 + class TestBuildDisplayWideBlocks: 253 + """Wide blocks should render centered, breaking the grid.""" 254 + 255 + def test_wide_block_centered(self, wide_block: tuple): 256 + parts = [("text", "Before"), wide_block, ("text", "After")] 257 + result = build_display(parts, WIDTH) 258 + aligns = [r for r in result.renderables if isinstance(r, Align)] 259 + assert len(aligns) == 1 260 + 261 + def test_wide_block_splits_grids(self, wide_block: tuple): 262 + parts = [("text", "Before"), wide_block, ("text", "After")] 263 + result = build_display(parts, WIDTH) 264 + grids = [r for r in result.renderables if isinstance(r, Table)] 265 + assert len(grids) == 2 # one before, one after the wide block 266 + 267 + 268 + class TestBuildDisplayTools: 269 + """Tool notifications go in the left column.""" 270 + 271 + def test_tool_in_grid(self): 272 + parts = [ 273 + ("text", "Narrative"), 274 + ("tool", "[Rolling...]"), 275 + ("text", "Result"), 276 + ] 277 + result = build_display(parts, WIDTH) 278 + grids = [r for r in result.renderables if isinstance(r, Table)] 279 + assert len(grids) == 1 280 + assert grids[0].row_count == 1 281 + 282 + def test_tool_with_block(self, narrow_block: tuple): 283 + parts = [ 284 + ("text", "Narrative"), 285 + ("tool", "[Rolling...]"), 286 + narrow_block, 287 + ("text", "After"), 288 + ] 289 + result = build_display(parts, WIDTH) 290 + grids = [r for r in result.renderables if isinstance(r, Table)] 291 + assert len(grids) == 1 292 + 293 + 294 + class TestBuildDisplayBlockPreview: 295 + """In-progress blocks should render like finalized blocks.""" 296 + 297 + def test_preview_treated_as_block(self): 298 + parts = [ 299 + ("text", "Narrative"), 300 + ("block_preview", "aside", "Note", "In progress..."), 301 + ] 302 + result = build_display(parts, WIDTH) 303 + grids = [r for r in result.renderables if isinstance(r, Table)] 304 + assert len(grids) == 1 305 + assert grids[0].row_count == 1
+231
tests/test_session.py
··· 1 + """Tests for session state management.""" 2 + 3 + from pathlib import Path 4 + 5 + import pytest 6 + 7 + from storied.session import ( 8 + extract_wiki_links, 9 + format_session_context, 10 + load_session, 11 + name_to_slug, 12 + parse_session, 13 + resolve_wiki_link, 14 + save_session, 15 + update_session, 16 + ) 17 + 18 + 19 + @pytest.fixture 20 + def session_base(tmp_path: Path) -> Path: 21 + """Create a base directory with player structure.""" 22 + (tmp_path / "players" / "test-player").mkdir(parents=True) 23 + return tmp_path 24 + 25 + 26 + @pytest.fixture 27 + def saved_session(session_base: Path) -> dict: 28 + """Create and save a session with situation and threads.""" 29 + data = { 30 + "location": "rusty-anchor", 31 + "body": ( 32 + "## Situation\n" 33 + "At the tavern, talking to Vera.\n\n" 34 + "## Present\n" 35 + "- [[Vera Blackwater]]\n" 36 + "- [[Henrik]]\n\n" 37 + "## Open Threads\n" 38 + "- Investigate the warehouse\n" 39 + "- Find the missing merchant" 40 + ), 41 + } 42 + save_session("test-player", data, session_base) 43 + return load_session("test-player", session_base) 44 + 45 + 46 + # ── Parsing ────────────────────────────────────────────────────────────── 47 + 48 + 49 + class TestParseSession: 50 + def test_with_frontmatter(self): 51 + content = "---\nlocation: tavern\n---\n\n## Situation\nAt the bar." 52 + result = parse_session(content) 53 + assert result["location"] == "tavern" 54 + assert "Situation" in result["body"] 55 + 56 + def test_without_frontmatter(self): 57 + result = parse_session("Just notes") 58 + assert result["body"] == "Just notes" 59 + 60 + def test_empty_frontmatter(self): 61 + result = parse_session("---\n---\n\nBody here") 62 + assert result["body"] == "Body here" 63 + 64 + 65 + # ── Load / Save ────────────────────────────────────────────────────────── 66 + 67 + 68 + class TestLoadSaveSession: 69 + def test_save_and_load(self, session_base: Path): 70 + save_session("test-player", {"location": "docks", "body": "At the docks."}, session_base) 71 + loaded = load_session("test-player", session_base) 72 + assert loaded["location"] == "docks" 73 + assert "At the docks" in loaded["body"] 74 + 75 + def test_load_nonexistent(self, session_base: Path): 76 + assert load_session("nobody", session_base) is None 77 + 78 + def test_save_adds_timestamp(self, session_base: Path): 79 + save_session("test-player", {"body": ""}, session_base) 80 + loaded = load_session("test-player", session_base) 81 + assert "updated" in loaded 82 + 83 + 84 + # ── Updates ────────────────────────────────────────────────────────────── 85 + 86 + 87 + class TestUpdateSession: 88 + def test_update_situation(self, session_base: Path, saved_session: dict): 89 + result = update_session( 90 + "test-player", 91 + {"situation": "Escaped to the harbor."}, 92 + session_base, 93 + ) 94 + assert "Updated situation" in result 95 + loaded = load_session("test-player", session_base) 96 + assert "Escaped to the harbor" in loaded["body"] 97 + 98 + def test_update_threads_from_list(self, session_base: Path, saved_session: dict): 99 + update_session( 100 + "test-player", 101 + {"threads": ["New thread one", "New thread two"]}, 102 + session_base, 103 + ) 104 + loaded = load_session("test-player", session_base) 105 + assert "- New thread one" in loaded["body"] 106 + assert "- New thread two" in loaded["body"] 107 + 108 + def test_update_present_from_list(self, session_base: Path, saved_session: dict): 109 + update_session( 110 + "test-player", 111 + {"present": ["[[Captain Harrik]]"]}, 112 + session_base, 113 + ) 114 + loaded = load_session("test-player", session_base) 115 + assert "Captain Harrik" in loaded["body"] 116 + 117 + def test_update_location(self, session_base: Path, saved_session: dict): 118 + result = update_session( 119 + "test-player", 120 + {"location": "harbor"}, 121 + session_base, 122 + ) 123 + assert "location = harbor" in result 124 + loaded = load_session("test-player", session_base) 125 + assert loaded["location"] == "harbor" 126 + 127 + def test_creates_session_if_missing(self, session_base: Path): 128 + update_session( 129 + "test-player", 130 + {"situation": "Starting fresh."}, 131 + session_base, 132 + ) 133 + loaded = load_session("test-player", session_base) 134 + assert "Starting fresh" in loaded["body"] 135 + 136 + def test_no_changes(self, session_base: Path, saved_session: dict): 137 + result = update_session("test-player", {}, session_base) 138 + assert "No changes" in result 139 + 140 + def test_appends_new_section(self, session_base: Path): 141 + save_session("test-player", {"body": ""}, session_base) 142 + update_session( 143 + "test-player", 144 + {"situation": "Brand new situation."}, 145 + session_base, 146 + ) 147 + loaded = load_session("test-player", session_base) 148 + assert "## Situation" in loaded["body"] 149 + assert "Brand new situation" in loaded["body"] 150 + 151 + 152 + # ── Wiki links ─────────────────────────────────────────────────────────── 153 + 154 + 155 + class TestExtractWikiLinks: 156 + def test_single_link(self): 157 + assert extract_wiki_links("Talk to [[Vera]]") == ["Vera"] 158 + 159 + def test_multiple_links(self): 160 + result = extract_wiki_links("[[Vera]] and [[Henrik]] at [[The Rusty Anchor]]") 161 + assert result == ["Vera", "Henrik", "The Rusty Anchor"] 162 + 163 + def test_no_links(self): 164 + assert extract_wiki_links("No links here") == [] 165 + 166 + def test_empty_string(self): 167 + assert extract_wiki_links("") == [] 168 + 169 + 170 + class TestResolveWikiLink: 171 + def test_finds_npc(self, tmp_path: Path): 172 + npc_dir = tmp_path / "worlds" / "default" / "npcs" 173 + npc_dir.mkdir(parents=True) 174 + (npc_dir / "Vera Blackwater.md").write_text("---\nname: Vera\n---\n") 175 + result = resolve_wiki_link("Vera Blackwater", "default", tmp_path) 176 + assert result is not None 177 + assert result.name == "Vera Blackwater.md" 178 + 179 + def test_finds_location(self, tmp_path: Path): 180 + loc_dir = tmp_path / "worlds" / "default" / "locations" 181 + loc_dir.mkdir(parents=True) 182 + (loc_dir / "The Rusty Anchor.md").write_text("---\nname: The Rusty Anchor\n---\n") 183 + result = resolve_wiki_link("The Rusty Anchor", "default", tmp_path) 184 + assert result is not None 185 + 186 + def test_not_found(self, tmp_path: Path): 187 + (tmp_path / "worlds" / "default").mkdir(parents=True) 188 + assert resolve_wiki_link("Nobody", "default", tmp_path) is None 189 + 190 + def test_priority_order(self, tmp_path: Path): 191 + """NPCs are checked before locations.""" 192 + for entity_type in ("npcs", "locations"): 193 + d = tmp_path / "worlds" / "default" / entity_type 194 + d.mkdir(parents=True) 195 + (d / "Ambiguous.md").write_text(f"---\ntype: {entity_type}\n---\n") 196 + result = resolve_wiki_link("Ambiguous", "default", tmp_path) 197 + assert "npcs" in str(result) 198 + 199 + 200 + # ── Slugification ──────────────────────────────────────────────────────── 201 + 202 + 203 + class TestNameToSlug: 204 + def test_simple_name(self): 205 + assert name_to_slug("Vera Blackwater") == "vera-blackwater" 206 + 207 + def test_special_characters(self): 208 + assert name_to_slug("The Rusty Anchor!") == "the-rusty-anchor" 209 + 210 + def test_multiple_spaces(self): 211 + assert name_to_slug("Captain Harrik") == "captain-harrik" 212 + 213 + 214 + # ── Context formatting ─────────────────────────────────────────────────── 215 + 216 + 217 + class TestFormatSessionContext: 218 + def test_includes_location(self): 219 + data = {"location": "harbor", "body": ""} 220 + result = format_session_context(data) 221 + assert "harbor" in result 222 + 223 + def test_includes_body(self): 224 + data = {"body": "## Situation\nAt the docks."} 225 + result = format_session_context(data) 226 + assert "At the docks" in result 227 + 228 + def test_no_location(self): 229 + data = {"body": "Just a body"} 230 + result = format_session_context(data) 231 + assert "Location" not in result