Files
aetherbound-guild/runtime/tests/p9_2/integrated_battle_runtime_test.gd
T

291 lines
11 KiB
GDScript

extends SceneTree
const MainScene = preload("res://main.tscn")
const PROFILES: Array[String] = ["normal", "active", "efficient", "adversarial", "returning"]
var failures: Array[String] = []
var game: Control
var touch_count := 0
var fallback_count := 0
var direct_count := 0
var profile_results: Dictionary = {}
var capture_root := ""
var recording_only := false
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
capture_root = OS.get_environment("ABG_P9_2_INTEGRATED_CAPTURE_ROOT")
recording_only = OS.get_environment("ABG_P9_2_INTEGRATED_RECORDING_ONLY") == "1"
if recording_only:
await _configure_recording_window()
var profiles: Array[String] = []
if recording_only:
profiles.append("normal")
else:
profiles.assign(PROFILES)
for profile in profiles:
await run_profile(profile)
await destroy_game()
_assert_profiles(profiles)
if failures.is_empty():
if recording_only:
print("ABG_P9_2_INTEGRATED_RECORDING_OK viewport=844x390 profile=normal touch=%d fallback=%d direct=%d handoff=true contact=true inspect=true result=true home=true" % [touch_count, fallback_count, direct_count])
else:
print("ABG_P9_2_INTEGRATED_RUNTIME_OK profiles=5 touch=%d fallback=%d direct=%d decision_tick=50 continue=victory retreat=retreat deterministic_continue=true deterministic_retreat=true max_no_decision_ticks=%d zh_CN=true en=true text_130=true reduced_motion=true mute=true low_power=true home=true" % [touch_count, fallback_count, direct_count, _maximum_gap()])
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _configure_recording_window() -> void:
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
root.content_scale_size = Vector2i(844, 390)
root.size = Vector2i(844, 390)
DisplayServer.window_set_size(Vector2i(844, 390))
await process_frame
func run_profile(profile: String) -> void:
game = MainScene.instantiate()
game.auto_boot = false
game.authoritative_auto_advance = false
game.audio_enabled = false
root.add_child(game)
await settle(8)
_apply_presentation_profile(profile)
_prepare_p9_1_result()
game.render()
await settle(5)
var touches_before := touch_count
await tap("ContinueAuthoritativeBattleButton")
_expect(game.authoritative_battle_active, "%s enters P9.2 from the visible P9.1 result" % profile)
_expect(game.authoritative_battle_service.state.member_hp == {"Hearthguard": 72, "Farstring": 46, "Kindhand": 85}, "%s imports exact P9.1 HP" % profile)
var decision_ticks: Array[int] = []
match profile:
"normal":
decision_ticks.append(0)
await tap("P92InspectButton")
await hold_frames(20)
await tap("P92InspectButton")
await capture("01-handoff-controls")
"active":
decision_ticks.append(0)
await tap("P92Speed2Button")
"efficient":
decision_ticks.append(0)
await tap("P92Speed2Button")
"adversarial":
decision_ticks.append(0)
await tap("P92Speed1Button")
"returning":
decision_ticks.append(0)
await tap("P92InspectButton")
await tap("P92InspectButton")
await tap("P92Speed2Button")
if profile == "active":
await advance_to_tick(20)
decision_ticks.append(20)
var tick_before_pause: int = game.authoritative_battle_service.state.tick
await tap("P92PauseButton")
game.advance_authoritative_ticks(5)
_expect(game.authoritative_battle_service.state.tick == tick_before_pause, "active Pause accepts zero simulation ticks")
await hold_frames(20)
await tap("P92PauseButton")
await advance_to_tick(24)
if profile == "normal":
await capture("02-contact")
await advance_to_tick(50)
var decision_projection := _gameplay_projection()
var decision_hash: int = game.authoritative_battle_service.state.state_hash()
decision_ticks.append(50)
if profile in ["normal", "efficient", "active"]:
await tap("P92InspectButton")
if profile == "normal":
await capture("03-tick-50-inspect")
await tap("P92InspectButton")
await advance_to_tick(100)
decision_ticks.append(100)
await tap("P92InspectButton")
await tap("P92InspectButton")
await advance_to_terminal()
else:
await tap("P92RetreatButton")
if profile == "adversarial":
await tap("P92RetreatButton")
await advance_to_terminal()
var outcome: String = game.authoritative_battle_service.state.outcome
var terminal_tick: int = game.authoritative_battle_service.state.tick
decision_ticks.append(terminal_tick)
var result_projection := _gameplay_projection()
var result_hash: int = game.authoritative_battle_service.state.state_hash()
var event_hash: int = game.authoritative_battle_service.state.event_hash()
_expect(outcome == ("retreat" if profile in ["adversarial", "returning"] else "victory"), "%s reaches its intended outcome" % profile)
_expect(find_control(game, "P92ResultPanel") != null, "%s renders the five-line result" % profile)
for index in range(1, 6):
_expect(find_control(game, "P92ResultLine%d" % index) != null, "%s result keeps line %d" % [profile, index])
if profile == "normal":
await capture("04-victory-result")
await hold_frames(45)
await tap("P92HomeButton")
_expect(not game.authoritative_battle_active and game.page == game.AppPage.MAIN_MENU, "%s Home exits to Main Menu" % profile)
profile_results[profile] = {
"decision_projection": decision_projection,
"decision_hash": decision_hash,
"result_projection": result_projection,
"result_hash": result_hash,
"event_hash": event_hash,
"decision_ticks": decision_ticks,
"max_gap": _max_gap(decision_ticks),
"outcome": outcome,
"terminal_tick": terminal_tick,
"touches": touch_count - touches_before,
}
print("ABG_P9_2_PROFILE_RESULT profile=%s outcome=%s terminal_tick=%d decisions=%s max_gap=%d state_hash=%d event_hash=%d" % [profile, outcome, terminal_tick, decision_ticks, _max_gap(decision_ticks), result_hash, event_hash])
func _apply_presentation_profile(profile: String) -> void:
game.locale = "en" if profile in ["active", "returning"] else "zh_CN"
game.text_scale = 1.3 if profile in ["active", "returning"] else 1.0
game.reduced_motion = profile in ["active", "returning"]
game.muted = true
game.facade.presentation_settings.low_power = profile == "returning"
func _prepare_p9_1_result() -> void:
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 advance_to_tick(target_tick: int) -> void:
while not game.authoritative_battle_service.state.terminal and game.authoritative_battle_service.state.tick < target_tick:
game.advance_authoritative_ticks(1)
if recording_only:
await hold_frames(4)
elif game.authoritative_battle_service.state.tick % 10 == 0:
await process_frame
await settle(3)
func advance_to_terminal() -> void:
var guard := 0
while not game.authoritative_battle_service.state.terminal and guard < 900:
game.advance_authoritative_ticks(1)
guard += 1
if recording_only:
await hold_frames(4)
elif guard % 10 == 0:
await process_frame
await settle(4)
_expect(game.authoritative_battle_service.state.terminal, "battle reaches a bounded terminal")
func _gameplay_projection() -> Dictionary:
var state = game.authoritative_battle_service.state
return {
"tick": state.tick,
"outcome": state.outcome,
"cause": state.outcome_cause,
"member_hp": state.member_hp.duplicate(true),
"member_positions": state.member_positions.duplicate(true),
"enemy_hp": state.enemy_hp.duplicate(true),
"enemy_positions": state.enemy_positions.duplicate(true),
}
func _assert_profiles(profiles: Array[String]) -> void:
for profile in profiles:
_expect(profile_results.has(profile), "profile %s reports a result" % profile)
if profile_results.has(profile):
_expect(int(profile_results[profile].max_gap) <= 50, "%s no-decision gap stays at or below 50 ticks" % profile)
if recording_only or not failures.is_empty():
return
var decision_projection: Dictionary = profile_results.normal.decision_projection
for profile in PROFILES:
_expect(profile_results[profile].decision_projection == decision_projection, "%s reaches the same visible tick-50 counterfactual state" % profile)
_expect(profile_results.normal.result_projection == profile_results.efficient.result_projection, "speed and Inspect preserve the continue result")
_expect(profile_results.normal.result_hash == profile_results.efficient.result_hash, "speed and Inspect preserve the continue state hash")
_expect(profile_results.normal.event_hash == profile_results.efficient.event_hash, "speed and Inspect preserve the continue event hash")
_expect(profile_results.active.result_projection == profile_results.normal.result_projection, "Pause preserves the gameplay result")
_expect(profile_results.adversarial.result_projection == profile_results.returning.result_projection, "presentation settings preserve the retreat result")
_expect(profile_results.adversarial.result_hash == profile_results.returning.result_hash, "same-tick retreat preserves state hash")
_expect(profile_results.adversarial.event_hash == profile_results.returning.event_hash, "same-tick retreat preserves event hash")
func _maximum_gap() -> int:
var result := 0
for profile in profile_results:
result = maxi(result, int(profile_results[profile].max_gap))
return result
func _max_gap(ticks: Array[int]) -> int:
var result := 0
for index in range(1, ticks.size()):
result = maxi(result, ticks[index] - ticks[index - 1])
return result
func tap(node_name: String) -> void:
var control: Control = find_control(game, node_name)
if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled):
failures.append("unavailable parsed-touch target %s" % node_name)
return
var position := root.get_final_transform() * control.get_global_rect().get_center()
var down := InputEventScreenTouch.new()
down.index = 0
down.position = position
down.pressed = true
Input.parse_input_event(down)
await process_frame
var up := InputEventScreenTouch.new()
up.index = 0
up.position = position
up.pressed = false
Input.parse_input_event(up)
touch_count += 1
await settle(6)
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 := find_control(child, node_name)
if found != null:
return found
return null
func capture(name: String) -> void:
if capture_root.is_empty():
return
DirAccess.make_dir_recursive_absolute(capture_root)
await settle(3)
await RenderingServer.frame_post_draw
var image := root.get_texture().get_image()
var error := image.save_png(capture_root.path_join(name + ".png"))
_expect(error == OK, "capture %s succeeds" % name)
func hold_frames(frames: int) -> void:
if not recording_only:
return
await settle(frames)
func destroy_game() -> void:
if game != null and is_instance_valid(game):
game.queue_free()
game = null
await settle(60)
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() < 160:
failures.append("P9.2 integrated: %s" % message)