203 lines
8.1 KiB
GDScript
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)
|