The strict resolved-delta evaluation now fails unless the proof event carries a non-empty kind and a phase >= 0, matching the M109 manifest contract. Local 1280x720 zh_CN retest stays PROVEN (kind=damage, phase=3).
1170 lines
53 KiB
GDScript
1170 lines
53 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"
|
|
|
|
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 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 := ""
|
|
|
|
|
|
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"},
|
|
{"size": Vector2i(844, 390), "locale": "en"},
|
|
{"size": Vector2i(1280, 720), "locale": "zh_CN"},
|
|
{"size": Vector2i(1280, 720), "locale": "en"},
|
|
]
|
|
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
|
|
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()
|
|
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 packet_status := "COMPLETE" if failures.is_empty() else "INCOMPLETE"
|
|
print("ABG_DEV5_BATTLE_LIVE_CAPTURE_OK fixture=%d viewport=%dx%d locale=%s text_100=true states=%s state_captures=%d views=%d captures=%d source=runtime" % [fixture_index, viewport_size.x, viewport_size.y, locale, ",".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=m109.bat002.capture_manifest.v3 packet=%s resolved_delta=%s event=%s amount=%d direction=%s delta_text=%s placeholder_amount0=%s text_status=%s text_min_observed=%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)), String(scale_summary.get("status", "NOT_PROVEN")), str(bool(scale_summary.get("all_proxy_pass", false))), manifest_path.get_file()])
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
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": "m109.bat002.capture_manifest.v3",
|
|
"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,
|
|
"text_scale": 1.0,
|
|
"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(),
|
|
"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 = 1.0
|
|
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-100-%s.png" % [viewport_size.x, viewport_size.y, locale, 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),
|
|
}
|
|
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),
|
|
"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 _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_SIZES.get(SHARED_TEXT_TOKEN, 11))
|
|
|
|
|
|
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,
|
|
"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,
|
|
"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)
|