#!/usr/bin/env python3 """Validate generated character asset manifest and PNG files.""" from __future__ import annotations import json import struct import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DATA = ROOT / "data" PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" def load_json(path: Path) -> dict: with path.open("r", encoding="utf-8") as handle: return json.load(handle) def fail(message: str) -> None: print(f"ERROR: {message}", file=sys.stderr) raise SystemExit(1) def png_dimensions(path: Path) -> tuple[int, int]: with path.open("rb") as handle: signature = handle.read(8) if signature != PNG_SIGNATURE: fail(f"{path} is not a PNG file") length_bytes = handle.read(4) chunk_type = handle.read(4) if len(length_bytes) != 4 or chunk_type != b"IHDR": fail(f"{path} is missing a PNG IHDR chunk") length = struct.unpack(">I", length_bytes)[0] if length < 8: fail(f"{path} has an invalid PNG IHDR chunk") width, height = struct.unpack(">II", handle.read(8)) return width, height def validate_asset_image(path: Path, min_width: int, min_height: int, label: str) -> None: if not path.exists(): fail(f"{label} asset is missing: {path}") width, height = png_dimensions(path) if width < min_width or height < min_height: fail( f"{label} asset is too small: " f"{width}x{height}, minimum {min_width}x{min_height}" ) def main() -> None: characters_doc = load_json(DATA / "characters.json") locations_doc = load_json(DATA / "locations.json") assets_doc = load_json(DATA / "character_assets.json") character_ids = [character["id"] for character in characters_doc["characters"]] character_id_set = set(character_ids) location_ids = {location["id"] for location in locations_doc["locations"]} asset_entries = assets_doc["assets"] asset_ids = [entry["character_id"] for entry in asset_entries] if set(asset_ids) != character_id_set: missing = sorted(character_id_set.difference(asset_ids)) extra = sorted(set(asset_ids).difference(character_id_set)) fail(f"asset manifest mismatch; missing={missing}, extra={extra}") if len(asset_ids) != len(set(asset_ids)): fail("asset manifest contains duplicate character ids") minimum = assets_doc["minimum_dimensions"] min_width = minimum["width"] min_height = minimum["height"] accepted = 0 reference = ROOT / assets_doc["style"]["reference"] if not reference.exists(): fail(f"style reference is missing: {reference}") png_dimensions(reference) for alternate in assets_doc["style"].get("alternates", []): alternate_reference = ROOT / alternate["reference"] if not alternate_reference.exists(): fail(f"alternate style reference is missing: {alternate_reference}") png_dimensions(alternate_reference) for entry in asset_entries: status = entry["status"] if status not in {"accepted", "needs_revision", "pending", "rejected"}: fail(f"{entry['character_id']} has invalid status {status}") if status not in {"accepted", "needs_revision"}: continue if status == "accepted": accepted += 1 if "path" not in entry: fail(f"{entry['character_id']} has status {status} but no path") validate_asset_image( ROOT / entry["path"], min_width, min_height, entry["character_id"], ) revision_count = sum( 1 for entry in asset_entries if entry["status"] == "needs_revision" ) date_cg_accepted = 0 date_cg_revision_count = 0 for cg_set in assets_doc.get("date_cgs", []): character_id = cg_set["character_id"] if character_id not in character_id_set: fail(f"date CG set references missing character id: {character_id}") if cg_set["status"] not in {"accepted", "partial", "needs_revision", "pending", "rejected"}: fail(f"{cg_set['set_id']} has invalid status {cg_set['status']}") previous_max = -1 for tier in cg_set["tiers"]: status = tier["status"] if status not in {"accepted", "needs_revision", "pending", "rejected"}: fail(f"{cg_set['set_id']} tier has invalid status {status}") affection_min = tier["affection_min"] affection_max = tier["affection_max"] if affection_min <= previous_max: fail(f"{cg_set['set_id']} has overlapping affection tiers") if affection_max is not None and affection_max < affection_min: fail(f"{cg_set['set_id']} has an invalid affection tier range") previous_max = affection_max if affection_max is not None else affection_min path_value = tier.get("path") if status == "accepted": date_cg_accepted += 1 if not path_value: fail(f"{cg_set['set_id']} accepted tier lacks a path") validate_asset_image( ROOT / path_value, min_width, min_height, f"{cg_set['set_id']} affection {affection_min}", ) elif status == "needs_revision": date_cg_revision_count += 1 if path_value: validate_asset_image( ROOT / path_value, min_width, min_height, f"{cg_set['set_id']} revision candidate {affection_min}", ) location_asset_path = DATA / "location_assets.json" location_accepted = 0 location_revision_count = 0 if location_asset_path.exists(): location_assets_doc = load_json(location_asset_path) location_minimum = location_assets_doc["minimum_dimensions"] location_min_width = location_minimum["width"] location_min_height = location_minimum["height"] contact_sheet = ROOT / location_assets_doc["contact_sheet"] if not contact_sheet.exists(): fail(f"location contact sheet is missing: {contact_sheet}") png_dimensions(contact_sheet) location_asset_entries = location_assets_doc["assets"] asset_location_ids = [entry["location_id"] for entry in location_asset_entries] if set(asset_location_ids) != location_ids: missing = sorted(location_ids.difference(asset_location_ids)) extra = sorted(set(asset_location_ids).difference(location_ids)) fail(f"location asset manifest mismatch; missing={missing}, extra={extra}") if len(asset_location_ids) != len(set(asset_location_ids)): fail("location asset manifest contains duplicate location ids") for entry in location_asset_entries: status = entry["status"] if status not in {"accepted", "needs_revision", "pending", "rejected"}: fail(f"{entry['location_id']} has invalid location asset status {status}") path_value = entry.get("path") if status == "accepted": location_accepted += 1 if not path_value: fail(f"{entry['location_id']} accepted location asset lacks a path") validate_asset_image( ROOT / path_value, location_min_width, location_min_height, entry["location_id"], ) elif status == "needs_revision": location_revision_count += 1 if path_value: validate_asset_image( ROOT / path_value, location_min_width, location_min_height, f"{entry['location_id']} revision candidate", ) character_location_asset_path = DATA / "character_location_assets.json" character_location_accepted = 0 character_location_revision_count = 0 if character_location_asset_path.exists(): character_location_assets_doc = load_json(character_location_asset_path) character_location_minimum = character_location_assets_doc[ "minimum_dimensions" ] character_location_min_width = character_location_minimum["width"] character_location_min_height = character_location_minimum["height"] contact_sheet = ROOT / character_location_assets_doc["contact_sheet"] if not contact_sheet.exists(): fail(f"character-location contact sheet is missing: {contact_sheet}") png_dimensions(contact_sheet) presence_doc = load_json(DATA / "character_location_presence.json") allowed_pairs = { (entry["character_id"], location["location_id"]) for entry in presence_doc["characters"] for location in entry["locations"] } character_location_asset_entries = character_location_assets_doc["assets"] seen_pairs = set() for entry in character_location_asset_entries: character_id = entry["character_id"] location_id = entry["location_id"] pair = (character_id, location_id) if character_id not in character_id_set: fail( "character-location asset references missing character id: " f"{character_id}" ) if location_id not in location_ids: fail( "character-location asset references missing location id: " f"{location_id}" ) if pair not in allowed_pairs: fail( "character-location asset references unavailable location: " f"{character_id}/{location_id}" ) if pair in seen_pairs: fail( "character-location asset manifest contains duplicate pair: " f"{character_id}/{location_id}" ) seen_pairs.add(pair) status = entry["status"] if status not in {"accepted", "needs_revision", "pending", "rejected"}: fail( f"{character_id}/{location_id} has invalid " f"character-location asset status {status}" ) path_value = entry.get("path") if status == "accepted": character_location_accepted += 1 if not path_value: fail( f"{character_id}/{location_id} accepted asset lacks a path" ) validate_asset_image( ROOT / path_value, character_location_min_width, character_location_min_height, f"{character_id}/{location_id}", ) elif status == "needs_revision": character_location_revision_count += 1 if path_value: validate_asset_image( ROOT / path_value, character_location_min_width, character_location_min_height, f"{character_id}/{location_id} revision candidate", ) print( "Validated character asset manifest: " f"{accepted}/{len(asset_entries)} accepted, " f"{revision_count} needs revision, " f"{date_cg_accepted} accepted date CG tiers, " f"{date_cg_revision_count} date CG tiers need revision, " f"{location_accepted} accepted location backgrounds, " f"{location_revision_count} location backgrounds need revision, " f"{character_location_accepted} accepted character-location CGs, " f"{character_location_revision_count} character-location CGs need revision." ) if __name__ == "__main__": main()