diff --git a/runtime/compat/p9_0_root_facade.gd b/runtime/compat/p9_0_root_facade.gd index 5fe2e59c..1773d539 100644 --- a/runtime/compat/p9_0_root_facade.gd +++ b/runtime/compat/p9_0_root_facade.gd @@ -1,5 +1,7 @@ extends Control +signal authoritative_battle_requested + const FirstSessionStateScript = preload("res://domain/p9_1/session_state.gd") const FirstSessionServiceScript = preload("res://domain/p9_1/session_service.gd") const P91ContractsScript = preload("res://domain/p9_1/contracts.gd") @@ -1823,8 +1825,23 @@ func render_third_cycle_result() -> void: var levels := centered_label(member_levels_summary(), 8, Color("#6d5523")) levels.name = "MemberLevels" box.add_child(levels) - box.add_child(game_button("ReplayGrowthButton", tr_text("重新开始完整体验", "Restart the full experience"), request_restart, true)) - box.add_child(game_button("ThirdCycleHomeButton", tr_text("回到主页", "Home"), go_home, false)) + box.add_child(game_button("ContinueAuthoritativeBattleButton", tr_text("继续:裂隙守望战", "Continue: Breach Watch"), request_authoritative_battle, true)) + var result_actions := HBoxContainer.new() + result_actions.name = "ThirdCycleResultActions" + result_actions.add_theme_constant_override("separation", 6) + box.add_child(result_actions) + var replay := game_button("ReplayGrowthButton", tr_text("重新开始", "Restart"), request_restart, false) + replay.size_flags_horizontal = Control.SIZE_EXPAND_FILL + result_actions.add_child(replay) + var home := game_button("ThirdCycleHomeButton", tr_text("回到主页", "Home"), go_home, false) + home.size_flags_horizontal = Control.SIZE_EXPAND_FILL + result_actions.add_child(home) + + +func request_authoritative_battle() -> void: + if page != AppPage.THIRD_CYCLE_RESULT: + return + authoritative_battle_requested.emit() func add_causal_result_lines(container: VBoxContainer, result_index: int, prefix: String, legacy_names: Dictionary = {}) -> void: diff --git a/runtime/domain/p9_2/battle_service.gd b/runtime/domain/p9_2/battle_service.gd index f9b3b10d..eb2bdf27 100644 --- a/runtime/domain/p9_2/battle_service.gd +++ b/runtime/domain/p9_2/battle_service.gd @@ -9,7 +9,7 @@ var state func _init(initial_state = null) -> void: state = initial_state if initial_state != null else State.new() -func start_fixture(selected_members: Array[String], fixture_seed := 1) -> Dictionary: +func start_fixture(selected_members: Array[String], fixture_seed := 1, starting_hp: Dictionary = {}) -> Dictionary: if selected_members.is_empty() or selected_members.size() > Rules.MEMBER_IDS.size(): return _reject("selection_required") var seen := {} @@ -22,7 +22,8 @@ func start_fixture(selected_members: Array[String], fixture_seed := 1) -> Dictio state.member_ids = selected_members.duplicate() for index in state.member_ids.size(): var member_id: String = state.member_ids[index] - state.member_hp[member_id] = int(Rules.member_definition(member_id).get("max_hp", 0)) + var maximum: int = int(Rules.member_definition(member_id).get("max_hp", 0)) + state.member_hp[member_id] = Rules.clamp_member_hp(member_id, int(starting_hp.get(member_id, maximum))) state.member_positions[member_id] = -index * 12 state.member_next_action_tick[member_id] = 0 state.enemy_ids = Rules.ENEMY_IDS.duplicate() @@ -108,11 +109,13 @@ func _move_lines(events: Array[Dictionary]) -> void: state.member_positions[member_id] = mini(current + 2, desired) if current < desired else maxi(current - 2, desired) func _resolve_member_actions(events: Array[Dictionary]) -> void: - var living_enemies: Array[String] = state.living_enemy_ids() - if living_enemies.is_empty(): + if state.living_enemy_ids().is_empty(): return - var target_enemy: String = living_enemies[0] for member_id in state.living_member_ids(): + var living_enemies: Array[String] = state.living_enemy_ids() + if living_enemies.is_empty(): + break + var target_enemy: String = living_enemies[0] var ready_tick: int = int(state.member_next_action_tick.get(member_id, 0)) if state.tick < ready_tick: continue diff --git a/runtime/main.gd b/runtime/main.gd index a0d46831..1cb67731 100644 --- a/runtime/main.gd +++ b/runtime/main.gd @@ -1,12 +1,20 @@ extends Control const FacadeScript = preload("res://compat/p9_0_root_facade.gd") +const P92BattleServiceScript = preload("res://domain/p9_2/battle_service.gd") +const P92BattlePresenterScript = preload("res://presentation/p9_2_battle_presenter.gd") const AppPage = FacadeScript.AppPage const ANIMATION_SPECS = FacadeScript.ANIMATION_SPECS const BATTLE_PHASE_DURATIONS = FacadeScript.BATTLE_PHASE_DURATIONS const BATTLE_FEEDBACK_DURATION = FacadeScript.BATTLE_FEEDBACK_DURATION var facade := FacadeScript.new() +var authoritative_battle_service := P92BattleServiceScript.new() +var authoritative_battle_presenter +var authoritative_battle_active := false +var authoritative_auto_advance := true +var authoritative_tick_accumulator := 0.0 +var authoritative_last_events: Array[Dictionary] = [] var active_battle_feedback: get: return facade.active_battle_feedback @@ -184,6 +192,8 @@ func _ready() -> void: facade.name = "RuntimeFacade" facade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(facade) + facade.authoritative_battle_requested.connect(start_authoritative_battle) + authoritative_battle_presenter = P92BattlePresenterScript.new(self) func _get(property: StringName) -> Variant: return facade.get(property) @@ -207,7 +217,52 @@ func _get_property_list() -> Array[Dictionary]: }) return result -func _process(delta: float) -> void: facade._process(delta) +func _process(delta: float) -> void: + if authoritative_battle_active: + _process_authoritative_battle(delta) + return + facade._process(delta) + +func start_authoritative_battle() -> bool: + var members: Array[String] = facade.session_state.current_member_ids() + if members.is_empty(): + return false + var starting_hp: Dictionary = {} + for member_id in members: + starting_hp[member_id] = facade.session_state.member_health(member_id) + var result: Dictionary = authoritative_battle_service.start_fixture(members, 9201, starting_hp) + if not result.ok: + return false + authoritative_battle_active = true + authoritative_tick_accumulator = 0.0 + authoritative_last_events.clear() + for event in result.events: + authoritative_last_events.append(Dictionary(event).duplicate(true)) + facade.visible = false + authoritative_battle_presenter.render(authoritative_battle_service.state.current_snapshot(), authoritative_last_events) + return true + +func advance_authoritative_ticks(ticks: int) -> Dictionary: + if not authoritative_battle_active: + return {"ok": false, "code": "inactive", "events": []} + var result: Dictionary = authoritative_battle_service.advance_ticks(ticks) + authoritative_last_events.clear() + for event in result.events: + authoritative_last_events.append(Dictionary(event).duplicate(true)) + authoritative_battle_presenter.render(result.snapshot, authoritative_last_events) + return result + +func _process_authoritative_battle(delta: float) -> void: + if authoritative_battle_service.state.terminal: + return + if not authoritative_auto_advance: + return + authoritative_tick_accumulator += maxf(0.0, delta) + var ticks := int(floor(authoritative_tick_accumulator / 0.1)) + if ticks <= 0: + return + authoritative_tick_accumulator -= float(ticks) * 0.1 + advance_authoritative_ticks(ticks) func active_boot_paths() -> Array[String]: return facade.active_boot_paths() func adaptation_effect(id: String) -> String: return facade.adaptation_effect(id) func adaptation_name(id: String) -> String: return facade.adaptation_name(id) diff --git a/runtime/presentation/p9_2_battle_presenter.gd b/runtime/presentation/p9_2_battle_presenter.gd new file mode 100644 index 00000000..229f4847 --- /dev/null +++ b/runtime/presentation/p9_2_battle_presenter.gd @@ -0,0 +1,216 @@ +class_name P92BattlePresenter +extends RefCounted + +const FIELD_BACKGROUND := preload("res://assets/backgrounds/first_expedition_field.png") +const Rules := preload("res://domain/p9_2/battle_rules.gd") + +var host: Control +var root: Control + +func _init(runtime_host: Control) -> void: + host = runtime_host + +func render(snapshot: Dictionary, events: Array[Dictionary]) -> void: + _ensure_root() + _clear_root() + root.set_meta("domain_tick", int(snapshot.get("tick", 0))) + root.set_meta("state_hash", host.authoritative_battle_service.state.state_hash()) + root.set_meta("event_hash", host.authoritative_battle_service.state.event_hash()) + root.set_meta("outcome", String(snapshot.get("outcome", ""))) + _add_background() + _add_wash() + _add_heading(snapshot) + _add_status_panels(snapshot) + _add_world_line(snapshot, events) + _add_event_ribbon(snapshot, events) + +func cleanup() -> void: + if root != null and is_instance_valid(root): + root.queue_free() + root = null + +func _ensure_root() -> void: + if root != null and is_instance_valid(root): + return + root = Control.new() + root.name = "P92BattleRoot" + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + host.add_child(root) + +func _clear_root() -> void: + for child in root.get_children(): + root.remove_child(child) + child.queue_free() + +func _add_background() -> void: + var background := TextureRect.new() + background.name = "P92WorldBackground" + background.texture = FIELD_BACKGROUND + background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + background.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + background.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(background) + +func _add_wash() -> void: + var wash := ColorRect.new() + wash.name = "P92BattleWash" + wash.color = Color(0.01, 0.025, 0.055, 0.26) + wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + wash.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(wash) + +func _add_heading(snapshot: Dictionary) -> void: + var title: Label = host.facade.game_label(host.facade.tr_text("裂隙守望 · 权威自动战斗", "Breach Watch · Authoritative Auto Battle"), 18, host.facade.COLOR_WHITE) + title.name = "P92BattleTitle" + title.position = Vector2(18, 12) + title.size = Vector2(host.size.x * 0.58, 34) + root.add_child(title) + var status_text: String = host.facade.tr_text("结果已锁定:%s" % _outcome_name(String(snapshot.outcome)), "Outcome locked: %s" % String(snapshot.outcome)) if bool(snapshot.terminal) else host.facade.tr_text("固定步 %d · %.1f 秒" % [int(snapshot.tick), float(snapshot.tick) * 0.1], "Tick %d · %.1f seconds" % [int(snapshot.tick), float(snapshot.tick) * 0.1]) + var status: Label = host.facade.centered_label(status_text, 11, host.facade.COLOR_GOLD_LIGHT) + status.name = "P92TickStatus" + status.position = Vector2(host.size.x * 0.66, 10) + status.size = Vector2(host.size.x * 0.32, 38) + status.add_theme_stylebox_override("normal", host.facade.lacquer_style(Color(0.03, 0.05, 0.10, 0.9), host.facade.COLOR_GOLD, 2)) + root.add_child(status) + +func _add_status_panels(snapshot: Dictionary) -> void: + var members: Dictionary = snapshot.members + var enemies: Dictionary = snapshot.enemies + _add_health_column("P92PartyStatus", Vector2(18, 54), Vector2(230, 92), members.ids, members.hp, true) + _add_health_column("P92EnemyStatus", Vector2(host.size.x - 248, 54), Vector2(230, 72), enemies.ids, enemies.hp, false) + +func _add_health_column(node_name: String, panel_position: Vector2, panel_size: Vector2, ids: Array, hp: Dictionary, members: bool) -> void: + var panel := PanelContainer.new() + panel.name = node_name + panel.position = panel_position + panel.size = panel_size + panel.add_theme_stylebox_override("panel", host.facade.lacquer_style(Color(0.025, 0.045, 0.09, 0.9), host.facade.COLOR_TEAL if members else host.facade.COLOR_CRIMSON, 2)) + root.add_child(panel) + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 1) + panel.add_child(box) + for actor_id in ids: + var maximum: int = int(Rules.member_definition(actor_id).get("max_hp", 0)) if members else int(Rules.enemy_definition(actor_id).get("max_hp", 0)) + var row := HBoxContainer.new() + row.name = ("MemberHP_" if members else "EnemyHP_") + String(actor_id) + row.set_meta("actor_id", actor_id) + row.set_meta("hp", int(hp.get(actor_id, 0))) + box.add_child(row) + var label: Label = host.facade.game_label((_member_name(actor_id) if members else _enemy_name(actor_id)), 8, host.facade.COLOR_WHITE) + label.custom_minimum_size.x = 80 + row.add_child(label) + var bar := ProgressBar.new() + bar.name = "Bar" + bar.max_value = maximum + bar.value = int(hp.get(actor_id, 0)) + bar.show_percentage = false + bar.custom_minimum_size = Vector2(112, 10) + bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bar.add_theme_stylebox_override("background", host.facade.lacquer_style(Color("#14192a"), Color("#667085"), 1)) + bar.add_theme_stylebox_override("fill", host.facade.lacquer_style(host.facade.COLOR_TEAL if members else host.facade.COLOR_CRIMSON, host.facade.COLOR_WHITE, 1)) + row.add_child(bar) + +func _add_world_line(snapshot: Dictionary, events: Array[Dictionary]) -> void: + var world_top := maxf(150.0, host.size.y * 0.38) + var world_bottom := host.size.y - 20.0 + var ground := ColorRect.new() + ground.name = "P92ContactGround" + ground.color = Color(0.05, 0.09, 0.13, 0.88) + ground.position = Vector2(16, world_bottom - 16) + ground.size = Vector2(host.size.x - 32, 4) + root.add_child(ground) + var members: Dictionary = snapshot.members + var enemies: Dictionary = snapshot.enemies + for member_id in members.ids: + _add_actor(String(member_id), int(members.positions.get(member_id, 0)), int(members.hp.get(member_id, 0)), true, world_top, world_bottom, events) + for enemy_id in enemies.ids: + _add_actor(String(enemy_id), int(enemies.positions.get(enemy_id, 100)), int(enemies.hp.get(enemy_id, 0)), false, world_top, world_bottom, events) + +func _add_actor(actor_id: String, logical_position: int, hp: int, member: bool, world_top: float, world_bottom: float, events: Array[Dictionary]) -> void: + var actor_height := clampf(host.size.y * 0.25, 78.0, 132.0) + var actor_width := actor_height + var center_x := lerpf(76.0, host.size.x - 76.0, inverse_lerp(-36.0, 112.0, float(logical_position))) + var event := _latest_actor_event(actor_id, member, events) + var animation_state := _member_animation(actor_id, event, logical_position) if member else _enemy_animation(event, hp) + var texture: Texture2D = host.facade.animation_texture(actor_id if member else "Riftblade", animation_state, float(host.authoritative_battle_service.state.tick) * 0.1) + var actor := TextureRect.new() + actor.name = ("P92Member_" if member else "P92Enemy_") + actor_id + actor.texture = texture + actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + actor.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + actor.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + actor.position = Vector2(center_x - actor_width * 0.5, world_bottom - actor_height) + actor.size = Vector2(actor_width, actor_height) + actor.mouse_filter = Control.MOUSE_FILTER_IGNORE + actor.modulate = Color(0.75, 0.65, 1.0) if actor_id == "RiftSpitter" else Color.WHITE + actor.set_meta("actor_id", actor_id) + actor.set_meta("logical_position", logical_position) + actor.set_meta("hp", hp) + actor.set_meta("event_index", int(event.get("index", -1))) + actor.set_meta("animation_state", animation_state) + root.add_child(actor) + var plaque: Label = host.facade.centered_label((_member_name(actor_id) if member else _enemy_name(actor_id)) + " %d" % hp, 8, host.facade.COLOR_WHITE) + plaque.name = actor.name + "_Plaque" + plaque.position = Vector2(actor.position.x - 6, world_top - 4) + plaque.size = Vector2(actor_width + 12, 24) + root.add_child(plaque) + +func _add_event_ribbon(snapshot: Dictionary, events: Array[Dictionary]) -> void: + var event: Dictionary = events[-1] if not events.is_empty() else (snapshot.events[-1] if not snapshot.events.is_empty() else {}) + var text: String = _event_text(event) + var ribbon: Label = host.facade.centered_label(text, 11, host.facade.COLOR_WHITE) + ribbon.name = "P92EventRibbon" + ribbon.position = Vector2(host.size.x * 0.27, 60) + ribbon.size = Vector2(host.size.x * 0.46, 58) + ribbon.add_theme_stylebox_override("normal", host.facade.lacquer_style(Color(0.04, 0.07, 0.13, 0.95), host.facade.COLOR_GOLD, 2)) + ribbon.set_meta("event_index", int(event.get("index", -1))) + ribbon.set_meta("event_kind", String(event.get("kind", ""))) + ribbon.set_meta("source_member_id", String(event.get("source_member_id", ""))) + ribbon.set_meta("target_member_id", String(event.get("target_member_id", ""))) + root.add_child(ribbon) + +func _latest_actor_event(actor_id: String, member: bool, events: Array[Dictionary]) -> Dictionary: + for index in range(events.size() - 1, -1, -1): + var event: Dictionary = events[index] + if member and String(event.get("source_member_id", "")) == actor_id: + return event + if not member and String(event.get("source_enemy_id", "")) == actor_id: + return event + return {} + +func _member_animation(actor_id: String, event: Dictionary, logical_position: int) -> String: + if not event.is_empty(): + if String(event.get("cue", "")) == "heal": return "lantern_heal" + return host.facade.profession_action_state(actor_id) + if logical_position < 40: + return "advance" + return "idle" + +func _enemy_animation(event: Dictionary, hp: int) -> String: + if hp <= 0: return "death" + if not event.is_empty(): return "attack" + return "advance" if host.authoritative_battle_service.state.tick < 24 else "idle" + +func _event_text(event: Dictionary) -> String: + if event.is_empty(): + return host.facade.tr_text("双方正在接近…", "Both lines are advancing…") + match String(event.get("kind", "")): + "battle_started": return host.facade.tr_text("队伍进入裂隙守望,战斗自动执行", "The Company enters Breach Watch; combat resolves automatically") + "contact": return host.facade.tr_text("前线接触:近战停止推进并开始交战", "Contact: melee stops advancing and engages") + "member_action": + var source := _member_name(String(event.get("source_member_id", ""))) + return host.facade.tr_text("%s 执行职业行动 %+d" % [source, int(event.get("delta", 0))], "%s used a profession action %+d" % [source, int(event.get("delta", 0))]) + "enemy_action": return host.facade.tr_text("敌方施压 %+d", "Enemy pressure %+d") % int(event.get("delta", 0)) + "outcome_locked": return host.facade.tr_text("结果锁定:%s" % _outcome_name(String(event.get("outcome", ""))), "Outcome locked: %s" % String(event.get("outcome", ""))) + return host.facade.tr_text("自动战斗正在解析", "Automatic battle is resolving") + +func _member_name(member_id: String) -> String: + return host.facade.candidate_name(member_id) + +func _enemy_name(enemy_id: String) -> String: + return {"RiftBulwark": host.facade.tr_text("裂隙壁垒", "Rift Bulwark"), "RiftSpitter": host.facade.tr_text("裂隙喷吐者", "Rift Spitter")}.get(enemy_id, enemy_id) + +func _outcome_name(outcome: String) -> String: + return {"victory": host.facade.tr_text("胜利", "Victory"), "defeat": host.facade.tr_text("失败", "Defeat"), "timeout": host.facade.tr_text("超时", "Timeout"), "retreat": host.facade.tr_text("撤退", "Retreat")}.get(outcome, host.facade.tr_text("进行中", "In progress")) diff --git a/runtime/tests/p9_2/authoritative_battle_domain_test.gd b/runtime/tests/p9_2/authoritative_battle_domain_test.gd index d8529931..967e0ed7 100644 --- a/runtime/tests/p9_2/authoritative_battle_domain_test.gd +++ b/runtime/tests/p9_2/authoritative_battle_domain_test.gd @@ -43,6 +43,15 @@ func _test_start_and_rejections() -> void: _expect(service.start_fixture(party(["Hearthguard", "Farstring", "Kindhand"]), 17).ok, "three member fixture starts") _expect(service.state.member_hp["Hearthguard"] == 130, "Hearthguard max hp") _expect(service.state.enemy_hp["RiftBulwark"] == 180, "Bulwark max hp") + var imported := Service.new() + _expect(imported.start_fixture(party(["Hearthguard", "Farstring"]), 18, {"Hearthguard": 72, "Farstring": 46}).ok, "P9.1 HP fixture starts") + _expect(imported.state.member_hp == {"Hearthguard": 72, "Farstring": 46}, "P9.1 end HP imports exactly") + imported.state.enemy_hp["RiftBulwark"] = 5 + imported.state.enemy_hp["RiftSpitter"] = 9 + imported.state.member_positions["Hearthguard"] = 40 + imported.state.enemy_positions["RiftBulwark"] = 50 + imported.advance_ticks(1) + _expect(imported.state.enemy_hp["RiftBulwark"] == 0 and imported.state.enemy_hp["RiftSpitter"] < 9, "later same-tick member retargets after defeat") func _test_movement_contact_and_roles() -> void: var service: Variant = _service(party(["Hearthguard", "Farstring", "Kindhand"]), 4) diff --git a/runtime/tests/p9_2/horizontal_battle_runtime_test.gd b/runtime/tests/p9_2/horizontal_battle_runtime_test.gd new file mode 100644 index 00000000..67d32f56 --- /dev/null +++ b/runtime/tests/p9_2/horizontal_battle_runtime_test.gd @@ -0,0 +1,159 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var failures: Array[String] = [] +var game: Control +var viewport: SubViewport +var capture_root := "" + +func _initialize() -> void: + call_deferred("run_test") + +func run_test() -> void: + capture_root = OS.get_environment("ABG_P9_2_RUNTIME_CAPTURE_ROOT") + viewport = SubViewport.new() + viewport.size = Vector2i(844, 390) + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + game = MainScene.instantiate() + game.auto_boot = false + game.authoritative_auto_advance = false + game.audio_enabled = false + viewport.add_child(game) + await settle(8) + _prepare_p9_1_result() + game.render() + await settle(5) + var continue_button: Control = find_control(game, "ContinueAuthoritativeBattleButton") + _expect(continue_button != null and continue_button.is_visible_in_tree(), "P9.1 result exposes the P9.2 continuation") + if continue_button == null: + _finish() + return + continue_button.pressed.emit() + await settle(4) + _expect(game.authoritative_battle_active, "continuation enters authoritative battle") + if not game.authoritative_battle_active or game.authoritative_battle_presenter.root == null: + _finish() + return + _expect(not game.facade.visible, "legacy facade is hidden during P9.2") + var initial: Dictionary = game.authoritative_battle_service.state.current_snapshot() + _expect(initial.members.ids == ["Hearthguard", "Farstring", "Kindhand"], "P9.1 field order imports") + _expect(initial.members.hp == {"Hearthguard": 72, "Farstring": 46, "Kindhand": 85}, "P9.1 end HP imports") + _assert_runtime_binding("initial") + await capture("01-entry") + var initial_lead_x: int = logical_position("P92Member_Hearthguard") + game.advance_authoritative_ticks(10) + await settle(3) + _expect(logical_position("P92Member_Hearthguard") > initial_lead_x, "Guild line visibly advances from domain position") + _assert_runtime_binding("advance") + await capture("02-advance") + game.advance_authoritative_ticks(14) + await settle(3) + _expect(has_event("contact"), "domain reaches contact") + _assert_runtime_binding("contact") + await capture("03-contact") + game.advance_authoritative_ticks(18) + await settle(3) + _expect(has_event("member_action"), "member profession event reaches runtime") + _expect(has_event("enemy_action"), "enemy event reaches runtime") + _expect(event_ribbon_bound(), "event ribbon retains source-bound event identity") + _assert_runtime_binding("actions") + await capture("04-actions") + for _index in 220: + if game.authoritative_battle_service.state.terminal: + break + game.advance_authoritative_ticks(1) + _expect(game.authoritative_battle_service.state.outcome == "victory", "imported three-member line reaches deterministic victory") + _assert_runtime_binding("victory") + await capture("05-victory") + _verify_bounds(game.authoritative_battle_presenter.root) + _finish() + +func _finish() -> void: + if failures.is_empty(): + print("ABG_P9_2_HORIZONTAL_BATTLE_RUNTIME_OK viewport=844x390 source=p9_1_result members=3 hp_import=true movement=true contact=true member_events=true enemy_events=true outcome=victory domain_bound=true") + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + +func _prepare_p9_1_result() -> void: + game.locale = "zh_CN" + game.muted = true + game.facade.session_state.field_order.assign(["Hearthguard", "Farstring", "Kindhand"]) + game.facade.session_state.starting_pair.assign(["Hearthguard", "Farstring"]) + game.facade.session_state.front_choice = "Hearthguard" + game.facade.session_state.rear_choice = "Farstring" + game.facade.session_state.added_rear = "Kindhand" + game.facade.session_state.member_hp = {"Hearthguard": 72, "Farstring": 46, "Kindhand": 85} + game.facade.session_state.member_levels = {"Hearthguard": 2, "Farstring": 1, "Kindhand": 1} + game.page = game.AppPage.THIRD_CYCLE_RESULT + +func _assert_runtime_binding(label: String) -> void: + var snapshot: Dictionary = game.authoritative_battle_service.state.current_snapshot() + var battle_root: Control = game.authoritative_battle_presenter.root + _expect(battle_root != null and battle_root.name == "P92BattleRoot", "%s renders P9.2 root" % label) + _expect(int(battle_root.get_meta("domain_tick", -1)) == int(snapshot.tick), "%s root tick matches domain" % label) + _expect(int(battle_root.get_meta("state_hash", 0)) == game.authoritative_battle_service.state.state_hash(), "%s root hash matches domain" % label) + for member_id in snapshot.members.ids: + var actor: Control = find_control(battle_root, "P92Member_" + String(member_id)) + _expect(actor != null, "%s renders member %s" % [label, member_id]) + if actor != null: + _expect(int(actor.get_meta("logical_position", -999)) == int(snapshot.members.positions[member_id]), "%s %s position bound" % [label, member_id]) + _expect(int(actor.get_meta("hp", -1)) == int(snapshot.members.hp[member_id]), "%s %s HP bound" % [label, member_id]) + for enemy_id in snapshot.enemies.ids: + var actor: Control = find_control(battle_root, "P92Enemy_" + String(enemy_id)) + _expect(actor != null, "%s renders enemy %s" % [label, enemy_id]) + if actor != null: + _expect(int(actor.get_meta("logical_position", -999)) == int(snapshot.enemies.positions[enemy_id]), "%s %s position bound" % [label, enemy_id]) + _expect(int(actor.get_meta("hp", -1)) == int(snapshot.enemies.hp[enemy_id]), "%s %s HP bound" % [label, enemy_id]) + +func logical_position(node_name: String) -> int: + var actor: Control = find_control(game.authoritative_battle_presenter.root, node_name) + return int(actor.get_meta("logical_position", -999)) if actor != null else -999 + +func has_event(kind: String) -> bool: + for event in game.authoritative_battle_service.state.event_log: + if String(event.get("kind", "")) == kind: + return true + return false + +func event_ribbon_bound() -> bool: + var ribbon: Control = find_control(game.authoritative_battle_presenter.root, "P92EventRibbon") + return ribbon != null and int(ribbon.get_meta("event_index", -1)) >= 0 and not String(ribbon.get_meta("event_kind", "")).is_empty() + +func _verify_bounds(node: Node) -> void: + if node is Control and node.is_visible_in_tree(): + var rect: Rect2 = node.get_global_rect() + if rect.position.x < -0.5 or rect.position.y < -0.5 or rect.end.x > 844.5 or rect.end.y > 390.5: + failures.append("control %s outside 844x390: %s" % [node.name, rect]) + for child in node.get_children(): + _verify_bounds(child) + +func capture(name: String) -> void: + if capture_root.is_empty(): + return + DirAccess.make_dir_recursive_absolute(capture_root) + await settle(2) + await RenderingServer.frame_post_draw + var image := viewport.get_texture().get_image() + var error := image.save_png(capture_root.path_join(name + ".png")) + _expect(error == OK, "capture %s succeeds" % name) + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found: Control = find_control(child, node_name) + if found != null: return found + return null + +func settle(frames: int) -> void: + for _frame in frames: + await process_frame + +func _expect(condition: bool, message: String) -> void: + if not condition and failures.size() < 100: + failures.append("P9.2 horizontal runtime: %s" % message)