magicciv/tools/sprite-generation/engine/starter.py
Natalie 7eb2897854 feat(@projects/@magic-civilization): 🎨 Grok sprite-generation pipeline + starter orchestration
Adds a Grok image backend (grok_generator.py) behind a generator factory, a
starter-set orchestrator (starter.py, orchestrate_starter.py, starter_manifest.json)
and a Grok PoC harness with proof renders. Updates the ranker/processor/composition
prompts and sprite-config; refreshes the sprite-gallery design preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:57:01 -05:00

217 lines
No EOL
7.1 KiB
Python

"""Register and track the curated Game-1 starter sprite set."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from engine.prompts import (
compose_prompt,
get_generation_size,
get_negative,
get_target_size,
)
from engine.registry import SpriteRegistry
TOOL_DIR = Path(__file__).resolve().parents[1]
PROJECT = TOOL_DIR.parent.parent
RESOURCES = PROJECT / "public" / "resources"
MANIFEST_PATH = TOOL_DIR / "starter_manifest.json"
WILDS_PATH = RESOURCES / "wilds" / "wilds.json"
WONDERS_PATH = RESOURCES / "tiles" / "water_and_wonders.json"
LAND_SPECIAL_PATH = RESOURCES / "tiles" / "land_special.json"
@dataclass
class StarterReport:
registered: list[str] = field(default_factory=list)
reset: list[str] = field(default_factory=list)
missing_data: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
def load_manifest(path: Path | None = None) -> dict:
return json.loads((path or MANIFEST_PATH).read_text(encoding="utf-8"))
def starter_sprite_ids(manifest: dict | None = None) -> list[str]:
"""Flat list of registry sprite ids for the starter set."""
m = manifest or load_manifest()
ids: list[str] = []
for uid in m.get("units", []):
ids.append(f"units/{uid}")
for bid in m.get("buildings", []):
ids.append(f"buildings/{bid}")
for lid in m.get("landmarks", []):
ids.append(f"landmarks/{lid}")
for lid in m.get("lairs", []):
ids.append(f"lairs/{lid}")
return ids
def _load_building(entity_id: str) -> dict | None:
path = RESOURCES / "buildings" / f"{entity_id}.json"
if not path.exists():
return None
raw = json.loads(path.read_text(encoding="utf-8"))
if isinstance(raw, list):
for item in raw:
if isinstance(item, dict) and item.get("id") == entity_id:
return {**item, "entity_id": entity_id}
return None
if isinstance(raw, dict):
return {**raw, "entity_id": entity_id}
return None
def _load_landmark(entity_id: str) -> dict | None:
for source in (WONDERS_PATH, LAND_SPECIAL_PATH):
if not source.exists():
continue
raw = json.loads(source.read_text(encoding="utf-8"))
terrains = raw.get("terrains", raw.get("tiles", []))
if isinstance(raw, dict):
for value in raw.values():
if isinstance(value, list):
terrains = value
break
for entry in terrains:
if isinstance(entry, dict) and entry.get("id") == entity_id:
return {**entry, "entity_id": entity_id}
return None
def _load_lair(entity_id: str) -> dict | None:
if not WILDS_PATH.exists():
return None
raw = json.loads(WILDS_PATH.read_text(encoding="utf-8"))
lair_types = raw.get("wilds", raw).get("lair_types", [])
for entry in lair_types:
if entry.get("id") == entity_id:
return {**entry, "entity_id": entity_id}
return None
def _unit_base_id(entity_id: str) -> str:
base = entity_id
for suffix in ("_dwarves_m", "_dwarves_f", "_m", "_f"):
if base.endswith(suffix):
base = base[: -len(suffix)]
break
return base
def _load_unit_entity(entity_id: str) -> dict:
"""Best-effort unit entity_data for prompt composition."""
base_id = _unit_base_id(entity_id)
for base in (PROJECT / "public" / "games" / "age-of-dwarves" / "data" / "units",
RESOURCES / "units"):
if not base.exists():
continue
direct = base / f"{base_id}.json"
if direct.exists():
data = json.loads(direct.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {**data, "entity_id": entity_id}
for path in base.glob("*.json"):
raw = json.loads(path.read_text(encoding="utf-8"))
if isinstance(raw, list):
for item in raw:
if isinstance(item, dict) and item.get("id") == base_id:
return {**item, "entity_id": entity_id}
elif isinstance(raw, dict) and raw.get("id") == base_id:
return {**raw, "entity_id": entity_id}
combat = "civilian" if base_id in ("worker", "founder") else "melee"
return {
"entity_id": entity_id,
"name": base_id.replace("_", " ").title(),
"description": "",
"combat_type": combat,
"keywords": [],
}
def register_starter_set(
registry: SpriteRegistry,
*,
manifest: dict | None = None,
reset_status: bool = True,
) -> StarterReport:
"""Ensure every starter sprite exists in spritegen.db and is queued."""
m = manifest or load_manifest()
report = StarterReport()
def _upsert(
sprite_id: str,
category: str,
entity_id: str,
entity_data: dict,
install_path: str,
source_file: str,
) -> None:
prompt = compose_prompt(category, entity_data)
negative = get_negative(category, combat_type=entity_data.get("combat_type", ""))
gen_w, gen_h = get_generation_size(category)
tgt_w, tgt_h = get_target_size(category)
existed = registry.conn.execute(
"SELECT status FROM sprites WHERE id=?", (sprite_id,),
).fetchone()
registry.upsert_sprite(
id=sprite_id,
category=category,
entity_id=entity_id,
prompt=prompt,
negative_prompt=negative,
gen_width=gen_w,
gen_height=gen_h,
target_width=tgt_w,
target_height=tgt_h,
source_file=source_file,
install_path=install_path,
)
if reset_status or not existed:
registry.update_sprite_status(sprite_id, "needed")
report.reset.append(sprite_id)
report.registered.append(sprite_id)
for uid in m.get("units", []):
sprite_id = f"units/{uid}"
entity_data = _load_unit_entity(uid)
_upsert(
sprite_id, "units", uid, entity_data,
f"sprites/units/{uid}.png", "starter_manifest.json",
)
for bid in m.get("buildings", []):
entity_data = _load_building(bid)
if not entity_data:
report.missing_data.append(f"buildings/{bid}")
continue
_upsert(
f"buildings/{bid}", "buildings", bid, entity_data,
f"sprites/buildings/{bid}.png", f"resources/buildings/{bid}.json",
)
for lid in m.get("landmarks", []):
entity_data = _load_landmark(lid)
if not entity_data:
report.missing_data.append(f"landmarks/{lid}")
continue
_upsert(
f"landmarks/{lid}", "landmarks", lid, entity_data,
f"sprites/terrain/{lid}.png", "resources/tiles/",
)
for lid in m.get("lairs", []):
entity_data = _load_lair(lid)
if not entity_data:
report.missing_data.append(f"lairs/{lid}")
continue
_upsert(
f"lairs/{lid}", "lairs", lid, entity_data,
f"sprites/lairs/{lid}.png", "resources/wilds/wilds.json",
)
return report