feat: add second-cycle route decision
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user