Files
heros_web/scripts/check-unit-sprite-art-quality.py

244 lines
9.0 KiB
Python

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())