"""Load & save world bundle artifacts (Section 6). Artifacts are markdown files with optional YAML frontmatter: --- --- This module parses frontmatter, builds the typed models in ``models.py``, and writes artifacts back in the same shape (used by the generator). """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Any import yaml from .models import ( Alibi, CharacterCard, ClueNode, EnvObject, KnowledgeBoundary, Solution, Timeline, TimelineSlice, WorldMeta, ) _FM_DELIM = "---" def parse_frontmatter(text: str) -> tuple[dict[str, Any], str]: """Split a markdown doc into (frontmatter dict, body).""" stripped = text.lstrip() if not stripped.startswith(_FM_DELIM): return {}, text # Find the closing delimiter after the opening one. lines = stripped.splitlines() # lines[0] == '---' end = None for i in range(1, len(lines)): if lines[i].strip() == _FM_DELIM: end = i break if end is None: return {}, text fm_text = "\n".join(lines[1:end]) body = "\n".join(lines[end + 1 :]).lstrip("\n") data = yaml.safe_load(fm_text) or {} if not isinstance(data, dict): data = {} return data, body def write_frontmatter(frontmatter: dict[str, Any], body: str) -> str: """Serialize a (frontmatter, body) pair into a markdown doc.""" fm = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).rstrip() return f"{_FM_DELIM}\n{fm}\n{_FM_DELIM}\n\n{body.strip()}\n" @dataclass class World: """A loaded world bundle (engine-side view; holds ground truth).""" meta: WorldMeta world_md: str solution: Solution timeline: Timeline environment: list[EnvObject] clues: list[ClueNode] characters: dict[str, CharacterCard] alibis: dict[str, Alibi] path: Path def character(self, name: str) -> CharacterCard | None: # Case-insensitive lookup. An exact full-name match always wins; # otherwise accept any single name word ("clara", "pollard"). A query # that matches more than one person (e.g. "vale") is ambiguous and # resolves to nothing — use the full name to disambiguate. query = name.strip().lower() for cname, card in self.characters.items(): if cname.lower() == query: return card partial = self.candidates(name) return partial[0] if len(partial) == 1 else None def candidates(self, name: str) -> list[CharacterCard]: """Characters whose name contains the query as a whole word. Used to report ambiguity (e.g. "vale" → Edmund Vale, Tessa Vale).""" query = name.strip().lower() return [ card for cname, card in self.characters.items() if query in {w.strip(".,").lower() for w in cname.split()} ] def clue(self, clue_id: str) -> ClueNode | None: for c in self.clues: if c.id == clue_id: return c return None def env_object(self, obj_id: str) -> EnvObject | None: for o in self.environment: if o.id == obj_id: return o return None @property def suspects(self) -> list[CharacterCard]: return [c for c in self.characters.values() if c.role != "victim"] # -------------------------------------------------------------------------- # Loading # -------------------------------------------------------------------------- def _read(path: Path) -> str: return path.read_text(encoding="utf-8") if path.exists() else "" def load_world(world_dir: Path) -> World: world_id = world_dir.name world_md_path = world_dir / "world.md" world_fm, world_body = parse_frontmatter(_read(world_md_path)) meta = WorldMeta(world_id=world_id, **{ k: world_fm[k] for k in ("created", "seed", "one_line", "setting", "twist_tag", "play_count", "title") if k in world_fm }) sol_fm, sol_body = parse_frontmatter(_read(world_dir / "solution.md")) solution = Solution( culprit=sol_fm.get("culprit", ""), means=sol_fm.get("means", ""), motive=sol_fm.get("motive", ""), opportunity=sol_fm.get("opportunity", ""), true_sequence=sol_fm.get("true_sequence", ""), body=sol_body, ) tl_fm, _ = parse_frontmatter(_read(world_dir / "timeline.md")) timeline = Timeline( slices=[TimelineSlice(**s) for s in tl_fm.get("slices", [])] ) env_fm, _ = parse_frontmatter(_read(world_dir / "environment.md")) environment = [EnvObject(**o) for o in env_fm.get("objects", [])] clue_fm, _ = parse_frontmatter(_read(world_dir / "clue_graph.md")) clues = [ClueNode(**c) for c in clue_fm.get("nodes", [])] characters: dict[str, CharacterCard] = {} char_dir = world_dir / "characters" if char_dir.exists(): for cpath in sorted(char_dir.glob("*.md")): fm, body = parse_frontmatter(_read(cpath)) knows = KnowledgeBoundary(**(fm.get("knows") or {})) card = CharacterCard( name=fm.get("name", cpath.stem), role=fm.get("role", "suspect"), guilty=fm.get("guilty", False), truth=fm.get("truth", ""), knows=knows, cover=fm.get("cover", ""), never_admit=fm.get("never_admit", []), cracks_when=fm.get("cracks_when", ""), crack_behavior=fm.get("crack_behavior", "deflect"), tells=fm.get("tells", []), secret_kind=fm.get("secret_kind", "guilty"), exoneration=fm.get("exoneration"), voice=body, ) characters[card.name] = card alibis: dict[str, Alibi] = {} alibi_dir = world_dir / "alibis" if alibi_dir.exists(): for apath in sorted(alibi_dir.glob("*.md")): fm, body = parse_frontmatter(_read(apath)) alibi = Alibi( id=fm.get("id", apath.stem), characters=fm.get("characters", []), agreed_facts=fm.get("agreed_facts", ""), agreed_timeline=fm.get("agreed_timeline", ""), cover_per_character=fm.get("cover_per_character", {}), seams=fm.get("seams", []), body=body, ) alibis[alibi.id] = alibi return World( meta=meta, world_md=world_body, solution=solution, timeline=timeline, environment=environment, clues=clues, characters=characters, alibis=alibis, path=world_dir, )