208 lines
9.4 KiB
GDScript
208 lines
9.4 KiB
GDScript
extends SceneTree
|
|
|
|
const Fixture = preload("res://tests/p10_1/first_crossing_fixture.gd")
|
|
const BattleService = preload("res://p10_1/first_crossing_battle_service.gd")
|
|
const ResolutionService = preload("res://p10_1/first_crossing_resolution_service.gd")
|
|
const SaveV7 = preload("res://p10_1/save_v7_service.gd")
|
|
|
|
const PROFILES := [
|
|
{"id": "normal", "route": 1, "plan": "hold_line", "times": [0.0, 2.0, 4.0, 6.0, 8.0]},
|
|
{"id": "active", "route": 2, "plan": "follow_tide", "times": [0.0, 1.0, 2.0, 3.0, 4.0]},
|
|
{"id": "efficient", "route": 0, "plan": "hold_line", "times": [0.0, 1.5, 3.0, 4.5, 6.0]},
|
|
{"id": "adversarial", "route": 2, "plan": "hold_line", "times": [0.0, 2.0, 4.0, 6.0, 8.0]},
|
|
{"id": "returning", "route": 1, "plan": "follow_tide", "times": [0.0, 3.0, 6.0, 9.0, 11.0]},
|
|
]
|
|
|
|
var failures: Array[String] = []
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run_test")
|
|
|
|
|
|
func run_test() -> void:
|
|
var results: Array[Dictionary] = []
|
|
for profile in PROFILES:
|
|
results.append(simulate_profile(profile))
|
|
verify_results(results)
|
|
verify_counterfactuals()
|
|
if failures.is_empty():
|
|
var decisions := 0
|
|
var longest_gap := 0.0
|
|
var recoveries := 0
|
|
var reloads := 0
|
|
for result in results:
|
|
decisions += int(result.decision_count)
|
|
longest_gap = maxf(longest_gap, float(result.max_gap))
|
|
recoveries += int(result.recoveries)
|
|
reloads += int(result.reloads)
|
|
print("ABG_P10_1_FIVE_PROFILE_OK profiles=normal,active,efficient,adversarial,returning policies=5 risks=0,1,2 plans=hold_line,follow_tide decisions=%d longest_gap=%.1f counterfactual=opposed recoveries=%d reloads=%d starvation=0 resurrection=0 offline_rewards=0" % [decisions, longest_gap, recoveries, reloads])
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
func simulate_profile(profile: Dictionary) -> Dictionary:
|
|
var prepared: Dictionary = Fixture.new().prepared_snapshot(int(profile.route))
|
|
var reloads := 0
|
|
if String(profile.id) == "returning":
|
|
prepared = save_roundtrip(prepared, 40)
|
|
reloads += 1
|
|
var battle_result: Dictionary = BattleService.new(prepared).run_to_terminal(String(profile.plan))
|
|
if not battle_result.ok:
|
|
failures.append("%s battle rejected: %s" % [profile.id, battle_result.code])
|
|
return {"id": profile.id, "failed": true}
|
|
var battle: Dictionary = battle_result.snapshot
|
|
var resolver = ResolutionService.new(prepared)
|
|
var settled: Dictionary = resolver.settle("profile-%s-settle" % profile.id, resolver.state.revision, battle)
|
|
if not settled.ok:
|
|
failures.append("%s settlement rejected: %s" % [profile.id, settled.code])
|
|
return {"id": profile.id, "failed": true}
|
|
var fallen_ids: Array = Array(battle.fallen_recruit_ids).duplicate()
|
|
var recoveries := 0
|
|
var recovered_ids: Array = []
|
|
if String(battle.outcome) == "defeat":
|
|
var recovered: Dictionary = resolver.recover(
|
|
"profile-%s-recover" % profile.id,
|
|
resolver.state.revision,
|
|
String(battle.snapshot_hash),
|
|
int(battle.recovery_coin)
|
|
)
|
|
if not recovered.ok:
|
|
failures.append("%s recovery rejected: %s coin=%d cost=%d" % [profile.id, recovered.code, resolver.state.current_run.coin, battle.recovery_coin])
|
|
return {"id": profile.id, "failed": true}
|
|
recoveries = 1
|
|
recovered_ids = Array(resolver.state.current_run.company).duplicate()
|
|
var final_snapshot: Dictionary = resolver.state.snapshot()
|
|
if String(profile.id) == "returning":
|
|
final_snapshot = save_roundtrip(final_snapshot, 41)
|
|
reloads += 1
|
|
var labels: Array[String] = ["inspect", "hire_pair", "equipment", "route", "plan"]
|
|
var timestamps: Array[float] = []
|
|
for value in profile.times:
|
|
timestamps.append(float(value))
|
|
var terminal_time := timestamps[-1] + float(battle.tick) / 10.0
|
|
labels.append("battle_result")
|
|
timestamps.append(terminal_time)
|
|
labels.append("settle")
|
|
timestamps.append(terminal_time + 1.0)
|
|
if recoveries > 0:
|
|
labels.append("recover")
|
|
timestamps.append(terminal_time + 2.0)
|
|
var result := {
|
|
"id": String(profile.id),
|
|
"failed": false,
|
|
"policy": "%d:%s" % [profile.route, profile.plan],
|
|
"risk": int(battle.risk),
|
|
"plan": String(battle.plan_id),
|
|
"outcome": String(battle.outcome),
|
|
"score": int(battle.score),
|
|
"total_hp": total_hp(battle),
|
|
"fallen_ids": fallen_ids,
|
|
"recovered_ids": recovered_ids,
|
|
"recoveries": recoveries,
|
|
"reloads": reloads,
|
|
"coin": int(final_snapshot.current_run.coin),
|
|
"phase": String(final_snapshot.current_run.phase),
|
|
"first_decisive_kind": String(battle.first_decisive_event.kind),
|
|
"labels": labels,
|
|
"timestamps": timestamps,
|
|
"decision_count": timestamps.size(),
|
|
"max_gap": maximum_gap(timestamps),
|
|
}
|
|
print("P101_PROFILE_RESULT profile=%s policy=%s outcome=%s score=%d timestamps=%s max_gap=%.1f coin=%d fallen=%d reloads=%d" % [result.id, result.policy, result.outcome, result.score, format_timestamps(labels, timestamps), result.max_gap, result.coin, fallen_ids.size(), reloads])
|
|
return result
|
|
|
|
|
|
func verify_results(results: Array[Dictionary]) -> void:
|
|
expect(results.size() == 5 and results.all(func(result): return not bool(result.get("failed", true))), "all five profiles complete")
|
|
if results.size() != 5 or results.any(func(result): return bool(result.get("failed", true))):
|
|
return
|
|
var policies := {}
|
|
var decisive_kinds := {}
|
|
for result in results:
|
|
policies[String(result.policy)] = true
|
|
decisive_kinds[String(result.first_decisive_kind)] = true
|
|
expect(int(result.decision_count) >= 7, "%s records every decision timestamp" % result.id)
|
|
expect(float(result.max_gap) <= 12.0, "%s maximum no-decision gap is bounded" % result.id)
|
|
expect(int(result.coin) >= 0, "%s never starves below zero Coin" % result.id)
|
|
expect(result.labels.size() == result.timestamps.size() and strictly_increasing(result.timestamps), "%s timestamps are complete and strictly increasing" % result.id)
|
|
expect(policies.size() == 5, "five profiles use five distinct route-plan policies")
|
|
expect(results.any(func(result): return int(result.risk) == 0) and results.any(func(result): return int(result.risk) == 1) and results.any(func(result): return int(result.risk) == 2), "profiles collectively use every disclosed risk")
|
|
expect(decisive_kinds.size() >= 3, "profiles expose pull, healing and tide decision causes")
|
|
var efficient: Dictionary = result_by_id(results, "efficient")
|
|
var active: Dictionary = result_by_id(results, "active")
|
|
var adversarial: Dictionary = result_by_id(results, "adversarial")
|
|
expect(efficient.outcome == "victory" and efficient.plan == "hold_line", "safe-route stable order wins")
|
|
expect(active.outcome == "victory" and active.plan == "follow_tide", "high-route deliberate movement wins")
|
|
expect(adversarial.outcome == "defeat" and adversarial.recoveries == 1 and not adversarial.fallen_ids.is_empty(), "wrong high-route response fails and recovers")
|
|
for fallen_id in adversarial.fallen_ids:
|
|
expect(fallen_id not in adversarial.recovered_ids, "recovery never resurrects Fallen %s" % fallen_id)
|
|
var returning: Dictionary = result_by_id(results, "returning")
|
|
expect(returning.reloads == 2 and returning.outcome == "victory" and returning.phase == "market_2", "returning profile reloads before battle and after settlement")
|
|
|
|
|
|
func verify_counterfactuals() -> void:
|
|
var safe: Dictionary = Fixture.new().prepared_snapshot(0)
|
|
var safe_hold: Dictionary = BattleService.new(safe).run_to_terminal("hold_line").snapshot
|
|
var safe_move: Dictionary = BattleService.new(safe).run_to_terminal("follow_tide").snapshot
|
|
expect(safe_hold.outcome == "victory" and safe_move.outcome == "victory" and total_hp(safe_hold) > total_hp(safe_move), "safe route rewards stable order with more preserved vitality")
|
|
var high: Dictionary = Fixture.new().prepared_snapshot(2)
|
|
var high_hold: Dictionary = BattleService.new(high).run_to_terminal("hold_line").snapshot
|
|
var high_move: Dictionary = BattleService.new(high).run_to_terminal("follow_tide").snapshot
|
|
expect(high_hold.outcome == "defeat" and high_move.outcome == "victory" and high_move.score > high_hold.score, "high route reverses the plan preference")
|
|
|
|
|
|
func save_roundtrip(snapshot: Dictionary, sequence: int) -> Dictionary:
|
|
var encoded: Dictionary = SaveV7.new().encode_snapshot(snapshot, sequence)
|
|
if not encoded.ok:
|
|
failures.append("Save-v7 encode rejected at sequence %d" % sequence)
|
|
return {}
|
|
var decoded: Dictionary = SaveV7.new().decode_text(String(encoded.text), sequence)
|
|
if not decoded.ok or decoded.snapshot != snapshot or decoded.offline_delta != SaveV7.OFFLINE_ZERO:
|
|
failures.append("Save-v7 roundtrip changed state or offline value at sequence %d" % sequence)
|
|
return {}
|
|
return Dictionary(decoded.snapshot).duplicate(true)
|
|
|
|
|
|
func total_hp(battle: Dictionary) -> int:
|
|
var value := 0
|
|
for actor in battle.actors.values():
|
|
value += int(actor.hp)
|
|
return value
|
|
|
|
|
|
func maximum_gap(timestamps: Array[float]) -> float:
|
|
var result := 0.0
|
|
for index in range(1, timestamps.size()):
|
|
result = maxf(result, timestamps[index] - timestamps[index - 1])
|
|
return result
|
|
|
|
|
|
func strictly_increasing(timestamps: Array) -> bool:
|
|
for index in range(1, timestamps.size()):
|
|
if float(timestamps[index]) <= float(timestamps[index - 1]):
|
|
return false
|
|
return true
|
|
|
|
|
|
func format_timestamps(labels: Array[String], timestamps: Array[float]) -> String:
|
|
var rows: Array[String] = []
|
|
for index in range(labels.size()):
|
|
rows.append("%s@%.1f" % [labels[index], timestamps[index]])
|
|
return ",".join(rows)
|
|
|
|
|
|
func result_by_id(results: Array[Dictionary], profile_id: String) -> Dictionary:
|
|
for result in results:
|
|
if String(result.id) == profile_id:
|
|
return result
|
|
return {}
|
|
|
|
|
|
func expect(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append("P10.1 five-profile: %s" % message)
|