538 lines
20 KiB
Python
538 lines
20 KiB
Python
"""Scan game data JSON files and populate the sprite registry with all needed sprites.
|
||
|
||
Idempotent — running again adds new sprites without duplicating existing ones.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from engine.prompts import (
|
||
BIOME_VEGETATION,
|
||
compose_biome_prompt,
|
||
compose_prompt,
|
||
get_generation_size,
|
||
get_negative,
|
||
get_quality_range,
|
||
get_target_size,
|
||
is_valid_biome,
|
||
)
|
||
from engine.registry import SpriteRegistry
|
||
|
||
DEMO_RACES = ("high_elves", "humans", "dwarves", "orcs")
|
||
|
||
WILD_UNIT_FILES = {"wild_t1.json", "wild_t2t3.json"}
|
||
|
||
def _ui(id: str, name: str, desc: str) -> dict[str, str]:
|
||
return {"id": id, "name": name, "description": desc}
|
||
|
||
UI_ICONS: list[dict[str, str]] = [
|
||
_ui("race_high_elves", "High Elves Race Icon", "Elegant elven crest with crystalline spire and silver-blue palette"),
|
||
_ui("race_humans", "Humans Race Icon", "Noble heraldic shield with crown, warm gold and stone palette"),
|
||
_ui("race_dwarves", "Dwarves Race Icon", "Dwarven hammer and anvil crest, copper and bronze palette"),
|
||
_ui("race_orcs", "Orcs Race Icon", "Orcish tribal skull totem with crossed axes, dark iron palette"),
|
||
_ui("school_life", "Life School Icon", "Golden radiant sun with holy light, white and gold divine symbol"),
|
||
_ui("school_death", "Death School Icon", "Dark skull wreathed in purple-black necromantic energy"),
|
||
_ui("school_chaos", "Chaos School Icon", "Volcanic flame burst with demonic rune, red-orange fire symbol"),
|
||
_ui("school_nature", "Nature School Icon", "Living tree with deep roots and green leaves, druidic circle"),
|
||
_ui("school_aether", "Aether School Icon", "Crystalline blue-white arcane eye with geometric runes"),
|
||
_ui("era_1", "Age of Founding Icon", "Primitive campfire beside a standing stone, dawn sky"),
|
||
_ui("era_2", "Age of Discovery Icon", "Compass rose over an unfurled map, golden ink"),
|
||
_ui("era_3", "Age of Conflict Icon", "Crossed swords over a magical explosion, red and purple energy"),
|
||
_ui("era_4", "Age of Ascension Icon", "Spiraling tower reaching into the clouds, radiant light at top"),
|
||
_ui("era_5", "Age of Dominion Icon", "Crown floating above a conquered world, all five magic colors swirling"),
|
||
_ui("yield_food", "Food Yield Icon", "Sheaf of golden wheat, bountiful harvest symbol"),
|
||
_ui("yield_production", "Production Yield Icon", "Iron hammer striking an anvil, sparks flying"),
|
||
_ui("yield_gold", "Gold Yield Icon", "Stack of shining gold coins, gleaming treasure"),
|
||
_ui("yield_culture", "Culture Yield Icon", "Ancient scroll with quill pen, purple ink"),
|
||
_ui("yield_mana", "Mana Yield Icon", "Floating crystalline mana orb, blue-white arcane glow"),
|
||
_ui("yield_research", "Research Yield Icon", "Open tome with glowing runes, magical knowledge"),
|
||
]
|
||
|
||
|
||
@dataclass
|
||
class ScanReport:
|
||
new_sprites: int = 0
|
||
existing_sprites: int = 0
|
||
new_dimensions: int = 0
|
||
existing_dimensions: int = 0
|
||
categories: dict[str, int] = field(default_factory=dict)
|
||
|
||
|
||
class SpriteScanner:
|
||
def __init__(self, data_dir: Path, assets_dir: Path, registry: SpriteRegistry) -> None:
|
||
self.data_dir = data_dir
|
||
self.assets_dir = assets_dir
|
||
self.registry = registry
|
||
self.report = ScanReport()
|
||
|
||
def scan_all(
|
||
self,
|
||
skip_biome_grid: bool = False,
|
||
skip_ui: bool = False,
|
||
sprite_type: str | None = None,
|
||
) -> ScanReport:
|
||
"""Full scan of all data files. Idempotent.
|
||
|
||
Args:
|
||
skip_biome_grid: Skip biome grid generation
|
||
skip_ui: Skip UI icon scanning
|
||
sprite_type: If set, only scan this category (e.g. "units", "buildings")
|
||
"""
|
||
self.report = ScanReport()
|
||
|
||
_CATEGORY_SCANNERS = {
|
||
"terrain": lambda: self.scan_terrain(),
|
||
"biome_grid": lambda: None if skip_biome_grid else self.scan_biome_grid(),
|
||
"units": lambda: self.scan_units(),
|
||
"buildings": lambda: self.scan_buildings(),
|
||
"resources": lambda: self.scan_resources(),
|
||
"improvements": lambda: self.scan_improvements(),
|
||
"spells": lambda: self.scan_spells(),
|
||
"ui": lambda: None if skip_ui else self.scan_ui(),
|
||
}
|
||
|
||
if sprite_type:
|
||
if sprite_type not in _CATEGORY_SCANNERS:
|
||
raise ValueError(f"Unknown sprite_type '{sprite_type}'. Valid: {list(_CATEGORY_SCANNERS)}")
|
||
_CATEGORY_SCANNERS[sprite_type]()
|
||
else:
|
||
for fn in _CATEGORY_SCANNERS.values():
|
||
fn()
|
||
|
||
print(
|
||
f"\nScan complete: {self.report.new_sprites} new sprites, "
|
||
f"{self.report.existing_sprites} existing, "
|
||
f"{self.report.new_dimensions} new dimensions, "
|
||
f"{self.report.existing_dimensions} existing dimensions"
|
||
)
|
||
return self.report
|
||
|
||
# -- data loading ----------------------------------------------------------
|
||
|
||
def _load_json_entries(self, path: str) -> list[tuple[dict, str]]:
|
||
"""Load entries from a JSON file or directory.
|
||
|
||
If path is a directory, reads all *.json files.
|
||
If root is a dict, extracts all list values.
|
||
If root is a list, uses directly.
|
||
|
||
Returns list of (entry_dict, source_filename) tuples.
|
||
"""
|
||
full = self.data_dir / path
|
||
results: list[tuple[dict, str]] = []
|
||
|
||
if full.is_dir():
|
||
for json_file in sorted(full.glob("*.json")):
|
||
entries = self._parse_json_file(json_file)
|
||
for entry in entries:
|
||
results.append((entry, json_file.name))
|
||
elif full.is_file():
|
||
entries = self._parse_json_file(full)
|
||
for entry in entries:
|
||
results.append((entry, full.name))
|
||
else:
|
||
print(f" Warning: path not found: {full}")
|
||
|
||
return results
|
||
|
||
@staticmethod
|
||
def _parse_json_file(file_path: Path) -> list[dict]:
|
||
"""Parse a single JSON file into a list of entry dicts."""
|
||
raw = json.loads(file_path.read_text(encoding="utf-8"))
|
||
if isinstance(raw, list):
|
||
return raw
|
||
if isinstance(raw, dict):
|
||
# Extract all list values from the root dict
|
||
entries: list[dict] = []
|
||
for value in raw.values():
|
||
if isinstance(value, list):
|
||
entries.extend(item for item in value if isinstance(item, dict))
|
||
return entries
|
||
return []
|
||
|
||
# -- dimension helpers -----------------------------------------------------
|
||
|
||
def _register_quality_dimensions(
|
||
self, sprite_id: str, category: str, entity_data: dict,
|
||
) -> None:
|
||
"""Register quality dimension variants for a sprite."""
|
||
qr = get_quality_range(category, entity_data)
|
||
if qr is None:
|
||
return
|
||
qual_cat = "terrain" if category == "biome_grid" else category
|
||
from engine.prompts import QUALITY_MODIFIERS
|
||
qual_table = QUALITY_MODIFIERS.get(qual_cat, {})
|
||
for q in range(qr[0], qr[1] + 1):
|
||
self._upsert_dim(sprite_id, category, "quality", f"q{q}",
|
||
qual_table.get(q, f"quality level {q}"))
|
||
|
||
def _register_race_dimensions(self, sprite_id: str, category: str) -> None:
|
||
"""Register racial variant dimensions for a sprite."""
|
||
from engine.prompts import RACE_AESTHETICS
|
||
for race in DEMO_RACES:
|
||
self._upsert_dim(sprite_id, category, "race", race,
|
||
RACE_AESTHETICS.get(race, race))
|
||
|
||
def _register_gender_dimensions(self, sprite_id: str, category: str) -> None:
|
||
"""Register gender variant dimensions for a sprite."""
|
||
from engine.prompts import GENDER_MODIFIERS
|
||
for gender, modifier in GENDER_MODIFIERS.items():
|
||
self._upsert_dim(sprite_id, category, "gender", gender, modifier)
|
||
|
||
def _upsert_dim(
|
||
self, sprite_id: str, category: str,
|
||
dim_type: str, dim_value: str, modifier: str,
|
||
) -> None:
|
||
"""Upsert a single dimension and track it."""
|
||
install = self._install_path(category, sprite_id, dim_type, dim_value)
|
||
dim_id = self.registry.upsert_dimension(
|
||
sprite_id, dim_type, dim_value,
|
||
prompt_modifier=modifier, install_path=install,
|
||
)
|
||
self._track_dimension(dim_id)
|
||
|
||
def _track_dimension(self, dim_id: int) -> None:
|
||
"""Track whether the dimension upsert was new or existing."""
|
||
if not hasattr(self, "_max_dim_id"):
|
||
row = self.registry.conn.execute(
|
||
"SELECT MAX(id) as m FROM sprite_dimensions"
|
||
).fetchone()
|
||
self._max_dim_id = (row["m"] or 0) if row else 0
|
||
|
||
if dim_id > self._max_dim_id:
|
||
self._max_dim_id = dim_id
|
||
self.report.new_dimensions += 1
|
||
else:
|
||
self.report.existing_dimensions += 1
|
||
|
||
def _install_path(
|
||
self, category: str, sprite_id: str,
|
||
dim_type: str | None = None, dim_value: str | None = None,
|
||
) -> str:
|
||
"""Build the install path for a sprite or dimensional variant.
|
||
|
||
Follows the messy reference naming convention:
|
||
- race dimension: {id}_{race}.png (e.g. spearmen_dwarves.png)
|
||
- gender dimension: {id}_{g}.png where g = m/f (e.g. spearmen_m.png)
|
||
- quality dimension: {id}_q{n}.png
|
||
- no dimension: {id}.png
|
||
|
||
Note: compound paths (race × gender) are resolved at generation time
|
||
by combining dimensions — the individual dimension install paths serve
|
||
as templates that the pipeline composes into final paths like
|
||
spearmen_dwarves_m.png.
|
||
"""
|
||
entity_part = sprite_id.split("/", 1)[-1] if "/" in sprite_id else sprite_id
|
||
if dim_type == "race":
|
||
suffix = f"_{dim_value}"
|
||
elif dim_type == "gender":
|
||
suffix = f"_{'m' if dim_value == 'male' else 'f'}"
|
||
elif dim_type == "quality":
|
||
suffix = f"_{dim_value}"
|
||
elif dim_type and dim_value:
|
||
suffix = f"_{dim_value}"
|
||
else:
|
||
suffix = ""
|
||
return f"sprites/{category}/{entity_part}{suffix}.png"
|
||
|
||
def _register_sprite(
|
||
self,
|
||
sprite_id: str,
|
||
category: str,
|
||
entity_id: str,
|
||
entity_data: dict,
|
||
source_file: str,
|
||
*,
|
||
prompt_override: str | None = None,
|
||
add_quality: bool = True,
|
||
add_races: bool = False,
|
||
add_genders: bool = False,
|
||
) -> bool:
|
||
"""Register a base sprite and its dimensional variants.
|
||
|
||
Returns True if the sprite was new.
|
||
"""
|
||
prompt = prompt_override or compose_prompt(category, entity_data)
|
||
combat_type = entity_data.get("combat_type", "")
|
||
race = entity_data.get("race", "")
|
||
if not race and category == "units":
|
||
for r in ("dwarves", "humans", "high_elves", "orcs"):
|
||
if f"_{r}" in entity_id:
|
||
race = r
|
||
break
|
||
negative = get_negative(category, combat_type=combat_type, race=race)
|
||
gen_w, gen_h = get_generation_size(category)
|
||
tgt_w, tgt_h = get_target_size(category)
|
||
install = self._install_path(category, sprite_id)
|
||
|
||
is_new = self.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,
|
||
)
|
||
|
||
if is_new:
|
||
self.report.new_sprites += 1
|
||
self.report.categories[category] = self.report.categories.get(category, 0) + 1
|
||
else:
|
||
self.report.existing_sprites += 1
|
||
|
||
if add_quality:
|
||
self._register_quality_dimensions(sprite_id, category, entity_data)
|
||
if add_races:
|
||
self._register_race_dimensions(sprite_id, category)
|
||
if add_genders:
|
||
self._register_gender_dimensions(sprite_id, category)
|
||
|
||
return is_new
|
||
|
||
# -- category scanners -----------------------------------------------------
|
||
|
||
def scan_terrain(self) -> None:
|
||
"""Scan terrain/*.json. Register base sprites + visual variants + quality dimensions."""
|
||
entries = self._load_json_entries("terrain")
|
||
new_count = 0
|
||
total = 0
|
||
|
||
for entry, source in entries:
|
||
entity_id = entry.get("id")
|
||
if not entity_id:
|
||
continue
|
||
total += 1
|
||
|
||
sprite_id = f"terrain/{entity_id}"
|
||
is_new = self._register_sprite(
|
||
sprite_id, "terrain", entity_id, entry, source,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
# Visual variants for terrain with variant_count > 1
|
||
variant_count = entry.get("variant_count", 1)
|
||
for v in range(1, variant_count):
|
||
var_id = f"terrain/{entity_id}_v{v}"
|
||
var_data = {**entry, "name": f"{entry.get('name', entity_id)} variant {v}"}
|
||
is_new_v = self._register_sprite(
|
||
var_id, "terrain", f"{entity_id}_v{v}", var_data, source,
|
||
)
|
||
if is_new_v:
|
||
new_count += 1
|
||
total += 1
|
||
|
||
print(f"Scanning terrain... {total} found, {new_count} new sprites registered")
|
||
|
||
def scan_biome_grid(self) -> None:
|
||
"""Generate all valid biome grid combinations."""
|
||
new_count = 0
|
||
total = 0
|
||
elevations = ("lowland", "highland", "alpine")
|
||
|
||
for temp in range(5):
|
||
for moist in range(5):
|
||
for elev in elevations:
|
||
if not is_valid_biome(temp, moist, elev):
|
||
continue
|
||
total += 1
|
||
|
||
sprite_id = f"biome_grid/t{temp}_m{moist}_{elev}"
|
||
entity_id = f"t{temp}_m{moist}_{elev}"
|
||
|
||
# Compose prompt at base quality (q3) for the base sprite
|
||
prompt = compose_biome_prompt(temp, moist, elev, 3)
|
||
entity_data = {
|
||
"name": f"Biome t{temp} m{moist} {elev}",
|
||
"description": prompt or "",
|
||
"temp": temp,
|
||
"moist": moist,
|
||
"elev": elev,
|
||
}
|
||
|
||
is_new = self._register_sprite(
|
||
sprite_id, "biome_grid", entity_id, entity_data,
|
||
"generated",
|
||
prompt_override=prompt,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
print(f"Scanning biome grid... {total} found, {new_count} new sprites registered")
|
||
|
||
def scan_units(self) -> None:
|
||
"""Scan units/*.json. Register sprites with gender + race + quality dimensions."""
|
||
entries = self._load_json_entries("units")
|
||
new_count = 0
|
||
total = 0
|
||
|
||
for entry, source in entries:
|
||
entity_id = entry.get("id")
|
||
if not entity_id:
|
||
continue
|
||
total += 1
|
||
|
||
sprite_id = f"units/{entity_id}"
|
||
has_gender = isinstance(entry.get("gender"), dict)
|
||
needs_racial = self._unit_needs_racial_variants(entry, source)
|
||
|
||
# Tag entity data with type hint for quality range calculation
|
||
enriched = {**entry}
|
||
if source in WILD_UNIT_FILES:
|
||
enriched["type"] = "wild"
|
||
|
||
is_new = self._register_sprite(
|
||
sprite_id, "units", entity_id, enriched, source,
|
||
add_quality=True,
|
||
add_races=needs_racial,
|
||
add_genders=has_gender,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
print(f"Scanning units... {total} found, {new_count} new sprites registered")
|
||
|
||
def _unit_needs_racial_variants(self, entry: dict, source_file: str) -> bool:
|
||
"""Determine if a unit needs racial dimension variants."""
|
||
if entry.get("race_required"):
|
||
return False
|
||
school = entry.get("school")
|
||
if school and school != "mundane":
|
||
return False
|
||
if source_file in WILD_UNIT_FILES or entry.get("faction") == "wild":
|
||
return False
|
||
return True
|
||
|
||
def scan_buildings(self) -> None:
|
||
"""Scan buildings/*.json. Register with race + quality dimensions."""
|
||
entries = self._load_json_entries("buildings")
|
||
new_count = 0
|
||
total = 0
|
||
|
||
for entry, source in entries:
|
||
entity_id = entry.get("id")
|
||
if not entity_id:
|
||
continue
|
||
total += 1
|
||
|
||
sprite_id = f"buildings/{entity_id}"
|
||
needs_racial = self._building_needs_racial_variants(entry, source)
|
||
|
||
# Tag for quality range
|
||
enriched = {**entry}
|
||
if source == "wonders.json":
|
||
enriched["type"] = "wonder"
|
||
elif entry.get("category") in ("wonder",):
|
||
enriched["type"] = "wonder"
|
||
|
||
is_new = self._register_sprite(
|
||
sprite_id, "buildings", entity_id, enriched, source,
|
||
add_quality=True,
|
||
add_races=needs_racial,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
print(f"Scanning buildings... {total} found, {new_count} new sprites registered")
|
||
|
||
def _building_needs_racial_variants(self, entry: dict, source_file: str) -> bool:
|
||
"""Determine if a building needs racial dimension variants."""
|
||
if entry.get("race_required") or entry.get("race"):
|
||
return False
|
||
if entry.get("school"):
|
||
return False
|
||
if source_file in ("wonders.json", "npc.json"):
|
||
return False
|
||
if entry.get("wonder_type"):
|
||
return False
|
||
return True
|
||
|
||
def scan_resources(self) -> None:
|
||
"""Scan resources.json. Register with quality dimensions only."""
|
||
entries = self._load_json_entries("resources.json")
|
||
new_count = 0
|
||
total = 0
|
||
|
||
for entry, source in entries:
|
||
entity_id = entry.get("id")
|
||
if not entity_id:
|
||
continue
|
||
total += 1
|
||
|
||
sprite_id = f"resources/{entity_id}"
|
||
is_new = self._register_sprite(
|
||
sprite_id, "resources", entity_id, entry, source,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
print(f"Scanning resources... {total} found, {new_count} new sprites registered")
|
||
|
||
def scan_improvements(self) -> None:
|
||
"""Scan improvements.json. Register with quality dimensions only."""
|
||
entries = self._load_json_entries("improvements.json")
|
||
new_count = 0
|
||
total = 0
|
||
|
||
for entry, source in entries:
|
||
entity_id = entry.get("id")
|
||
if not entity_id:
|
||
continue
|
||
total += 1
|
||
|
||
sprite_id = f"improvements/{entity_id}"
|
||
is_new = self._register_sprite(
|
||
sprite_id, "improvements", entity_id, entry, source,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
print(f"Scanning improvements... {total} found, {new_count} new sprites registered")
|
||
|
||
def scan_spells(self) -> None:
|
||
"""Scan spells/*.json. Register with quality dimensions (casting power)."""
|
||
entries = self._load_json_entries("spells")
|
||
new_count = 0
|
||
total = 0
|
||
|
||
for entry, source in entries:
|
||
entity_id = entry.get("id")
|
||
if not entity_id:
|
||
continue
|
||
total += 1
|
||
|
||
sprite_id = f"spells/{entity_id}"
|
||
is_new = self._register_sprite(
|
||
sprite_id, "spells", entity_id, entry, source,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
print(f"Scanning spells... {total} found, {new_count} new sprites registered")
|
||
|
||
def scan_ui(self) -> None:
|
||
"""Register known UI icon sprites (race, school, era, etc).
|
||
|
||
No quality dimensions on UI icons.
|
||
"""
|
||
new_count = 0
|
||
total = len(UI_ICONS)
|
||
|
||
for icon in UI_ICONS:
|
||
sprite_id = f"ui/{icon['id']}"
|
||
is_new = self._register_sprite(
|
||
sprite_id, "ui", icon["id"], icon, "ui_icons",
|
||
add_quality=False,
|
||
)
|
||
if is_new:
|
||
new_count += 1
|
||
|
||
print(f"Scanning UI icons... {total} found, {new_count} new sprites registered")
|