Files
date_game/scripts/validate_assets.py

310 lines
12 KiB
Python

#!/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()