extends SceneTree 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") const PROFILES := { "normal": { "recruit": "Wayrunner", "speed": 1, "delays": [2.0, 1.0, 1.5, 0.8, 0.8], }, "active": { "recruit": "Kindhand", "speed": 1, "delays": [0.8, 0.5, 0.7, 0.5, 0.4], }, "efficient": { "recruit": "Wayrunner", "speed": 2, "delays": [0.4, 0.3, 0.5, 0.3, 0.3], }, "adversarial": { "recruit": "Kindhand", "speed": 1, "delays": [3.0, 1.5, 2.0, 1.0, 1.2], }, "returning": { "recruit": "Kindhand", "speed": 2, "delays": [1.2, 0.8, 1.0, 0.6, 0.8], }, } const EXPECTED := { "Wayrunner": { "terminal_tick": 99, "hearthguard_hp": 22, "heals": 0, "coin": 22, "remaining_offer": "Kindhand", }, "Kindhand": { "terminal_tick": 133, "hearthguard_hp": 84, "heals": 11, "coin": 21, "remaining_offer": "Wayrunner", }, } var failures: Array[String] = [] var projections: Dictionary = {} var longest_gap := 0.0 var decision_count := 0 func _initialize() -> void: call_deferred("run_test") func run_test() -> void: for profile_name in ["normal", "active", "efficient", "adversarial", "returning"]: run_profile(profile_name) verify_cross_profile_determinism() if failures.is_empty(): print("ABG_P9_4_FIVE_PROFILE_OK profiles=5 routes=2 decisions=%d longest_gap=%.2f counterfactual_ticks=99,133 hearthguard_hp=22,84 heals=0,11 coin=22,21 starvation=0 invalid_mutations=0" % [decision_count, longest_gap]) 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 recruit_id := String(profile.recruit) var expected: Dictionary = EXPECTED[recruit_id] var service = complete_first_cycle() expect(service.state.phase == State.Phase.HALL, "%s begins at the returned Hall" % profile_name) expect(service.state.coin == 20, "%s begins with the accepted 20 Coin" % profile_name) expect(service.state.company == ["Hearthguard", "Farstring"], "%s begins with the accepted two-person Company" % profile_name) if profile_name == "adversarial": verify_rejected_actions_are_inert(service) var decision_times: Array[float] = [0.0] var delays: Array = profile.delays decision_times.append(decision_times[-1] + float(delays[0])) expect(service.focus_offer(recruit_id).ok, "%s focuses %s" % [profile_name, recruit_id]) decision_times.append(decision_times[-1] + float(delays[1])) expect(service.hire(recruit_id).ok, "%s hires %s" % [profile_name, recruit_id]) if profile_name == "adversarial": var before_unknown: Dictionary = service.state.snapshot() var unknown: Dictionary = service.select_encounter("missing-encounter") expect(not unknown.ok and service.state.snapshot() == before_unknown, "adversarial unknown encounter is inert") decision_times.append(decision_times[-1] + float(delays[2])) expect(service.select_encounter("crosswind").ok, "%s selects Crosswind" % profile_name) decision_times.append(decision_times[-1] + float(delays[3])) expect(service.start_battle().ok, "%s starts Crosswind" % profile_name) if profile_name == "adversarial": var before_duplicate_start: Dictionary = service.state.snapshot() var duplicate_start: Dictionary = service.start_battle() expect(not duplicate_start.ok and service.state.snapshot() == before_duplicate_start, "adversarial duplicate start is inert") while service.state.phase == State.Phase.BATTLE: service.advance_battle_ticks(1) var terminal_tick := int(service.battle.state.tick) var battle_seconds := float(terminal_tick) * Rules.TICK_SECONDS / float(profile.speed) decision_times.append(decision_times[-1] + battle_seconds) expect(service.state.phase == State.Phase.RESULT and service.state.last_outcome == "victory", "%s reaches victory" % profile_name) expect(terminal_tick == int(expected.terminal_tick), "%s preserves terminal tick" % profile_name) expect(int(service.battle.state.member_hp.Hearthguard) == int(expected.hearthguard_hp), "%s preserves Hearthguard consequence" % profile_name) var heals := count_heals(service, recruit_id) expect(heals == int(expected.heals), "%s preserves heal count" % profile_name) decision_times.append(decision_times[-1] + float(delays[4])) expect(service.claim_and_return().ok, "%s claims and returns" % profile_name) expect(service.state.coin == int(expected.coin), "%s preserves final Coin" % profile_name) expect(service.state.company == ["Hearthguard", "Farstring", recruit_id], "%s preserves the selected 3/10 Company" % profile_name) var remaining_offer := String(expected.remaining_offer) var remaining_cost := int(service.offer(remaining_offer).cost) expect(service.state.available_offers == [remaining_offer], "%s preserves the unselected offer" % profile_name) expect(service.state.coin >= remaining_cost, "%s does not starve the next recruit decision" % profile_name) var profile_longest_gap := max_gap(decision_times) longest_gap = maxf(longest_gap, profile_longest_gap) decision_count += decision_times.size() expect(profile_longest_gap <= 13.31, "%s longest no-decision gap stays within the fixed 133-tick 1x battle" % profile_name) var projection := { "terminal_tick": terminal_tick, "hearthguard_hp": int(service.battle.state.member_hp.Hearthguard), "heals": heals, "coin": service.state.coin, "company": service.state.company.duplicate(), "remaining_offer": remaining_offer, } if not projections.has(recruit_id): projections[recruit_id] = projection else: expect(projections[recruit_id] == projection, "%s preserves the %s deterministic projection" % [profile_name, recruit_id]) print("ABG_P9_4_PROFILE_RESULT profile=%s recruit=%s speed=%dx timestamps=%s terminal_tick=%d hearthguard_hp=%d heals=%d coin=%d max_gap=%.2f" % [profile_name, recruit_id, int(profile.speed), format_times(decision_times), terminal_tick, int(expected.hearthguard_hp), heals, service.state.coin, profile_longest_gap]) func verify_rejected_actions_are_inert(service) -> void: var before_hire: Dictionary = service.state.snapshot() var hire_without_focus: Dictionary = service.hire("Kindhand") expect(not hire_without_focus.ok and service.state.snapshot() == before_hire, "adversarial hire without focus is inert") var before_offer: Dictionary = service.state.snapshot() var missing_offer: Dictionary = service.focus_offer("MissingRecruit") expect(not missing_offer.ok and service.state.snapshot() == before_offer, "adversarial missing offer is inert") func verify_cross_profile_determinism() -> void: expect(projections.size() == 2, "both counterfactual routes are represented") expect(projections.has("Wayrunner") and projections.has("Kindhand"), "both recruit projections exist") if projections.has("Wayrunner") and projections.has("Kindhand"): expect(projections.Wayrunner != projections.Kindhand, "Wayrunner and Kindhand remain mechanically distinct") func complete_first_cycle(): var service = Service.new() service.enter_hall() service.focus_offer("Farstring") service.hire("Farstring") service.select_encounter("open_road") service.start_battle() while service.state.phase == State.Phase.BATTLE: service.advance_battle_ticks(1) service.claim_and_return() return service func count_heals(service, recruit_id: String) -> int: var result := 0 for event in service.battle.state.event_log: if String(event.get("kind", "")) == "member_action" and String(event.get("source_member_id", "")) == recruit_id and String(event.get("qualifier_en", "")) == "healing": result += 1 return result 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(times: Array[float]) -> String: var labels: Array[String] = [] var names := ["threat", "focus", "hire", "select", "start", "result", "return"] for index in range(times.size()): labels.append("%s@%.2f" % [names[index], times[index]]) return ",".join(labels) func expect(condition: bool, message: String) -> void: if not condition: failures.append("P9.4 five-profile pacing: %s" % message)