magicciv/tools/standin-sprites/build_standins.py
Natalie 8e77d36434 feat(@projects/@magic-civilization): add dwarf gendered unit standin sprites + gen tooling
New per-unit male/female standin PNGs, build_standins.py + icon_rules
updates, license/standins ledgers, manifest roster + DevSpritesPage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 05:29:36 -05:00

673 lines
28 KiB
Python

#!/usr/bin/env python3
"""Build OSS stand-in sprites for every Age-of-Dwarves Game-1 renderer slot.
Stand-ins are PLACEHOLDER art: CC-BY-3.0 silhouettes from github.com/game-icons/icons,
recoloured + framed to fill every renderer sprite slot so the Game-1 Godot client
ships visually complete pending bespoke paid art (objective 5ee5e73e).
DATA-DRIVEN COVERAGE (the key contract). We do NOT hardcode a slot list. The
Game-1 content surface is whatever `public/games/age-of-dwarves/manifest.json`
subscribes to. For each subscribed unit / building id we open its resource file
under `public/resources/<cat>/<id>.json` and emit a stand-in PNG to *every literal
`sprite` path it declares* — top-level `sprite`, each `gender.<sex>.sprite`, plus
the renderer-constructed on-map keys `<stem>_dwarf_male.png` / `<stem>_dwarf_female.png`
(unit_renderer.gd composes `<type_id>_<race_id>_<sex>`; race "dwarf", gender_preset
default "male"). Output paths therefore come from the data, never reconstructed
from id + a guessed suffix — this is what makes the standin land at the exact path
the renderer requests (resolving the `_m`/`_f` vs `_dwarves_m`/`_dwarves_f` spelling
inconsistency baked into the unit data).
Wonders (buildings with a `wonder_type` field) get a bronze plate at 96px and write
to `sprites/buildings/wonders/<id>.png` (a subdirectory the resource data names);
base buildings get a stone plate at 64px under `sprites/buildings/`. Cities get
five tier silhouettes `sprites/cities/city_q<N>.png`.
Icon selection per id: `icon_rules.json` — explicit `overrides` first, then the
longest matching substring in `keywords`, then the category `default`. Every chosen
icon is verified to resolve against the live game-icons repo by `--check` before a
full generation pass.
Every generated PNG is registered in LICENSES.md (provenance ledger, p2-28) and
STANDINS.md (the replace-list for the paid-art pass).
Re-runnable: SVGs are cached under .cache/svg/; pass --no-net to build only from cache.
Usage:
python3 tools/standin-sprites/build_standins.py [--no-net] [--check] [--only CATS]
--check : do not write outputs; only report which chosen icons fail to resolve
and which subscribed ids have no resource file / no sprite path.
--only : comma-separated category list (e.g. `lairs`) — emit only those slots
and MERGE their rows into LICENSES.md / STANDINS.md, preserving every
existing row verbatim. Use this to add a category without clobbering a
coexisting hand-placed art layer (e.g. the Wesnoth demo sprites, which
replace the build_standins PNGs on disk but share this ledger).
"""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import re
import sys
import urllib.request
from datetime import date
from pathlib import Path
import cairosvg
from PIL import Image, ImageDraw, ImageFilter
TOOL_DIR = Path(__file__).resolve().parent
REPO_ROOT = TOOL_DIR.parent.parent
GAME_ROOT = REPO_ROOT / "public/games/age-of-dwarves"
RESOURCES_ROOT = REPO_ROOT / "public/resources"
# A unit/building `sprite` field is "sprites/<...>.png" — a path relative to the
# theme ASSETS dir (ThemeAssets._base_path = "res://public/games/<id>/assets",
# resolve() joins the relative key onto it). So sprites land under ASSETS_ROOT,
# NOT under ASSETS_ROOT/"sprites" (that would double the segment).
ASSETS_ROOT = GAME_ROOT / "assets"
SPRITES_ROOT = ASSETS_ROOT / "sprites" # ledger/manifest live here; rows are
# path-relative to this dir per schema.
MANIFEST_JSON = GAME_ROOT / "manifest.json"
RULES_JSON = TOOL_DIR / "icon_rules.json"
SVG_CACHE = TOOL_DIR / ".cache" / "svg"
LEDGER = SPRITES_ROOT / "LICENSES.md"
MANIFEST = SPRITES_ROOT / "STANDINS.md"
# game-icons source canvas is 512x512; the glyph path renders white on a black
# background rect we strip out so the silhouette lands on transparency.
BG_PATH_RE = re.compile(r'<path d="M0 0h512v512H0z"\s*/>')
# --------------------------------------------------------------------------- #
# Data resolution: manifest subscription -> slot list #
# --------------------------------------------------------------------------- #
def load_resource(cat: str, rid: str) -> dict | None:
"""Load a resource JSON (object, or first element of a list)."""
p = RESOURCES_ROOT / cat / f"{rid}.json"
if not p.exists():
return None
d = json.loads(p.read_text())
if isinstance(d, list):
return d[0] if d and isinstance(d[0], dict) else None
return d if isinstance(d, dict) else None
def unit_sprite_paths(uid: str, d: dict) -> list[str]:
"""The paths the renderer actually requests for unit `uid`.
unit_renderer.gd / unit_renderer_draw.gd key everything off the unit's
`type_id`, which `unit.gd:_init` sets equal to the resource id (NOT the
`sprite` field — no Godot renderer reads that field). It builds:
generic: sprites/units/<id>.png (always)
race+sex: sprites/units/<id>_dwarf_<male|female>.png (gendered units;
race "dwarf", gender_preset default "male", sex token
"male"/"female" per player.gd / standin_sprite_proof.gd).
Wild units never carry a race/sex, so only the generic path is requested."""
paths = [f"sprites/units/{uid}.png"]
gender = d.get("gender") or {}
is_gendered = isinstance(gender, dict) and bool(gender)
if is_gendered and not is_wild(d):
paths.append(f"sprites/units/{uid}_dwarf_male.png")
paths.append(f"sprites/units/{uid}_dwarf_female.png")
return paths
def is_wild(d: dict) -> bool:
return d.get("faction") == "wild" or "wild" in (d.get("keywords") or [])
def build_slots(rules: dict) -> tuple[list[dict], list[str]]:
"""Return (slots, problems). Each slot:
{category, id, icon, out_paths:[...], size, style, glyph}
`problems` lists subscribed ids with no resource file or no sprite path."""
manifest = json.loads(MANIFEST_JSON.read_text())
sub = manifest["subscribes"]
render = rules["render"]
problems: list[str] = []
slots: list[dict] = []
# --- units (incl. wild) ---
for uid in sub.get("units", []):
d = load_resource("units", uid)
if d is None:
problems.append(f"unit {uid}: no resource file")
continue
paths = unit_sprite_paths(uid, d)
cat = "wild" if is_wild(d) else "units"
icon = resolve_icon(uid, rules["units"], wild=(cat == "wild"))
r = render[cat]
slots.append({
"category": cat, "id": uid, "icon": icon, "out_paths": paths,
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
# --- buildings (base + wonders) ---
# city_renderer.gd:223 builds `sprites/buildings/<id>.png` from the building
# id, flat, with no `wonders/` branch — wonders render at the same flat path
# (see standin_sprite_proof.gd MUNDANE WONDERS rows). `wonder_type` only
# selects the bronze plate / 96px size, not a subdirectory.
for bid in sub.get("buildings", []):
d = load_resource("buildings", bid)
if d is None:
problems.append(f"building {bid}: no resource file")
continue
is_wonder = bool(d.get("wonder_type"))
cat = "wonders" if is_wonder else "buildings"
icon = resolve_icon(bid, rules["buildings"], wonder=is_wonder)
r = render[cat]
slots.append({
"category": cat, "id": bid, "icon": icon,
"out_paths": [f"sprites/buildings/{bid}.png"],
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
# --- cities (no data dependency; fixed five tiers) ---
r = render["cities"]
for tier, icon in sorted(rules["cities"].items()):
slots.append({
"category": "cities", "id": tier, "icon": icon,
"out_paths": [f"sprites/cities/{tier}.png"],
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
# --- throne-room decorations ---
# throne_room.gd:112 reads each decoration's literal `sprite` field and loads
# it via ThemeAssets.load_sprite — so the fill set is the set of distinct
# `sprite` paths declared across public/resources/throne_rooms/*.json. The
# icon is resolved from the sprite *basename* (the slot-type token), not the
# decoration id, because the basename carries the semantic prefix.
r = render["throne_room"]
for basename, sprite_path in sorted(throne_room_sprites().items()):
icon = resolve_icon(basename, rules["throne_room"])
slots.append({
"category": "throne_room", "id": basename, "icon": icon,
"out_paths": [sprite_path],
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
# --- treasury items ---
# treasury_tab.gd:90 reads each item's `sprite` field and loads it. Fill set
# is the distinct `sprites/items/*` paths declared in resources/items/*.json.
r = render["items"]
for basename, sprite_path in sorted(item_sprites().items()):
icon = resolve_icon(basename, rules["items"])
slots.append({
"category": "items", "id": basename, "icon": icon,
"out_paths": [sprite_path],
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
# --- map resource / deposit overlays ---
# overlay_renderer.gd:118 constructs sprites/resources/<resource_id>.png. Fill
# set is the distinct ids declared in resources.json + deposits/*.json. Skip
# any id already covered by an on-disk .svg (would shadow it).
r = render["resources"]
for rid in sorted(resource_ids()):
if (ASSETS_ROOT / "sprites/resources" / f"{rid}.svg").exists():
continue
icon = resolve_icon(rid, rules["resources"])
slots.append({
"category": "resources", "id": rid, "icon": icon,
"out_paths": [f"sprites/resources/{rid}.png"],
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
# --- map lair POI overlays ---
# lair_overlay_renderer.gd:11 builds sprites/lairs/<type_id>.png, where the
# npc-building type_id is set by village_lair_placer.gd:222 from each
# wilds.json lair_types[].id (rust_fauna_integration.gd:63 confirms the
# Building.type_id == lair_types[].id contract). Emit one POI sprite per id.
r = render["lairs"]
for lid in sorted(lair_ids()):
icon = resolve_icon(lid, rules["lairs"])
slots.append({
"category": "lairs", "id": lid, "icon": icon,
"out_paths": [f"sprites/lairs/{lid}.png"],
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
# --- special terrain tile-features lacking an SVG ---
# hex_renderer.gd preloads sprites/terrain/<biome_id>.png. Most biomes ship an
# SVG (gen-fallback-sprites.py); a handful of wonder/feature tile ids do not.
# GUARD: load_sprite tries .png before .svg, so emitting a placeholder .png for
# an SVG-covered biome would SHADOW the real biome art — only emit for ids with
# neither .png nor .svg on disk.
r = render["terrain"]
terrain_tbl = rules["terrain"]
for tid in sorted(set(terrain_tbl.get("overrides", {}))):
tdir = ASSETS_ROOT / "sprites/terrain"
if (tdir / f"{tid}.svg").exists() or (tdir / f"{tid}.png").exists():
continue
icon = resolve_icon(tid, terrain_tbl)
slots.append({
"category": "terrain", "id": tid, "icon": icon,
"out_paths": [f"sprites/terrain/{tid}.png"],
"size": r["size"], "style": r["style"], "glyph": r["glyph"],
})
return slots, problems
def throne_room_sprites() -> dict[str, str]:
"""{sprite_basename: sprite_path} for every distinct `sprite` field declared
in public/resources/throne_rooms/*.json (throne_room.gd loads these)."""
out: dict[str, str] = {}
for f in (RESOURCES_ROOT / "throne_rooms").glob("*.json"):
_collect_sprite_paths(json.loads(f.read_text()), "sprites/throne_room/", out)
return out
def item_sprites() -> dict[str, str]:
"""{item_basename: sprite_path} for every distinct `sprites/items/*` field
declared in public/resources/items/*.json (treasury_tab.gd loads these)."""
out: dict[str, str] = {}
items_dir = RESOURCES_ROOT / "items"
if items_dir.is_dir():
for f in items_dir.glob("*.json"):
_collect_sprite_paths(json.loads(f.read_text()), "sprites/items/", out)
return out
def resource_ids() -> set[str]:
"""Resource/deposit ids that overlay_renderer keys sprites/resources/<id>.png
off — from resources.json plus deposits/*.json."""
ids: set[str] = set()
rjson = RESOURCES_ROOT / "resources.json"
if rjson.exists():
_collect_ids(json.loads(rjson.read_text()), ids)
dep_dir = RESOURCES_ROOT / "deposits"
if dep_dir.is_dir():
for f in dep_dir.glob("*.json"):
_collect_ids(json.loads(f.read_text()), ids)
return ids
def lair_ids() -> set[str]:
"""Lair type ids the lair overlay keys sprites/lairs/<id>.png off — every
`lair_types[].id` in public/resources/wilds/wilds.json. The file nests the
array as `[ { "wilds": { …, "lair_types": [...] } } ]` (single-object,
list-wrapped, keyed by category id), so locate the array wherever it sits."""
p = RESOURCES_ROOT / "wilds" / "wilds.json"
if not p.exists():
return set()
def find_lair_types(obj: object) -> list | None:
if isinstance(obj, dict):
if isinstance(obj.get("lair_types"), list):
return obj["lair_types"]
for v in obj.values():
hit = find_lair_types(v)
if hit is not None:
return hit
elif isinstance(obj, list):
for v in obj:
hit = find_lair_types(v)
if hit is not None:
return hit
return None
lts = find_lair_types(json.loads(p.read_text())) or []
return {e["id"] for e in lts if isinstance(e, dict) and e.get("id")}
def _collect_sprite_paths(obj: object, prefix: str, out: dict[str, str]) -> None:
"""Walk `obj`, recording {basename: path} for every string `sprite` field
whose value starts with `prefix`."""
if isinstance(obj, dict):
sp = obj.get("sprite")
if isinstance(sp, str) and sp.startswith(prefix):
out[sp[len(prefix):-4]] = sp # strip prefix + ".png"
for v in obj.values():
_collect_sprite_paths(v, prefix, out)
elif isinstance(obj, list):
for v in obj:
_collect_sprite_paths(v, prefix, out)
def _collect_ids(obj: object, ids: set[str]) -> None:
"""Walk `obj`, recording every `id` field on a dict that also looks like a
resource entry (has a `category` or `sprite` or `yields` sibling)."""
if isinstance(obj, dict):
rid = obj.get("id")
if isinstance(rid, str) and (
"category" in obj or "sprite" in obj or "yields" in obj
or "yield" in obj or "rarity" in obj
):
ids.add(rid)
for v in obj.values():
_collect_ids(v, ids)
elif isinstance(obj, list):
for v in obj:
_collect_ids(v, ids)
def resolve_icon(rid: str, table: dict, *, wild: bool = False,
wonder: bool = False) -> str:
"""overrides[id] -> longest keyword substring -> (wonder_)default."""
ov = table.get("overrides", {})
if rid in ov:
return ov[rid]
kws = table.get("keywords", {})
best_key = ""
for kw in kws:
if kw in rid and len(kw) > len(best_key):
best_key = kw
if best_key:
return kws[best_key]
if wonder and table.get("wonder_default"):
return table["wonder_default"]
return table["default"]
# --------------------------------------------------------------------------- #
# Rasterisation + framing #
# --------------------------------------------------------------------------- #
def fetch_svg(icon: str, raw_base: str, allow_net: bool) -> bytes:
cache_path = SVG_CACHE / f"{icon}.svg"
if cache_path.exists():
return cache_path.read_bytes()
if not allow_net:
raise FileNotFoundError(f"SVG not cached and --no-net set: {icon}")
url = f"{raw_base}/{icon}.svg"
with urllib.request.urlopen(url, timeout=20) as resp:
data = resp.read()
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_bytes(data)
return data
def glyph_mask(svg_bytes: bytes, size: int) -> Image.Image:
stripped = BG_PATH_RE.sub("", svg_bytes.decode("utf-8")).encode("utf-8")
png = cairosvg.svg2png(bytestring=stripped, output_width=size, output_height=size)
img = Image.open(io.BytesIO(png)).convert("RGBA")
return img.split()[3]
def hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
def tinted_glyph(mask: Image.Image, color: tuple[int, int, int]) -> Image.Image:
out = Image.new("RGBA", mask.size, (*color, 0))
out.putalpha(mask)
solid = Image.new("RGBA", mask.size, (*color, 255))
return Image.composite(solid, out, mask)
def with_shadow(glyph: Image.Image, scale: float = 0.82) -> Image.Image:
size = glyph.width
inner = max(8, int(size * scale))
g = glyph.resize((inner, inner), Image.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
off = (size - inner) // 2
sh_alpha = g.split()[3].point(lambda a: int(a * 0.55))
shadow = Image.new("RGBA", (inner, inner), (0, 0, 0, 0))
shadow.putalpha(sh_alpha)
shadow = shadow.filter(ImageFilter.GaussianBlur(max(1.0, size * 0.025)))
canvas.alpha_composite(shadow, (off + max(1, size // 40), off + max(1, size // 28)))
canvas.alpha_composite(g, (off, off))
return canvas
def _rounded_plate(size: int, top: tuple, bottom: tuple, border: tuple) -> Image.Image:
pad = max(2, size // 16)
radius = max(4, size // 6)
grad = Image.new("RGBA", (size, size), (0, 0, 0, 0))
px = grad.load()
for y in range(size):
t = y / (size - 1)
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(size):
px[x, y] = (r, g, b, 255)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[pad, pad, size - pad, size - pad], radius=radius, fill=255)
plate = Image.new("RGBA", (size, size), (0, 0, 0, 0))
plate.paste(grad, (0, 0), mask)
ImageDraw.Draw(plate).rounded_rectangle(
[pad, pad, size - pad - 1, size - pad - 1],
radius=radius, outline=(*border, 230), width=max(2, size // 32))
return plate
def with_plate(glyph: Image.Image, kind: str) -> Image.Image:
size = glyph.width
if kind == "plate_gold":
plate = _rounded_plate(size, (122, 92, 38), (60, 42, 14), (214, 176, 92))
gscale = 0.60
else: # plate_stone
plate = _rounded_plate(size, (74, 70, 64), (38, 35, 31), (150, 142, 128))
gscale = 0.62
inner = int(size * gscale)
g = glyph.resize((inner, inner), Image.LANCZOS)
off = (size - inner) // 2
out = plate.copy()
sh = Image.new("RGBA", (inner, inner), (0, 0, 0, 0))
sh.putalpha(g.split()[3].point(lambda a: int(a * 0.5)))
sh = sh.filter(ImageFilter.GaussianBlur(max(1.0, size * 0.02)))
out.alpha_composite(sh, (off + 1, off + max(1, size // 40)))
out.alpha_composite(g, (off, off))
return out
def render_slot(mask: Image.Image, slot: dict) -> Image.Image:
glyph = tinted_glyph(mask, hex_to_rgb(slot["glyph"]))
style = slot["style"]
if style == "glyph_shadow":
return with_shadow(glyph)
if style in ("plate_stone", "plate_gold"):
return with_plate(glyph, style)
raise ValueError(f"unknown style {style}")
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()
def page_url(icon: str, page_base: str) -> str:
return f"{page_base}/{icon}.html"
# --------------------------------------------------------------------------- #
# Main #
# --------------------------------------------------------------------------- #
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--no-net", action="store_true", help="build from cache only")
ap.add_argument("--check", action="store_true",
help="verify chosen icons resolve + report data gaps; write nothing")
ap.add_argument("--only", default="",
help="comma-separated categories to emit + merge (e.g. 'lairs')")
args = ap.parse_args(argv)
rules = json.loads(RULES_JSON.read_text())
src = rules["source"]
allow_net = not args.no_net
only = {c.strip() for c in args.only.split(",") if c.strip()}
slots, problems = build_slots(rules)
if only:
slots = [s for s in slots if s["category"] in only]
if args.check:
# Unique icons -> confirm each fetches.
icons = sorted({s["icon"] for s in slots})
bad: list[str] = []
for icon in icons:
try:
fetch_svg(icon, src["raw_base"], allow_net)
except Exception as e: # noqa: BLE001
bad.append(f"{icon}: {e}")
n_files = sum(len(s["out_paths"]) for s in slots)
print(f"slots: {len(slots)} unique icons: {len(icons)} "
f"output files: {n_files}")
if problems:
print(f"\nDATA GAPS ({len(problems)}):")
for p in problems:
print(" -", p)
if bad:
print(f"\nUNRESOLVED ICONS ({len(bad)}):")
for b in bad:
print(" -", b)
return 1
print("\nOK — every chosen icon resolves.")
return 0
today = date.today().isoformat()
# Keyed by output path so a sprite shared by >1 slot (e.g. warrior /
# dwarf_warrior both declare sprites/units/warrior.png) yields exactly one
# ledger + manifest row — the on-disk PNG is identical either way.
ledger_by_path: dict[str, str] = {}
manifest_by_path: dict[str, str] = {}
for slot in slots:
svg = fetch_svg(slot["icon"], src["raw_base"], allow_net)
mask = glyph_mask(svg, slot["size"])
img = render_slot(mask, slot)
author_slug = slot["icon"].split("/")[0]
author = src["authors"].get(author_slug, author_slug)
url = page_url(slot["icon"], src["page_base"])
for sprite_path in slot["out_paths"]:
# sprite_path is "sprites/<...>.png" relative to the assets dir.
out_path = ASSETS_ROOT / sprite_path
out_path.parent.mkdir(parents=True, exist_ok=True)
img.save(out_path, "PNG")
# Ledger/manifest rows are relative to the sprites dir (schema).
rel = out_path.relative_to(SPRITES_ROOT).as_posix()
sha = sha256_of(out_path)
ledger_by_path[rel] = (
f"| {rel} | game-icons.net (stand-in) | {src['license']} | "
f"{author} | {url} | {sha} | {today} |")
manifest_by_path[rel] = (
f"| {rel} | {slot['category']} | {slot['id']} | "
f"{slot['icon']} | {slot['size']}px |")
ledger_rows = list(ledger_by_path.values())
manifest_rows = list(manifest_by_path.values())
written = len(ledger_rows)
write_ledger(ledger_rows, merge=bool(only))
write_manifest(manifest_rows, len(slots), problems, merge=bool(only))
print(f"Wrote {written} stand-in PNGs across {len(slots)} slots "
f"-> {SPRITES_ROOT}" + (f" (--only {','.join(sorted(only))}, merged)" if only else ""))
if problems:
print(f"{len(problems)} data gaps (see STANDINS.md gap list).")
print(f"Updated ledger: {LEDGER}")
print(f"Updated manifest: {MANIFEST}")
return 0
def _row_path(line: str) -> str:
"""First markdown table cell (the sprite path) of a `| path | … |` row."""
return line.split("|")[1].strip()
def write_ledger(rows: list[str], merge: bool = False) -> None:
"""Replace the '## Assets' table body in LICENSES.md with `rows`,
preserving the header above and the '## Audit' section below.
merge=True: union `rows` (keyed by path) with the rows already in the file,
so a partial (--only) run adds its rows without dropping any others."""
text = LEDGER.read_text()
if merge:
existing = {_row_path(l): l for l in text.splitlines()
if l.startswith("| ") and "game-icons" in l}
for line in rows:
existing[_row_path(line)] = line
rows = list(existing.values())
header = (
"| Path | Source | License | Author | URL | SHA256 | Added |\n"
"|---|---|---|---|---|---|---|\n"
)
block = "## Assets\n\n" + header + "\n".join(sorted(rows)) + "\n\n"
pat = re.compile(r"## Assets\n.*?(?=\n## )", re.DOTALL)
if not pat.search(text):
raise RuntimeError("could not locate '## Assets' section in LICENSES.md")
LEDGER.write_text(pat.sub(block.rstrip() + "\n", text))
def write_manifest(rows: list[str], slot_count: int, problems: list[str],
merge: bool = False) -> None:
if merge:
_merge_manifest_rows(rows)
return
gap_block = ""
if problems:
gap_block = (
"\n## Coverage gaps (subscribed but no stand-in emitted)\n\n"
+ "\n".join(f"- {p}" for p in sorted(problems)) + "\n"
)
body = f"""# Stand-in Sprite Manifest — Age of Dwarves
**These are OSS stand-in sprites, not final art.** Every file listed here is a
CC-BY-3.0 silhouette from [game-icons.net](https://game-icons.net) recoloured
and framed to fill a renderer sprite slot so the Game 1 Godot client ships
visually complete. They are tracked for replacement by the bespoke paid-art
pass — objective **5ee5e73e** (hire artist) and the per-category sprite
objectives **p2-23** (dwarf units), **p2-24** (wild creatures), **p2-25**
(buildings), **p2-26** (mundane wonders), **p2-27** (city tiers).
Coverage is data-driven: the slot set is derived from
`public/games/age-of-dwarves/manifest.json` (`subscribes.units` / `.buildings`)
resolved against `public/resources/<cat>/<id>.json` sprite paths — not a frozen
list. Re-run after the subscription or any unit/building `sprite` field changes.
Those objectives stay **partial**: stand-ins give complete *slot coverage* but do
not meet their 256/512 px native-resolution + ranker acceptance bars. This
manifest is the exact replace-list for the paid pass.
- Source pool: `github.com/game-icons/icons` (CC-BY-3.0).
- License provenance per file: `LICENSES.md`.
- Regenerate: `python3 tools/standin-sprites/build_standins.py`
(icon rules in `tools/standin-sprites/icon_rules.json`).
- Slots covered: {slot_count}. Files emitted: {len(rows)}.
{gap_block}
| Path | Category | Slot id | Source icon | Native size |
|---|---|---|---|---|
""" + "\n".join(sorted(rows)) + "\n"
MANIFEST.write_text(body)
def _merge_manifest_rows(new_rows: list[str]) -> None:
"""Insert `new_rows` into STANDINS.md's rows table, keyed by path, leaving the
prose, coverage-gap list, and every existing row byte-identical. Bumps the
'Slots covered: N. Files emitted: M.' line by the count of rows actually added
(each --only row is exactly one slot and one file)."""
text = MANIFEST.read_text()
lines = text.splitlines()
try:
hdr = next(i for i, l in enumerate(lines)
if l.startswith("| Path | Category |"))
except StopIteration as e:
raise RuntimeError("could not locate rows table header in STANDINS.md") from e
pre, sep = lines[:hdr + 2], hdr + 2 # header line + the |---| separator
rowmap = {_row_path(l): l for l in lines[sep:] if l.startswith("| ")}
before = len(rowmap)
for line in new_rows:
rowmap[_row_path(line)] = line
added = len(rowmap) - before
body = "\n".join(pre) + "\n" + "\n".join(sorted(rowmap.values())) + "\n"
m = re.search(r"Slots covered: (\d+)\. Files emitted: (\d+)\.", body)
if m:
body = body.replace(
m.group(0),
f"Slots covered: {int(m.group(1)) + added}. "
f"Files emitted: {int(m.group(2)) + added}.")
MANIFEST.write_text(body)
if __name__ == "__main__":
sys.exit(main())