feat: add second-cycle route decision

This commit is contained in:
2026-08-13 03:27:27 +08:00
parent 7941227721
commit 3620e5a7cf
7 changed files with 1020 additions and 18 deletions
@@ -0,0 +1,142 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
const STATES := ["board_guarded", "board_tempo", "board_full_line", "review_split", "battle_crush", "battle_tempo", "battle_rear", "result_covered", "result_gap"]
const VIEWPORT_SIZE := Vector2i(844, 390)
var output_root := ""
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
output_root = OS.get_environment("ABG_P9_0R6_CAPTURE_ROOT")
if output_root.is_empty():
output_root = "/tmp/abg-p9-0r6-preview"
DirAccess.make_dir_recursive_absolute(output_root)
var viewport := SubViewport.new()
viewport.size = VIEWPORT_SIZE
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var game := MainScene.instantiate()
game.auto_boot = false
game.audio_enabled = false
viewport.add_child(game)
await settle(12)
game.locale = "zh_CN"
game.text_scale = 1.0
game.reduced_motion = false
game.muted = true
for index in range(STATES.size()):
apply_state(game, STATES[index])
game.render()
await settle(7)
verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(VIEWPORT_SIZE)), STATES[index])
await RenderingServer.frame_post_draw
var image := viewport.get_texture().get_image()
var path := output_root.path_join("%02d-%s.png" % [index + 1, STATES[index]])
var error := image.save_png(path)
if error != OK:
failures.append("capture %s failed with %s" % [STATES[index], error])
game.queue_free()
viewport.queue_free()
await settle(120)
if failures.is_empty():
print("ABG_P9_0R6_PREVIEW_CAPTURE_OK frames=9 viewport=844x390 locale=zh_CN min_touch=44 root=%s" % output_root)
call_deferred("finish", 0)
else:
for failure in failures:
push_error(failure)
call_deferred("finish", 1)
func apply_state(game: Control, state_name: String) -> void:
var profile := "guarded"
if state_name in ["board_tempo", "battle_tempo", "result_covered"]:
profile = "tempo"
elif state_name in ["board_full_line", "review_split", "battle_rear"]:
profile = "full_line"
prepare_profile(game, profile)
match state_name:
"board_guarded", "board_tempo", "board_full_line":
game.page = game.AppPage.ROUTE_BOARD
"review_split":
game.route_choice = "R6-03"
game.page = game.AppPage.ROUTE_REVIEW
"battle_crush":
prepare_battle(game, "R6-01", 2, 90, 100, 120)
game.emit_battle_feedback(2, "front", -10, "胫甲抗碎", "brace answered", "medium")
game.battle_feedback_clock = 0.16
"battle_tempo":
prepare_battle(game, "R6-02", 1, 100, 100, 90)
game.emit_battle_feedback(1, "enemy", -30, "晨钟抢速", "tempo counter", "medium")
game.battle_feedback_clock = 0.16
"battle_rear":
prepare_battle(game, "R6-03", 2, 100, 65, 120)
game.emit_battle_feedback(2, "rear", -35, "后排分击", "rear split", "medium")
game.battle_feedback_clock = 0.16
"result_covered":
prepare_result(game, "R6-02", 15, 82, 100, 6.00)
"result_gap":
prepare_profile(game, "guarded")
prepare_result(game, "R6-03", 18, 100, 65, 7.80)
func prepare_profile(game: Control, profile: String) -> void:
game.clear_experience()
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.adaptation_choice = "EQ-061" if profile == "tempo" else "EQ-002"
game.adaptation_committed = true
game.guild_committed = true
game.guild_choice = "recruit_rear" if profile == "full_line" else "retain_item"
game.retained_item = "" if profile == "full_line" else game.adaptation_choice
game.added_rear = "Kindhand" if profile == "full_line" else ""
game.coin = 6 if profile == "full_line" else 8
func prepare_battle(game: Control, route_id: String, phase: int, front_health: int, rear_health: int, enemy_health: int) -> void:
game.page = game.AppPage.FOURTH_BATTLE
game.route_choice = route_id
game.route_committed = true
game.battle_playing = false
game.battle_phase = phase
game.battle_clock = 0.22
game.front_hp = front_health
game.rear_hp = rear_health
game.enemy_hp = enemy_health
func prepare_result(game: Control, route_id: String, result_coin: int, front_health: int, rear_health: int, result_time: float) -> void:
game.page = game.AppPage.SECOND_CYCLE_RESULT
game.route_choice = route_id
game.route_committed = true
game.route_reward_committed = true
game.coin = result_coin
game.front_hp = front_health
game.rear_hp = rear_health
game.enemy_hp = 0
game.fourth_result_time = result_time
func verify_interactive_bounds(node: Node, bounds: Rect2, label: String) -> void:
if node is BaseButton 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 > bounds.end.x + 0.5 or rect.end.y > bounds.end.y + 0.5:
failures.append("%s button %s outside viewport: %s" % [label, node.name, rect])
if rect.size.x < 44.0 or rect.size.y < 44.0:
failures.append("%s button %s below 44px: %s" % [label, node.name, rect.size])
for child in node.get_children():
verify_interactive_bounds(child, bounds, label)
func finish(exit_code: int) -> void:
quit(exit_code)
func settle(frames: int) -> void:
for frame in range(frames):
await process_frame
@@ -0,0 +1 @@
uid://b1yjd1e35374d
+119
View File
@@ -0,0 +1,119 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
var game: Control
var touch_count := 0
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
game = MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle(10)
game.locale = "zh_CN"
game.muted = true
prepare_real_session_result()
await settle(6)
await tap("OpenRouteBoardButton")
expect(game.page == game.AppPage.ROUTE_BOARD, "session result touch opens route board")
await tap("Route_R6_01")
await tap("Route_R6_03")
expect(game.route_choice == "R6-03", "touch revises route selection")
await tap("ReviewRouteButton")
expect(game.page == game.AppPage.ROUTE_REVIEW, "touch opens route review")
await tap("CancelRouteReviewButton")
expect(game.page == game.AppPage.ROUTE_BOARD and game.route_choice == "R6-03", "touch cancel preserves selection")
await tap("Route_R6_02")
await tap("ReviewRouteButton")
await tap("CommitRouteButton")
expect(game.page == game.AppPage.FOURTH_BATTLE and game.route_committed, "touch commits route and starts battle")
await wait_for_page(game.AppPage.SECOND_CYCLE_RESULT, 720)
expect(game.route_choice == "R6-02" and game.coin == 15, "route reward reaches exact Coin")
expect(find_control(game, "SecondCycleFacts") != null, "touch route renders causal result")
game.queue_free()
game = null
await settle(90)
if failures.is_empty():
print("ABG_P9_0R6_ROUTE_TOUCH_OK touch=%d fallback=0 direct=0 revise=1 review=1 cancel=1 commit=1 battle=1 result=1" % touch_count)
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func prepare_real_session_result() -> void:
game.front_choice = "Hearthguard"
game.rear_choice = "Kindhand"
game.adaptation_choice = "EQ-061"
game.adaptation_committed = true
game.guild_choice = "retain_item"
game.guild_committed = true
game.retained_item = "EQ-061"
game.coin = 8
game.front_hp = 82
game.enemy_hp = 0
game.third_result_time = 6.60
game.page = game.AppPage.SESSION_RESULT
game.render()
func wait_for_page(target_page: int, max_frames: int) -> void:
var previous_scale := Engine.time_scale
Engine.time_scale = 12.0
for frame in range(max_frames):
if game.page == target_page:
break
await process_frame
Engine.time_scale = previous_scale
expect(game.page == target_page, "automatic fourth battle reaches result")
await settle(6)
func tap(node_name: String) -> void:
var 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 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(7)
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 settle(frames: int) -> void:
for frame in range(frames):
await process_frame
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.0R6 route touch: %s" % message)
@@ -0,0 +1 @@
uid://cperqwuy044rx
@@ -0,0 +1,156 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
const ROUTES := ["R6-01", "R6-02", "R6-03"]
const PROFILES := ["guarded", "tempo", "full_line"]
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var game := MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle(10)
game.locale = "zh_CN"
game.muted = true
prepare_session_result(game, "guarded", "Farstring")
game.open_route_board()
expect(game.page == game.AppPage.ROUTE_BOARD and game.route_choice.is_empty(), "route board opens unselected")
expect(find_control(game, "Route_R6_01") != null and find_control(game, "Route_R6_02") != null and find_control(game, "Route_R6_03") != null, "three stable routes render")
var review := find_control(game, "ReviewRouteButton") as BaseButton
expect(review != null and review.disabled, "review waits for one route")
game.choose_route("invalid")
expect(game.route_choice.is_empty(), "invalid route is rejected")
game.choose_route("R6-01")
game.choose_route("R6-03")
expect(game.route_choice == "R6-03" and game.coin == 8, "selection is reversible and does not mutate Coin")
game.open_route_review()
expect(game.page == game.AppPage.ROUTE_REVIEW, "selected route opens explicit review")
game.cancel_route_review()
expect(game.page == game.AppPage.ROUTE_BOARD and game.route_choice == "R6-03", "cancel preserves selected route")
game.choose_route("R6-01")
game.open_route_review()
game.commit_route()
expect(game.page == game.AppPage.FOURTH_BATTLE and game.route_committed, "commit starts fourth battle")
expect(game.coin == 8 and not game.route_reward_committed, "route commit costs zero and does not prepay reward")
game.commit_route()
expect(game.coin == 8, "duplicate route commit is inert")
resolve_fourth_battle(game)
expect(game.page == game.AppPage.SECOND_CYCLE_RESULT, "fourth battle reaches second-cycle result")
expect(game.coin == 12 and game.route_reward_committed, "R6-01 reward adds 4 Coin exactly once")
game.commit_route_reward()
expect(game.coin == 12, "duplicate reward commit is inert")
for profile in PROFILES:
for route_id in ROUTES:
for original_rear in ["Farstring", "Kindhand"]:
prepare_session_result(game, profile, original_rear)
game.open_route_board()
expect(game.route_coverage_state(route_id) == expected_coverage(route_id, profile), "%s/%s coverage matches contract" % [route_id, profile])
game.choose_route(route_id)
game.open_route_review()
var coin_before: int = game.coin
game.commit_route()
var declared_time := resolve_fourth_battle(game)
var expected := expected_result(route_id, profile, original_rear)
expect(game.front_hp == expected["front"], "%s/%s/%s front HP" % [route_id, profile, original_rear])
expect(game.rear_hp == expected["rear"], "%s/%s/%s rear HP" % [route_id, profile, original_rear])
expect(is_equal_approx(declared_time, expected["time"]), "%s/%s declared time" % [route_id, profile])
expect(game.coin == coin_before + game.route_reward(route_id), "%s reward commits once" % route_id)
expect(game.enemy_hp == 0 and game.page == game.AppPage.SECOND_CYCLE_RESULT, "%s/%s clears" % [route_id, profile])
expect(find_control(game, "SecondCycleFacts") != null, "%s result facts render" % route_id)
game.request_restart()
game.confirm_restart()
expect(game.page == game.AppPage.TITLE, "confirmed Restart returns to Title")
expect(game.route_choice.is_empty() and not game.route_committed and not game.route_reward_committed and game.coin == 0, "Restart clears both cycles")
game.queue_free()
await settle(90)
if failures.is_empty():
print("ABG_P9_0R6_SECOND_CYCLE_OK routes=3 profiles=3 cells=18 unselected=true revision=true review_cancel=true commit_once=true reward=4,7,10 times=7.40,6.40,7.00,8.00,6.00,6.80,7.80,6.70 cleanup=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func prepare_session_result(game: Control, profile: String, original_rear: String) -> void:
game.clear_experience()
game.front_choice = "Hearthguard"
game.rear_choice = original_rear
game.adaptation_choice = "EQ-002" if profile != "tempo" else "EQ-061"
game.adaptation_committed = true
game.guild_committed = true
game.guild_choice = "recruit_rear" if profile == "full_line" else "retain_item"
game.retained_item = "" if profile == "full_line" else game.adaptation_choice
game.added_rear = ("Kindhand" if original_rear == "Farstring" else "Farstring") if profile == "full_line" else ""
game.coin = 6 if profile == "full_line" else 8
game.front_hp = 82 if profile == "full_line" else (90 if profile == "guarded" else 70)
game.enemy_hp = 0
game.third_result_time = 7.00 if profile == "full_line" else (7.60 if profile == "guarded" else 6.60)
game.page = game.AppPage.SESSION_RESULT
game.render()
func resolve_fourth_battle(game: Control) -> float:
var declared_time := 0.0
for phase in range(7):
var duration: float = game.phase_duration(phase)
declared_time += duration
game.fourth_battle_elapsed += duration
game.advance_battle_phase()
return declared_time
func expected_coverage(route_id: String, profile: String) -> String:
if route_id == "R6-01":
return "covered" if profile == "guarded" else ("partial" if profile == "full_line" else "gap")
if route_id == "R6-02":
return "covered" if profile == "tempo" else ("partial" if profile == "full_line" else "gap")
return "covered" if profile == "full_line" else "gap"
func expected_result(route_id: String, profile: String, original_rear: String) -> Dictionary:
var result := {"front": 100, "rear": 100, "time": 0.0}
match route_id:
"R6-01":
result["front"] = 90 if profile == "guarded" else (60 if profile == "tempo" else 70)
if original_rear == "Kindhand" or profile == "full_line":
result["front"] = mini(100, int(result["front"]) + 12)
result["time"] = 7.40 if profile == "guarded" else (6.40 if profile == "tempo" else 7.00)
"R6-02":
result["front"] = 70
if original_rear == "Kindhand" or profile == "full_line":
result["front"] = 82
result["time"] = 8.00 if profile == "guarded" else (6.00 if profile == "tempo" else 6.80)
"R6-03":
result["rear"] = 88 if profile == "full_line" else (77 if original_rear == "Kindhand" else 65)
result["time"] = 7.80 if profile == "guarded" else (6.70 if profile == "tempo" else 7.00)
return result
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 settle(frames: int) -> void:
for frame in range(frames):
await process_frame
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.0R6 second cycle: %s" % message)
@@ -0,0 +1 @@
uid://t3rb07jstkti