Files
aetherbound-guild/runtime/tests/p9_4/second_cycle_five_profile_test.gd
T

203 lines
8.1 KiB
GDScript

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)