244 lines
9.0 KiB
Python
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())
|