"""axiom.decompile — Universe-as-Code: decompiling a `.db` into a source tree.
The inverse of `axiom.compile`: reads an existing SQLite universe and
regenerates the equivalent tree of text files (TOML/MD). Also used to migrate
v1 `.db` universes to the versionable text format.
Preserves: entity_ids, rule_ids, calendar, full lore, locations + connections,
scheduled events, story setup, item & stat definitions, personas.
TOML writing: `tomlkit` (keeps the formatting clean, helpful for human editing).
Zero Qt dependency.
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
import tomlkit
from axiom.compile import (
CACHE_DIRNAME,
_FRONTMATTER_DELIM,
_META_CALENDAR,
_META_COMPANION_ENABLED,
_META_COMPANION_HERO,
_META_FIRST_MESSAGE,
_META_GLOBAL_LORE,
_META_NAME,
_META_SYSTEM_PROMPT,
_META_WORLD_TENSION,
_STRUCTURED_META_KEYS,
)
from axiom.schema import get_connection
from axiom.time_system import CalendarConfig
_GLOBAL_LORE_FILE = "lore/_global_lore.md"
_FIRST_MESSAGE_FILE = "lore/_first_message.md"
[docs]
class DecompileError(Exception):
"""Universe decompilation error."""
def _nl(text: str) -> str:
"""Normalise les fins de ligne en LF (format texte git-friendly, déterministe)."""
return text.replace("\r\n", "\n").replace("\r", "\n")
def _write_text(path: Path, text: str) -> None:
"""Écrit du texte en UTF-8 avec des fins de ligne LF garanties (newline='')."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="")
# ---------------------------------------------------------------------------
# Lecteur de définition normalisé (partagé avec les tests de round-trip)
# ---------------------------------------------------------------------------
[docs]
def read_definition(db_path: str | Path) -> dict[str, Any]:
"""Read all the **definition** tables of a universe into normalised structures.
Never reads the runtime/save tables. Used by the decompilation and by the
round-trip tests (semantic comparison).
"""
path = str(db_path)
with get_connection(path) as conn:
conn.row_factory = sqlite3.Row
meta = {
row["key"]: row["value"]
for row in conn.execute("SELECT key, value FROM Universe_Meta;")
}
entities = []
for row in conn.execute(
"SELECT entity_id, entity_type, name, description, is_active "
"FROM Entities ORDER BY entity_id;"
):
stats = {
r["stat_key"]: r["stat_value"]
for r in conn.execute(
"SELECT stat_key, stat_value FROM Entity_Stats "
"WHERE entity_id = ? ORDER BY stat_key;",
(row["entity_id"],),
)
}
entities.append({
"entity_id": row["entity_id"],
"entity_type": row["entity_type"],
"name": row["name"],
"description": row["description"],
"is_active": row["is_active"],
"stats": stats,
})
rules = [
{
"rule_id": r["rule_id"],
"priority": r["priority"],
"conditions": json.loads(r["conditions"]),
"actions": json.loads(r["actions"]),
"target_entity": r["target_entity"],
}
for r in conn.execute(
"SELECT rule_id, priority, conditions, actions, target_entity "
"FROM Rules ORDER BY rule_id;"
)
]
stat_definitions = [
{
"stat_id": r["stat_id"],
"name": r["name"],
"description": r["description"],
"value_type": r["value_type"],
"parameters": json.loads(r["parameters"] or "{}"),
}
for r in conn.execute(
"SELECT stat_id, name, description, value_type, parameters "
"FROM Stat_Definitions ORDER BY stat_id;"
)
]
locations = [
dict(r) for r in conn.execute(
"SELECT location_id, name, scale, parent_id, description, x, y "
"FROM Locations ORDER BY location_id;"
)
]
connections = [
dict(r) for r in conn.execute(
"SELECT source_id, target_id, distance_km FROM Location_Connections "
"ORDER BY source_id, target_id;"
)
]
lore = [
dict(r) for r in conn.execute(
"SELECT entry_id, category, name, keywords, content FROM Lore_Book "
"ORDER BY entry_id;"
)
]
events = [
dict(r) for r in conn.execute(
"SELECT event_id, trigger_minute, title, description "
"FROM Scheduled_Events ORDER BY event_id;"
)
]
setup = [
{
"setup_id": r["setup_id"],
"question": r["question"],
"type": r["type"],
"options": json.loads(r["options"] or "[]"),
"max_selections": r["max_selections"],
"priority": r["priority"],
}
for r in conn.execute(
"SELECT setup_id, question, type, options, max_selections, priority "
"FROM Story_Setup ORDER BY setup_id;"
)
]
items = [
dict(r) for r in conn.execute(
"SELECT item_id, name, description, category, weight, rarity "
"FROM Item_Definitions ORDER BY item_id;"
)
]
return {
"meta": meta,
"entities": entities,
"rules": rules,
"stat_definitions": stat_definitions,
"locations": locations,
"connections": connections,
"lore": lore,
"events": events,
"setup": setup,
"items": items,
}
# ---------------------------------------------------------------------------
# Helpers d'écriture
# ---------------------------------------------------------------------------
def _write_toml(path: Path, doc: Any) -> None:
_write_text(path, tomlkit.dumps(doc))
def _build_universe_toml(meta: dict[str, str], src_dir: Path) -> None:
"""Reconstruit universe.toml + fichiers narratifs depuis Universe_Meta."""
doc = tomlkit.document()
meta_tbl = tomlkit.table()
if _META_NAME in meta:
meta_tbl["name"] = meta[_META_NAME]
doc["meta"] = meta_tbl
narrative = tomlkit.table()
if _META_SYSTEM_PROMPT in meta:
narrative["system_prompt"] = meta[_META_SYSTEM_PROMPT]
if meta.get(_META_GLOBAL_LORE):
_write_text(src_dir / _GLOBAL_LORE_FILE, _nl(meta[_META_GLOBAL_LORE]))
narrative["global_lore_file"] = _GLOBAL_LORE_FILE
if meta.get(_META_FIRST_MESSAGE):
_write_text(src_dir / _FIRST_MESSAGE_FILE, _nl(meta[_META_FIRST_MESSAGE]))
narrative["first_message_file"] = _FIRST_MESSAGE_FILE
if _META_WORLD_TENSION in meta:
# Conservé en chaîne verbatim (round-trip lossless).
narrative["world_tension_level"] = meta[_META_WORLD_TENSION]
if len(narrative):
doc["narrative"] = narrative
if meta.get(_META_CALENDAR):
cfg = CalendarConfig.from_json(meta[_META_CALENDAR])
cal = tomlkit.table()
cal["minutes_per_hour"] = cfg.minutes_per_hour
cal["hours_per_day"] = cfg.hours_per_day
cal["days_per_month"] = cfg.days_per_month
cal["month_names"] = cfg.month_names
cal["start_day"] = cfg.start_day
cal["start_hour"] = cfg.start_hour
cal["start_minute"] = cfg.start_minute
doc["calendar"] = cal
if _META_COMPANION_ENABLED in meta or _META_COMPANION_HERO in meta:
comp = tomlkit.table()
if _META_COMPANION_ENABLED in meta:
comp["enabled"] = meta[_META_COMPANION_ENABLED] == "1"
if _META_COMPANION_HERO in meta:
comp["hero_id"] = meta[_META_COMPANION_HERO]
doc["companion"] = comp
extra = {k: v for k, v in meta.items() if k not in _STRUCTURED_META_KEYS}
if extra:
extra_tbl = tomlkit.table()
for key, value in sorted(extra.items()):
extra_tbl[key] = value
doc["extra"] = extra_tbl
_write_toml(src_dir / "universe.toml", doc)
def _write_lore_entry(path: Path, entry: dict) -> None:
"""Écrit une entrée de lore en .md avec frontmatter TOML.
Format : `+++\\n<toml>+++\\n<contenu>` — le contenu suit immédiatement le
délimiteur fermant et est préservé tel quel (round-trip fidèle, modulo LF).
"""
front = tomlkit.document()
front["entry_id"] = entry["entry_id"]
front["category"] = entry["category"] or ""
front["name"] = entry["name"] or ""
front["keywords"] = entry["keywords"] or ""
text = (
f"{_FRONTMATTER_DELIM}\n"
f"{tomlkit.dumps(front)}"
f"{_FRONTMATTER_DELIM}\n"
f"{_nl(entry['content'])}"
)
_write_text(path, text)
def _safe_filename(raw: str) -> str:
"""Nom de fichier sûr dérivé d'un id (sans toucher au contenu stocké)."""
cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in raw)
return cleaned or "unnamed"
class _UniqueNames:
"""Alloue des noms de fichiers uniques par dossier (déterministe).
Deux ids distincts peuvent donner le même nom via `_safe_filename`
(« bob.smith » / « bob_smith ») : sans désambiguïsation, le second fichier
écraserait le premier en silence. Le compilateur lisant les ids DANS le
fichier, le suffixe n'altère pas le round-trip.
"""
def __init__(self) -> None:
self._used: set[str] = set()
def claim(self, raw_id: str) -> str:
name = _safe_filename(raw_id)
candidate = name
counter = 2
while candidate in self._used:
candidate = f"{name}_{counter}"
counter += 1
self._used.add(candidate)
return candidate
# ---------------------------------------------------------------------------
# API publique
# ---------------------------------------------------------------------------
[docs]
def decompile_universe(db_path: str | Path, output_dir: str | Path) -> Path:
"""Decompile a `.db` universe into a text source tree.
Args:
db_path: Path of the universe `.db` to read.
output_dir: Destination folder (created if missing).
Returns:
The path of the generated source folder.
"""
db_path = Path(db_path)
if not db_path.exists():
raise DecompileError(f"Universe not found: {db_path}")
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
data = read_definition(db_path)
_build_universe_toml(data["meta"], out)
if data["stat_definitions"]:
doc = tomlkit.document()
arr = tomlkit.aot()
for d in data["stat_definitions"]:
t = tomlkit.table()
t["stat_id"] = d["stat_id"]
t["name"] = d["name"]
t["description"] = d["description"]
t["value_type"] = d["value_type"]
t["parameters"] = d["parameters"]
arr.append(t)
doc["definitions"] = arr
_write_toml(out / "stats" / "definitions.toml", doc)
ent_names = _UniqueNames()
for ent in data["entities"]:
doc = tomlkit.document()
doc["entity_id"] = ent["entity_id"]
doc["entity_type"] = ent["entity_type"]
doc["name"] = ent["name"]
doc["description"] = ent["description"]
doc["is_active"] = bool(ent["is_active"])
if ent["stats"]:
stats_tbl = tomlkit.table()
for k, v in ent["stats"].items():
stats_tbl[k] = v
doc["stats"] = stats_tbl
_write_toml(out / "entities" / f"{ent_names.claim(ent['entity_id'])}.toml", doc)
rule_names = _UniqueNames()
for rule in data["rules"]:
doc = tomlkit.document()
doc["rule_id"] = rule["rule_id"]
doc["priority"] = rule["priority"]
doc["target_entity"] = rule["target_entity"]
doc["conditions"] = rule["conditions"]
doc["actions"] = rule["actions"]
_write_toml(out / "rules" / f"{rule_names.claim(rule['rule_id'])}.toml", doc)
if data["locations"] or data["connections"]:
doc = tomlkit.document()
locs = tomlkit.aot()
for loc in data["locations"]:
t = tomlkit.table()
t["location_id"] = loc["location_id"]
t["name"] = loc["name"]
t["scale"] = loc["scale"]
if loc["parent_id"]:
t["parent_id"] = loc["parent_id"]
t["description"] = loc["description"]
t["x"] = loc["x"]
t["y"] = loc["y"]
locs.append(t)
if data["locations"]:
doc["locations"] = locs
conns = tomlkit.aot()
for c in data["connections"]:
t = tomlkit.table()
t["source_id"] = c["source_id"]
t["target_id"] = c["target_id"]
t["distance_km"] = c["distance_km"]
conns.append(t)
if data["connections"]:
doc["connections"] = conns
_write_toml(out / "locations" / "map.toml", doc)
lore_names = _UniqueNames()
for entry in data["lore"]:
_write_lore_entry(out / "lore" / f"{lore_names.claim(entry['entry_id'])}.md", entry)
event_names = _UniqueNames()
for ev in data["events"]:
doc = tomlkit.document()
doc["event_id"] = ev["event_id"]
doc["trigger_minute"] = ev["trigger_minute"]
doc["title"] = ev["title"]
doc["description"] = ev["description"]
_write_toml(out / "events" / f"{event_names.claim(ev['event_id'])}.toml", doc)
if data["setup"]:
doc = tomlkit.document()
arr = tomlkit.aot()
for q in data["setup"]:
t = tomlkit.table()
t["setup_id"] = q["setup_id"]
t["question"] = q["question"]
t["type"] = q["type"]
t["options"] = q["options"]
t["max_selections"] = q["max_selections"]
t["priority"] = q["priority"]
arr.append(t)
doc["questions"] = arr
_write_toml(out / "setup" / "questions.toml", doc)
item_names = _UniqueNames()
for it in data["items"]:
doc = tomlkit.document()
doc["item_id"] = it["item_id"]
doc["name"] = it["name"]
doc["description"] = it["description"]
doc["category"] = it["category"]
doc["weight"] = it["weight"]
doc["rarity"] = it["rarity"]
_write_toml(out / "items" / f"{item_names.claim(it['item_id'])}.toml", doc)
# .gitignore : le cache compilé n'est jamais versionné.
(out / ".gitignore").write_text(f"{CACHE_DIRNAME}/\n", encoding="utf-8")
return out