diff --git a/runtime/main.gd b/runtime/main.gd index ca402206..e0548bd9 100644 --- a/runtime/main.gd +++ b/runtime/main.gd @@ -4,6 +4,8 @@ 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 P93CoreLoopControllerScript = preload("res://p9_3/core_loop_controller.gd") +const P97RuntimeControllerScript = preload("res://p9_7/runtime_controller.gd") +const P97SaveServiceScript = preload("res://p9_7/save_service.gd") const AppPage = FacadeScript.AppPage const ANIMATION_SPECS = FacadeScript.ANIMATION_SPECS const BATTLE_PHASE_DURATIONS = FacadeScript.BATTLE_PHASE_DURATIONS @@ -23,6 +25,7 @@ var authoritative_last_command_code := "" var authoritative_last_events: Array[Dictionary] = [] @export var p93_experience_enabled := true var p93_experience +var p97_experience var active_battle_feedback: get: return facade.active_battle_feedback @@ -197,8 +200,10 @@ var victory_events: set(value): facade.victory_events = value func _ready() -> void: - var should_start_p93: bool = auto_boot and p93_experience_enabled - if should_start_p93: + var p97_resume_snapshot := _p97_resume_snapshot() + var should_start_p97: bool = auto_boot and p93_experience_enabled and not p97_resume_snapshot.is_empty() + var should_start_p93: bool = auto_boot and p93_experience_enabled and not should_start_p97 + if should_start_p93 or should_start_p97: facade.auto_boot = false facade.name = "RuntimeFacade" facade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) @@ -208,6 +213,8 @@ func _ready() -> void: authoritative_battle_presenter = P92BattlePresenterScript.new(self) if should_start_p93: call_deferred("start_p93_experience") + elif should_start_p97: + call_deferred("start_p97_experience", p97_resume_snapshot) func _get(property: StringName) -> Variant: return facade.get(property) @@ -232,7 +239,7 @@ func _get_property_list() -> Array[Dictionary]: return result func _process(delta: float) -> void: - if is_instance_valid(p93_experience): + if is_instance_valid(p93_experience) or is_instance_valid(p97_experience): return if authoritative_battle_active: _process_authoritative_battle(delta) @@ -240,14 +247,55 @@ func _process(delta: float) -> void: facade._process(delta) func start_p93_experience() -> void: - if is_instance_valid(p93_experience): + if is_instance_valid(p93_experience) or is_instance_valid(p97_experience): return facade.visible = false p93_experience = P93CoreLoopControllerScript.new() p93_experience.name = "P93CoreLoop" p93_experience.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + p93_experience.windglass_market_requested.connect(start_p97_experience) add_child(p93_experience) + +func start_p97_experience(snapshot: Dictionary) -> void: + if snapshot.is_empty() or is_instance_valid(p97_experience): + return + var source_locale := facade.locale + var source_text_scale := facade.text_scale + var source_reduced_motion := facade.reduced_motion + var source_muted := facade.muted + var source_low_power := false + if is_instance_valid(p93_experience): + source_locale = p93_experience.locale + source_text_scale = p93_experience.text_scale + source_reduced_motion = p93_experience.reduced_motion + source_muted = p93_experience.muted + source_low_power = p93_experience.low_power + p93_experience.visible = false + p93_experience.process_mode = Node.PROCESS_MODE_DISABLED + p93_experience.queue_free() + p93_experience = null + facade.visible = false + p97_experience = P97RuntimeControllerScript.new() + p97_experience.name = "P97WindglassRuntime" + p97_experience.locale = source_locale + p97_experience.text_scale = source_text_scale + p97_experience.reduced_motion = source_reduced_motion + p97_experience.muted = source_muted + p97_experience.low_power = source_low_power + p97_experience.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + p97_experience.configure_entry(snapshot) + add_child(p97_experience) + + +func _p97_resume_snapshot() -> Dictionary: + if not auto_boot or not p93_experience_enabled: + return {} + var loaded: Dictionary = P97SaveServiceScript.new().load_snapshot() + if not loaded.ok or int(loaded.get("source_version", -1)) != 2: + return {} + return Dictionary(loaded.snapshot).duplicate(true) + func start_authoritative_battle() -> bool: var members: Array[String] = facade.session_state.current_member_ids() if members.is_empty(): diff --git a/runtime/p9_3/core_loop_controller.gd b/runtime/p9_3/core_loop_controller.gd index 529be5bf..da846b85 100644 --- a/runtime/p9_3/core_loop_controller.gd +++ b/runtime/p9_3/core_loop_controller.gd @@ -1,6 +1,8 @@ class_name P93CoreLoopController extends Control +signal windglass_market_requested(snapshot: Dictionary) + const Service = preload("res://p9_3/core_loop_service.gd") const State = preload("res://p9_3/core_loop_state.gd") const Rules = preload("res://domain/p9_2/battle_rules.gd") @@ -170,6 +172,13 @@ func recover_injured_company() -> void: _queue_render_current() +func enter_windglass_market() -> void: + if service.state.phase != State.Phase.HALL or service.state.recovery_required or service.state.reward_receipts.size() < 4: + return + _save_stable() + windglass_market_requested.emit(service.state.snapshot()) + + func tap_encounter(encounter_id: String) -> void: if service.state.phase != State.Phase.ENCOUNTER: return @@ -659,6 +668,10 @@ func _render_rift_tide_return_hall() -> void: display_root.add_child(condition) _add_member_status(member_id, actor_position + Vector2(-12, actor_height - 30), Vector2(actor_height + 24, 42), "P96FinalStatus_%s" % member_id, true) _add_equipped_icon(member_id, actor_position + Vector2(actor_height - 18, actor_height * 0.38)) + var continue_button := _action_button("P97EnterMarketButton", _tr("前往风晶集市", "ENTER WINDGLASS MARKET"), enter_windglass_market) + continue_button.position = Vector2(size.x - 232, 156) + continue_button.size = Vector2(216, 52) + display_root.add_child(continue_button) func _render_encounters() -> void: diff --git a/runtime/p9_7/runtime_controller.gd b/runtime/p9_7/runtime_controller.gd index 3ce132fe..1065202d 100644 --- a/runtime/p9_7/runtime_controller.gd +++ b/runtime/p9_7/runtime_controller.gd @@ -134,6 +134,7 @@ func _bootstrap_market() -> void: last_code = "market_ready" battle_active = false battle_result_visible = false + _save_stable() _queue_render() diff --git a/runtime/tests/p9_7/runtime_controller_compile_test.gd b/runtime/tests/p9_7/runtime_controller_compile_test.gd index 1cad6220..ca247e8a 100644 --- a/runtime/tests/p9_7/runtime_controller_compile_test.gd +++ b/runtime/tests/p9_7/runtime_controller_compile_test.gd @@ -11,6 +11,7 @@ const BattleService = preload("res://p9_7/battle_service.gd") const FIXTURES := [ {"name": "market-small-zh", "size": Vector2i(760, 360), "state": "market", "locale": "zh_CN", "scale": 1.0, "reduced": false, "low_power": false}, {"name": "market-reference-en-130", "size": Vector2i(844, 390), "state": "market", "locale": "en", "scale": 1.3, "reduced": true, "low_power": false}, + {"name": "market-reference-zh-130", "size": Vector2i(844, 390), "state": "market", "locale": "zh_CN", "scale": 1.3, "reduced": true, "low_power": false}, {"name": "market-large-low-power", "size": Vector2i(1280, 720), "state": "market", "locale": "zh_CN", "scale": 1.0, "reduced": false, "low_power": true}, {"name": "recovery-zh-130", "size": Vector2i(844, 390), "state": "recovery", "locale": "zh_CN", "scale": 1.3, "reduced": true, "low_power": true}, {"name": "relay-en-130", "size": Vector2i(844, 390), "state": "relay", "locale": "en", "scale": 1.3, "reduced": true, "low_power": true}, @@ -38,7 +39,7 @@ func run_test() -> void: await verify_fixture(fixture, snapshots[String(fixture.state)]) if failures.is_empty(): print("ABG_P9_7_RUNTIME_CONTROLLER_COMPILE_OK viewports=760x360,844x390,1280x720,390x844 landscape=world portrait=rotate") - print("ABG_P9_7_RUNTIME_LAYOUT_OK fixtures=6 states=market,recovery,relay viewports=760x360,844x390,1280x720,390x844 locales=zh_CN,en text_130=true reduced_motion=true muted=true low_power=true controls=48 cards=0 captures=%d" % capture_count) + print("ABG_P9_7_RUNTIME_LAYOUT_OK fixtures=7 states=market,recovery,relay viewports=760x360,844x390,1280x720,390x844 locales=zh_CN,en text_130=true reduced_motion=true muted=true low_power=true controls=48 cards=0 captures=%d" % capture_count) quit(0) else: for failure in failures: diff --git a/runtime/tests/p9_7/three_round_continuation_runtime_test.gd b/runtime/tests/p9_7/three_round_continuation_runtime_test.gd new file mode 100644 index 00000000..aed35bb8 --- /dev/null +++ b/runtime/tests/p9_7/three_round_continuation_runtime_test.gd @@ -0,0 +1,212 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") +const LegacyService = preload("res://p9_3/core_loop_service.gd") +const LegacyState = preload("res://p9_3/core_loop_state.gd") +const P97SaveService = preload("res://p9_7/save_service.gd") +const VIEWPORT_SIZE := Vector2i(844, 390) + +const ROUND_STEPS := [ + {"market": 5, "recruit": "P97-M5-R2", "route": "P97-R5-Q0"}, + {"market": 6, "recruit": "P97-M6-R2", "route": "P97-R6-Q0"}, + {"market": 7, "recruit": "P97-M7-R3", "route": "P97-R7-Q0"}, +] + +var failures: Array[String] = [] +var parsed_touches := 0 +var capture_root := "" +var game +var save_service = P97SaveService.new() + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + capture_root = OS.get_environment("ABG_P9_7_CONTINUATION_CAPTURE_ROOT") + expect(save_service.clear().ok, "test-owned v2 save state clears before the opening handoff") + root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS + root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND + root.content_scale_size = VIEWPORT_SIZE + root.size = VIEWPORT_SIZE + game = MainScene.instantiate() + game.auto_boot = true + game.p93_experience_enabled = true + root.add_child(game) + await settle_frames(5) + expect(game.p93_experience != null, "main starts the accepted P9.3-P9.6 controller when no v2 save exists") + if game.p93_experience == null: + await finish() + return + game.p93_experience.persistence_enabled = false + game.p93_experience.service = complete_p96() + game.p93_experience.returned_from_battle = true + game.p93_experience._render_current() + await settle_frames() + expect(find_control(game, "P96FinalHeadline") != null and find_control(game, "P97EnterMarketButton") != null, "settled Rift Tide Hall exposes one Windglass continuation") + await capture("01-p96-windglass-handoff") + await tap_named("P97EnterMarketButton") + await settle_frames(4) + var experience = game.p97_experience + expect(experience != null and game.p93_experience == null, "handoff replaces the old controller with P9.7") + if experience == null: + await finish() + return + experience.battle_auto_advance = false + expect(int(experience.market.state.current_market) == 5 and int(experience.market.state.cleared_rounds) == 4, "handoff preserves the exact settled P9.6 state") + await capture("02-market5-entry") + + for step in ROUND_STEPS: + await play_round(experience, step) + + expect(int(experience.market.state.current_market) == 8 and int(experience.market.state.cleared_rounds) == 7, "three rounds reach Market 8") + expect(experience.market.state.relay_restored and experience.market.state.continuation_locked, "Round 7 restores the Relay and opens the explicit threshold") + expect(experience.market.state.route_history == ["P97-R5-Q0", "P97-R6-Q0", "P97-R7-Q0"], "all three committed routes remain ordered") + expect(experience.market.state.battle_settlement_receipts.size() == 3, "three battle settlements commit exactly once") + expect(experience.market.state.company.size() == 6 and experience.market.state.deployed_recruit_ids.size() == 6 and experience.market.state.line_capacity == 7, "Company, Line and post-Round-6 capacity remain distinct") + await capture("09-relay-restored") + + var loaded: Dictionary = save_service.load_snapshot() + expect(loaded.ok and int(loaded.source_version) == 2 and loaded.snapshot == experience.market.state.snapshot(), "Market 8 save reloads without reroll or offline progress") + var saved_snapshot: Dictionary = experience.market.state.snapshot() + game.queue_free() + await settle_frames(4) + game = MainScene.instantiate() + game.auto_boot = true + game.p93_experience_enabled = true + root.add_child(game) + await settle_frames(6) + expect(game.p97_experience != null and game.p93_experience == null, "main routes a valid v2 save directly to P9.7") + if game.p97_experience != null: + expect(game.p97_experience.market.state.snapshot() == saved_snapshot, "reopened Market 8 preserves Company, HP, Coin, gear and route history") + expect(find_control(game, "P97RelayTitle") != null, "reopened Market 8 renders the restored Relay") + await capture("10-market8-reloaded") + await finish() + + +func play_round(experience, step: Dictionary) -> void: + var market_index := int(step.market) + var recruit_id := String(step.recruit) + var route_id := String(step.route) + expect(int(experience.market.state.current_market) == market_index, "Market %d opens before its preparation" % market_index) + await tap_named("P97RecruitTarget_%s" % recruit_id) + await tap_named("P97RecruitTarget_%s" % recruit_id) + expect(recruit_id in experience.market.state.reserve_recruit_ids, "Market %d hire stages %s in Reserve" % [market_index, recruit_id]) + if market_index == 5: + await tap_named("P97EquipmentTarget_P97-M5-EQ-021") + await tap_named("P97EquipmentTarget_P97-M5-EQ-021") + await tap_named("P97CompanyTarget_%s" % recruit_id) + expect(experience.market.state.equipped_by_recruit[recruit_id] == "P97-M5-EQ-021-ITEM", "Market 5 equips the purchased Threatglass to the new Recruit") + await tap_named("P97CompanyTarget_%s" % recruit_id) + await tap_named("P97CompanyTarget_%s" % recruit_id) + await tap_named("P97CommitLineButton") + expect(recruit_id in experience.market.state.deployed_recruit_ids and recruit_id not in experience.market.state.reserve_recruit_ids, "Market %d commits %s to the Line" % [market_index, recruit_id]) + await capture("%02d-market%d-prepared" % [market_index - 2, market_index]) + await tap_named("P97RouteTarget_%s" % route_id) + await tap_named("P97RouteTarget_%s" % route_id) + expect(experience.battle_active and experience.battle.state.route_id == route_id, "Market %d starts the chosen route" % market_index) + var guard := 0 + while not experience.battle.state.terminal and guard < 2200: + experience.advance_battle_ticks(1) + if guard % 8 == 0: + await process_frame + guard += 1 + await settle_frames() + expect(guard < 2200 and experience.battle.state.outcome == "victory", "Round %d reaches deterministic victory" % market_index) + await capture("%02d-round%d-result" % [market_index - 1, market_index]) + await tap_named("P97SettleButton") + expect(experience.market.state.battle_settlement_receipts.size() == market_index - 4, "Round %d settlement commits once" % market_index) + if experience.market.state.recovery_required: + expect(find_control(game, "P97RecoveryButton") != null, "Round %d exposes recovery for fallen members" % market_index) + await tap_named("P97RecoveryButton") + expect(not experience.market.state.recovery_required, "Round %d recovery restores the next decision state" % market_index) + await settle_frames() + + +func tap_named(node_name: String) -> void: + var control := find_control(game, node_name) + expect(control != null, "%s exists before parsed touch" % node_name) + if control == null: + return + var point := root.get_final_transform() * control.get_global_rect().get_center() + var press := InputEventScreenTouch.new() + press.index = 0 + press.position = point + press.pressed = true + Input.parse_input_event(press) + parsed_touches += 1 + await process_frame + var release := InputEventScreenTouch.new() + release.index = 0 + release.position = point + release.pressed = false + Input.parse_input_event(release) + await settle_frames() + + +func find_control(root_node: Node, node_name: String) -> Control: + return root_node.find_child(node_name, true, false) as Control + + +func capture(name: String) -> void: + if capture_root.is_empty(): + return + DirAccess.make_dir_recursive_absolute(capture_root) + await settle_frames() + await RenderingServer.frame_post_draw + var error := root.get_texture().get_image().save_png(capture_root.path_join(name + ".png")) + expect(error == OK, "%s capture writes" % name) + await process_frame + + +func complete_p96(): + var service = LegacyService.new() + service.enter_hall() + service.focus_offer("Farstring") + service.hire("Farstring") + complete_legacy_battle(service, "open_road") + service.focus_offer("Kindhand") + service.hire("Kindhand") + complete_legacy_battle(service, "crosswind") + service.focus_equipment("EQ-002") + service.equip_focused("Hearthguard") + complete_legacy_battle(service, "bell_breach") + service.choose_rift_tide_plan("press_on") + complete_legacy_battle(service, "rift_tide") + return service + + +func complete_legacy_battle(service, encounter_id: String) -> void: + service.select_encounter(encounter_id) + service.start_battle() + var guard := 0 + while service.state.phase == LegacyState.Phase.BATTLE and guard < 4000: + service.advance_battle_ticks(1) + guard += 1 + service.claim_and_return() + + +func settle_frames(count := 2) -> void: + for _index in range(count): + await process_frame + + +func finish() -> void: + if game != null and is_instance_valid(game): + game.queue_free() + game = null + expect(save_service.clear().ok, "test-owned v2 save state clears after the reload proof") + await settle_frames(120) + if failures.is_empty(): + print("ABG_P9_7_THREE_ROUND_CONTINUATION_OK touch=%d fallback=0 direct=0 handoff=p9.6_to_market5 rounds=5,6,7 routes=steady,steady,steady settlements=3 company=6 line=6/7 relay=true market8=reload_exact v2_resume=direct" % parsed_touches) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.7 three-round continuation: %s" % message) diff --git a/runtime/tests/p9_7/three_round_continuation_runtime_test.gd.uid b/runtime/tests/p9_7/three_round_continuation_runtime_test.gd.uid new file mode 100644 index 00000000..07944e32 --- /dev/null +++ b/runtime/tests/p9_7/three_round_continuation_runtime_test.gd.uid @@ -0,0 +1 @@ +uid://d9p97threerounds diff --git a/runtime/tests/p9_7/three_round_five_profile_test.gd b/runtime/tests/p9_7/three_round_five_profile_test.gd new file mode 100644 index 00000000..307a84be --- /dev/null +++ b/runtime/tests/p9_7/three_round_five_profile_test.gd @@ -0,0 +1,237 @@ +extends SceneTree + +const LegacyService = preload("res://p9_3/core_loop_service.gd") +const LegacyState = preload("res://p9_3/core_loop_state.gd") +const MarketService = preload("res://p9_7/market_service.gd") +const MarketState = preload("res://p9_7/market_state.gd") +const BattleService = preload("res://p9_7/battle_service.gd") +const SaveService = preload("res://p9_7/save_service.gd") + +const PROFILES := { + "normal": { + "recruits": ["P97-M5-R2", "P97-M6-R2", "P97-M7-R3"], + "routes": ["P97-R5-Q0", "P97-R6-Q0", "P97-R7-Q0"], + "gear": "EQ-021", "wearer": "P97-M5-R2", "speed": 1, + }, + "active": { + "recruits": ["P97-M5-R3", "P97-M6-R1", "P97-M7-R3"], + "routes": ["P97-R5-Q1", "P97-R6-Q1", "P97-R7-Q2"], + "gear": "EQ-036", "wearer": "P97-M5-R3", "speed": 2, + }, + "efficient": { + "recruits": ["P97-M5-R3", "P97-M6-R2", "P97-M7-R1"], + "routes": ["P97-R5-Q0", "P97-R6-Q0", "P97-R7-Q1"], + "gear": "", "wearer": "", "speed": 2, + }, + "adversarial": { + "recruits": ["P97-M5-R2", "P97-M6-R3", "P97-M7-R1"], + "routes": ["P97-R5-Q0", "P97-R6-Q2", "P97-R7-Q2"], + "gear": "EQ-028", "wearer": "P97-M5-R2", "speed": 1, + }, + "returning": { + "recruits": ["P97-M5-R2", "P97-M6-R2", "P97-M7-R3"], + "routes": ["P97-R5-Q1", "P97-R6-Q0", "P97-R7-Q1"], + "gear": "EQ-023", "wearer": "P97-M5-R2", "speed": 1, + }, +} + +var failures: Array[String] = [] +var represented_route_risks: Dictionary = {} +var represented_gear: Dictionary = {} +var longest_gap := 0.0 +var timestamp_count := 0 +var defeat_recoveries := 0 +var interruption_reloads := 0 +var invalid_mutations := 0 +var save_root := "" + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + save_root = OS.get_environment("ABG_P9_7_PROFILE_SAVE_ROOT") + if save_root.is_empty(): + save_root = OS.get_user_data_dir().path_join("p9_7_profiles") + DirAccess.make_dir_recursive_absolute(save_root) + for profile_name in ["normal", "active", "efficient", "adversarial", "returning"]: + run_profile(profile_name) + expect(represented_route_risks.size() == 3, "profiles cover steady, pressured and elite routes") + expect(represented_gear.size() == 5 and represented_gear.has("none"), "profiles cover four distinct purchases plus no purchase") + expect(defeat_recoveries == 1, "adversarial profile proves exactly one defeat/recovery/replan") + expect(interruption_reloads == 1, "returning profile proves exactly one Encounter interruption reload") + expect(longest_gap <= 25.0, "longest automatic battle gap remains at most 25 seconds") + if failures.is_empty(): + print("ABG_P9_7_THREE_ROUND_FIVE_PROFILE_OK profiles=5 route_risks=steady,pressured,elite gear=EQ-021,EQ-023,EQ-028,EQ-036,none timestamps=%d longest_gap=%.2f defeat_recoveries=%d interruption_reloads=%d offline_rewards=0 starvation=0 invalid_mutations=%d relay=5/5" % [timestamp_count, longest_gap, defeat_recoveries, interruption_reloads, invalid_mutations]) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func run_profile(profile_name: String) -> void: + var profile: Dictionary = PROFILES[profile_name] + var market = MarketService.new(complete_p96()) + var labels: Array[String] = ["market5_read"] + var times: Array[float] = [0.0] + var profile_defeats := 0 + var profile_interruptions := 0 + represented_gear[String(profile.gear) if not String(profile.gear).is_empty() else "none"] = true + for round_index in range(3): + var market_index := 5 + round_index + var recruit_id := String(profile.recruits[round_index]) + var route_id := String(profile.routes[round_index]) + var route: Dictionary = market.content.routes[route_id] + represented_route_risks[int(route.risk)] = true + append_time(labels, times, "m%d_focus_recruit" % market_index, 0.7) + expect(market.focus_recruit_offer(recruit_id).ok, "%s Market %d focuses Recruit" % [profile_name, market_index]) + append_time(labels, times, "m%d_hire" % market_index, 0.5) + expect(market.hire("%s-hire-%d" % [profile_name, market_index], recruit_id).ok, "%s Market %d hires Recruit" % [profile_name, market_index]) + if round_index == 0 and not String(profile.gear).is_empty(): + buy_profile_gear(market, profile_name, profile, labels, times) + var deployment: Array[String] = [] + deployment.assign(market.auto_order_candidate()) + if profile_name == "adversarial" and market_index == 6: + deployment.assign(["Hearthguard", "Farstring", "Kindhand", "P97-M6-R3"]) + append_time(labels, times, "m%d_commit_line" % market_index, 0.7) + expect(market.commit_deployment("%s-deploy-%d" % [profile_name, market_index], deployment).ok, "%s Market %d commits Line" % [profile_name, market_index]) + append_time(labels, times, "m%d_focus_route" % market_index, 1.0) + expect(market.focus_route(route_id).ok, "%s Market %d focuses route" % [profile_name, market_index]) + append_time(labels, times, "m%d_commit_route" % market_index, 0.5) + expect(market.commit_route("%s-route-%d" % [profile_name, market_index], route_id).ok, "%s Market %d commits route" % [profile_name, market_index]) + if profile_name == "returning" and market_index == 6: + market = interrupt_and_reload(market, profile_name, market_index, route_id, labels, times) + profile_interruptions += 1 + var battle_result: Dictionary = run_battle(market, int(profile.speed)) + append_time(labels, times, "m%d_%s" % [market_index, battle_result.outcome], float(battle_result.ticks) * 0.08 / float(profile.speed)) + var settlement: Dictionary = market.settle_battle("%s-settle-%d-%d" % [profile_name, market_index, profile_defeats], battle_result.snapshot) + expect(settlement.ok, "%s Round %d settles" % [profile_name, market_index]) + append_time(labels, times, "m%d_settle" % market_index, 0.5) + if battle_result.outcome == "defeat": + profile_defeats += 1 + defeat_recoveries += 1 + expect(profile_name == "adversarial" and market_index == 6, "only the declared adversarial Round 6 may defeat") + append_time(labels, times, "m%d_recover8" % market_index, 0.8) + expect(market.recover_injured_company("%s-defeat-recover-%d" % [profile_name, market_index]).ok, "%s Round %d recovers defeat" % [profile_name, market_index]) + append_time(labels, times, "m%d_replan_line" % market_index, 0.7) + expect(market.commit_deployment("%s-redeploy-%d" % [profile_name, market_index], market.auto_order_candidate()).ok, "%s Round %d commits recovered full Line" % [profile_name, market_index]) + append_time(labels, times, "m%d_retry_route" % market_index, 0.8) + expect(market.focus_route(route_id).ok and market.commit_route("%s-retry-route-%d" % [profile_name, market_index], route_id).ok, "%s Round %d recommits route" % [profile_name, market_index]) + var retry: Dictionary = run_battle(market, int(profile.speed)) + append_time(labels, times, "m%d_retry_%s" % [market_index, retry.outcome], float(retry.ticks) * 0.08 / float(profile.speed)) + expect(retry.outcome == "victory", "%s Round %d recovered full Line wins" % [profile_name, market_index]) + expect(market.settle_battle("%s-retry-settle-%d" % [profile_name, market_index], retry.snapshot).ok, "%s Round %d retry settles" % [profile_name, market_index]) + append_time(labels, times, "m%d_retry_settle" % market_index, 0.5) + if market.state.recovery_required: + append_time(labels, times, "m%d_injury_recovery" % market_index, 0.6) + expect(market.recover_injured_company("%s-injury-recover-%d" % [profile_name, market_index]).ok, "%s Round %d recovers fallen members" % [profile_name, market_index]) + if market_index < 7: + append_time(labels, times, "market%d_read" % (market_index + 1), 0.9) + var snapshot: Dictionary = market.state.snapshot() + expect(snapshot.current_market == 8 and snapshot.relay_restored and snapshot.continuation_locked, "%s reaches the Relay threshold" % profile_name) + expect(int(snapshot.coin) >= 0, "%s never starves below zero Coin" % profile_name) + expect(snapshot.battle_settlement_receipts.size() == 3 + profile_defeats, "%s settlement count includes only committed attempts" % profile_name) + var duplicate_before := snapshot.duplicate(true) + var duplicate_recovery: Dictionary = market.recover_injured_company("%s-last-recovery" % profile_name) + expect(not duplicate_recovery.ok and market.state.snapshot() == duplicate_before, "%s invalid final recovery mutates nothing" % profile_name) + invalid_mutations += 1 + var profile_gap := max_gap(times) + longest_gap = maxf(longest_gap, profile_gap) + timestamp_count += times.size() + interruption_reloads += profile_interruptions + expect(profile_defeats == (1 if profile_name == "adversarial" else 0), "%s has the declared defeat count" % profile_name) + print("P97_PROFILE_RESULT profile=%s routes=%s gear=%s speed=%dx timestamps=%s final_coin=%d settlements=%d max_gap=%.2f" % [profile_name, ",".join(profile.routes), String(profile.gear) if not String(profile.gear).is_empty() else "none", int(profile.speed), format_times(labels, times), int(snapshot.coin), snapshot.battle_settlement_receipts.size(), profile_gap]) + + +func buy_profile_gear(market, profile_name: String, profile: Dictionary, labels: Array[String], times: Array[float]) -> void: + var catalog_id := String(profile.gear) + var offer_id := "P97-M5-%s" % catalog_id + append_time(labels, times, "m5_focus_%s" % catalog_id, 0.4) + expect(market.focus_equipment_offer(offer_id).ok, "%s focuses %s" % [profile_name, catalog_id]) + append_time(labels, times, "m5_buy_%s" % catalog_id, 0.4) + var purchase: Dictionary = market.buy_equipment("%s-buy-%s" % [profile_name, catalog_id], offer_id) + expect(purchase.ok, "%s buys %s" % [profile_name, catalog_id]) + if not purchase.ok: + return + append_time(labels, times, "m5_equip_%s" % catalog_id, 0.5) + expect(market.equip("%s-equip-%s" % [profile_name, catalog_id], String(purchase.receipt.instance_id), String(profile.wearer)).ok, "%s equips %s" % [profile_name, catalog_id]) + + +func interrupt_and_reload(market, profile_name: String, market_index: int, route_id: String, labels: Array[String], times: Array[float]): + var saves = SaveService.new() + saves.save_path = save_root.path_join("%s.json" % profile_name) + saves.clear() + expect(saves.save_snapshot(market.state.snapshot(), 2000).ok, "%s saves committed Encounter" % profile_name) + append_time(labels, times, "m%d_interrupted" % market_index, 2.5) + var loaded: Dictionary = saves.load_snapshot(2015) + expect(loaded.ok and loaded.battle_interrupted and loaded.offline_coin == 0 and loaded.offline_xp == 0 and loaded.offline_battle_ticks == 0, "%s interruption restores pre-battle Market without offline progress" % profile_name) + var restored = MarketService.new() + expect(restored.state.restore_snapshot(loaded.snapshot), "%s restores interruption checkpoint" % profile_name) + expect(int(restored.state.current_market) == market_index and restored.state.phase == MarketState.Phase.MARKET, "%s returns to the same Market decision" % profile_name) + append_time(labels, times, "m%d_resume_route" % market_index, 0.8) + expect(restored.focus_route(route_id).ok and restored.commit_route("%s-resume-route-%d" % [profile_name, market_index], route_id).ok, "%s recommits route after reload" % profile_name) + return restored + + +func run_battle(market, speed: int) -> Dictionary: + var battle = BattleService.new() + expect(battle.start_from_market(market.state).ok, "%s battle starts" % market.state.selected_route) + var guard := 0 + while not battle.state.terminal and guard < 2400: + battle.advance_ticks(1) + guard += 1 + expect(guard < 2400 and battle.state.outcome in ["victory", "defeat"], "%s battle terminates" % market.state.selected_route) + return {"outcome": battle.state.outcome, "ticks": guard, "speed": speed, "snapshot": battle.state.snapshot()} + + +func complete_p96() -> Dictionary: + var service = LegacyService.new() + service.enter_hall() + service.focus_offer("Farstring") + service.hire("Farstring") + complete_legacy_battle(service, "open_road") + service.focus_offer("Kindhand") + service.hire("Kindhand") + complete_legacy_battle(service, "crosswind") + service.focus_equipment("EQ-002") + service.equip_focused("Hearthguard") + complete_legacy_battle(service, "bell_breach") + service.choose_rift_tide_plan("press_on") + complete_legacy_battle(service, "rift_tide") + return service.state.snapshot() + + +func complete_legacy_battle(service, encounter_id: String) -> void: + service.select_encounter(encounter_id) + service.start_battle() + var guard := 0 + while service.state.phase == LegacyState.Phase.BATTLE and guard < 4000: + service.advance_battle_ticks(1) + guard += 1 + service.claim_and_return() + + +func append_time(labels: Array[String], times: Array[float], label: String, delta: float) -> void: + labels.append(label) + times.append(times[-1] + delta) + + +func max_gap(times: Array[float]) -> float: + var result := 0.0 + for index in range(1, times.size()): + result = maxf(result, times[index] - times[index - 1]) + return result + + +func format_times(labels: Array[String], times: Array[float]) -> String: + var values: Array[String] = [] + for index in range(times.size()): + values.append("%s@%.2f" % [labels[index], times[index]]) + return ",".join(values) + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.7 five profile: %s" % message) diff --git a/runtime/tests/p9_7/three_round_five_profile_test.gd.uid b/runtime/tests/p9_7/three_round_five_profile_test.gd.uid new file mode 100644 index 00000000..c7552614 --- /dev/null +++ b/runtime/tests/p9_7/three_round_five_profile_test.gd.uid @@ -0,0 +1 @@ +uid://d9p97fiveprofiles