1909 lines
87 KiB
GDScript
1909 lines
87 KiB
GDScript
extends SceneTree
|
|
|
|
const EntryShell = preload("res://entry/entry_shell.gd")
|
|
const SettingsStore = preload("res://entry/settings_store.gd")
|
|
const DraftService = preload("res://entry/initial_company_draft_service.gd")
|
|
const ContractService = preload("res://entry/guild_contract_service.gd")
|
|
const FormationService = preload("res://entry/formation_risk_service.gd")
|
|
const BattleService = preload("res://entry/battle_service.gd")
|
|
const UITokens = preload("res://foundation/ui_tokens.gd")
|
|
|
|
const DRAFT_PATH := "user://dev5_battle_live_capture_draft.json"
|
|
const COMPANY_PATH := "user://dev5_battle_live_capture_company.json"
|
|
const SEED := "2468ace013579bdf2468ace013579bdf"
|
|
const CONTRACT_OP := "abcdef0123456789abcdef0123456789"
|
|
const HIRE_OP_1 := "11111111222222223333333344444444"
|
|
const HIRE_OP_2 := "55555555666666667777777788888888"
|
|
const ORDER_OP := "99999999aaaaaaaabbbbbbbbcccccccc"
|
|
const READY_OP := "ddddddddeeeeeeeeffffffff00000000"
|
|
const BATTLE_OP := "1234567890abcdef1234567890abcdef"
|
|
const INTERRUPT_BATTLE_OP := "fedcba0987654321fedcba0987654321"
|
|
|
|
# M109: internal M77 route-gate proxy bands. These are measurement proxies only;
|
|
# they are never Owner-accepted scale (see AGENTS.md B1/M109-I04).
|
|
const PROXY_BAND_ALLY := [0.12, 0.30]
|
|
const PROXY_BAND_ENEMY := [0.10, 0.34]
|
|
const SHARED_TEXT_TOKEN := "micro"
|
|
const RESOLVED_DELTA_NAME := "resolved-delta"
|
|
|
|
# M110 v002: active fixed-slot contract (approved/v002/text-slots.json) plus the
|
|
# engine-owned dynamic placement policy for BattleLiveEventDetail.
|
|
const HUD_FIXED_SLOTS := {
|
|
"BattleLiveTitle": {"slot_id": "encounter_identity", "norm": [0.025, 0.145, 0.31, 0.030]},
|
|
"BattleLiveEventState": {"slot_id": "current_event_state", "norm": [0.370, 0.140, 0.26, 0.040]},
|
|
"BattleLiveRecentEvents": {"slot_id": "recent_event_sequence", "norm": [0.170, 0.760, 0.550, 0.040]},
|
|
}
|
|
const HUD_RULE_SUBSLOTS := {
|
|
"BattleLiveNoRespawn": {"slot_id": "encounter_rule_A", "norm": [0.025, 0.220, 0.14875, 0.035]},
|
|
"BattleLiveNoReserve": {"slot_id": "encounter_rule_B", "norm": [0.18625, 0.220, 0.14875, 0.035]},
|
|
}
|
|
const HUD_RULE_BAND := [0.025, 0.220, 0.31, 0.035]
|
|
const DESIGN_SIZE := Vector2(1280.0, 720.0)
|
|
const SLOT_TOLERANCE_PX := 2.0
|
|
const RELATION_CORRIDOR := Rect2(0.440, 0.280, 0.220, 0.420)
|
|
const RELATION_MIN_CLEARANCE_PX := 4.0
|
|
const RELATION_FALLBACK_SOURCE_PX := Vector2(0.47 * 1280.0, 0.56 * 720.0)
|
|
const RELATION_FALLBACK_TARGET_PX := Vector2(0.67 * 1280.0, 0.56 * 720.0)
|
|
const CONTRAST_MIN := 4.5
|
|
const LONGEST_SHIPPED := {
|
|
"BattleLiveTitle": {"zh": "风镜初渡 · 自动", "en": "WINDGLASS · AUTO"},
|
|
"BattleLiveNoRespawn": {"zh": "无复活", "en": "NO RESPAWN"},
|
|
"BattleLiveNoReserve": {"zh": "无后备", "en": "NO RESERVE"},
|
|
"BattleLiveEventState": {"zh": "正在验证快照", "en": "OBSERVATION INTERRUPTED"},
|
|
"BattleLiveRecentEvents": {"zh": "敌方威胁已公开 · 决定性伤害改变结果", "en": "Enemy threat revealed · Decisive damage changed the outcome"},
|
|
"BattleLiveEventDetail": {"zh": "敌12 → 0号 · -33", "en": "E12 -> P0 · -33"},
|
|
}
|
|
|
|
var failures: Array[String] = []
|
|
var capture_count := 0
|
|
var output_root := ""
|
|
var viewport_size := Vector2i(1280, 720)
|
|
var fixture_index := 0
|
|
var locale := "en"
|
|
var text_scale := 1.0
|
|
var reference_viewport := false
|
|
var state_records: Array = []
|
|
var evidence_records: Array = []
|
|
var capture_by_name := {}
|
|
var non_opening_kind := ""
|
|
var non_opening_phase := -1
|
|
var action_event_id := ""
|
|
var waiting_copy := ""
|
|
var hud_gate_failures: Array[String] = []
|
|
var hud_gate_cache := {}
|
|
var relation_summary_cache := {}
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run_capture")
|
|
|
|
|
|
func run_capture() -> void:
|
|
fixture_index = int(OS.get_environment("ABG_CAPTURE_FIXTURE_INDEX"))
|
|
var fixtures := [
|
|
{"size": Vector2i(760, 360), "locale": "zh_CN", "scale": 1.0},
|
|
{"size": Vector2i(844, 390), "locale": "en", "scale": 1.0},
|
|
{"size": Vector2i(1280, 720), "locale": "zh_CN", "scale": 1.0},
|
|
{"size": Vector2i(1280, 720), "locale": "en", "scale": 1.0},
|
|
{"size": Vector2i(1280, 720), "locale": "zh_CN", "scale": 1.3},
|
|
{"size": Vector2i(1280, 720), "locale": "en", "scale": 1.3},
|
|
]
|
|
if fixture_index < 0 or fixture_index >= fixtures.size():
|
|
push_error("unknown BAT-002 live capture fixture %d" % fixture_index)
|
|
quit(1)
|
|
return
|
|
viewport_size = fixtures[fixture_index].size
|
|
locale = fixtures[fixture_index].locale
|
|
text_scale = float(fixtures[fixture_index].scale)
|
|
reference_viewport = viewport_size == Vector2i(1280, 720)
|
|
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
|
|
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
|
|
root.size = viewport_size
|
|
output_root = OS.get_environment("ABG_EVIDENCE_DIR")
|
|
if output_root.is_empty():
|
|
output_root = "/tmp/abg-bat002-live-candidate"
|
|
output_root = output_root.path_join("captures")
|
|
DirAccess.make_dir_recursive_absolute(output_root)
|
|
_cleanup()
|
|
var prepared := _create_prepared_company()
|
|
var started: Dictionary = _battle_service().begin_battle(BATTLE_OP, String(prepared.active_contract.formation.risk_options[1].risk_option_id)) if not prepared.is_empty() else {}
|
|
_expect(bool(started.get("ok", false)), "capture fixture creates one battle receipt")
|
|
if started.is_empty() or not bool(started.get("ok", false)):
|
|
_finish()
|
|
return
|
|
var viewport := SubViewport.new()
|
|
viewport.name = "BattleLiveTimelineCaptureViewport"
|
|
viewport.size = viewport_size
|
|
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
root.add_child(viewport)
|
|
var shell := await _new_shell(viewport)
|
|
shell.show_battle(started.company, false)
|
|
await _frames(5)
|
|
_expect(shell.battle_live_state == "waiting_for_next_event", "capture opens at waiting boundary")
|
|
var waiting := shell.find_child("BattleWaitingForNextEvent", true, false) as Label
|
|
_expect(waiting != null and not waiting.text.is_empty(), "waiting boundary keeps a nonblank reserved event slot")
|
|
if locale == "en" and waiting != null:
|
|
_expect(waiting.text == "Waiting for next event", "waiting boundary copy is exact")
|
|
waiting_copy = waiting.text if waiting != null else ""
|
|
|
|
# M107-P2 diagnosis: the previous harness advanced four steps straight from
|
|
# the waiting boundary, so forecast/committed/resolving all belonged to the
|
|
# phase-0 opening event. Its chrome is suppressed, so those three frames were
|
|
# byte-identical. Sample the opening boundary once, resolve it, then sample
|
|
# the first non-opening event for the four action states.
|
|
shell.toggle_battle_pause()
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
_expect(shell.battle_live_state == "event_forecast", "opening forecast is reached")
|
|
_expect(String(shell.battle_live_event.get("kind", "")) == "opening", "the first sampled event is the phase-0 opening")
|
|
await _capture(viewport, shell, "01-opening", "boundary", {
|
|
"matrix_state": "",
|
|
"action_coverage": false,
|
|
"note": "phase-0 opening (BAT-001 to BAT-002 boundary); state word hidden by existing design (M106 F2 product decision); not counted toward forecast/committed/resolving coverage",
|
|
})
|
|
|
|
for _step in range(3):
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
_expect(shell.battle_live_state == "event_resolved", "the opening event resolves")
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
_expect(shell.battle_live_state == "waiting_for_next_event", "observation returns to the waiting boundary")
|
|
var waiting_again := shell.find_child("BattleWaitingForNextEvent", true, false) as Label
|
|
_expect(waiting_again != null and not waiting_again.text.is_empty() and waiting_again.text == waiting_copy, "waiting boundary reappears with the same nonblank copy")
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
_expect(shell.battle_live_state == "event_forecast", "the first non-opening forecast is reached")
|
|
non_opening_kind = String(shell.battle_live_event.get("kind", ""))
|
|
non_opening_phase = int(shell.battle_live_event.get("phase", -1))
|
|
_expect(non_opening_kind != "" and non_opening_kind != "opening", "the first action event is not the opening beat")
|
|
_expect(non_opening_phase >= 1, "the first action event has a non-opening phase")
|
|
await _capture(viewport, shell, "02-forecast")
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
await _capture(viewport, shell, "03-committed")
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
await _capture(viewport, shell, "04-resolving")
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
_expect(shell.battle_live_state == "event_resolved", "capture reaches resolved event boundary")
|
|
await _capture(viewport, shell, "05-resolved")
|
|
var opening_hash := String(Dictionary(capture_by_name.get("01-opening", {})).get("sha256", ""))
|
|
var action_hash := String(Dictionary(capture_by_name.get("02-forecast", {})).get("sha256", ""))
|
|
_expect(not opening_hash.is_empty() and not action_hash.is_empty() and opening_hash != action_hash, "the first action state differs from the opening capture")
|
|
var committed_hash := String(Dictionary(capture_by_name.get("03-committed", {})).get("sha256", ""))
|
|
var resolving_hash := String(Dictionary(capture_by_name.get("04-resolving", {})).get("sha256", ""))
|
|
_expect(not (action_hash == committed_hash and committed_hash == resolving_hash), "forecast/committed/resolving are no longer one repeated opening frame")
|
|
|
|
# M107 per-state evidence (product review 2026-09-21 §2.1): each action state
|
|
# must prove its own window and ownership, not just a pixel difference.
|
|
var forecast := Dictionary(capture_by_name.get("02-forecast", {}))
|
|
var committed := Dictionary(capture_by_name.get("03-committed", {}))
|
|
var resolving := Dictionary(capture_by_name.get("04-resolving", {}))
|
|
var resolved := Dictionary(capture_by_name.get("05-resolved", {}))
|
|
action_event_id = String(forecast.get("event_id", ""))
|
|
_expect(not action_event_id.is_empty(), "the action event carries an event_id")
|
|
_expect(String(committed.get("event_id", "")) == action_event_id, "committed keeps the forecast reservation id")
|
|
_expect(bool(forecast.get("state_word_visible", false)) and bool(committed.get("state_word_visible", false)) and bool(resolving.get("state_word_visible", false)), "forecast/committed/resolving expose a visible state word")
|
|
_expect(String(forecast.get("state_word", "")) != String(committed.get("state_word", "")), "committed state word is identifiable from forecast")
|
|
_expect(bool(forecast.get("event_id_visible", false)) and bool(committed.get("event_id_visible", false)), "forecast/committed keep the event identity visible")
|
|
_expect(String(forecast.get("target_id", "")) != "", "forecast names a target")
|
|
_expect(String(resolving.get("source_id", "")) != "" and String(resolving.get("target_id", "")) != "", "resolving keeps source and target")
|
|
_expect(not bool(forecast.get("delta_visible", false)) and not bool(committed.get("delta_visible", false)) and not bool(resolving.get("delta_visible", false)), "forecast/committed/resolving do not claim a settled result")
|
|
_expect(bool(resolved.get("delta_visible", false)) and String(resolved.get("delta_text", "")) != "", "the first-event resolved boundary exposes an engine delta slot")
|
|
_expect(int(forecast.get("next_phase_index", -1)) == int(forecast.get("phase", -2)), "forecast sits in the pre-commit window")
|
|
_expect(int(committed.get("next_phase_index", -1)) == int(committed.get("phase", -2)), "committed sits before the authoritative resolve")
|
|
_expect(int(resolving.get("next_phase_index", -1)) == int(resolving.get("phase", -2)), "resolving is the authoritative contact window")
|
|
_expect(int(resolved.get("next_phase_index", -1)) == int(resolved.get("phase", -2)) + 1, "resolved is post-commit")
|
|
var art := shell.find_child("BattleLiveArt", true, false)
|
|
_expect(art != null and String(art.get("event_kind")) == String(resolving.get("event_kind", "")) and String(art.get("source_side")) != "" and String(art.get("target_side")) != "", "resolving owns a source-to-target relation path")
|
|
await _frames(10)
|
|
var settled := _state_evidence(shell)
|
|
_expect(String(shell.battle_live_state) == "event_resolved", "resolved persists for inspection frames")
|
|
_expect(bool(settled.get("delta_visible", false)) and String(settled.get("delta_text", "")) == String(resolved.get("delta_text", "")), "the first-event resolved delta text persists across frames")
|
|
# M109-I01: the first non-opening event (phase 1 telegraph) commits amount=0,
|
|
# so its resolved copy is the placeholder. Prove that this placeholder is real
|
|
# and explicitly exclude it from the delta claim instead of asserting it.
|
|
_expect(int(resolved.get("amount", -1)) == 0 and String(resolved.get("delta_text", "")) == _delta_placeholder_copy(), "the first non-opening resolved event is the amount=0 placeholder copy")
|
|
resolved["delta_proof"] = false
|
|
resolved["delta_proof_note"] = "amount=0 placeholder; never satisfies the resolved delta proof (M109-I01)"
|
|
resolved["delta_representation"] = {
|
|
"kind": "engine_text_glyphs",
|
|
"text": String(resolved.get("delta_text", "")),
|
|
"visible": bool(resolved.get("delta_visible", false)),
|
|
"font_color": String(resolved.get("delta_font_color", "")),
|
|
"measured": true,
|
|
}
|
|
resolved["persistence_measured"] = {
|
|
"frames": 10,
|
|
"state_stable": String(shell.battle_live_state) == "event_resolved",
|
|
"delta_text_stable": String(settled.get("delta_text", "")) == String(resolved.get("delta_text", "")),
|
|
"delta_visible_stable": bool(settled.get("delta_visible", false)),
|
|
}
|
|
|
|
# M109: the resolved delta proof must come from a deterministic non-zero
|
|
# event. The four action states above stay on the first non-opening event;
|
|
# this separately named later view walks forward to the first event whose
|
|
# committed amount is non-zero and captures its resolved state with the
|
|
# authoritative event identity, amount, direction, source/target and text.
|
|
var delta_walk := 0
|
|
while (
|
|
delta_walk < 12
|
|
and String(shell.battle_live_state) != "outcome_locked"
|
|
and not (
|
|
String(shell.battle_live_state) == "event_resolved"
|
|
and int(shell.battle_live_event.get("amount", 0)) != 0
|
|
)
|
|
):
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
delta_walk += 1
|
|
var proof_ready := (
|
|
String(shell.battle_live_state) == "event_resolved"
|
|
and int(shell.battle_live_event.get("amount", 0)) != 0
|
|
)
|
|
_expect(proof_ready, "a deterministic non-zero event reaches the resolved state before outcome")
|
|
if proof_ready:
|
|
var proof_direction := String(Dictionary(shell.battle_live_event.get("state_delta", {})).get("direction", "none"))
|
|
var proof_amount := int(shell.battle_live_event.get("amount", 0))
|
|
var expected_delta_copy := "%s%d" % ["-" if proof_direction == "loss" else "+", proof_amount]
|
|
_expect(proof_direction in ["loss", "gain"], "the non-zero proof event carries a legal state-delta direction")
|
|
await _capture(viewport, shell, RESOLVED_DELTA_NAME, "view", {
|
|
"role_note": "later deterministic non-zero event; authoritative resolved delta proof view",
|
|
"walk_phase_steps": delta_walk,
|
|
})
|
|
var proof_view := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
|
|
_expect(bool(proof_view.get("delta_visible", false)) and not String(proof_view.get("delta_text", "")).is_empty(), "resolved-delta exposes the engine delta text")
|
|
_expect(String(proof_view.get("delta_text", "")) == expected_delta_copy and String(proof_view.get("delta_text", "")) != _delta_placeholder_copy(), "resolved-delta text matches the non-zero amount and direction")
|
|
_expect(String(proof_view.get("event_id", "")) != "" and String(proof_view.get("source_id", "")) != "" and String(proof_view.get("target_id", "")) != "", "resolved-delta records event identity and source/target")
|
|
await _frames(5)
|
|
var proof_settled := _state_evidence(shell)
|
|
var persistence_measured := {
|
|
"frames": 5,
|
|
"state_stable": String(shell.battle_live_state) == "event_resolved",
|
|
"event_id_stable": String(proof_settled.get("event_id", "")) == String(proof_view.get("event_id", "")),
|
|
"amount_stable": int(proof_settled.get("amount", 0)) == int(proof_view.get("amount", 0)),
|
|
"direction_stable": String(proof_settled.get("state_delta_direction", "none")) == String(proof_view.get("state_delta_direction", "none")),
|
|
"delta_text_stable": String(proof_settled.get("delta_text", "")) == String(proof_view.get("delta_text", "")),
|
|
}
|
|
proof_view["persistence_measured"] = persistence_measured
|
|
_expect(bool(persistence_measured.state_stable) and bool(persistence_measured.event_id_stable) and bool(persistence_measured.delta_text_stable), "resolved-delta identity and text persist across inspection frames")
|
|
_expect(_delta_proof_status() == "PROVEN", "resolved-delta proof passes legal-direction and strict identity/text checks")
|
|
else:
|
|
resolved["delta_proof_status"] = "NOT_PROVEN"
|
|
failures.append("no deterministic non-zero resolved event before outcome")
|
|
|
|
# M109-I02: no self-authored ownership strings. The manifest derives the
|
|
# changed-fields list from measured node visibility, text, bounds, relation
|
|
# endpoints and the PNG hash after every capture is complete.
|
|
|
|
# M107-P2: static ownership and dynamic-on-neutral evidence views, taken from
|
|
# the resolved non-opening state through existing nodes only.
|
|
await _capture_static_only(viewport, shell)
|
|
await _capture_dynamic_neutral(viewport, shell)
|
|
|
|
shell.toggle_battle_pause()
|
|
await _frames(2)
|
|
_expect(shell.battle_live_state == "paused" and shell.battle_paused, "capture freezes paused state")
|
|
await _capture(viewport, shell, "06-paused")
|
|
shell.toggle_battle_pause()
|
|
|
|
while shell.battle_live_state != "event_resolving":
|
|
shell.advance_battle_phase()
|
|
await _frames(1)
|
|
if shell.battle_live_state == "outcome_locked":
|
|
break
|
|
_expect(shell.battle_live_state == "event_resolving", "capture reaches an authoritative resolving event")
|
|
if shell.battle_live_state == "event_resolving":
|
|
shell.battle_live_event.event_hash = "0".repeat(64)
|
|
shell.advance_battle_phase()
|
|
await _frames(2)
|
|
_expect(shell.battle_live_state == "invalid_event_data" and shell.battle_paused, "capture exposes invalid-event pause")
|
|
await _capture(viewport, shell, "07-invalid")
|
|
shell._battle_live_retry_snapshot()
|
|
await _frames(1)
|
|
_expect(shell.battle_live_state == "loading_resume", "capture exposes loading-resume validation")
|
|
await _capture(viewport, shell, "08-loading")
|
|
await _frames(3)
|
|
_expect(shell.battle_live_state == "event_resolved" and shell.battle_paused, "capture retry returns to the last verified event boundary")
|
|
shell.toggle_battle_pause()
|
|
|
|
var guard := 0
|
|
while shell.battle_live_state != "outcome_locked" and guard < 90:
|
|
if shell.battle_paused:
|
|
shell.toggle_battle_pause()
|
|
shell.advance_battle_phase()
|
|
await _frames(1)
|
|
guard += 1
|
|
_expect(shell.battle_live_state == "outcome_locked", "capture reaches immutable outcome")
|
|
await _capture(viewport, shell, "10-outcome")
|
|
shell.queue_free()
|
|
viewport.queue_free()
|
|
await _frames(12)
|
|
|
|
_cleanup()
|
|
var interrupted := _create_prepared_company()
|
|
var interrupted_start: Dictionary = _battle_service().begin_battle(INTERRUPT_BATTLE_OP, String(interrupted.active_contract.formation.risk_options[1].risk_option_id)) if not interrupted.is_empty() else {}
|
|
_expect(bool(interrupted_start.get("ok", false)), "interruption fixture creates a second isolated receipt")
|
|
if bool(interrupted_start.get("ok", false)):
|
|
var interrupt_viewport := SubViewport.new()
|
|
interrupt_viewport.name = "BattleLiveInterruptCaptureViewport"
|
|
interrupt_viewport.size = viewport_size
|
|
interrupt_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
|
root.add_child(interrupt_viewport)
|
|
var interrupt_shell := await _new_shell(interrupt_viewport)
|
|
interrupt_shell.show_battle(interrupted_start.company, false)
|
|
await _frames(4)
|
|
interrupt_shell._battle_live_interrupt("capture_background")
|
|
await _frames(2)
|
|
_expect(interrupt_shell.battle_live_state == "interrupted_background" and interrupt_shell.battle_paused, "capture exposes BAT-006 interruption handoff")
|
|
await _capture(interrupt_viewport, interrupt_shell, "09-interrupted")
|
|
interrupt_shell.queue_free()
|
|
interrupt_viewport.queue_free()
|
|
await _frames(12)
|
|
_finish()
|
|
|
|
|
|
func _finish() -> void:
|
|
_cleanup()
|
|
_evaluate_hud_gates()
|
|
var manifest_path := _write_manifest()
|
|
if failures.is_empty():
|
|
var state_names: Array = []
|
|
for record in state_records:
|
|
state_names.append(String(Dictionary(record).get("name", "")))
|
|
state_names.sort()
|
|
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
|
|
var text_summary := _text_minimum_summary()
|
|
var scale_summary := _actor_scale_summary()
|
|
var hud := _hud_gate_summary()
|
|
var relation := _relation_path_summary()
|
|
var packet_status := "COMPLETE" if failures.is_empty() else "INCOMPLETE"
|
|
var scale_pct := int(round(text_scale * 100.0))
|
|
print("ABG_DEV5_BATTLE_LIVE_CAPTURE_OK fixture=%d viewport=%dx%d locale=%s text_scale_pct=%d text_100=%s states=%s state_captures=%d views=%d captures=%d source=runtime" % [fixture_index, viewport_size.x, viewport_size.y, locale, scale_pct, str(scale_pct == 100), ",".join(state_names), state_records.size(), evidence_records.size(), capture_count])
|
|
print("ABG_M107_BAT002_CAPTURE_MANIFEST_OK fixture=%d viewport=%dx%d locale=%s opening=phase0 action_kind=%s action_phase=%d states=%d views=%d manifest=%s" % [fixture_index, viewport_size.x, viewport_size.y, locale, non_opening_kind, non_opening_phase, state_records.size(), evidence_records.size(), manifest_path.get_file()])
|
|
print("ABG_M109_BAT002_EVIDENCE_SEMANTICS_OK fixture=%d viewport=%dx%d locale=%s schema=m110.bat002.capture_manifest.v4 packet=%s resolved_delta=%s event=%s amount=%d direction=%s delta_text=%s placeholder_amount0=%s text_status=%s text_min_observed=%d text_token_px=%d scale_status=%s scale_proxy_all_pass=%s product_claim=false manifest=%s" % [fixture_index, viewport_size.x, viewport_size.y, locale, packet_status, _delta_proof_status(), String(proof.get("event_kind", "")), int(proof.get("amount", 0)), String(proof.get("state_delta_direction", "none")), String(proof.get("delta_text", "")), str(int(Dictionary(capture_by_name.get("05-resolved", {})).get("amount", -1)) == 0), String(text_summary.get("status", "NOT_PROVEN")), int(text_summary.get("minimum_observed_font_size", -1)), int(text_summary.get("shared_minimum_px", 0)), String(scale_summary.get("status", "NOT_PROVEN")), str(bool(scale_summary.get("all_proxy_pass", false))), manifest_path.get_file()])
|
|
print("ABG_M110_BATTLE_HUD_LEGIBILITY_OK fixture=%d viewport=%dx%d locale=%s text_scale_pct=%d reference=%s states=%d text_token=micro text_token_px=%d text_min_px=%d under_minimum=%d slot_alignment=%s rule_subslots_disjoint=%s relation_status=%s relation_events=%d fallback_anchor_uses=%d composition_variants=1 second_layout=false" % [fixture_index, viewport_size.x, viewport_size.y, locale, scale_pct, str(reference_viewport), state_records.size() + evidence_records.size(), int(text_summary.get("shared_minimum_px", 0)), int(text_summary.get("minimum_observed_font_size", -1)), int(text_summary.get("states_with_under_minimum", 0)), String(hud.get("slot_alignment", "FAIL")), str(bool(hud.get("rule_subslots_disjoint", false))), String(relation.get("status", "NOT_PROVEN")), int(relation.get("relation_events", 0)), int(relation.get("fallback_anchor_uses", 0))])
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
func _evaluate_hud_gates() -> void:
|
|
# M110 A1-A3 machine gates. A1/A2/A3 gate the reference viewports only; the
|
|
# smoke fixtures keep the same measurements recorded without product claims.
|
|
var text_summary := _text_minimum_summary()
|
|
if reference_viewport:
|
|
if int(text_summary.get("states_with_under_minimum", 0)) > 0:
|
|
failures.append("A1: %d captured states contain visible type below the %dpx applicable token" % [int(text_summary.get("states_with_under_minimum", 0)), _shared_text_minimum()])
|
|
elif String(text_summary.get("status", "")) != "PASS_MEASURED":
|
|
failures.append("A1: reference text census did not reach PASS_MEASURED")
|
|
var hud := _hud_gate_summary()
|
|
if reference_viewport and String(hud.get("slot_alignment", "FAIL")) != "PASS":
|
|
failures.append("A2: v002 slot alignment failed: %s" % str(hud.get("slot_alignment_failures", [])))
|
|
if reference_viewport and not bool(hud.get("rule_subslots_disjoint", false)):
|
|
failures.append("A2: encounter_rule subslots are not disjoint")
|
|
var relation := _relation_path_summary()
|
|
if reference_viewport:
|
|
if String(relation.get("status", "NOT_PROVEN")) != "PRESENT":
|
|
failures.append("A3: relation path measurement is %s" % String(relation.get("status", "NOT_PROVEN")))
|
|
if int(relation.get("fallback_anchor_uses", 0)) != 0:
|
|
failures.append("A3: %d authored events still resolved to fallback anchors" % int(relation.get("fallback_anchor_uses", 0)))
|
|
if int(relation.get("bridge_relation_events", 0)) < 1:
|
|
failures.append("A3: no bridge-authored event resolved its relation path")
|
|
if not bool(relation.get("proof_present", false)):
|
|
failures.append("A3: the proof event relation path is not PRESENT")
|
|
var contrast := _path_contrast_record()
|
|
if reference_viewport:
|
|
if String(contrast.get("status", "NOT_PROVEN")) != "MET":
|
|
failures.append("A5: path contrast %s (worst %.2f:1 < %.1f:1, %s)" % [
|
|
String(contrast.get("status", "NOT_PROVEN")),
|
|
float(contrast.get("worst_ratio", 0.0)),
|
|
CONTRAST_MIN,
|
|
String(contrast.get("reason", "measured local carrier")),
|
|
])
|
|
|
|
|
|
func _delta_proof_status() -> String:
|
|
return String(_delta_proof_evaluation().get("status", "NOT_PROVEN"))
|
|
|
|
|
|
func _delta_proof_evaluation() -> Dictionary:
|
|
# M109: the proof status itself folds in the strict checks, so a captured
|
|
# view can never be reported as PROVEN while it fails direction legality,
|
|
# identity, text formatting or persistence.
|
|
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
|
|
var amount := int(proof.get("amount", 0))
|
|
var direction := String(proof.get("state_delta_direction", "none"))
|
|
var delta_text := String(proof.get("delta_text", ""))
|
|
var visible := bool(proof.get("delta_visible", false))
|
|
var event_kind := String(proof.get("event_kind", ""))
|
|
var phase := int(proof.get("phase", -1))
|
|
var event_id := String(proof.get("event_id", ""))
|
|
var source_id := String(proof.get("source_id", ""))
|
|
var target_id := String(proof.get("target_id", ""))
|
|
var matrix_state := String(proof.get("matrix_state", ""))
|
|
var expected_delta_text := "" if amount == 0 else "%s%d" % ["-" if direction == "loss" else "+", amount]
|
|
var persistence: Dictionary = proof.get("persistence_measured", {})
|
|
var checks := {
|
|
"captured": not proof.is_empty(),
|
|
"resolved_state": matrix_state == "event_resolved",
|
|
"event_kind_present": not event_kind.is_empty(),
|
|
"phase_present": phase >= 0,
|
|
"amount_nonzero": amount != 0,
|
|
"legal_direction": direction in ["loss", "gain"],
|
|
"delta_visible": visible,
|
|
"delta_text_matches_amount_direction": not delta_text.is_empty() and delta_text == expected_delta_text,
|
|
"placeholder_excluded": not delta_text.is_empty() and delta_text != _delta_placeholder_copy(),
|
|
"event_identity_present": not event_id.is_empty() and not source_id.is_empty() and not target_id.is_empty(),
|
|
"persistence_identity_text_stable": (
|
|
bool(persistence.get("state_stable", false))
|
|
and bool(persistence.get("event_id_stable", false))
|
|
and bool(persistence.get("amount_stable", false))
|
|
and bool(persistence.get("direction_stable", false))
|
|
and bool(persistence.get("delta_text_stable", false))
|
|
),
|
|
}
|
|
var failed_checks: Array = []
|
|
for key in checks.keys():
|
|
if not bool(checks[key]):
|
|
failed_checks.append(String(key))
|
|
failed_checks.sort()
|
|
return {
|
|
"status": "PROVEN" if failed_checks.is_empty() else "NOT_PROVEN",
|
|
"checks": checks,
|
|
"failed_checks": failed_checks,
|
|
"expected_delta_text": expected_delta_text,
|
|
}
|
|
|
|
|
|
func _write_manifest() -> String:
|
|
var opening := Dictionary(capture_by_name.get("01-opening", {}))
|
|
var forecast := Dictionary(capture_by_name.get("02-forecast", {}))
|
|
var resolved := Dictionary(capture_by_name.get("05-resolved", {}))
|
|
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
|
|
var manifest := {
|
|
"schema": "m110.bat002.capture_manifest.v4",
|
|
"source": "runtime",
|
|
"packet_status": "COMPLETE" if failures.is_empty() else "INCOMPLETE",
|
|
"packet_failures": failures.size(),
|
|
"product_claim": false,
|
|
"fixture_index": fixture_index,
|
|
"viewport": "%dx%d" % [viewport_size.x, viewport_size.y],
|
|
"locale": locale,
|
|
"reference_viewport": reference_viewport,
|
|
"delta_proof_rule": "resolved delta proof requires a deterministic amount != 0 event; the amount=0 placeholder copy never satisfies it",
|
|
"boundaries": {
|
|
"waiting_for_next_event": {
|
|
"asserted": not waiting_copy.is_empty(),
|
|
"copy": waiting_copy,
|
|
"never_blank": not waiting_copy.is_empty(),
|
|
"matrix_state": "waiting_for_next_event",
|
|
},
|
|
"opening": {
|
|
"coverage": "boundary_pre_roll",
|
|
"matrix_state": "",
|
|
"action_coverage": false,
|
|
"state_word_visible": bool(opening.get("state_word_visible", false)),
|
|
"note": "phase-0 opening (BAT-001 to BAT-002 boundary); state word hidden by existing design (M106 F2 product decision); not counted toward forecast/committed/resolving coverage",
|
|
},
|
|
},
|
|
"action_event": {
|
|
"phase": non_opening_phase,
|
|
"kind": non_opening_kind,
|
|
"event_id": action_event_id,
|
|
"amount": int(forecast.get("amount", 0)),
|
|
"state_delta_direction": String(forecast.get("state_delta_direction", "none")),
|
|
"states": ["forecast", "committed", "resolving", "resolved"],
|
|
"resolved_placeholder": {
|
|
"event_id": String(resolved.get("event_id", "")),
|
|
"phase": int(resolved.get("phase", -1)),
|
|
"amount": int(resolved.get("amount", 0)),
|
|
"state_delta_direction": String(resolved.get("state_delta_direction", "none")),
|
|
"delta_text": String(resolved.get("delta_text", "")),
|
|
"delta_visible": bool(resolved.get("delta_visible", false)),
|
|
"delta_proof": false,
|
|
"reason": "amount=0 placeholder copy; VERIFIED + amount=0 never satisfies the delta proof",
|
|
},
|
|
},
|
|
"resolved_delta_proof": _resolved_delta_proof_record(proof),
|
|
"text_minimum": _text_minimum_summary(),
|
|
"text_scale": text_scale,
|
|
"text_token_px": _shared_text_minimum(),
|
|
"under_minimum": int(_text_minimum_summary().get("states_with_under_minimum", 0)),
|
|
"hud_legibility": _hud_legibility_record(),
|
|
"relation_path_summary": _relation_path_summary(),
|
|
"path_contrast_measurement": _path_contrast_record(),
|
|
"actor_scale_proxy": _actor_scale_summary(),
|
|
"product_findings": _product_findings(),
|
|
"ownership_deltas_measured": _ownership_delta_pairs(),
|
|
"states": state_records,
|
|
"evidence_views": evidence_records,
|
|
"failures": failures.duplicate(),
|
|
}
|
|
var path := output_root.path_join("bat002-capture-manifest-%dx%d-%s.json" % [viewport_size.x, viewport_size.y, locale])
|
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
if file == null:
|
|
failures.append("manifest write failed: %s" % path)
|
|
return ""
|
|
file.store_string(JSON.stringify(manifest, " "))
|
|
file.close()
|
|
return path
|
|
|
|
|
|
func _resolved_delta_proof_record(proof: Dictionary) -> Dictionary:
|
|
var amount := int(proof.get("amount", 0))
|
|
var direction := String(proof.get("state_delta_direction", "none"))
|
|
var delta_text := String(proof.get("delta_text", ""))
|
|
var visible := bool(proof.get("delta_visible", false))
|
|
var ownership: Dictionary = proof.get("ownership", {})
|
|
var source_side := String(ownership.get("art.relation_source_side", ""))
|
|
var target_side := String(ownership.get("art.relation_target_side", ""))
|
|
var evaluation := _delta_proof_evaluation()
|
|
return {
|
|
"status": String(evaluation.get("status", "NOT_PROVEN")),
|
|
"checks": evaluation.get("checks", {}),
|
|
"failed_checks": evaluation.get("failed_checks", []),
|
|
"expected_delta_text": String(evaluation.get("expected_delta_text", "")),
|
|
"persistence_measured": proof.get("persistence_measured", {}),
|
|
"view": RESOLVED_DELTA_NAME,
|
|
"event_kind": String(proof.get("event_kind", "")),
|
|
"event_id": String(proof.get("event_id", "")),
|
|
"phase": int(proof.get("phase", -1)),
|
|
"amount": amount,
|
|
"amount_nonzero": amount != 0,
|
|
"state_delta_direction": direction,
|
|
"source_id": String(proof.get("source_id", "")),
|
|
"target_id": String(proof.get("target_id", "")),
|
|
"matrix_state": String(proof.get("matrix_state", "")),
|
|
"delta_text": delta_text,
|
|
"delta_visible": visible,
|
|
"delta_font_color": String(proof.get("delta_font_color", "")),
|
|
"png_sha256": String(proof.get("sha256", "")),
|
|
"relation_path_measurement": {
|
|
"source_side": source_side,
|
|
"target_side": target_side,
|
|
"source_anchor_px": ownership.get("art.relation_source_anchor_px", []),
|
|
"target_anchor_px": ownership.get("art.relation_target_anchor_px", []),
|
|
"measured": bool(ownership.get("art.exists", false)),
|
|
"status": "PRESENT" if not source_side.is_empty() and not target_side.is_empty() else "NOT_PROVEN",
|
|
"reason": "BattleLiveArt measured source_side/target_side for this event; empty means no source-to-target relation path is drawn, recorded without repair",
|
|
},
|
|
"placeholder_excluded": "amount=0 placeholder copy is recorded separately and never satisfies the delta proof",
|
|
"measured": not proof.is_empty(),
|
|
}
|
|
|
|
|
|
func _ownership_delta_pairs() -> Array:
|
|
var pairs := [
|
|
["01-opening", "02-forecast"],
|
|
["02-forecast", "03-committed"],
|
|
["03-committed", "04-resolving"],
|
|
["04-resolving", "05-resolved"],
|
|
["05-resolved", RESOLVED_DELTA_NAME],
|
|
["01-opening", "05-resolved"],
|
|
]
|
|
var result: Array = []
|
|
for pair in pairs:
|
|
var from_name := String(pair[0])
|
|
var to_name := String(pair[1])
|
|
var from_record := Dictionary(capture_by_name.get(from_name, {}))
|
|
var to_record := Dictionary(capture_by_name.get(to_name, {}))
|
|
if from_record.is_empty() or to_record.is_empty():
|
|
result.append({
|
|
"from": from_name,
|
|
"to": to_name,
|
|
"method": "computed_from_measured_node_fields",
|
|
"status": "NOT_PROVEN",
|
|
"reason": "one or both captures are missing",
|
|
})
|
|
continue
|
|
var changes := _measured_changes(
|
|
Dictionary(from_record.get("ownership", {})),
|
|
Dictionary(to_record.get("ownership", {}))
|
|
)
|
|
var from_hash := String(from_record.get("sha256", ""))
|
|
var to_hash := String(to_record.get("sha256", ""))
|
|
if from_hash != to_hash:
|
|
changes.append({"field": "png_sha256", "from": from_hash.substr(0, 16), "to": to_hash.substr(0, 16), "changed": true})
|
|
result.append({
|
|
"from": from_name,
|
|
"to": to_name,
|
|
"method": "computed_from_measured_node_fields",
|
|
"status": "MEASURED" if not changes.is_empty() else "NO_CHANGE_MEASURED",
|
|
"changes": changes,
|
|
})
|
|
return result
|
|
|
|
|
|
func _measured_changes(from_fields: Dictionary, to_fields: Dictionary) -> Array:
|
|
var changes: Array = []
|
|
var keys: Array = from_fields.keys()
|
|
keys.sort()
|
|
for key in keys:
|
|
var field := String(key)
|
|
if not to_fields.has(field):
|
|
changes.append({"field": field, "from": from_fields[key], "to": null, "changed": true})
|
|
elif to_fields[field] != from_fields[field]:
|
|
changes.append({"field": field, "from": from_fields[key], "to": to_fields[field], "changed": true})
|
|
for key in to_fields.keys():
|
|
var field := String(key)
|
|
if not from_fields.has(field):
|
|
changes.append({"field": field, "from": null, "to": to_fields[key], "changed": true})
|
|
return changes
|
|
|
|
|
|
func _new_shell(parent: Node) -> Control:
|
|
var shell := EntryShell.new()
|
|
shell.auto_start = false
|
|
shell.initial_company_service.path = DRAFT_PATH
|
|
shell.initial_company_service.company_path = COMPANY_PATH
|
|
parent.add_child(shell)
|
|
await _frames(3)
|
|
var settings := SettingsStore.new().defaults()
|
|
settings.setup_completed = true
|
|
settings.locale = locale
|
|
settings.text_scale = text_scale
|
|
shell.settings = settings
|
|
shell.setup_draft = settings.duplicate(true)
|
|
return shell
|
|
|
|
|
|
func _capture(viewport: SubViewport, shell: Control, state_name: String, role := "state", extra := {}) -> Dictionary:
|
|
_verify_layout(shell, state_name)
|
|
await RenderingServer.frame_post_draw
|
|
var image := viewport.get_texture().get_image()
|
|
var path := output_root.path_join("battle-live-timeline-%dx%d-%s-%d-%s.png" % [viewport_size.x, viewport_size.y, locale, int(round(text_scale * 100.0)), state_name])
|
|
if image.is_empty() or image.get_width() != viewport_size.x or image.get_height() != viewport_size.y:
|
|
failures.append("invalid capture image for %s" % state_name)
|
|
return {}
|
|
if image.save_png(path) != OK:
|
|
failures.append("capture failed for %s" % state_name)
|
|
return {}
|
|
capture_count += 1
|
|
var record := {
|
|
"name": state_name,
|
|
"role": role,
|
|
"state": String(shell.battle_live_state),
|
|
"phase": int(shell.battle_live_event.get("phase", -1)),
|
|
"event_kind": String(shell.battle_live_event.get("kind", "")),
|
|
"file": path.get_file(),
|
|
"sha256": _file_sha256(path),
|
|
"path_contrast": _path_contrast(image, shell),
|
|
}
|
|
record.merge(_state_evidence(shell), true)
|
|
record.merge(extra, true)
|
|
capture_by_name[state_name] = record
|
|
if role == "state" or role == "boundary":
|
|
state_records.append(record)
|
|
else:
|
|
evidence_records.append(record)
|
|
return record
|
|
|
|
|
|
func _state_evidence(shell: Control) -> Dictionary:
|
|
var state_word := shell.find_child("BattleLiveEventState", true, false) as Label
|
|
var event_id_label := shell.find_child("BattleLiveEventId", true, false) as Label
|
|
var delta := shell.find_child("BattleLiveEventDelta", true, false) as Label
|
|
var amount := int(shell.battle_live_event.get("amount", 0))
|
|
var direction := String(Dictionary(shell.battle_live_event.get("state_delta", {})).get("direction", "none"))
|
|
return {
|
|
"matrix_state": _matrix_state_for(String(shell.battle_live_state)),
|
|
"next_phase_index": int(shell.battle_live_observation.get("next_phase_index", -1)),
|
|
"event_id": String(shell.battle_live_event.get("event_id", "")),
|
|
"source_id": String(shell.battle_live_event.get("actor_id", "")),
|
|
"target_id": String(shell.battle_live_event.get("target_id", "")),
|
|
"amount": amount,
|
|
"state_delta_direction": direction,
|
|
"delta_proof_eligible": amount != 0,
|
|
"state_word": state_word.text if state_word != null else "",
|
|
"state_word_visible": state_word != null and state_word.visible,
|
|
"event_id_visible": event_id_label != null and event_id_label.visible,
|
|
"delta_text": delta.text if delta != null else "",
|
|
"delta_visible": delta != null and delta.visible,
|
|
"delta_font_color": _color_hex(delta.get_theme_color("font_color")) if delta != null else "",
|
|
"ownership": _ownership_fields(shell),
|
|
"visible_text_census": _visible_text_census(shell),
|
|
"hud_measurements": _hud_measurements(shell),
|
|
"actor_scale_proxy": _actor_scale_proxy(shell),
|
|
}
|
|
|
|
|
|
func _delta_placeholder_copy() -> String:
|
|
return "已验证" if locale == "zh_CN" else "VERIFIED"
|
|
|
|
|
|
func _color_hex(color: Color) -> String:
|
|
return color.to_html(false)
|
|
|
|
|
|
func _rounded(value: float) -> float:
|
|
return snappedf(value, 0.1)
|
|
|
|
|
|
func _rounded_fraction(value: float) -> float:
|
|
return snappedf(value, 0.0001)
|
|
|
|
|
|
func _rect_values(control: Control) -> Array:
|
|
var rect := control.get_global_rect()
|
|
return [_rounded(rect.position.x), _rounded(rect.position.y), _rounded(rect.size.x), _rounded(rect.size.y)]
|
|
|
|
|
|
func _measure_label(label: Label, prefix: String, fields: Dictionary) -> void:
|
|
if label == null:
|
|
fields["%s.exists" % prefix] = false
|
|
fields["%s.measurement_status" % prefix] = "NOT_PROVEN"
|
|
return
|
|
fields["%s.exists" % prefix] = true
|
|
fields["%s.node_name" % prefix] = String(label.name)
|
|
fields["%s.visible" % prefix] = label.visible
|
|
fields["%s.visible_in_tree" % prefix] = label.is_visible_in_tree()
|
|
fields["%s.text" % prefix] = label.text
|
|
fields["%s.font_size" % prefix] = int(label.get_theme_font_size("font_size"))
|
|
fields["%s.rect" % prefix] = _rect_values(label)
|
|
|
|
|
|
func _rect_array(rect: Rect2) -> Array:
|
|
return [_rounded(rect.position.x), _rounded(rect.position.y), _rounded(rect.size.x), _rounded(rect.size.y)]
|
|
|
|
|
|
func _normalized_array(rect: Rect2) -> Array:
|
|
return [
|
|
_rounded_fraction(rect.position.x / DESIGN_SIZE.x),
|
|
_rounded_fraction(rect.position.y / DESIGN_SIZE.y),
|
|
_rounded_fraction(rect.size.x / DESIGN_SIZE.x),
|
|
_rounded_fraction(rect.size.y / DESIGN_SIZE.y),
|
|
]
|
|
|
|
|
|
func _design_slot(norm: Array) -> Rect2:
|
|
return Rect2(
|
|
float(norm[0]) * DESIGN_SIZE.x,
|
|
float(norm[1]) * DESIGN_SIZE.y,
|
|
float(norm[2]) * DESIGN_SIZE.x,
|
|
float(norm[3]) * DESIGN_SIZE.y
|
|
)
|
|
|
|
|
|
func _design_scale(canvas: Control) -> Vector2:
|
|
if canvas == null or canvas.size.x <= 0.0 or canvas.size.y <= 0.0:
|
|
return Vector2.ONE
|
|
return DESIGN_SIZE / canvas.size
|
|
|
|
|
|
func _design_rect(control: Control, canvas: Control) -> Rect2:
|
|
var global := control.get_global_rect()
|
|
if canvas == null:
|
|
return global
|
|
var local := global.position - canvas.get_global_rect().position
|
|
return Rect2(local * _design_scale(canvas), global.size * _design_scale(canvas))
|
|
|
|
|
|
func _label_ink_size(label: Label, rect: Rect2) -> Vector2:
|
|
var font := label.get_theme_font("font")
|
|
if font == null or label.text.is_empty():
|
|
return Vector2.ZERO
|
|
var font_size := int(label.get_theme_font_size("font_size"))
|
|
if label.autowrap_mode == TextServer.AUTOWRAP_OFF:
|
|
return font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
|
return font.get_multiline_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, rect.size.x, font_size)
|
|
|
|
|
|
func _ink_box_for(label: Label, rect: Rect2, ink: Vector2) -> Rect2:
|
|
if ink == Vector2.ZERO:
|
|
return rect
|
|
var x := rect.position.x
|
|
match label.horizontal_alignment:
|
|
HORIZONTAL_ALIGNMENT_CENTER: x = rect.position.x + (rect.size.x - ink.x) * 0.5
|
|
HORIZONTAL_ALIGNMENT_RIGHT: x = rect.end.x - ink.x
|
|
var y := rect.position.y
|
|
match label.vertical_alignment:
|
|
VERTICAL_ALIGNMENT_CENTER: y = rect.position.y + (rect.size.y - ink.y) * 0.5
|
|
VERTICAL_ALIGNMENT_BOTTOM: y = rect.end.y - ink.y
|
|
return Rect2(Vector2(x, y), ink)
|
|
|
|
|
|
func _design_ink_box(label: Label, canvas: Control) -> Rect2:
|
|
var rect := label.get_global_rect()
|
|
var ink := _label_ink_size(label, rect)
|
|
var box := _ink_box_for(label, rect, ink)
|
|
var canvas_origin := canvas.get_global_rect().position if canvas != null else Vector2.ZERO
|
|
return Rect2((box.position - canvas_origin) * _design_scale(canvas), box.size * _design_scale(canvas))
|
|
|
|
|
|
func _label_ink_margins(label: Label, rect: Rect2, ink: Vector2) -> Array:
|
|
var margin_x := 0.0 if rect.size.x <= 0.0 else 1.0 - ink.x / rect.size.x
|
|
var margin_y := 0.0 if rect.size.y <= 0.0 else 1.0 - ink.y / rect.size.y
|
|
return [snappedf(margin_x, 0.0001), snappedf(margin_y, 0.0001)]
|
|
|
|
|
|
func _hud_node_record(shell: Control, canvas: Control, node_name: String, slot_id: String, slot_norm: Array) -> Dictionary:
|
|
var label := shell.find_child(node_name, true, false) as Label
|
|
var record := {"node": node_name, "authored_slot_id": slot_id, "exists": label != null, "measurement_status": "NOT_PROVEN"}
|
|
if label == null:
|
|
return record
|
|
var rect := label.get_global_rect()
|
|
var design_rect := _design_rect(label, canvas)
|
|
var slot := _design_slot(slot_norm)
|
|
var corner_delta := [
|
|
absf(design_rect.position.x - slot.position.x),
|
|
absf(design_rect.position.y - slot.position.y),
|
|
absf(design_rect.end.x - slot.end.x),
|
|
absf(design_rect.end.y - slot.end.y),
|
|
]
|
|
var delta: float = corner_delta[0]
|
|
for value in corner_delta:
|
|
delta = maxf(delta, float(value))
|
|
var ink := _label_ink_size(label, rect)
|
|
var ink_box := _ink_box_for(label, rect, ink)
|
|
var design_ink := Rect2((ink_box.position - canvas.get_global_rect().position) * _design_scale(canvas), ink_box.size * _design_scale(canvas))
|
|
var margins := _label_ink_margins(label, rect, ink)
|
|
var ellipsized := label.text_overrun_behavior != TextServer.OVERRUN_NO_TRIMMING and ink.x > rect.size.x + 0.5
|
|
record.merge({
|
|
"visible": label.is_visible_in_tree(),
|
|
"text": label.text,
|
|
"font_size_px": int(label.get_theme_font_size("font_size")),
|
|
"applicable_token_px": _shared_text_minimum(),
|
|
"owner_rect_px": _rect_array(design_rect),
|
|
"owner_rect_normalized": _normalized_array(design_rect),
|
|
"slot_rect_px": _rect_array(slot),
|
|
"slot_delta_px": _rounded(delta),
|
|
"ink_box_px": _rect_array(design_ink),
|
|
"ink_inside_rect": (
|
|
design_ink.position.x >= design_rect.position.x - 0.5
|
|
and design_ink.position.y >= design_rect.position.y - 0.5
|
|
and design_ink.end.x <= design_rect.end.x + 0.5
|
|
and design_ink.end.y <= design_rect.end.y + 0.5
|
|
),
|
|
"ink_margin_ratio_x": margins[0],
|
|
"ink_margin_ratio_y": margins[1],
|
|
"lines_used": label.get_line_count(),
|
|
"visible_lines": label.get_visible_line_count(),
|
|
"ellipsized": ellipsized,
|
|
"measurement_status": "MEASURED",
|
|
}, true)
|
|
var shipped: Dictionary = LONGEST_SHIPPED.get(node_name, {})
|
|
var shipped_text := String(shipped.get("zh" if locale == "zh_CN" else "en", ""))
|
|
if not shipped_text.is_empty():
|
|
var font := label.get_theme_font("font")
|
|
var shipped_ink := font.get_string_size(shipped_text, HORIZONTAL_ALIGNMENT_LEFT, -1, int(label.get_theme_font_size("font_size"))) if font != null else Vector2.ZERO
|
|
var shipped_margins := _label_ink_margins(label, design_rect, shipped_ink)
|
|
record["longest_shipped_text"] = shipped_text
|
|
record["longest_shipped_ink_px"] = [_rounded(shipped_ink.x), _rounded(shipped_ink.y)]
|
|
record["longest_shipped_ink_inside_rect"] = shipped_ink.x <= design_rect.size.x + 0.5 and shipped_ink.y <= design_rect.size.y + 0.5
|
|
record["longest_shipped_margin_ratio_x"] = shipped_margins[0]
|
|
record["longest_shipped_margin_ratio_y"] = shipped_margins[1]
|
|
return record
|
|
|
|
|
|
func _rule_band_record(shell: Control, canvas: Control, nodes: Dictionary) -> Dictionary:
|
|
var a: Dictionary = Dictionary(nodes.get("BattleLiveNoRespawn", {}))
|
|
var b: Dictionary = Dictionary(nodes.get("BattleLiveNoReserve", {}))
|
|
var band := _design_slot(HUD_RULE_BAND)
|
|
var record := {
|
|
"exists": bool(a.get("exists", false)) and bool(b.get("exists", false)),
|
|
"band_rect_px": _rect_array(band),
|
|
"measurement_status": "NOT_PROVEN",
|
|
}
|
|
if not bool(record.exists):
|
|
return record
|
|
var ra := Rect2(a.owner_rect_px[0], a.owner_rect_px[1], a.owner_rect_px[2], a.owner_rect_px[3])
|
|
var rb := Rect2(b.owner_rect_px[0], b.owner_rect_px[1], b.owner_rect_px[2], b.owner_rect_px[3])
|
|
var union := ra.merge(rb)
|
|
var union_delta := maxf(maxf(absf(union.position.x - band.position.x), absf(union.position.y - band.position.y)), maxf(absf(union.end.x - band.end.x), absf(union.end.y - band.end.y)))
|
|
var disjoint := not ra.intersects(rb)
|
|
var neither_spans := ra.size.x < band.size.x - 0.5 and rb.size.x < band.size.x - 0.5
|
|
var gap := 0.0
|
|
if disjoint:
|
|
gap = maxf(ra.position.x - rb.end.x, rb.position.x - ra.end.x)
|
|
record.merge({
|
|
"subslot_a_rect_px": _rect_array(ra),
|
|
"subslot_b_rect_px": _rect_array(rb),
|
|
"subslot_a_rect_normalized": a.owner_rect_normalized,
|
|
"subslot_b_rect_normalized": b.owner_rect_normalized,
|
|
"union_rect_px": _rect_array(union),
|
|
"band_union_delta_px": _rounded(union_delta),
|
|
"disjoint": disjoint,
|
|
"subslot_gap_px": _rounded(gap),
|
|
"neither_spans_band": neither_spans,
|
|
"measurement_status": "MEASURED",
|
|
}, true)
|
|
return record
|
|
|
|
|
|
func _relation_label_record(shell: Control, canvas: Control) -> Dictionary:
|
|
var label := shell.find_child("BattleLiveEventDetail", true, false) as Label
|
|
var record := {"exists": label != null, "measurement_status": "NOT_PROVEN"}
|
|
if label == null:
|
|
return record
|
|
var design_rect := _design_rect(label, canvas)
|
|
var design_ink := _design_ink_box(label, canvas)
|
|
var corridor := Rect2(RELATION_CORRIDOR.position * DESIGN_SIZE, RELATION_CORRIDOR.size * DESIGN_SIZE)
|
|
record.merge({
|
|
"visible": label.is_visible_in_tree(),
|
|
"text": label.text,
|
|
"placement_id": String(label.get_meta("relation_placement_id", "")),
|
|
"rect_px": _rect_array(design_rect),
|
|
"rect_normalized": _normalized_array(design_rect),
|
|
"clearance_px": _rounded(float(label.get_meta("relation_clearance_px", -1.0))),
|
|
"lines": int(label.get_meta("relation_lines", 0)),
|
|
"token_px": int(label.get_meta("relation_token_px", 0)),
|
|
"ink_box_px": _rect_array(design_ink),
|
|
"center_in_corridor": corridor.has_point(design_rect.get_center()),
|
|
"ink_inside_corridor": (
|
|
design_ink.position.x >= corridor.position.x - 0.5
|
|
and design_ink.position.y >= corridor.position.y - 0.5
|
|
and design_ink.end.x <= corridor.end.x + 0.5
|
|
and design_ink.end.y <= corridor.end.y + 0.5
|
|
),
|
|
"corridor_rect_px": _rect_array(corridor),
|
|
"measurement_status": "MEASURED",
|
|
}, true)
|
|
return record
|
|
|
|
|
|
func _hud_measurements(shell: Control) -> Dictionary:
|
|
var canvas := shell.find_child("BattleLiveCanvas", true, false) as Control
|
|
var nodes := {}
|
|
for node_name in HUD_FIXED_SLOTS.keys():
|
|
var spec: Dictionary = HUD_FIXED_SLOTS[node_name]
|
|
nodes[node_name] = _hud_node_record(shell, canvas, node_name, String(spec.slot_id), spec.norm)
|
|
for node_name in HUD_RULE_SUBSLOTS.keys():
|
|
var spec: Dictionary = HUD_RULE_SUBSLOTS[node_name]
|
|
nodes[node_name] = _hud_node_record(shell, canvas, node_name, String(spec.slot_id), spec.norm)
|
|
return {
|
|
"nodes": nodes,
|
|
"rule_band": _rule_band_record(shell, canvas, nodes),
|
|
"relation_label": _relation_label_record(shell, canvas),
|
|
}
|
|
|
|
|
|
func _hud_gate_summary() -> Dictionary:
|
|
if not hud_gate_cache.is_empty():
|
|
return hud_gate_cache
|
|
var alignment_failures: Array = []
|
|
var disjoint := true
|
|
var disjoint_seen := false
|
|
var relation_non_omitted := 0
|
|
var relation_omitted := 0
|
|
var max_lines := 2 if text_scale > 1.15 else 1
|
|
for name_value in capture_by_name.keys():
|
|
var record: Dictionary = capture_by_name[name_value]
|
|
var hud: Dictionary = record.get("hud_measurements", {})
|
|
if hud.is_empty():
|
|
continue
|
|
var nodes: Dictionary = hud.get("nodes", {})
|
|
var capture_label := String(record.get("name", name_value))
|
|
for node_name in HUD_FIXED_SLOTS.keys():
|
|
var node: Dictionary = Dictionary(nodes.get(node_name, {}))
|
|
_check_hud_node(capture_label, node, max_lines, alignment_failures)
|
|
for node_name in HUD_RULE_SUBSLOTS.keys():
|
|
var node: Dictionary = Dictionary(nodes.get(node_name, {}))
|
|
_check_hud_node(capture_label, node, max_lines, alignment_failures)
|
|
var rule: Dictionary = hud.get("rule_band", {})
|
|
if bool(rule.get("exists", false)):
|
|
disjoint_seen = true
|
|
if not bool(rule.get("disjoint", false)) or not bool(rule.get("neither_spans_band", false)):
|
|
disjoint = false
|
|
alignment_failures.append("%s rule subslots not disjoint or full-band" % capture_label)
|
|
if float(rule.get("band_union_delta_px", 99.0)) > SLOT_TOLERANCE_PX:
|
|
alignment_failures.append("%s rule union delta %.1fpx" % [capture_label, float(rule.get("band_union_delta_px", 99.0))])
|
|
var relation: Dictionary = hud.get("relation_label", {})
|
|
if bool(relation.get("visible", false)):
|
|
var placement := String(relation.get("placement_id", ""))
|
|
if placement.is_empty() or placement == "omitted_crowded":
|
|
relation_omitted += 1
|
|
alignment_failures.append("%s relation copy omitted (%s)" % [capture_label, placement])
|
|
else:
|
|
relation_non_omitted += 1
|
|
if float(relation.get("clearance_px", -99.0)) < RELATION_MIN_CLEARANCE_PX:
|
|
alignment_failures.append("%s relation clearance %.1fpx" % [capture_label, float(relation.get("clearance_px", -99.0))])
|
|
if reference_viewport and int(relation.get("token_px", 0)) != _shared_text_minimum():
|
|
alignment_failures.append("%s relation token %d != %d" % [capture_label, int(relation.get("token_px", 0)), _shared_text_minimum()])
|
|
if int(relation.get("lines", 0)) > max_lines:
|
|
alignment_failures.append("%s relation lines %d > %d" % [capture_label, int(relation.get("lines", 0)), max_lines])
|
|
if not bool(relation.get("center_in_corridor", false)) or not bool(relation.get("ink_inside_corridor", false)):
|
|
alignment_failures.append("%s relation copy outside the authored corridor" % capture_label)
|
|
hud_gate_cache = {
|
|
"slot_alignment": "PASS" if alignment_failures.is_empty() else "FAIL",
|
|
"slot_alignment_failures": alignment_failures,
|
|
"rule_subslots_disjoint": disjoint_seen and disjoint,
|
|
"relation_non_omitted_measurements": relation_non_omitted,
|
|
"relation_omitted_measurements": relation_omitted,
|
|
"slot_tolerance_px": SLOT_TOLERANCE_PX,
|
|
"applicable_token_px": _shared_text_minimum(),
|
|
"text_scale": text_scale,
|
|
"reference_viewport": reference_viewport,
|
|
"product_claim": false,
|
|
}
|
|
return hud_gate_cache
|
|
|
|
|
|
func _check_hud_node(capture_label: String, node: Dictionary, max_lines: int, alignment_failures: Array) -> void:
|
|
var node_name := String(node.get("node", ""))
|
|
if not bool(node.get("exists", false)):
|
|
alignment_failures.append("%s %s missing" % [capture_label, node_name])
|
|
return
|
|
if float(node.get("slot_delta_px", 99.0)) > SLOT_TOLERANCE_PX:
|
|
alignment_failures.append("%s %s slot delta %.1fpx" % [capture_label, node_name, float(node.get("slot_delta_px", 99.0))])
|
|
if reference_viewport and int(node.get("font_size_px", 0)) < _shared_text_minimum():
|
|
alignment_failures.append("%s %s font %dpx below token" % [capture_label, node_name, int(node.get("font_size_px", 0))])
|
|
if bool(node.get("ellipsized", false)):
|
|
alignment_failures.append("%s %s ellipsized" % [capture_label, node_name])
|
|
if int(node.get("lines_used", 0)) > max_lines:
|
|
alignment_failures.append("%s %s lines %d > %d" % [capture_label, node_name, int(node.get("lines_used", 0)), max_lines])
|
|
if bool(node.get("visible", false)):
|
|
if not bool(node.get("ink_inside_rect", false)):
|
|
alignment_failures.append("%s %s ink outside owner rect" % [capture_label, node_name])
|
|
if float(node.get("ink_margin_ratio_x", -1.0)) < 0.06 or float(node.get("ink_margin_ratio_y", -1.0)) < 0.06:
|
|
alignment_failures.append("%s %s ink margin below 6%%" % [capture_label, node_name])
|
|
if not bool(node.get("longest_shipped_ink_inside_rect", false)):
|
|
alignment_failures.append("%s %s longest shipped string does not fit" % [capture_label, node_name])
|
|
if float(node.get("longest_shipped_margin_ratio_x", -1.0)) < 0.06 or float(node.get("longest_shipped_margin_ratio_y", -1.0)) < 0.06:
|
|
alignment_failures.append("%s %s longest shipped string margin below 6%%" % [capture_label, node_name])
|
|
|
|
|
|
func _relation_path_summary() -> Dictionary:
|
|
if not relation_summary_cache.is_empty():
|
|
return relation_summary_cache
|
|
var relation_events := 0
|
|
var bridge_events := 0
|
|
var fallback_uses := 0
|
|
var proof_present := false
|
|
var measured_states := 0
|
|
for name_value in capture_by_name.keys():
|
|
var record: Dictionary = capture_by_name[name_value]
|
|
var fields: Dictionary = record.get("ownership", {})
|
|
var kind := String(record.get("event_kind", ""))
|
|
var source_id := String(record.get("source_id", ""))
|
|
var target_id := String(record.get("target_id", ""))
|
|
var source_side := String(fields.get("art.relation_source_side", ""))
|
|
var target_side := String(fields.get("art.relation_target_side", ""))
|
|
var source_anchor: Array = fields.get("art.relation_source_anchor_px", [])
|
|
var target_anchor: Array = fields.get("art.relation_target_anchor_px", [])
|
|
if not kind.is_empty():
|
|
measured_states += 1
|
|
if not source_side.is_empty() and not target_side.is_empty():
|
|
relation_events += 1
|
|
if source_id.begins_with("ally-") or source_id.begins_with("enemy-") or target_id.begins_with("ally-") or target_id.begins_with("enemy-"):
|
|
bridge_events += 1
|
|
if not source_id.is_empty() and source_anchor.size() == 2 and absf(float(source_anchor[0]) - RELATION_FALLBACK_SOURCE_PX.x) <= 0.5 and absf(float(source_anchor[1]) - RELATION_FALLBACK_SOURCE_PX.y) <= 0.5:
|
|
fallback_uses += 1
|
|
if not target_id.is_empty() and target_anchor.size() == 2 and absf(float(target_anchor[0]) - RELATION_FALLBACK_TARGET_PX.x) <= 0.5 and absf(float(target_anchor[1]) - RELATION_FALLBACK_TARGET_PX.y) <= 0.5:
|
|
fallback_uses += 1
|
|
if name_value == RESOLVED_DELTA_NAME and not source_side.is_empty() and not target_side.is_empty():
|
|
proof_present = true
|
|
relation_summary_cache = {
|
|
"status": "PRESENT" if relation_events > 0 else "NOT_PROVEN",
|
|
"relation_events": relation_events,
|
|
"bridge_relation_events": bridge_events,
|
|
"fallback_anchor_uses": fallback_uses,
|
|
"proof_present": proof_present,
|
|
"measured_states": measured_states,
|
|
"fallback_source_anchor_px": [RELATION_FALLBACK_SOURCE_PX.x, RELATION_FALLBACK_SOURCE_PX.y],
|
|
"fallback_target_anchor_px": [RELATION_FALLBACK_TARGET_PX.x, RELATION_FALLBACK_TARGET_PX.y],
|
|
}
|
|
return relation_summary_cache
|
|
|
|
|
|
func _hud_legibility_record() -> Dictionary:
|
|
var reference_capture := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
|
|
if reference_capture.is_empty():
|
|
reference_capture = Dictionary(capture_by_name.get("05-resolved", {}))
|
|
var measurements: Dictionary = reference_capture.get("hud_measurements", {})
|
|
return {
|
|
"gate": _hud_gate_summary(),
|
|
"measured_nodes": measurements.get("nodes", {}),
|
|
"rule_band": measurements.get("rule_band", {}),
|
|
"relation_label": measurements.get("relation_label", {}),
|
|
"reference_capture": String(reference_capture.get("name", "")),
|
|
"longest_shipped_source": "runtime/tests/dev5/battle_live_capture_test.gd LONGEST_SHIPPED",
|
|
}
|
|
|
|
|
|
func _path_contrast_record() -> Dictionary:
|
|
var per_capture: Array = []
|
|
var worst := 999.0
|
|
var applicable := 0
|
|
for name_value in capture_by_name.keys():
|
|
var record: Dictionary = capture_by_name[name_value]
|
|
var contrast: Dictionary = record.get("path_contrast", {})
|
|
var status := String(contrast.get("status", ""))
|
|
if contrast.is_empty() or status == "NOT_APPLICABLE" or status == "NOT_PROVEN":
|
|
continue
|
|
applicable += 1
|
|
per_capture.append({
|
|
"capture": String(record.get("name", name_value)),
|
|
"status": status,
|
|
"worst_ratio": float(contrast.get("worst_ratio", 0.0)),
|
|
"core_ratio": float(contrast.get("core_ratio", 0.0)),
|
|
"core_carrier_luminance": float(contrast.get("core_carrier_luminance", -1.0)),
|
|
"source_marker_ratio": float(contrast.get("source_marker_ratio", 0.0)),
|
|
"target_marker_ratio": float(contrast.get("target_marker_ratio", 0.0)),
|
|
"points_skipped": int(contrast.get("core_points_skipped", 0)),
|
|
"stroke": String(contrast.get("stroke", "")),
|
|
})
|
|
worst = minf(worst, float(contrast.get("worst_ratio", 0.0)))
|
|
if per_capture.is_empty():
|
|
return {
|
|
"status": "NOT_PROVEN",
|
|
"reason": "no captured state renders the relation path with a visible art node",
|
|
"contrast_min": CONTRAST_MIN,
|
|
"method": _path_contrast_method(),
|
|
}
|
|
return {
|
|
"status": "MET" if worst >= CONTRAST_MIN else "NOT_MET",
|
|
"worst_ratio": snappedf(worst, 0.01),
|
|
"contrast_min": CONTRAST_MIN,
|
|
"applicable_captures": applicable,
|
|
"method": _path_contrast_method(),
|
|
"per_capture": per_capture,
|
|
}
|
|
|
|
|
|
func _path_contrast_method() -> String:
|
|
return "M110 A5 local carrier: walk outward from the bright core (1.6-5.0px), first non-chrome pixel = carrier (UI-ink rects and palette-blend chrome with t>=0.15 excluded; actor-portrait pixels only as recorded fallback); per-point min gate; 1px annulus outside each bright ring; dynamic-neutral NOT_APPLICABLE when the art node is hidden; CONTRAST_MIN=%.1f" % CONTRAST_MIN
|
|
|
|
|
|
func _event_color_for(kind: String) -> Color:
|
|
match kind:
|
|
"healing": return Color("#70c59b", 0.96)
|
|
"guard", "guarded_recovery": return Color("#73b8dc", 0.96)
|
|
"magic": return Color("#b58bd6", 0.96)
|
|
"knockout": return Color("#e16c67", 0.98)
|
|
"result": return Color("#dfbd68", 0.98)
|
|
return Color("#e2b75f", 0.96)
|
|
|
|
|
|
func _luminance(color: Color) -> float:
|
|
var linear: Array = []
|
|
for value in [color.r, color.g, color.b]:
|
|
linear.append(value / 12.92 if value <= 0.03928 else pow((value + 0.055) / 1.055, 2.4))
|
|
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
|
|
|
|
|
func _contrast_ratio(left: Color, right: Color) -> float:
|
|
var lighter := maxf(_luminance(left), _luminance(right))
|
|
var darker := minf(_luminance(left), _luminance(right))
|
|
return (lighter + 0.05) / (darker + 0.05)
|
|
|
|
|
|
func _carrier_p95(samples: Array) -> Color:
|
|
if samples.is_empty():
|
|
return Color.BLACK
|
|
samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b))
|
|
return samples[mini(samples.size() - 1, int(float(samples.size() - 1) * 0.95))]
|
|
|
|
|
|
func _median_color(samples: Array) -> Color:
|
|
if samples.is_empty():
|
|
return Color.BLACK
|
|
var sorted := samples.duplicate()
|
|
sorted.sort_custom(func(a, b): return _luminance(a) < _luminance(b))
|
|
return sorted[sorted.size() / 2]
|
|
|
|
|
|
func _visible_ui_ink_rects(shell: Control) -> Array:
|
|
var rects: Array = []
|
|
var nodes: Array = []
|
|
_visible_text_nodes(shell, nodes)
|
|
for node in nodes:
|
|
rects.append((node as Control).get_global_rect().grow(1.0))
|
|
return rects
|
|
|
|
|
|
func _visible_actor_rects(shell: Control) -> Array:
|
|
var rects: Array = []
|
|
for actor_group in ["BattleActors", "BattleEnemies"]:
|
|
var group := shell.find_child(actor_group, true, false)
|
|
if group == null:
|
|
continue
|
|
for actor in group.get_children():
|
|
rects.append((actor as Control).get_global_rect().grow(1.0))
|
|
return rects
|
|
|
|
|
|
func _visible_text_nodes(node: Node, out: Array) -> void:
|
|
for child in node.get_children():
|
|
if (child is Label or child is BaseButton) and (child as CanvasItem).is_visible_in_tree():
|
|
out.append(child)
|
|
_visible_text_nodes(child, out)
|
|
|
|
|
|
func _point_in_any_rect(rects: Array, point: Vector2) -> bool:
|
|
for rect_value in rects:
|
|
if (rect_value as Rect2).has_point(point):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _point_near_polyline(point: Vector2, polyline: Array, radius: float) -> bool:
|
|
for index in range(polyline.size() - 1):
|
|
if _distance_to_segment(point, polyline[index], polyline[index + 1]) <= radius:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _distance_to_polyline(point: Vector2, polyline: Array) -> float:
|
|
var best := 1.0e9
|
|
for index in range(polyline.size() - 1):
|
|
best = minf(best, _distance_to_segment(point, polyline[index], polyline[index + 1]))
|
|
return best
|
|
|
|
|
|
func _distance_to_segment(point: Vector2, a: Vector2, b: Vector2) -> float:
|
|
var ab := b - a
|
|
var length_squared := ab.length_squared()
|
|
if length_squared <= 0.0001:
|
|
return point.distance_to(a)
|
|
var t := clampf((point - a).dot(ab) / length_squared, 0.0, 1.0)
|
|
return point.distance_to(a + ab * t)
|
|
|
|
|
|
func _is_palette_blend(color: Color, stroke: Color, underlays: Array) -> bool:
|
|
# A pixel is chrome when it lies on the underlay -> event-hue blend line at
|
|
# t >= 0.15 (the core, echoes, arrowheads, rings and flight marks are all
|
|
# drawn in that family). Pure underlay pixels (t < 0.15) stay valid carriers.
|
|
for underlay_value in underlays:
|
|
var underlay: Color = underlay_value
|
|
var direction := Vector3(stroke.r - underlay.r, stroke.g - underlay.g, stroke.b - underlay.b)
|
|
var to_color := Vector3(color.r - underlay.r, color.g - underlay.g, color.b - underlay.b)
|
|
var length_squared := direction.length_squared()
|
|
var t := 0.0 if length_squared <= 0.000001 else clampf(direction.dot(to_color) / length_squared, 0.0, 1.0)
|
|
if t < 0.15:
|
|
continue
|
|
var closest := underlay + Color(direction.x * t, direction.y * t, direction.z * t)
|
|
var distance := absf(color.r - closest.r) + absf(color.g - closest.g) + absf(color.b - closest.b)
|
|
if distance < 0.18:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _core_carrier_at(image: Image, point: Vector2, normal: Vector2, side: float, stroke: Color, underlays: Array, ui_rects: Array, actor_rects: Array) -> Dictionary:
|
|
# Walk outward from the bright core and take the first non-chrome pixel:
|
|
# with the A5 underlay in place this is the underlay; without it, the raw
|
|
# rendered carrier is returned (so the measurement can still fail honestly).
|
|
# A first valid pixel inside an actor portrait is a sprite occluder, not a
|
|
# carrier: the point is reported as occluded and excluded (M110 A5 rule).
|
|
for step in range(7):
|
|
var distance := 1.6 + 0.5 * float(step)
|
|
var probe := point + normal * distance * side
|
|
var px := int(round(probe.x))
|
|
var py := int(round(probe.y))
|
|
if px < 0 or py < 0 or px >= image.get_width() or py >= image.get_height():
|
|
continue
|
|
var pixel_position := Vector2(px, py)
|
|
if _point_in_any_rect(ui_rects, pixel_position):
|
|
continue
|
|
var color := image.get_pixel(px, py)
|
|
if _is_palette_blend(color, stroke, underlays):
|
|
continue
|
|
return {
|
|
"color": color,
|
|
"distance": distance,
|
|
"occluded": _point_in_any_rect(actor_rects, pixel_position),
|
|
}
|
|
return {}
|
|
|
|
|
|
func _ring_carrier_ratio(image: Image, center: Vector2, radius: float, stroke: Color, underlays: Array, ui_rects: Array) -> float:
|
|
var samples: Array = []
|
|
for step in range(24):
|
|
var angle := TAU * float(step) / 24.0
|
|
var direction := Vector2(cos(angle), sin(angle))
|
|
for offset in [1.4, 2.0]:
|
|
var probe: Vector2 = center + direction * (radius + float(offset))
|
|
var px := int(round(probe.x))
|
|
var py := int(round(probe.y))
|
|
if px < 0 or py < 0 or px >= image.get_width() or py >= image.get_height():
|
|
continue
|
|
if _point_in_any_rect(ui_rects, Vector2(px, py)):
|
|
continue
|
|
var color := image.get_pixel(px, py)
|
|
if _is_palette_blend(color, stroke, underlays):
|
|
continue
|
|
samples.append(color)
|
|
if samples.is_empty():
|
|
return 0.0
|
|
return _contrast_ratio(stroke, _median_color(samples))
|
|
|
|
|
|
|
|
func _path_contrast(image: Image, shell: Control) -> Dictionary:
|
|
var art := shell.find_child("BattleLiveArt", true, false) as CanvasItem
|
|
if art == null or image.is_empty():
|
|
return {"status": "NOT_PROVEN", "reason": "no art node or image"}
|
|
if not art.is_visible_in_tree():
|
|
return {"status": "NOT_APPLICABLE", "reason": "art node hidden in this view (no rendered path)"}
|
|
var kind := String(art.get("event_kind"))
|
|
if kind.is_empty() or String(art.get("source_side")).is_empty() or String(art.get("target_side")).is_empty():
|
|
return {"status": "NOT_APPLICABLE", "reason": "no relation path drawn in this state"}
|
|
var stroke := _event_color_for(kind)
|
|
var underlays: Array = [Color("#101b2a"), Color("#071015")]
|
|
var width := float(image.get_width())
|
|
var height := float(image.get_height())
|
|
var source: Vector2 = art.get("source_anchor") * Vector2(width, height)
|
|
var target: Vector2 = art.get("target_anchor") * Vector2(width, height)
|
|
var bend := -height * 0.095 if source.x < target.x else height * 0.075
|
|
var midpoint := (source + target) * 0.5 + Vector2(0.0, bend)
|
|
var points: Array = []
|
|
for index in range(21):
|
|
var t := float(index) / 20.0
|
|
points.append(source * pow(1.0 - t, 2.0) + midpoint * 2.0 * (1.0 - t) * t + target * pow(t, 2.0))
|
|
var ui_rects := _visible_ui_ink_rects(shell)
|
|
var actor_rects := _visible_actor_rects(shell)
|
|
var measured: Array = []
|
|
var skipped := 0
|
|
var occluded := 0
|
|
for index in range(2, 19):
|
|
var point: Vector2 = points[index]
|
|
var tangent: Vector2 = (Vector2(points[index + 1]) - Vector2(points[index - 1])).normalized()
|
|
var normal := Vector2(-tangent.y, tangent.x)
|
|
var point_carriers: Array = []
|
|
for side in [-1.0, 1.0]:
|
|
var entry := _core_carrier_at(image, point, normal, side, stroke, underlays, ui_rects, actor_rects)
|
|
if entry.is_empty():
|
|
continue
|
|
if bool(entry.get("occluded", false)):
|
|
occluded += 1
|
|
continue
|
|
point_carriers.append(entry)
|
|
if point_carriers.is_empty():
|
|
skipped += 1
|
|
continue
|
|
var worst_entry: Dictionary = point_carriers[0]
|
|
for entry_value in point_carriers:
|
|
var entry: Dictionary = entry_value
|
|
if _luminance(entry.color) > _luminance(worst_entry.color):
|
|
worst_entry = entry
|
|
measured.append({
|
|
"ratio": _contrast_ratio(stroke, worst_entry.color),
|
|
"carrier": worst_entry.color,
|
|
"distance": float(worst_entry.distance),
|
|
})
|
|
if measured.size() < 8:
|
|
return {
|
|
"status": "NOT_PROVEN",
|
|
"reason": "only %d of 17 core points measurable (occluded/skipped=%d)" % [measured.size(), skipped],
|
|
"stroke": stroke.to_html(false),
|
|
"core_points_measured": measured.size(),
|
|
"core_points_skipped": skipped,
|
|
"occluded": occluded,
|
|
}
|
|
measured.sort_custom(func(a, b): return float(a.ratio) < float(b.ratio))
|
|
var worst: Dictionary = measured[0]
|
|
var core_ratio := float(worst.ratio)
|
|
var core_carrier: Color = worst.carrier
|
|
var core_median := float(measured[measured.size() / 2].ratio)
|
|
var source_ratio := _ring_carrier_ratio(image, source, width * 0.012, stroke, underlays, ui_rects, actor_rects)
|
|
var target_ratio := _ring_carrier_ratio(image, target, width * 0.029, stroke, underlays, ui_rects, actor_rects)
|
|
var worst_ratio := minf(core_ratio, minf(source_ratio, target_ratio))
|
|
return {
|
|
"method": _path_contrast_method(),
|
|
"event_kind": kind,
|
|
"stroke": stroke.to_html(false),
|
|
"core_ratio": snappedf(core_ratio, 0.01),
|
|
"core_median_ratio": snappedf(core_median, 0.01),
|
|
"core_carrier": core_carrier.to_html(false),
|
|
"core_carrier_luminance": snappedf(_luminance(core_carrier), 0.0001),
|
|
"core_carrier_distance_px": snappedf(float(worst.distance), 0.1),
|
|
"core_points_measured": measured.size(),
|
|
"core_points_skipped": skipped,
|
|
"core_points_occluded": occluded,
|
|
"source_marker_ratio": snappedf(source_ratio, 0.01),
|
|
"target_marker_ratio": snappedf(target_ratio, 0.01),
|
|
"worst_ratio": snappedf(worst_ratio, 0.01),
|
|
"contrast_min": CONTRAST_MIN,
|
|
"status": "MET" if worst_ratio >= CONTRAST_MIN else "NOT_MET",
|
|
}
|
|
|
|
|
|
func _ownership_fields(shell: Control) -> Dictionary:
|
|
# M109-I02: measured node facts only. No self-authored ownership strings.
|
|
var fields := {}
|
|
_measure_label(shell.find_child("BattleLiveEventState", true, false) as Label, "state_word", fields)
|
|
_measure_label(shell.find_child("BattleLiveEventId", true, false) as Label, "event_id", fields)
|
|
_measure_label(shell.find_child("BattleLiveEventDelta", true, false) as Label, "delta", fields)
|
|
_measure_label(shell.find_child("BattleLiveEventDetail", true, false) as Label, "detail", fields)
|
|
_measure_label(shell.find_child("BattleLiveRecentEvents", true, false) as Label, "recent", fields)
|
|
var current := shell.find_child("BattleLiveCurrentEvent", true, false) as Label
|
|
if current == null:
|
|
current = shell.find_child("BattleWaitingForNextEvent", true, false) as Label
|
|
_measure_label(current, "current_event", fields)
|
|
var art := shell.find_child("BattleLiveArt", true, false)
|
|
if art == null:
|
|
fields["art.exists"] = false
|
|
fields["art.measurement_status"] = "NOT_PROVEN"
|
|
else:
|
|
fields["art.exists"] = true
|
|
fields["art.live_state"] = String(art.get("live_state"))
|
|
fields["art.event_kind"] = String(art.get("event_kind"))
|
|
fields["art.relation_source_side"] = String(art.get("source_side"))
|
|
fields["art.relation_target_side"] = String(art.get("target_side"))
|
|
var source_anchor: Vector2 = art.get("source_anchor")
|
|
var target_anchor: Vector2 = art.get("target_anchor")
|
|
fields["art.relation_source_anchor_px"] = [_rounded(source_anchor.x * viewport_size.x), _rounded(source_anchor.y * viewport_size.y)]
|
|
fields["art.relation_target_anchor_px"] = [_rounded(target_anchor.x * viewport_size.x), _rounded(target_anchor.y * viewport_size.y)]
|
|
fields["art.party_count"] = int(art.get("party_count"))
|
|
fields["art.enemy_count"] = int(art.get("enemy_count"))
|
|
var actors := shell.find_child("BattleActors", true, false)
|
|
var enemies := shell.find_child("BattleEnemies", true, false)
|
|
fields["actors.count"] = actors.get_child_count() if actors != null else -1
|
|
fields["enemies.count"] = enemies.get_child_count() if enemies != null else -1
|
|
var canvas := shell.find_child("BattleLiveCanvas", true, false) as Control
|
|
fields["canvas.exists"] = canvas != null
|
|
if canvas != null:
|
|
fields["canvas.rect"] = _rect_values(canvas)
|
|
var census := _visible_text_census(shell)
|
|
fields["visible_label_count"] = int(census.get("labels_visible", 0))
|
|
return fields
|
|
|
|
|
|
func _shared_text_minimum() -> int:
|
|
return int(UITokens.font_size(SHARED_TEXT_TOKEN, text_scale))
|
|
|
|
|
|
func _visible_text_census(shell: Control) -> Dictionary:
|
|
var labels: Array = []
|
|
_collect_visible_labels(shell, labels)
|
|
var minimum := _shared_text_minimum()
|
|
var entries: Array = []
|
|
var under := 0
|
|
var minimum_observed := -1
|
|
for label_value in labels:
|
|
var label := label_value as Label
|
|
var font_size := int(label.get_theme_font_size("font_size"))
|
|
var meets := font_size >= minimum
|
|
if not meets:
|
|
under += 1
|
|
if minimum_observed < 0 or font_size < minimum_observed:
|
|
minimum_observed = font_size
|
|
entries.append({
|
|
"node": String(label.name),
|
|
"parent": String(label.get_parent().name) if label.get_parent() != null else "",
|
|
"text": label.text.substr(0, 48),
|
|
"font_size": font_size,
|
|
"applicable_shared_token": SHARED_TEXT_TOKEN,
|
|
"shared_minimum": minimum,
|
|
"pass": meets,
|
|
"rect": _rect_values(label),
|
|
})
|
|
var status := "NO_VISIBLE_LABELS"
|
|
if not entries.is_empty():
|
|
if reference_viewport:
|
|
status = "PASS_MEASURED" if under == 0 else "NOT_PROVEN"
|
|
else:
|
|
status = "SMOKE_RECORDED"
|
|
return {
|
|
"reference_viewport": reference_viewport,
|
|
"shared_token": SHARED_TEXT_TOKEN,
|
|
"shared_minimum_px": minimum,
|
|
"token_rationale": "wide-slot field/HUD label; caption=8 remains reserved for narrow market-card chips",
|
|
"labels_visible": entries.size(),
|
|
"under_minimum": under,
|
|
"minimum_observed_font_size": minimum_observed,
|
|
"pass": under == 0 and not entries.is_empty(),
|
|
"status": status,
|
|
"product_claim": false,
|
|
"labels": entries,
|
|
}
|
|
|
|
|
|
func _collect_visible_labels(node: Node, out: Array) -> void:
|
|
for child in node.get_children():
|
|
if child is Label and (child as Label).is_visible_in_tree():
|
|
out.append(child)
|
|
_collect_visible_labels(child, out)
|
|
|
|
|
|
func _actor_scale_proxy(shell: Control) -> Dictionary:
|
|
# M109-I04: internal M77 route-gate proxy band, measured as portrait node
|
|
# rect height / viewport height. Never Owner-accepted scale.
|
|
var height := float(maxi(1, viewport_size.y))
|
|
var allies: Array = []
|
|
var enemies: Array = []
|
|
var actors := shell.find_child("BattleActors", true, false)
|
|
if actors != null:
|
|
for child in actors.get_children():
|
|
var portrait := child.find_child("BattlePortrait", true, false) as TextureRect
|
|
allies.append(_portrait_proxy_entry(child, portrait, PROXY_BAND_ALLY, height))
|
|
var enemies_node := shell.find_child("BattleEnemies", true, false)
|
|
if enemies_node != null:
|
|
for child in enemies_node.get_children():
|
|
var portrait := child.find_child("BattleEnemyPortrait", true, false) as TextureRect
|
|
enemies.append(_portrait_proxy_entry(child, portrait, PROXY_BAND_ENEMY, height))
|
|
var measured := 0
|
|
for entry_value in allies + enemies:
|
|
if bool(Dictionary(entry_value).get("measured", false)):
|
|
measured += 1
|
|
return {
|
|
"measurement": "portrait node rect height / viewport height (M77 route-gate proxy definition)",
|
|
"owner_accepted_scale": false,
|
|
"note": "internal proxy band only; Owner real-frame scale acceptance remains OPEN (B1/M109-I04)",
|
|
"internal_proxy_band": {
|
|
"ally": PROXY_BAND_ALLY,
|
|
"enemy": PROXY_BAND_ENEMY,
|
|
},
|
|
"ally_portrait_height_fraction_proxy": _fractions_of(allies),
|
|
"ally_proxy_pass": _proxy_pass_of(allies),
|
|
"enemy_portrait_height_fraction_proxy": _fractions_of(enemies),
|
|
"enemy_proxy_pass": _proxy_pass_of(enemies),
|
|
"units_measured": measured,
|
|
"status": "PROXY_MEASURED" if measured > 0 else "NOT_PROVEN",
|
|
"allies": allies,
|
|
"enemies": enemies,
|
|
}
|
|
|
|
|
|
func _portrait_proxy_entry(actor: Node, portrait: TextureRect, band: Array, height: float) -> Dictionary:
|
|
if portrait == null:
|
|
return {
|
|
"node": String(actor.name),
|
|
"measured": false,
|
|
"status": "NOT_PROVEN",
|
|
"reason": "portrait node missing",
|
|
}
|
|
var fraction := portrait.get_global_rect().size.y / height
|
|
return {
|
|
"node": String(actor.name),
|
|
"measured": true,
|
|
"portrait_height_fraction_proxy": _rounded_fraction(fraction),
|
|
"internal_proxy_band": band,
|
|
"proxy_pass": fraction >= float(band[0]) and fraction <= float(band[1]),
|
|
}
|
|
|
|
|
|
func _fractions_of(entries: Array) -> Array:
|
|
var fractions: Array = []
|
|
for entry_value in entries:
|
|
var entry: Dictionary = entry_value
|
|
if bool(entry.get("measured", false)):
|
|
fractions.append(entry.get("portrait_height_fraction_proxy", -1.0))
|
|
return fractions
|
|
|
|
|
|
func _proxy_pass_of(entries: Array) -> bool:
|
|
var any_measured := false
|
|
for entry_value in entries:
|
|
var entry: Dictionary = entry_value
|
|
if not bool(entry.get("measured", false)):
|
|
continue
|
|
any_measured = true
|
|
if not bool(entry.get("proxy_pass", false)):
|
|
return false
|
|
return any_measured
|
|
|
|
|
|
func _text_minimum_summary() -> Dictionary:
|
|
var minimum := _shared_text_minimum()
|
|
var measured_states := 0
|
|
var under_states := 0
|
|
var minimum_observed := -1
|
|
for name_value in capture_by_name.keys():
|
|
var record: Dictionary = capture_by_name[name_value]
|
|
var census: Dictionary = record.get("visible_text_census", {})
|
|
if census.is_empty() or int(census.get("labels_visible", 0)) == 0:
|
|
continue
|
|
measured_states += 1
|
|
var observed := int(census.get("minimum_observed_font_size", -1))
|
|
if observed > 0 and (minimum_observed < 0 or observed < minimum_observed):
|
|
minimum_observed = observed
|
|
if int(census.get("under_minimum", 0)) > 0:
|
|
under_states += 1
|
|
if measured_states == 0:
|
|
return {"status": "NOT_PROVEN", "reason": "no visible text was measured", "product_claim": false}
|
|
if not reference_viewport:
|
|
return {
|
|
"status": "SMOKE_RECORDED",
|
|
"reference_viewport": false,
|
|
"shared_token": SHARED_TEXT_TOKEN,
|
|
"text_scale": text_scale,
|
|
"shared_minimum_px": minimum,
|
|
"minimum_observed_font_size": minimum_observed,
|
|
"captured_states": measured_states,
|
|
"states_with_under_minimum": under_states,
|
|
"product_claim": false,
|
|
"note": "smoke viewport; no product-level text gate is claimed (M108 C1)",
|
|
}
|
|
return {
|
|
"status": "PASS_MEASURED" if under_states == 0 else "NOT_PROVEN",
|
|
"reference_viewport": true,
|
|
"shared_token": SHARED_TEXT_TOKEN,
|
|
"text_scale": text_scale,
|
|
"shared_minimum_px": minimum,
|
|
"minimum_observed_font_size": minimum_observed,
|
|
"captured_states": measured_states,
|
|
"states_with_under_minimum": under_states,
|
|
"product_claim": false,
|
|
"note": "visible type below the applicable shared token minimum is a product-level finding, not a harness failure and not a product PASS",
|
|
}
|
|
|
|
|
|
func _actor_scale_summary() -> Dictionary:
|
|
var ally_pass := true
|
|
var enemy_pass := true
|
|
var ally_fractions: Array = []
|
|
var enemy_fractions: Array = []
|
|
var any := false
|
|
for name_value in capture_by_name.keys():
|
|
var record: Dictionary = capture_by_name[name_value]
|
|
var proxy: Dictionary = record.get("actor_scale_proxy", {})
|
|
if proxy.is_empty() or int(proxy.get("units_measured", 0)) == 0:
|
|
continue
|
|
any = true
|
|
ally_pass = ally_pass and bool(proxy.get("ally_proxy_pass", false))
|
|
enemy_pass = enemy_pass and bool(proxy.get("enemy_proxy_pass", false))
|
|
if ally_fractions.is_empty():
|
|
ally_fractions = proxy.get("ally_portrait_height_fraction_proxy", [])
|
|
if enemy_fractions.is_empty():
|
|
enemy_fractions = proxy.get("enemy_portrait_height_fraction_proxy", [])
|
|
if not any:
|
|
return {"status": "NOT_PROVEN", "reason": "no actor portrait proxy was measured", "product_claim": false}
|
|
return {
|
|
"status": "PROXY_MEASURED",
|
|
"owner_accepted_scale": false,
|
|
"note": "internal proxy band only; not Owner-accepted scale (B1/M109-I04)",
|
|
"ally_portrait_height_fraction_proxy": ally_fractions,
|
|
"ally_proxy_pass": ally_pass,
|
|
"enemy_portrait_height_fraction_proxy": enemy_fractions,
|
|
"enemy_proxy_pass": enemy_pass,
|
|
"all_proxy_pass": ally_pass and enemy_pass,
|
|
"product_claim": false,
|
|
}
|
|
|
|
|
|
func _product_findings() -> Array:
|
|
var text_summary := _text_minimum_summary()
|
|
var scale_summary := _actor_scale_summary()
|
|
var proof := Dictionary(capture_by_name.get(RESOLVED_DELTA_NAME, {}))
|
|
var relation: Dictionary = Dictionary(proof.get("ownership", {}))
|
|
return [
|
|
{
|
|
"id": "M109-I01",
|
|
"topic": "resolved delta proof",
|
|
"status": _delta_proof_status(),
|
|
"detail": "proof view %s: event=%s id=%s phase=%d amount=%d direction=%s delta=%s" % [
|
|
RESOLVED_DELTA_NAME,
|
|
String(proof.get("event_kind", "")),
|
|
String(proof.get("event_id", "")),
|
|
int(proof.get("phase", -1)),
|
|
int(proof.get("amount", 0)),
|
|
String(proof.get("state_delta_direction", "none")),
|
|
String(proof.get("delta_text", "")),
|
|
],
|
|
},
|
|
{
|
|
"id": "M109-I02",
|
|
"topic": "self-authored ownership claims",
|
|
"status": "REPLACED_BY_MEASUREMENTS",
|
|
"detail": "self-authored ownership lists removed; ownership changes derive from measured node visibility/text/bounds, relation endpoints and PNG hashes",
|
|
},
|
|
{
|
|
"id": "M109-I03",
|
|
"topic": "reference-viewport text minimum",
|
|
"status": String(text_summary.get("status", "NOT_PROVEN")),
|
|
"product_claim": false,
|
|
"detail": "shared token %s=%d, minimum observed %d, states under minimum %d; a miss is a product finding and is never repaired in this packet" % [
|
|
SHARED_TEXT_TOKEN,
|
|
int(text_summary.get("shared_minimum_px", _shared_text_minimum())),
|
|
int(text_summary.get("minimum_observed_font_size", -1)),
|
|
int(text_summary.get("states_with_under_minimum", 0)),
|
|
],
|
|
},
|
|
{
|
|
"id": "M109-I04",
|
|
"topic": "actor-height proxy band",
|
|
"status": String(scale_summary.get("status", "NOT_PROVEN")),
|
|
"owner_accepted_scale": false,
|
|
"detail": "portrait node height / viewport height proxy only; Owner real-frame scale acceptance remains OPEN",
|
|
},
|
|
{
|
|
"id": "M109-I05",
|
|
"topic": "Owner comprehension, feel, listening, device and final-art",
|
|
"status": "OPEN",
|
|
"detail": "Owner-only decisions; this packet cannot close them",
|
|
},
|
|
{
|
|
"id": "relation-path-measurement",
|
|
"topic": "resolved-delta relation path",
|
|
"status": "PRESENT" if not String(relation.get("art.relation_source_side", "")).is_empty() and not String(relation.get("art.relation_target_side", "")).is_empty() else "NOT_PROVEN",
|
|
"detail": "BattleLiveArt source_side=%s target_side=%s for the non-zero proof event; an empty measurement is recorded as NOT_PROVEN and is not repaired in this packet" % [
|
|
String(relation.get("art.relation_source_side", "")),
|
|
String(relation.get("art.relation_target_side", "")),
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
func _matrix_state_for(engine_state: String) -> String:
|
|
match engine_state:
|
|
"waiting_for_next_event", "event_forecast", "action_committed", "event_resolving", "event_resolved":
|
|
return engine_state
|
|
return ""
|
|
|
|
|
|
func _capture_static_only(viewport: SubViewport, shell: Control) -> void:
|
|
# M107-P2: authored/static ownership evidence. World, actors and rails stay;
|
|
# engine text and command controls hide through existing node visibility.
|
|
var hidden: Array[Node] = []
|
|
_set_engine_text_visible(shell, false, hidden)
|
|
_expect(not hidden.is_empty(), "static-only isolates at least one engine text or control node")
|
|
await _frames(2)
|
|
await _capture(viewport, shell, "static-only", "view", {
|
|
"hidden_kinds": ["Label", "BaseButton"],
|
|
"hidden_nodes": hidden.size(),
|
|
"kept": ["BattleLiveWorld", "BattleLiveArt", "BattleActors", "BattleEnemies", "rails", "actor_bars"],
|
|
})
|
|
_set_visible(hidden, true)
|
|
await _frames(1)
|
|
var restored := true
|
|
for node in hidden:
|
|
if is_instance_valid(node) and not (node as CanvasItem).visible:
|
|
restored = false
|
|
_expect(restored, "static-only capture restores every hidden node")
|
|
Dictionary(capture_by_name.get("static-only", {})).set("restored", restored)
|
|
|
|
|
|
func _capture_dynamic_neutral(viewport: SubViewport, shell: Control) -> void:
|
|
# M107-P2: dynamic state on a neutral surface. Scene art hides through
|
|
# existing node visibility; the neutral plate is a harness-only ColorRect and
|
|
# is removed before the next capture. No runtime chrome is added.
|
|
var hidden: Array[Node] = []
|
|
for node_name in ["BattleLiveWorld", "BattleLiveArt", "BattleActors", "BattleEnemies"]:
|
|
var node := shell.find_child(node_name, true, false) as CanvasItem
|
|
if node != null and node.visible:
|
|
node.visible = false
|
|
hidden.append(node)
|
|
var neutral := ColorRect.new()
|
|
neutral.name = "M107CaptureNeutralField"
|
|
neutral.color = Color("#5b6165")
|
|
neutral.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
neutral.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
shell.add_child(neutral)
|
|
shell.move_child(neutral, 0)
|
|
await _frames(2)
|
|
await _capture(viewport, shell, "dynamic-neutral", "view", {
|
|
"hidden_nodes": hidden.size(),
|
|
"hidden": ["BattleLiveWorld", "BattleLiveArt", "BattleActors", "BattleEnemies"],
|
|
"neutral": "#5b6165 harness ColorRect, removed after capture",
|
|
})
|
|
_expect(hidden.size() == 4, "dynamic-neutral isolates world, field art, actors and enemies")
|
|
neutral.queue_free()
|
|
_set_visible(hidden, true)
|
|
await _frames(2)
|
|
var restored := true
|
|
for node in hidden:
|
|
if is_instance_valid(node) and not (node as CanvasItem).visible:
|
|
restored = false
|
|
_expect(restored and not is_instance_valid(neutral), "dynamic-neutral restores the scene and removes the neutral plate")
|
|
Dictionary(capture_by_name.get("dynamic-neutral", {})).set("restored", restored)
|
|
Dictionary(capture_by_name.get("dynamic-neutral", {})).set("neutral_removed", not is_instance_valid(neutral))
|
|
|
|
|
|
func _set_engine_text_visible(node: Node, visible: bool, hidden: Array[Node]) -> void:
|
|
for child in node.get_children():
|
|
if child is Label or child is BaseButton:
|
|
var item := child as CanvasItem
|
|
if item.visible != visible:
|
|
item.visible = visible
|
|
if not visible:
|
|
hidden.append(child)
|
|
_set_engine_text_visible(child, visible, hidden)
|
|
|
|
|
|
func _set_visible(nodes: Array[Node], visible: bool) -> void:
|
|
for node in nodes:
|
|
if is_instance_valid(node):
|
|
(node as CanvasItem).visible = visible
|
|
|
|
|
|
func _file_sha256(path: String) -> String:
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return ""
|
|
var context := HashingContext.new()
|
|
context.start(HashingContext.HASH_SHA256)
|
|
var remaining := file.get_length()
|
|
while remaining > 0:
|
|
var chunk := mini(65536, remaining)
|
|
context.update(file.get_buffer(chunk))
|
|
remaining -= chunk
|
|
file.close()
|
|
return context.finish().hex_encode()
|
|
|
|
|
|
func _verify_layout(shell: Control, state_name: String) -> void:
|
|
var canvas := shell.find_child("BattleLiveCanvas", true, false) as Control
|
|
if canvas == null:
|
|
failures.append("%s has no contained live canvas" % state_name)
|
|
return
|
|
var actors := shell.find_child("BattleActors", true, false) as Control
|
|
var enemies := shell.find_child("BattleEnemies", true, false) as Control
|
|
if actors == null or actors.get_child_count() != 4:
|
|
failures.append("%s loses the complete Party line" % state_name)
|
|
if enemies == null or enemies.get_child_count() != 3:
|
|
failures.append("%s loses the complete enemy line" % state_name)
|
|
_verify_buttons(shell, state_name)
|
|
|
|
|
|
func _verify_buttons(node: Node, state_name: String) -> void:
|
|
for child in node.get_children():
|
|
if child is BaseButton and child.is_visible_in_tree():
|
|
var rect := (child as Control).get_global_rect()
|
|
if rect.size.x < 47.9 or rect.size.y < 47.9:
|
|
failures.append("%s button %s below 48px" % [state_name, child.name])
|
|
if rect.position.x < -1.0 or rect.position.y < -1.0 or rect.end.x > viewport_size.x + 1.0 or rect.end.y > viewport_size.y + 1.0:
|
|
failures.append("%s button %s outside viewport" % [state_name, child.name])
|
|
_verify_buttons(child, state_name)
|
|
|
|
|
|
func _create_prepared_company() -> Dictionary:
|
|
var draft: Dictionary = _draft_service().begin_or_load(SEED)
|
|
if not draft.ok:
|
|
return {}
|
|
_draft_service().set_selection([String(draft.draft.offers[0].offer_id), String(draft.draft.offers[2].offer_id)])
|
|
if not _draft_service().commit_company().ok:
|
|
return {}
|
|
var created: Dictionary = _contract_service().create_contract(CONTRACT_OP, "CTR-R1-01", "standard")
|
|
if not created.ok:
|
|
return {}
|
|
var offers: Array = created.company.active_contract.market_board.recruit_offers
|
|
_contract_service().hire_recruit(HIRE_OP_1, String(offers[0].source_offer_id), 14)
|
|
var hired: Dictionary = _contract_service().hire_recruit(HIRE_OP_2, String(offers[1].source_offer_id), 14)
|
|
if not hired.ok:
|
|
return {}
|
|
var begun: Dictionary = _formation_service().begin_formation()
|
|
var saved: Dictionary = _formation_service().save_order(ORDER_OP, begun.formation.base_front_to_rear_ids)
|
|
var ready: Dictionary = _formation_service().accept_readiness(READY_OP, String(saved.receipt.order_hash))
|
|
return ready.company if ready.ok else {}
|
|
|
|
|
|
func _draft_service() -> RefCounted:
|
|
var service := DraftService.new()
|
|
service.path = DRAFT_PATH
|
|
service.company_path = COMPANY_PATH
|
|
return service
|
|
|
|
|
|
func _contract_service() -> RefCounted:
|
|
var service := ContractService.new()
|
|
service.company_path = COMPANY_PATH
|
|
return service
|
|
|
|
|
|
func _formation_service() -> RefCounted:
|
|
var service := FormationService.new()
|
|
service.company_path = COMPANY_PATH
|
|
return service
|
|
|
|
|
|
func _battle_service() -> RefCounted:
|
|
var service := BattleService.new()
|
|
service.company_path = COMPANY_PATH
|
|
return service
|
|
|
|
|
|
func _frames(count: int) -> void:
|
|
for _index in range(count):
|
|
await process_frame
|
|
|
|
|
|
func _expect(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append(message)
|
|
|
|
|
|
func _cleanup() -> void:
|
|
for path in [
|
|
DRAFT_PATH, "%s.bak" % DRAFT_PATH, "%s.candidate" % DRAFT_PATH,
|
|
"%s.bak.candidate" % DRAFT_PATH, COMPANY_PATH, "%s.bak" % COMPANY_PATH,
|
|
"%s.candidate" % COMPANY_PATH, "%s.bak.candidate" % COMPANY_PATH,
|
|
"%s.corrupt" % COMPANY_PATH,
|
|
]:
|
|
if FileAccess.file_exists(path):
|
|
DirAccess.remove_absolute(path)
|