Files
aetherbound-guild/tools/test_bat002_reconstruction.py
T

220 lines
10 KiB
Python

#!/usr/bin/env python3
"""Focused BAT-002 deconstruction/reconstruction-package assertions."""
from __future__ import annotations
import hashlib
import json
import unittest
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
PAGE = ROOT / "docs/design-authority/pages/BAT-002-live-battle"
APPROVED = PAGE / "approved/v001"
RECON = PAGE / "reconstruction/v001"
RUNTIME = PAGE / "runtime"
def load(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def inside(rect: list[float]) -> bool:
return (
isinstance(rect, list)
and len(rect) == 4
and all(isinstance(value, (int, float)) for value in rect)
and all(0 <= float(value) <= 1 for value in rect)
and rect[2] > 0
and rect[3] > 0
and rect[0] + rect[2] <= 1.000001
and rect[1] + rect[3] <= 1.000001
)
def resolve_asset(path_value: str) -> Path:
path = Path(path_value)
candidates = [ROOT / path, PAGE / path, RECON / path, RUNTIME / path]
for candidate in candidates:
if candidate.is_file():
return candidate
return candidates[0]
class Bat002ReconstructionTests(unittest.TestCase):
def test_required_package_files_and_authority_hashes(self) -> None:
required = [
APPROVED / "master.png",
APPROVED / "scale-proof.png",
APPROVED / "manifest.json",
APPROVED / "layer-map.json",
APPROVED / "text-slots.json",
APPROVED / "hit-areas.json",
RECON / "clean-static-master.png",
RECON / "deconstruction-plan.json",
RECON / "deconstruction-board.png",
RECON / "dynamic-regions.json",
RECON / "dynamic-regions.png",
RECON / "asset-generation-plan.json",
RECON / "asset-manifest.json",
RECON / "asset-contact-sheet.png",
RUNTIME / "reconstruction-spec.json",
RUNTIME / "asset-manifest.json",
RUNTIME / "hit-areas.json",
RUNTIME / "preflight.json",
]
for path in required:
self.assertTrue(path.is_file(), path)
self.assertGreater(path.stat().st_size, 0, path)
approved_manifest = load(APPROVED / "manifest.json")
source_master = ROOT / "docs/prototype/generation/phase6_ui_direction_01/outputs/05_battle_ui_japanese_scale.png"
source_scale = ROOT / "docs/prototype/generation/phase6_battle_scale_04/outputs/battle-scale-proof-01/battle-scale-opening.png"
for key, source in (("approved_master", source_master), ("scale_proof", source_scale)):
target = APPROVED / approved_manifest[key]["path"]
self.assertEqual(source.read_bytes(), target.read_bytes(), key)
self.assertEqual(sha256(source), approved_manifest[key]["sha256"], key)
bat001_clean = ROOT / "docs/design-authority/pages/BAT-001-battle-opening/reconstruction/v001/clean-static-master.png"
self.assertEqual(bat001_clean.read_bytes(), (RECON / "clean-static-master.png").read_bytes())
with Image.open(RECON / "clean-static-master.png") as image:
self.assertEqual(image.size, (2048, 1152))
with Image.open(RECON / "deconstruction-board.png") as image:
self.assertEqual(image.size, (1764, 780))
def test_layer_and_dynamic_ownership_are_normalized_and_unique(self) -> None:
layer_map = load(APPROVED / "layer-map.json")
layers = layer_map["layers"]
self.assertEqual([layer["z"] for layer in layers], sorted({layer["z"] for layer in layers}))
self.assertTrue({layer["owner_class"] for layer in layers} >= {"static_master", "runtime", "hit_area_only"})
for layer in layers:
self.assertTrue(inside(layer["bounds"]), layer["id"])
self.assertIn("runtime_owner", layer)
dynamic = load(RECON / "dynamic-regions.json")
self.assertEqual(dynamic["mask"], "dynamic-regions.png")
self.assertTrue(dynamic["regions"])
for region in dynamic["regions"]:
self.assertTrue(inside(region["bounds"]), region["id"])
self.assertEqual(region["owner_class"], "runtime", region["id"])
self.assertIn("dynamic region", dynamic["ownership_rule"].lower())
def test_text_slots_cover_live_state_and_waiting_copy(self) -> None:
data = load(APPROVED / "text-slots.json")
slots = data["slots"]
self.assertGreaterEqual(len(slots), 24)
ids = {slot["id"] for slot in slots}
for required in ("objective_label", "current_event_source", "current_event_target", "waiting_for_next_event", "recent_event_sequence", "invalid_event_reason", "outcome_handoff"):
self.assertIn(required, ids)
waiting = next(slot for slot in slots if slot["id"] == "waiting_for_next_event")
self.assertEqual(waiting["exact_english_copy"], "Waiting for next event")
for slot in slots:
self.assertEqual(slot["owner"], "engine", slot["id"])
self.assertTrue(inside(slot["anchor"]), slot["id"])
self.assertIn(100, slot["scale"], slot["id"])
self.assertEqual(set(slot["locales"]), {"zh_CN", "en"}, slot["id"])
self.assertIn("engine text", data["policy"].lower())
def test_hit_areas_are_independent_and_48px_safe(self) -> None:
for path in (APPROVED / "hit-areas.json", RUNTIME / "hit-areas.json"):
data = load(path)
areas = data["areas"]
self.assertEqual(len({area["id"] for area in areas}), len(areas))
for area in areas:
anchor = area["anchor"]
self.assertTrue(inside(anchor), area["id"])
self.assertGreaterEqual(anchor[2] * 640, 48.0, area["id"])
self.assertGreaterEqual(anchor[3] * 360, 48.0, area["id"])
focus = data["focus_order"]
self.assertEqual(len(focus), len(set(focus)))
self.assertTrue(set(focus).issubset({area["id"] for area in areas}))
forbidden = " ".join(area["action"] for area in load(RUNTIME / "hit-areas.json")["areas"])
for command in ("attack", "skill", "reorder", "equip", "hire", "reserve"):
self.assertNotIn(command, forbidden.lower())
def test_assets_are_reused_with_traceable_hashes_and_no_generation(self) -> None:
plan = load(RECON / "asset-generation-plan.json")
self.assertEqual(plan["state"], "RECONSTRUCTION_READY")
self.assertEqual(plan["new_ai_calls"], 0)
self.assertEqual(plan["new_cost_usd"], 0.0)
manifest = load(RECON / "asset-manifest.json")
self.assertEqual(manifest["state"], "RECONSTRUCTION_READY")
self.assertTrue(manifest["human_reviewed"])
reviewed = {record["id"]: record for record in manifest["assets"]}
self.assertTrue(reviewed["deconstruction_board"]["human_reviewed"])
self.assertTrue(reviewed["asset_contact_sheet"]["human_reviewed"])
self.assertEqual(manifest["generated_player_assets"], [])
self.assertGreaterEqual(len(manifest["assets"]), 18)
for record in manifest["assets"]:
self.assertRegex(record["sha256"], r"^[0-9a-f]{64}$", record["id"])
path = resolve_asset(record["path"])
if path.is_file():
self.assertEqual(sha256(path), record["sha256"], record["id"])
required_families = {
"melee_contact",
"ranged_release_and_contact",
"magic_cast_and_resolution",
"healing_source_and_recipient_change",
"guard_or_protection_intercept",
"damage_source_target_and_value",
"knockout_and_line_collapse",
"enemy_action_and_target_relation",
"outcome_lock",
}
self.assertEqual(set(plan["event_binding_policy"]), required_families)
runtime_manifest = load(RUNTIME / "asset-manifest.json")
runtime_records = (
runtime_manifest["static_runtime_assets"]
+ runtime_manifest["vfx_assets"]
+ [runtime_manifest["animation_manifest"], runtime_manifest["font"]]
)
for record in runtime_records:
res_path = record["res_path"]
self.assertTrue(res_path.startswith("res://assets/"), record.get("id", res_path))
source = ROOT / "runtime" / res_path.removeprefix("res://")
self.assertTrue(source.is_file(), source)
self.assertEqual(sha256(source), record["sha256"], record.get("id", res_path))
def test_runtime_handoff_is_not_runtime_implementation(self) -> None:
spec = load(RUNTIME / "reconstruction-spec.json")
self.assertEqual(spec["state"], "RECONSTRUCTION_READY")
self.assertEqual(set(spec["event_bindings"]), {
"melee_contact", "ranged_release_and_contact", "magic_cast_and_resolution",
"healing_source_and_recipient_change", "guard_or_protection_intercept",
"damage_source_target_and_value", "knockout_and_line_collapse",
"enemy_action_and_target_relation", "outcome_lock",
})
self.assertIn("not a Runtime implementation", spec["implementation_gate"])
self.assertIn("outcome_handoff", spec["input"]["focus_order"] or [])
self.assertIn("attack", spec["input"]["forbidden_commands"])
self.assertNotIn("res://runtime/assets", json.dumps(spec))
def test_reconstruction_handoff_remains_bound_after_runtime_implementation(self) -> None:
index = load(ROOT / "docs/design-authority/design-index.json")
contract = load(PAGE / "page-contract.json")
allowed_states = {
"IMPLEMENTATION_IN_PROGRESS",
"IMPLEMENTED_AWAITING_OWNER_RUNTIME_REVIEW",
"VERIFIED_AWAITING_OWNER_ACCEPTANCE",
"COMPLETE",
}
self.assertIn(index["pages"]["BAT-002"]["status"], allowed_states)
self.assertIn(contract["state"], allowed_states)
self.assertEqual(index["pages"]["BAT-002"]["deconstruction"], "pages/BAT-002-live-battle/reconstruction/v001/deconstruction-board.png")
if __name__ == "__main__":
unittest.main()