f-id / src /id /worldio.py
marcodsn's picture
Initial Gradio Space
0423b99
Raw
History Blame Contribute Delete
6.83 kB
"""Load & save world bundle artifacts (Section 6).
Artifacts are markdown files with optional YAML frontmatter:
---
<yaml machine fields>
---
<prose body>
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,
)