magicciv/tools/sprite-generation/engine/installer.py
Natalie 8e3107b92a feat(@projects/@magic-civilization): update tech-tree and mcts service implementation
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-25 22:48:40 -07:00

240 lines
9.6 KiB
Python

"""Copies approved sprites from the variants directory to the game assets directory.
Also appends a ledger row to `<assets_dir>/LICENSES.md` for each install
so `tools/sprite-license-audit.py` stays clean (p2-28). The row carries
the on-disk SHA256 plus model attribution from the registry.
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import json
import shutil
from pathlib import Path
from engine.registry import SpriteRegistry
_LEDGER_HEADER_MARKER = "## Assets"
_LEDGER_PLACEHOLDER = "*(empty"
def _sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 16), b""):
h.update(chunk)
return h.hexdigest()
def _resolve_model_attribution(sprite: dict, variant_id: int | None = None) -> tuple[str, str]:
"""Return (license_value, author) for the ledger row.
Reads `tools/sprite-generation/sprite-config.json` for the active
model name and fits the ledger's `model-commercial:<model-name>`
license token. Author defaults to the model id; commissioned art
can be added by hand.
"""
cfg_path = Path(__file__).resolve().parents[1] / "sprite-config.json"
model = "unknown"
if cfg_path.exists():
try:
cfg = json.loads(cfg_path.read_text())
model = cfg.get("model", model)
except json.JSONDecodeError:
pass
return f"model-commercial:{model}", model
def _append_ledger_row(assets_dir: Path, install_rel: str, sprite: dict) -> None:
"""Append one ledger row for the just-installed sprite. Idempotent — if a
row for this path already exists, leaves the file alone.
"""
ledger = assets_dir / "LICENSES.md"
if not ledger.exists():
# Don't auto-create the ledger; its header is hand-authored. Skip silently.
return
text = ledger.read_text()
# Skip if the path already has a row.
if f"| `{install_rel}` |" in text or f"| {install_rel} |" in text:
return
full = assets_dir / install_rel
if not full.exists():
return
sha = _sha256_of(full)
license_val, author = _resolve_model_attribution(sprite)
today = _dt.date.today().isoformat()
row = f"| {install_rel} | ai-generated | {license_val} | {author} | model-card | {sha} | {today} |"
lines = text.splitlines()
out: list[str] = []
inserted = False
in_assets = False
for line in lines:
if line.strip().startswith(_LEDGER_HEADER_MARKER):
in_assets = True
out.append(line)
continue
# Drop the placeholder once we hit it inside the Assets section.
if in_assets and line.strip().startswith(_LEDGER_PLACEHOLDER):
continue
if in_assets and not inserted and line.startswith("##"):
# Hit next section — append before it.
out.append(row)
out.append("")
inserted = True
out.append(line)
if in_assets and not inserted:
out.append(row)
ledger.write_text("\n".join(out) + "\n")
class SpriteInstaller:
def __init__(self, assets_dir: Path, registry: SpriteRegistry) -> None:
self.assets_dir = assets_dir
self.registry = registry
def install_approved(self, category: str | None = None, dry_run: bool = False) -> int:
"""Install all approved, uninstalled sprites. Returns count installed."""
entries = self.registry.get_approved_uninstalled(category)
count = 0
for sprite in entries:
sprite_id = sprite["id"]
install_path = sprite.get("install_path")
if not install_path:
continue
# Check if sprite-level has an approved variant (no dimension)
if sprite["status"] == "approved":
variants = self.registry.get_variants(sprite_id)
approved = [v for v in variants if v["is_approved"] and v["dimension_id"] is None]
if approved:
source = approved[0].get("processed_path")
if source and Path(source).exists():
dest = self.assets_dir / install_path
if dry_run:
print(f"[DRY RUN] Would install {install_path}")
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, dest)
self.registry.mark_installed(sprite_id)
print(f"Installed {install_path}")
count += 1
# Handle approved dimensions
for dim in sprite.get("approved_dimensions", []):
dim_install = dim.get("install_path")
if not dim_install:
continue
if dim["status"] != "approved":
continue
dim_id = dim["id"]
approved_vid = dim.get("approved_variant_id")
if not approved_vid:
continue
dim_variants = self.registry.get_variants(sprite_id, dim_id)
approved = [v for v in dim_variants if v["id"] == approved_vid]
if not approved:
continue
source = approved[0].get("processed_path")
if not source or not Path(source).exists():
continue
dest = self.assets_dir / dim_install
if dry_run:
print(f"[DRY RUN] Would install {dim_install}")
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, dest)
self.registry.mark_installed(sprite_id, dim_id)
print(f"Installed {dim_install}")
count += 1
return count
def install_one(self, sprite_id: str, dimension_id: int | None = None) -> Path | None:
"""Install a single sprite/dimension. Returns install path or None."""
sprite = self.registry.get_sprite(sprite_id)
if not sprite:
print(f"Sprite '{sprite_id}' not found")
return None
if dimension_id is not None:
dim = next((d for d in sprite["dimensions_list"] if d["id"] == dimension_id), None)
if not dim:
print(f"Dimension {dimension_id} not found for '{sprite_id}'")
return None
if dim["status"] not in ("approved", "installed"):
print(f"Dimension {dimension_id} is not approved (status: {dim['status']})")
return None
install_path = dim.get("install_path")
approved_vid = dim.get("approved_variant_id")
if not install_path or not approved_vid:
print(f"Dimension {dimension_id} has no install_path or approved variant")
return None
approved = [v for v in dim.get("variants", []) if v["id"] == approved_vid]
if not approved:
print(f"Approved variant {approved_vid} not found")
return None
source = approved[0].get("processed_path")
else:
if sprite["status"] not in ("approved", "installed"):
print(f"Sprite '{sprite_id}' is not approved (status: {sprite['status']})")
return None
install_path = sprite.get("install_path")
if not install_path:
print(f"Sprite '{sprite_id}' has no install_path")
return None
approved = [v for v in sprite.get("variants", []) if v["is_approved"]]
if not approved:
print(f"No approved variant for '{sprite_id}'")
return None
source = approved[0].get("processed_path")
if not source or not Path(source).exists():
print(f"Source file not found: {source}")
return None
dest = self.assets_dir / install_path
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, dest)
self.registry.mark_installed(sprite_id, dimension_id)
# p2-28: append the ledger row so sprite-license-audit.py stays clean.
_append_ledger_row(self.assets_dir, install_path, sprite)
print(f"Installed {install_path}")
return dest
def uninstall(self, sprite_id: str, dimension_id: int | None = None) -> bool:
"""Remove an installed sprite, revert status to approved."""
sprite = self.registry.get_sprite(sprite_id)
if not sprite:
print(f"Sprite '{sprite_id}' not found")
return False
if dimension_id is not None:
dim = next((d for d in sprite["dimensions_list"] if d["id"] == dimension_id), None)
if not dim or dim["status"] != "installed":
print(f"Dimension {dimension_id} is not installed")
return False
install_path = dim.get("install_path")
if install_path:
dest = self.assets_dir / install_path
if dest.exists():
dest.unlink()
self.registry.update_dimension_status(dimension_id, "approved")
print(f"Uninstalled dimension {dimension_id} of '{sprite_id}'")
return True
if sprite["status"] != "installed":
print(f"Sprite '{sprite_id}' is not installed (status: {sprite['status']})")
return False
install_path = sprite.get("install_path")
if install_path:
dest = self.assets_dir / install_path
if dest.exists():
dest.unlink()
self.registry.update_sprite_status(sprite_id, "approved")
print(f"Uninstalled '{sprite_id}'")
return True