diff --git a/prompts/planner-system.md b/prompts/planner-system.md index 775149f..e22680c 100644 --- a/prompts/planner-system.md +++ b/prompts/planner-system.md @@ -53,6 +53,7 @@ Prefer enriching the thin entities you were given over inventing new ones. - **Weave open threads in.** If there's a mystery about missing merchants, maybe a nearby NPC heard rumors. If there's a bounty, maybe a location has clues. - **Respect entity types.** Non-sentient things can still "want" and "know" — a bridge "wants" to collapse, a cursed ring "knows" its maker's name. Frame narrative tendency, not literal consciousness. - **Write Was entries for backstory.** Use `mark` to give entities pre-existing history that enriches the world. +- **Add deadlines to threads when natural.** "If Day 5 passes without intervention → the prisoners are moved." This creates urgency the DM can use. - **Anchor entities to locations.** Don't say "works at the city jail" — say "works at [[Greyhaven City Jail]]". - **Give names to everyone.** Don't create "a guard" — create "Mara, a guard". Named entities create a living world. diff --git a/prompts/world-seed.md b/prompts/world-seed.md index 8b55006..51eb5f1 100644 --- a/prompts/world-seed.md +++ b/prompts/world-seed.md @@ -23,23 +23,31 @@ Work outward from the character's backstory: 4. **People in the scene** — 1-2 NPCs the player can interact with immediately. A fellow traveler, a merchant, a guard. These are the first faces the player sees. -5. **Open threads** — 2-3 hooks woven into the entities: +5. **Nearby locations** — 2-3 places beyond the immediate area that NPCs might mention or the player might hear rumors about. A nearby ruin, a neighboring town, a dangerous stretch of road. These give the DM material when the player asks "what's around here?" + +6. **Regional lore** — 1 lore entry establishing the broader setting. What region is this? What's the political situation? What's the prevailing mood — peaceful, tense, war-torn? This gives the DM a tonal anchor. + +7. **Items of interest** — 1-2 notable items seeded into the world (not in the player's possession). A legendary weapon rumored to be in a nearby dungeon, a cursed artifact an NPC carries, a map fragment on a tavern wall. Give each Knows/Wants/Will. + +8. **Open threads** — 3-4 hooks woven into the entities: - One personal (tied to backstory — unfinished business, a rumor about someone they knew) - One local (something happening in the immediate area — a problem, an opportunity) - One larger (a distant rumor or sign of something bigger — war, plague, a quest) + - One environmental (something wrong or strange about the area itself — crops failing, animals fleeing, unnatural weather) -6. **The opening moment** — use `set_scene` to place the player in a specific, actionable situation. Not "you're in a tavern" — something with momentum. Walking toward something, arriving somewhere, witnessing something. +9. **The opening moment** — use `set_scene` to place the player in a specific, actionable situation. Not "you're in a tavern" — something with momentum. Walking toward something, arriving somewhere, witnessing something. -7. **The clock** — include `event` and `duration` in your final `set_scene` to start the clock. Morning of Day 1. +10. **The clock** — include `event` and `duration` in your final `set_scene` to start the clock. Morning of Day 1. ## Rules - **Build from the character.** Everything should feel like it grew from their backstory, not from a template. A soldier's world looks different from a wizard's. - **Use [[wikilinks]] liberally.** Every NPC should reference their location. Every location should reference who's there. Create a connected graph. -- **Keep it focused.** 6-10 entities total. Seeds, not an encyclopedia. The DM and player will fill in the rest. +- **Build a world worth exploring.** 12-16 entities total. Enough that the DM has material to work with, but seeds, not an encyclopedia. - **Give names to everyone.** Not "a guard" — "Mara, a guard at the west gate." Named entities create a living world. - **Use Knows/Wants/Will on everything.** Even locations and items. A bridge "wants" to be crossed. A sword "knows" its previous owner. - **Anchor Will triggers to player actions.** "If the player asks about the fire → reveal that it was arson." These give the DM ready-made drama. +- **Give threads deadlines when natural.** "If Day 5 passes without intervention → the prisoners are moved." Not every thread needs urgency, but 1-2 should have a ticking clock so the player feels the world won't wait. - **Don't write narrative.** Use the tools to create world state. The DM will handle the storytelling. - **Don't include the player character as an entity.** They already exist as a character sheet. diff --git a/prompts/world-tick.md b/prompts/world-tick.md new file mode 100644 index 0000000..b39113d --- /dev/null +++ b/prompts/world-tick.md @@ -0,0 +1,61 @@ +You are a World Architect advancing a 5e solo adventure world between sessions. Time has passed since the player last played, and you're evaluating what changed in the world while they were away. + +## Your Tools + +| Tool | Purpose | +|------|---------| +| `recall` | Look up existing world content | +| `establish` | Update entities that changed | +| `mark` | Record events that happened off-screen | + +## What You're Given + +- The current game time and how much time has passed since last session +- All entities with **Will** triggers (conditional behaviors) +- The campaign log with recent events +- Open plot threads from the session state + +## What to Do + +### 1. Evaluate Will Triggers + +Scan each entity's Will section. A trigger should fire if: +- Its condition is now met (based on time, events, or world state) +- Enough time has passed for it to plausibly happen off-screen +- It creates interesting consequences the DM can narrate + +When a trigger fires: +1. `mark` the entity with what happened +2. `establish` to update their state (new location, changed disposition, etc.) +3. Remove the fired trigger by updating the Will section + +### 2. Check Thread Deadlines + +Look for threads with time pressure — explicit deadlines in entity descriptions (e.g., "Will: If Day 5 passes without intervention → the prisoners are moved") or implied urgency. Evaluate whether inaction has consequences: +- A bounty might expire +- A villain might advance their plan +- Supplies might run out +- A window of opportunity closes + +When a deadline passes, `mark` the thread with what happened and `establish` to update affected entities. Don't resolve major plot threads — just let the world respond to the passage of time. + +### 3. Small World Motion + +Add 1-2 small changes that make the world feel alive: +- An NPC moved to a different location +- A rumor spread or changed +- Weather or seasonal shift affected a location +- A faction took a minor action + +Keep these subtle — the player should notice the world moved, not feel like they missed a chapter. + +## Rules + +- **Don't advance major plot.** The player should drive the story. You're adding texture, not resolution. +- **Don't create events involving the player character.** They weren't there. +- **Don't contradict established facts.** Read existing content carefully with `recall` before changing anything. +- **Keep changes proportional to time passed.** A few hours? Almost nothing changes. A few days? NPCs might move, rumors spread. A week+? Factions act, situations evolve. +- **Focus on entities near the player's last location.** Don't change things far away that the player can't observe. +- **Use [[wikilinks]]** when referencing entities. + +When you've evaluated the triggers and made appropriate changes, stop. diff --git a/src/storied/claude.py b/src/storied/claude.py index 0b1e619..8491685 100644 --- a/src/storied/claude.py +++ b/src/storied/claude.py @@ -95,12 +95,13 @@ def build_claude_args( if not persist_session: args.append("--no-session-persistence") + # Always include system prompt and model so context stays fresh on resume + args.extend(["--system-prompt", system_prompt, "--model", model]) + if resume_session_id: args.extend(["--resume", resume_session_id]) - else: - if session_id: - args.extend(["--session-id", session_id]) - args.extend(["--system-prompt", system_prompt, "--model", model]) + elif session_id: + args.extend(["--session-id", session_id]) return args diff --git a/src/storied/cli.py b/src/storied/cli.py index 26bba6e..fd1ef43 100644 --- a/src/storied/cli.py +++ b/src/storied/cli.py @@ -11,6 +11,7 @@ import argcomplete # Slash commands available during play SLASH_COMMANDS = { "/help": "Show this help message", + "/status": "Show character status (HP, AC, gold)", "/save": "Save session state (without quitting)", "/context": "Show token usage", "/dm": "Say something out-of-character to the DM (e.g. /dm less combat please)", @@ -266,7 +267,6 @@ def cmd_play(args: argparse.Namespace) -> int: f"{result.elapsed:.1f}s[/dim]" ) console.print() - transcript_path = Path(args.transcript) if args.transcript else None engine = DMEngine( world_id=world_id, @@ -276,6 +276,19 @@ def cmd_play(args: argparse.Namespace) -> int: ) engine.debug = args.debug + # Background ticker for mid-session world advancement + ticker = None + if not creation_mode: + from storied.planner import BackgroundTicker + + ticker = BackgroundTicker( + world_id=world_id, + player_id=player_id, + base_path=Path.cwd(), + ) + # Kick off initial tick in background + ticker.maybe_tick(engine._campaign_log) + # If in creation mode, start the conversation if creation_mode: console.print("[dim]The DM will guide you through character creation...[/dim]") @@ -398,6 +411,41 @@ def cmd_play(args: argparse.Namespace) -> int: console.print() continue + # Handle /status command + if action.strip().lower() == "/status": + from storied.character import load_character as load_char + + char = load_char(player_id) + if char: + hp = char.get("hp", {}) + hp_cur = hp.get("current", "?") + hp_max = hp.get("max", "?") + ac = char.get("ac", "?") + gold = char.get("gold", 0) + level = char.get("level", 1) + name = char.get("name", "Unknown") + char_class = char.get("class", "") + race = char.get("race", "") + + # HP bar + if isinstance(hp_cur, (int, float)) and isinstance(hp_max, (int, float)) and hp_max > 0: + bar_width = 20 + filled = int((hp_cur / hp_max) * bar_width) + bar_color = "green" if hp_cur > hp_max * 0.5 else "yellow" if hp_cur > hp_max * 0.25 else "red" + bar = f"[{bar_color}]{'█' * filled}[/{bar_color}][dim]{'░' * (bar_width - filled)}[/dim]" + hp_str = f"{bar} {hp_cur}/{hp_max}" + else: + hp_str = f"{hp_cur}/{hp_max}" + + console.print() + console.print(f"[bold]{name}[/bold] [dim]{race} {char_class} {level}[/dim]") + console.print(f" HP {hp_str}") + console.print(f" AC [cyan]{ac}[/cyan] Gold [yellow]{gold}[/yellow]") + console.print() + else: + console.print("[dim]No character loaded.[/dim]") + continue + # Handle /help command if action.strip().lower() == "/help": console.print() @@ -460,6 +508,17 @@ def cmd_play(args: argparse.Namespace) -> int: f"{engine._total_output_tokens:,} out[/dim]" ) + # Check for completed background tick + if ticker: + tick_result = ticker.pop_result() + if tick_result and tick_result.tool_calls > 0: + console.print( + f"[dim]The world shifted while you considered your next move. " + f"({tick_result.tool_calls} changes)[/dim]" + ) + # Maybe launch a new tick if the day advanced + ticker.maybe_tick(engine._campaign_log) + # Check if session ended (player quit gracefully) if engine.session_ended: console.print( @@ -552,6 +611,36 @@ def cmd_plan(args: argparse.Namespace) -> int: return 0 +def cmd_tick(args: argparse.Namespace) -> int: + """Advance the world by evaluating Will triggers.""" + from storied.planner import tick_world + + world_id = args.world or "default" + player_id = args.player or "default" + + def on_progress(msg: str) -> None: + print(msg, flush=True) + + result = tick_world( + world_id=world_id, + player_id=player_id, + model=args.model, + on_progress=on_progress, + ) + + if result.entities_checked == 0: + print("No entities with active Will triggers.") + return 0 + + print( + f"Done — {result.tool_calls} tool calls, " + f"{result.input_tokens:,} in / {result.output_tokens:,} out, " + f"{result.elapsed:.1f}s", + flush=True, + ) + return 0 + + def cmd_seed(args: argparse.Namespace) -> int: """Seed an empty world from a character sheet.""" from storied.planner import seed_world @@ -720,6 +809,25 @@ def build_parser() -> argparse.ArgumentParser: ) plan_parser.set_defaults(func=cmd_plan) + # tick command + tick_parser = subparsers.add_parser("tick", help="Advance the world by evaluating Will triggers") + tick_parser.add_argument( + "--world", "-w", + default="default", + help="World ID (default: default)", + ) + tick_parser.add_argument( + "--player", "-p", + default="default", + help="Player ID (default: default)", + ) + tick_parser.add_argument( + "--model", "-m", + default="claude-opus-4-6", + help="Model to use for ticking (default: claude-opus-4-6)", + ) + tick_parser.set_defaults(func=cmd_tick) + # seed command seed_parser = subparsers.add_parser("seed", help="Seed an empty world from a character sheet") seed_parser.add_argument( diff --git a/src/storied/content.py b/src/storied/content.py index ffa69b9..4701513 100644 --- a/src/storied/content.py +++ b/src/storied/content.py @@ -127,9 +127,10 @@ class ContentResolver: def search( self, query: str, content_type: str | None = None ) -> list[SearchResult]: - """Search content by keyword. + """Search content by keyword with fuzzy fallback. - Searches both filenames and file contents. + First tries exact substring matching on filenames and content. + If nothing matches, falls back to word-overlap scoring. Args: query: Search term @@ -139,7 +140,7 @@ class ContentResolver: List of SearchResult objects """ results: list[SearchResult] = [] - seen_names: set[str] = set() # Avoid duplicates from layer override + seen_names: set[str] = set() query_lower = query.lower() for search_dir, ctype in self._search_dirs(content_type): @@ -149,15 +150,12 @@ class ContentResolver: for path in search_dir.glob("*.md"): name = path.stem - # Skip if we already found this in a higher layer if name in seen_names: continue content = path.read_text() - # Check filename or content match if query_lower in name.lower() or query_lower in content.lower(): - # Extract a snippet around the match snippet = self._extract_snippet(content, query) results.append( SearchResult( @@ -169,8 +167,52 @@ class ContentResolver: ) seen_names.add(name) + # Fuzzy fallback: word-overlap scoring when exact match fails + if not results: + results = self._fuzzy_search(query, content_type) + return results + def _fuzzy_search( + self, query: str, content_type: str | None = None + ) -> list[SearchResult]: + """Word-overlap fallback when exact search finds nothing.""" + query_words = {w for w in query.lower().split() if len(w) > 2} + if not query_words: + return [] + + scored: list[tuple[float, SearchResult]] = [] + seen_names: set[str] = set() + + for search_dir, ctype in self._search_dirs(content_type): + if not search_dir.exists(): + continue + + for path in search_dir.glob("*.md"): + name = path.stem + if name in seen_names: + continue + + name_words = set(name.lower().replace("-", " ").split()) + content = path.read_text() + content_lower = content.lower() + + # Score: name matches count double + name_hits = len(query_words & name_words) + content_hits = sum(1 for w in query_words if w in content_lower) + score = name_hits * 2 + content_hits + + if score > 0: + snippet = content[:100].strip() + ("..." if len(content) > 100 else "") + scored.append(( + score, + SearchResult(name=name, path=path, content_type=ctype, snippet=snippet), + )) + seen_names.add(name) + + scored.sort(key=lambda x: x[0], reverse=True) + return [r for _, r in scored[:5]] + def _extract_snippet(self, content: str, query: str, context: int = 50) -> str: """Extract a snippet of text around the query match.""" query_lower = query.lower() diff --git a/src/storied/engine.py b/src/storied/engine.py index 73213f0..26d5b7a 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -48,6 +48,15 @@ def _drain_stderr(stderr, lines: list[str]) -> None: lines.append(raw.decode(errors="replace")) +def _extract_roll_reason(tool_json: str) -> str | None: + """Extract the reason field from accumulated roll tool JSON.""" + try: + args = json.loads(tool_json) + return args.get("reason") + except (json.JSONDecodeError, AttributeError): + return None + + def _tool_notification(name: str) -> str: """Build a friendly tool notification string from an MCP tool name. @@ -393,6 +402,8 @@ class DMEngine: # Parse NDJSON stream from stdout current_tool_json = "" + current_tool_name = "" + deferred_notification = False assert proc.stdout is not None for raw_line in proc.stdout: @@ -409,27 +420,40 @@ class DMEngine: case ToolStart(name=name): short = name.rsplit("__", 1)[-1] if "__" in name else name + current_tool_name = short + current_tool_json = "" + if short == "end_session": self.session_ended = True if short == "create_character": self.character_created = True - if self.debug: + if short == "roll" and not self.debug: + deferred_notification = True + elif self.debug: yield f"\n[→ {short}(...)]\n" + deferred_notification = False else: yield _tool_notification(name) - current_tool_json = "" + deferred_notification = False case ToolInputDelta(json_fragment=fragment): current_tool_json += fragment case ToolStop(): + if deferred_notification and current_tool_name == "roll": + reason = _extract_roll_reason(current_tool_json) + label = f"Rolling {reason}" if reason else "Rolling" + yield f"\n[{label}...]\n" + if self.debug and current_tool_json: truncated = current_tool_json[:200] if len(current_tool_json) > 200: truncated += f"...+{len(current_tool_json) - 200}" yield f"[input: {truncated}]\n" current_tool_json = "" + current_tool_name = "" + deferred_notification = False case Result() as r: self._session_id = r.session_id diff --git a/src/storied/log.py b/src/storied/log.py index 7b6625b..e8c3f0b 100644 --- a/src/storied/log.py +++ b/src/storied/log.py @@ -57,6 +57,25 @@ class GameTime: else: return "Evening" + def atmosphere(self) -> str: + """Return a short atmospheric hint for the time of day.""" + if self.hour < 5: + return "deep night" + elif self.hour < 7: + return "first light" + elif self.hour < 12: + return "morning light" + elif self.hour < 14: + return "high sun" + elif self.hour < 17: + return "afternoon" + elif self.hour < 20: + return "fading light" + elif self.hour < 22: + return "lamplight and shadow" + else: + return "deep night" + @dataclass class Duration: @@ -378,7 +397,9 @@ class CampaignLog: if self.current_entries: lines.append("") lines.append(f"**Today (Day {self.current_day}):**") - for entry in self.current_entries[-5:]: # Last 5 entries + if len(self.current_entries) > 10: + lines.append(f"({len(self.current_entries) - 10} earlier entries today)") + for entry in self.current_entries[-10:]: lines.append(f"- {entry.event}") return "\n".join(lines) diff --git a/src/storied/planner.py b/src/storied/planner.py index 741b58b..02d27d6 100644 --- a/src/storied/planner.py +++ b/src/storied/planner.py @@ -393,3 +393,198 @@ def seed_world( seed_result.output_tokens = claude_result.usage.get("output_tokens", 0) return seed_result + + +@dataclass +class TickResult: + """Result of a tick_world run.""" + + entities_checked: int = 0 + tool_calls: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + elapsed: float = 0.0 + + +def _find_entities_with_will( + world_id: str, + base_path: Path, +) -> list[tuple[str, Path]]: + """Find all entities that have Will triggers defined.""" + world_dir = base_path / "worlds" / world_id + if not world_dir.exists(): + return [] + + results: list[tuple[str, Path]] = [] + for etype in ("npcs", "locations", "items", "factions", "threads"): + type_dir = world_dir / etype + if not type_dir.exists(): + continue + for path in type_dir.glob("*.md"): + entity = _load_entity(path) + if entity and entity.get("will"): + results.append((path.stem, path)) + + return results + + +def build_tick_context( + world_id: str, + player_id: str, + base_path: Path, + entities: list[tuple[str, Path]], +) -> str: + """Build context for the world tick agent.""" + parts: list[str] = [] + + # Campaign log and time + log = CampaignLog(world_id, base_path) + current_time = log.get_current_time() + parts.append(f"## Current Game Time: {current_time}") + + # Session state for last-played context + session = load_session(player_id, base_path) + if session: + location = session.get("location", "unknown") + parts.append(f"## Player's Last Location: {location}") + body = session.get("body", "") + if body: + parts.append(body) + + # Recent events + recent = log.get_recent_entries(days=3) + if recent: + lines = ["## Recent Events", ""] + for entry in recent: + lines.append(f"- {entry.anchor} | {entry.event}") + parts.append("\n".join(lines)) + + # Entities with Will triggers + if entities: + parts.append("## Entities with Active Triggers") + parts.append("") + for name, path in entities: + content = path.read_text() + entity_type = path.parent.name + parts.append(f"### {name} ({entity_type})") + parts.append(f"File: {entity_type}/{name}.md") + parts.append("") + parts.append(content) + parts.append("") + + return "\n\n".join(parts) + + +def tick_world( + world_id: str = "default", + player_id: str = "default", + base_path: Path | None = None, + model: str = "claude-opus-4-6", + on_progress: Callable[[str], None] | None = None, +) -> TickResult: + """Advance the world by evaluating Will triggers and adding small changes.""" + if base_path is None: + base_path = Path.cwd() + + def progress(msg: str) -> None: + if on_progress: + on_progress(msg) + + start_time = time.monotonic() + + # Find entities with Will triggers + entities = _find_entities_with_will(world_id, base_path) + progress(f"Found {len(entities)} entities with active triggers") + + if not entities: + return TickResult(elapsed=time.monotonic() - start_time) + + # Build context and run + context = build_tick_context(world_id, player_id, base_path, entities) + system_prompt = load_prompt("world-tick") + + campaign_log = CampaignLog(world_id, base_path) + mcp = start_mcp_server( + world_id, player_id, base_path, "planner", campaign_log, + ) + mcp_config = build_mcp_config(mcp.url) + + progress(f"Ticking with {model}...") + + claude_result = _run_claude_collect( + system_prompt=system_prompt, + user_message=context, + model=model, + mcp_config=mcp_config, + base_path=base_path, + on_progress=on_progress, + ) + + result = TickResult( + entities_checked=len(entities), + elapsed=time.monotonic() - start_time, + ) + + if claude_result: + result.tool_calls = claude_result.usage.get("tool_calls", 0) + result.input_tokens = claude_result.usage.get("input_tokens", 0) + result.output_tokens = claude_result.usage.get("output_tokens", 0) + + return result + + +class BackgroundTicker: + """Runs world ticks in a background thread during gameplay. + + Triggers a tick when the game day advances (e.g., after a long rest or + multi-hour travel). Only one tick runs at a time. + """ + + def __init__( + self, + world_id: str, + player_id: str, + base_path: Path, + model: str = "claude-opus-4-6", + ): + self._world_id = world_id + self._player_id = player_id + self._base_path = base_path + self._model = model + self._last_tick_day: int = 0 + self._thread: Thread | None = None + self._result: TickResult | None = None + + def maybe_tick(self, campaign_log: CampaignLog) -> None: + """Launch a background tick if the game day advanced.""" + current_day = campaign_log.get_current_time().day + if current_day <= self._last_tick_day: + return + if self._thread and self._thread.is_alive(): + return + + triggers = _find_entities_with_will(self._world_id, self._base_path) + if not triggers: + self._last_tick_day = current_day + return + + self._last_tick_day = current_day + self._result = None + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + self._result = tick_world( + world_id=self._world_id, + player_id=self._player_id, + base_path=self._base_path, + model=self._model, + ) + + def pop_result(self) -> TickResult | None: + """Return and clear the last completed tick result, if any.""" + if self._thread and not self._thread.is_alive() and self._result: + result = self._result + self._result = None + return result + return None diff --git a/src/storied/tools.py b/src/storied/tools.py index 6c91f25..bd23583 100644 --- a/src/storied/tools.py +++ b/src/storied/tools.py @@ -5,10 +5,23 @@ the tool descriptions that Claude sees. """ import re +import threading from pathlib import Path import yaml +# Per-file locks for thread-safe entity writes (establish, mark) +_file_locks: dict[Path, threading.Lock] = {} +_file_locks_lock = threading.Lock() + + +def _get_file_lock(path: Path) -> threading.Lock: + """Get or create a lock for the given file path.""" + with _file_locks_lock: + if path not in _file_locks: + _file_locks[path] = threading.Lock() + return _file_locks[path] + from storied.character import create_character as char_create from storied.character import update_character as char_update from storied.content import ContentResolver @@ -255,7 +268,11 @@ def set_scene( if event and duration: log = campaign_log or load_log(world_id, base_path) anchor = log.append_entry(event, duration, tags=tags) - parts.append(f"Logged: {anchor} | {event} | {duration}") + current = log.get_current_time() + parts.append( + f"Logged: {anchor} | {event} | {duration} → " + f"Now: {current} ({current.period_of_day()}, {current.atmosphere()})" + ) # Update session state updates = {} @@ -272,6 +289,14 @@ def set_scene( result = session_update(player_id, updates, base_path) parts.append(result) + # Auto-mark present entities with this event + if event and present and world_id: + marked = _auto_mark_present( + present, event, world_id, base_path, campaign_log, + ) + if marked: + parts.append(f"Auto-marked: {', '.join(marked)}") + return "; ".join(parts) if parts else "No updates" @@ -329,27 +354,29 @@ def establish( world_dir.mkdir(parents=True, exist_ok=True) file_path = world_dir / f"{name}.md" - # Load existing content if file exists (for partial updates) - existing = _load_entity(file_path) - - # Merge with existing content (new values override) - if description is None: - description = existing.get("description", "") - if location is None: - location = existing.get("location", "") - if knows is None: - knows = existing.get("knows", []) - if wants is None: - wants = existing.get("wants", []) - if will is None: - will = existing.get("will", []) - was = existing.get("was", []) # Always preserve Was - - # Build file content - file_content = _format_entity(name, description, location, knows, wants, will, was) - file_path.write_text(file_content) - - action = "Updated" if existing else "Established" + lock = _get_file_lock(file_path) + with lock: + # Load existing content if file exists (for partial updates) + existing = _load_entity(file_path) + + # Merge with existing content (new values override) + if description is None: + description = existing.get("description", "") + if location is None: + location = existing.get("location", "") + if knows is None: + knows = existing.get("knows", []) + if wants is None: + wants = existing.get("wants", []) + if will is None: + will = existing.get("will", []) + was = existing.get("was", []) # Always preserve Was + + # Build file content + file_content = _format_entity(name, description, location, knows, wants, will, was) + file_path.write_text(file_content) + + action = "Updated" if existing else "Established" return f"{action} {entity_type.rstrip('s')} '{name}'" @@ -462,6 +489,47 @@ def _format_entity( return "\n".join(lines) +def _auto_mark_present( + present: list[str], + event: str, + world_id: str, + base_path: Path | None, + campaign_log: CampaignLog | None, +) -> list[str]: + """Auto-mark present entities with the current event. + + Extracts entity names from [[wikilink]] format in the present list + and appends the event to each entity's Was section. + """ + if base_path is None: + base_path = Path.cwd() + + marked: list[str] = [] + for ref in present: + # Extract name from "[[Name]]" or "[[Name]] - description" + link_match = re.search(r"\[\[([^\]]+)\]\]", ref) + if not link_match: + continue + name = link_match.group(1) + + # Try each entity type directory + for etype in ("npcs", "locations", "items", "factions"): + file_path = base_path / "worlds" / world_id / etype / f"{name}.md" + if file_path.exists(): + mark( + entity_type=etype, + name=name, + event=event, + world_id=world_id, + base_path=base_path, + campaign_log=campaign_log, + ) + marked.append(name) + break + + return marked + + def mark( entity_type: str, name: str, @@ -502,9 +570,6 @@ def mark( if not file_path.exists(): return f"Error: Entity '{name}' not found in {entity_type}" - # Load existing entity - existing = _load_entity(file_path) - # Get current game time for timestamp if campaign_log: timestamp = campaign_log.get_current_time().to_anchor() @@ -512,29 +577,34 @@ def mark( log = load_log(world_id, base_path) timestamp = log.get_current_time().to_anchor() - # Append to Was section - was = existing.get("was", []) - was.append(f"{timestamp} | {event}") - - # Remove resolved Will items - will = existing.get("will", []) - resolved = [] - for trigger in resolves or []: - if trigger in will: - will.remove(trigger) - resolved.append(trigger) - - # Rebuild and save the file - file_content = _format_entity( - name, - existing.get("description", ""), - existing.get("location", ""), - existing.get("knows", []), - existing.get("wants", []), - will, - was, - ) - file_path.write_text(file_content) + lock = _get_file_lock(file_path) + with lock: + # Load existing entity + existing = _load_entity(file_path) + + # Append to Was section + was = existing.get("was", []) + was.append(f"{timestamp} | {event}") + + # Remove resolved Will items + will = existing.get("will", []) + resolved = [] + for trigger in resolves or []: + if trigger in will: + will.remove(trigger) + resolved.append(trigger) + + # Rebuild and save the file + file_content = _format_entity( + name, + existing.get("description", ""), + existing.get("location", ""), + existing.get("knows", []), + existing.get("wants", []), + will, + was, + ) + file_path.write_text(file_content) result = f"Marked: {event}" if resolved: diff --git a/tests/test_claude.py b/tests/test_claude.py index b45fc64..930a59f 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -42,15 +42,18 @@ class TestBuildClaudeArgs: assert "--model" in args assert "sonnet" in args - def test_resume_session_skips_system_prompt(self, monkeypatch): + def test_resume_session_includes_system_prompt(self, monkeypatch): monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude") args = build_claude_args( - "sonnet", "ignored", "{}", + "sonnet", "You are a DM", "{}", resume_session_id="abc-123", ) assert "--resume" in args assert "abc-123" in args - assert "--system-prompt" not in args + assert "--system-prompt" in args + assert "You are a DM" in args + assert "--model" in args + assert "sonnet" in args def test_no_session_persistence_flag(self, monkeypatch): monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude") diff --git a/tests/test_log.py b/tests/test_log.py index 4a2a785..55918c1 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -70,6 +70,16 @@ class TestGameTime: assert GameTime(hour=18).period_of_day() == "Evening" assert GameTime(hour=23).period_of_day() == "Evening" + def test_atmosphere(self): + assert GameTime(hour=3).atmosphere() == "deep night" + assert GameTime(hour=5).atmosphere() == "first light" + assert GameTime(hour=9).atmosphere() == "morning light" + assert GameTime(hour=13).atmosphere() == "high sun" + assert GameTime(hour=15).atmosphere() == "afternoon" + assert GameTime(hour=18).atmosphere() == "fading light" + assert GameTime(hour=21).atmosphere() == "lamplight and shadow" + assert GameTime(hour=23).atmosphere() == "deep night" + class TestDuration: def test_parse_minutes(self):