from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] DOCS_DIR = ROOT / "docs" MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json" AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json" DEFAULT_REPORT = DOCS_DIR / "unit-sprite-art-quality-gate.md" REQUIRED_SUFFIXES = [ "contact.png", "animation.gif", "before-after.png", "scale-compare.png", "battle-composite.png", "debug-preview-battle-check.png", ] def load_json(path: Path) -> dict[str, Any]: if not path.exists(): raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}") return json.loads(path.read_text(encoding="utf-8")) def row_by_stem(audit: dict[str, Any]) -> dict[str, dict[str, Any]]: return {row["stem"]: row for row in audit.get("rows", [])} def rel(path: Path) -> str: return path.relative_to(ROOT).as_posix() def infer_prefix(candidate: dict[str, Any]) -> str: artifact_prefix = str(candidate.get("artifactPrefix", "")).strip() if artifact_prefix: return artifact_prefix source = Path(candidate.get("source", "")).name version = str(candidate.get("version", "")).strip() if source and version and source.endswith(f"-source-{version}.png"): return source.removesuffix(f"-source-{version}.png") + f"-{version}" if source.endswith(".png"): return source.removesuffix(".png") raise ValueError("cannot infer artifact prefix from currentApprovalCandidate source") def pending_artifacts(candidate: dict[str, Any]) -> dict[str, Path]: prefix = infer_prefix(candidate) artifacts = { "source": ROOT / candidate["source"], "approval packet": ROOT / candidate["approvalPacket"], "current approval board": DOCS_DIR / "unit-sprite-current-approval-board.png", } for suffix in REQUIRED_SUFFIXES: artifacts[suffix] = DOCS_DIR / f"{prefix}-{suffix}" return artifacts def metric_issue(stem: str, label: str, value: float | int | None, op: str, limit: float) -> str | None: if value is None: return f"{stem}: missing metric `{label}`" numeric = float(value) passed = numeric >= limit if op == ">=" else numeric <= limit if passed: return None return f"{stem}: `{label}` is {numeric:g}, expected {op} {limit:g}" def validate_row(stem: str, row: dict[str, Any]) -> list[str]: issues: list[str] = [] base = row["base"] actions = row["actions"] for key, sheet in (("base", base), ("actions", actions)): if sheet["status"] != "ok": issues.append(f"{stem}: {key} sheet status is `{sheet['status']}`") for field in ("partial_alpha", "border_hits", "empty_frames"): if sheet.get(field) not in (0, None): issues.append(f"{stem}: {key} `{field}` is {sheet.get(field)}, expected 0") thresholds = [ ("base battle_min_width_px", base.get("battle_min_width_px"), ">=", 22.0), ("base battle_max_width_px", base.get("battle_max_width_px"), ">=", 32.0), ("base battle_min_height_px", base.get("battle_min_height_px"), ">=", 43.0), ("base battle_max_height_px", base.get("battle_max_height_px"), "<=", 48.0), ("base height_swing", base.get("height_swing"), "<=", 18.0), ("base bottom_swing", base.get("bottom_swing"), "<=", 16.0), ("action battle_max_width_px", actions.get("battle_max_width_px"), ">=", 38.0), ("action battle_max_height_px", actions.get("battle_max_height_px"), ">=", 40.0), ("action battle_max_height_px", actions.get("battle_max_height_px"), "<=", 48.0), ("action bottom_swing", actions.get("bottom_swing"), "<=", 16.0), ] for label, value, op, limit in thresholds: issue = metric_issue(stem, label, value, op, limit) if issue: issues.append(issue) return issues def validate_candidate(stem: str, candidate: dict[str, Any], row: dict[str, Any]) -> tuple[list[str], list[str]]: issues = validate_row(stem, row) evidence: list[str] = [] artifacts = pending_artifacts(candidate) for label, path in artifacts.items(): if not path.exists(): issues.append(f"{stem}: missing {label}: {rel(path)}") else: evidence.append(f"- {label}: `{rel(path)}`") packet_path = artifacts.get("approval packet") if packet_path and packet_path.exists(): packet = packet_path.read_text(encoding="utf-8") required_text = [ "unit-sprite-current-approval-board.png", "Local browser console errors: `0`", f"debugSpritePreview={stem}", ] for text in required_text: if text not in packet: issues.append(f"{stem}: approval packet missing `{text}`") board_path = artifacts.get("current approval board") contact_path = artifacts.get("contact.png") if board_path and board_path.exists() and contact_path and contact_path.exists(): if board_path.stat().st_mtime < contact_path.stat().st_mtime: issues.append(f"{stem}: current approval board is older than contact sheet") return issues, evidence def write_report( report_path: Path, manifest: dict[str, Any], audit: dict[str, Any], candidate_results: list[tuple[str, list[str], list[str]]], ) -> None: lines = [ "# Unit Sprite Art Quality Gate", "", "This file is generated by `scripts/check-unit-sprite-art-quality.py`.", "", "This gate catches obvious sprite-art failures before user approval. It does not replace manual visual approval.", "", f"- Manifest: `{rel(MANIFEST_PATH)}`", f"- Audit: `{rel(AUDIT_PATH)}`", f"- Approved baseline units: {len(manifest.get('approvedBaseline', []))}", f"- Pending approval units: {len(manifest.get('pendingApproval', []))}", f"- Queued rework units: {audit.get('stage_counts', {}).get('queued-rework', 0)}", "", "## Checked Rules", "", "- Required approval artifacts exist, including contact sheet, GIF, scale comparison, battle preview, and current approval board.", "- Base/action sheets have no partial alpha, border hits, or empty frames.", "- 50px battle-scale silhouette is not too small or too tall.", "- Idle/walk base frames stay vertically stable.", "- Approval packet points at the current approval board and browser debug preview evidence.", "", ] failed = False for stem, issues, evidence in candidate_results: lines.extend([f"## {stem}", ""]) if issues: failed = True lines.append("Status: FAILED") lines.append("") lines.append("Issues:") for issue in issues: lines.append(f"- {issue}") else: lines.append("Status: OK") lines.append("") if evidence: lines.append("Evidence:") lines.extend(evidence) lines.append("") lines.extend(["## Result", "", "FAILED" if failed else "OK", ""]) report_path.write_text("\n".join(lines), encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser(description="Check pending unit sprite art-quality evidence.") parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) args = parser.parse_args() try: manifest = load_json(MANIFEST_PATH) audit = load_json(AUDIT_PATH) except Exception as exc: print(f"Unit sprite art quality gate: FAILED\n- {exc}") return 1 rows = row_by_stem(audit) candidates = manifest.get("currentApprovalCandidate", {}) pending = manifest.get("pendingApproval", []) candidate_results: list[tuple[str, list[str], list[str]]] = [] if not pending: candidate_results.append(("none", [], ["- no pending approval units: nothing to check"])) for stem in pending: row = rows.get(stem) candidate = candidates.get(stem) if not row: candidate_results.append((stem, [f"{stem}: missing from audit"], [])) continue if not candidate: candidate_results.append((stem, [f"{stem}: missing currentApprovalCandidate entry"], [])) continue issues, evidence = validate_candidate(stem, candidate, row) candidate_results.append((stem, issues, evidence)) args.report.parent.mkdir(parents=True, exist_ok=True) write_report(args.report, manifest, audit, candidate_results) failed = any(issues for _, issues, _ in candidate_results) if failed: print("Unit sprite art quality gate: FAILED") for stem, issues, _ in candidate_results: for issue in issues: print(f"- {issue}") print(f"Wrote {args.report}") return 1 print("Unit sprite art quality gate: OK") for stem, _, _ in candidate_results: print(f"- {stem}") print(f"Wrote {args.report}") return 0 if __name__ == "__main__": sys.exit(main())