220 lines
10 KiB
Python
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()
|