Files
aetherbound-guild/runtime/tests/p10_2/five_profile_simulation_test.gd
T

385 lines
18 KiB
GDScript

extends SceneTree
const Content = preload("res://p10_2/content_registry.gd")
const MarketService = preload("res://p10_2/market_service.gd")
const BattleService = preload("res://p10_2/mid_contract_battle_service.gd")
const ResolutionService = preload("res://p10_2/mid_contract_resolution_service.gd")
const SaveV8 = preload("res://p10_2/save_v8_service.gd")
const Fixture = preload("res://tests/p10_2/mid_contract_fixture.gd")
const PROFILES := [
{"id": "normal", "risks": [0, 0, 0], "policy": "conserve"},
{"id": "active", "risks": [1, 1, 1], "policy": "hire"},
{"id": "efficient", "risks": [2, 2, 1], "policy": "gear"},
{"id": "adversarial", "risks": [0, 1, 2], "policy": "recover"},
{"id": "returning", "risks": [1, 2, 0], "policy": "reload"},
]
const WRONG_PLANS := {
"RO-R2-M02-Q0": "follow_tide",
"RO-R2-M02-Q1": "hold_line",
"RO-R2-M02-Q2": "hold_line",
"RO-R2-M03-Q0": "follow_tide",
"RO-R2-M03-Q1": "follow_tide",
"RO-R2-M03-Q2": "hold_line",
"RO-R2-M04-Q0": "hold_line",
"RO-R2-M04-Q1": "hold_line",
"RO-R2-M04-Q2": "hold_line",
}
var failures: Array[String] = []
var catalog: Dictionary = Content.snapshot()
var equipment_rows: Dictionary = Content.rows_by_id(catalog, "equipment")
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var results: Array[Dictionary] = []
for profile in PROFILES:
results.append(simulate_profile(profile))
var anti_dominance := verify_all_counterfactuals()
verify_results(results, anti_dominance)
if failures.is_empty():
var decisions := 0
var longest_gap := 0.0
var recoveries := 0
var reloads := 0
var routes := {}
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)
for route_id in result.route_ids:
routes[String(route_id)] = true
print("ABG_P10_2_FIVE_PROFILE_OK profiles=normal,active,efficient,adversarial,returning policies=conserve,hire,gear,recover,reload routes=%d risks=0,1,2 plans=4 decisions=%d longest_gap=%.1f automatic_battle_excluded=true counterfactuals=%d recoveries=%d reloads=%d starvation=0 resurrection=0 offline_rewards=0 market5=5" % [routes.size(), decisions, longest_gap, int(anti_dominance.reversals), recoveries, reloads])
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func simulate_profile(profile: Dictionary) -> Dictionary:
var service = Fixture.new().market_service()
if service == null:
return failed_result(String(profile.id), "fixture")
var labels: Array[String] = []
var timestamps: Array[float] = []
var clock := 0.0
var automatic_seconds := 0.0
var reloads := 0
var recoveries := 0
var fallen_ids: Array[String] = []
var company_before: Array = Array(service.state.current_run.company).duplicate()
var route_ids: Array[String] = []
var decisive_kinds: Array[String] = []
var policy_receipts: Array[String] = []
for market_index in [2, 3, 4]:
clock = record_decision(labels, timestamps, clock, "market_%d_read" % market_index, 1.0)
var risk := int(profile.risks[market_index - 2])
var route: Dictionary = service.state.current_run.market_board.battle_offers[risk]
var policy_result := apply_policy(service, profile, market_index, route)
if not policy_result.ok:
return failed_result(String(profile.id), String(policy_result.code))
service = policy_result.service
policy_receipts.append(String(policy_result.receipt))
clock = record_decision(labels, timestamps, clock, "market_%d_%s" % [market_index, policy_result.receipt], 2.0)
if String(profile.id) == "returning":
var reloaded := save_roundtrip(service.state.snapshot(), 100 + reloads)
if reloaded.is_empty():
return failed_result(String(profile.id), "reload_before_route")
service = MarketService.new(reloaded)
reloads += 1
clock = record_decision(labels, timestamps, clock, "market_%d_reload" % market_index, 1.5)
route = service.state.current_run.market_board.battle_offers[risk]
route_ids.append(String(route.route_id))
var prepared: Dictionary = service.commit_route(
"profile-%s-route-%d" % [profile.id, market_index],
service.state.revision,
String(route.offer_id),
String(service.state.current_run.market_board.board_hash)
)
if not prepared.ok:
return failed_result(String(profile.id), "route_%s" % prepared.code)
clock = record_decision(labels, timestamps, clock, "market_%d_route_risk_%d" % [market_index, risk], 3.0)
var preferred := String(BattleService.PREFERRED_PLAN[String(route.route_id)])
clock = record_decision(labels, timestamps, clock, "round_%d_plan_%s" % [market_index, preferred], 2.0)
if String(profile.id) == "adversarial" and market_index == 4:
var wrong: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal(String(WRONG_PLANS[String(route.route_id)]))
if not wrong.ok or String(wrong.snapshot.outcome) != "defeat":
return failed_result(String(profile.id), "expected_defeat")
automatic_seconds += float(wrong.snapshot.tick) / 10.0
var failed_resolver = ResolutionService.new(service.state.snapshot())
var failed_settlement: Dictionary = failed_resolver.settle(
"profile-adversarial-fail",
failed_resolver.state.revision,
wrong.snapshot
)
if not failed_settlement.ok:
return failed_result(String(profile.id), "defeat_settlement")
fallen_ids.assign(wrong.snapshot.fallen_recruit_ids)
clock = record_decision(labels, timestamps, clock, "round_4_accept_loss", 4.0)
var recovered: Dictionary = failed_resolver.recover(
"profile-adversarial-recover",
failed_resolver.state.revision,
String(wrong.snapshot.snapshot_hash),
int(wrong.snapshot.recovery_coin)
)
if not recovered.ok:
return failed_result(String(profile.id), "recovery_%s" % recovered.code)
recoveries += 1
service = MarketService.new(failed_resolver.state.snapshot())
clock = record_decision(labels, timestamps, clock, "round_4_recover", 2.0)
var replacement: Dictionary = first_affordable_recruit(service)
if replacement.is_empty():
return failed_result(String(profile.id), "replacement_unavailable")
var replacement_hired: Dictionary = service.hire(
"profile-adversarial-replacement",
service.state.revision,
String(replacement.source_offer_id),
int(replacement.list_price)
)
if not replacement_hired.ok:
return failed_result(String(profile.id), "replacement_%s" % replacement_hired.code)
policy_receipts.append("replacement")
clock = record_decision(labels, timestamps, clock, "round_4_hire_replacement", 2.0)
route = service.state.current_run.market_board.battle_offers[risk]
prepared = service.commit_route(
"profile-adversarial-retry",
service.state.revision,
String(route.offer_id),
String(service.state.current_run.market_board.board_hash)
)
if not prepared.ok:
return failed_result(String(profile.id), "retry_route")
clock = record_decision(labels, timestamps, clock, "round_4_retry_%s" % preferred, 2.0)
var battle: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal(preferred)
if not battle.ok or String(battle.snapshot.outcome) != "victory":
return failed_result(String(profile.id), "market_%d_preferred_battle" % market_index)
automatic_seconds += float(battle.snapshot.tick) / 10.0
decisive_kinds.append(String(battle.snapshot.first_decisive_event.kind))
var resolver = ResolutionService.new(service.state.snapshot())
var settled: Dictionary = resolver.settle(
"profile-%s-settle-%d" % [profile.id, market_index],
resolver.state.revision,
battle.snapshot
)
if not settled.ok:
return failed_result(String(profile.id), "settle_%s" % settled.code)
service = MarketService.new(resolver.state.snapshot())
clock = record_decision(labels, timestamps, clock, "round_%d_settle" % market_index, 4.0)
if String(profile.id) == "returning":
var reloaded_after := save_roundtrip(service.state.snapshot(), 110 + reloads)
if reloaded_after.is_empty():
return failed_result(String(profile.id), "reload_after_settlement")
service = MarketService.new(reloaded_after)
reloads += 1
clock = record_decision(labels, timestamps, clock, "market_%d_return" % int(service.state.current_run.market_index), 1.5)
if int(service.state.current_run.coin) < 0:
return failed_result(String(profile.id), "negative_coin")
var final_run: Dictionary = service.state.current_run
var result := {
"id": String(profile.id),
"failed": false,
"policy": String(profile.policy),
"policy_signature": "%s:%s" % [profile.policy, profile.risks],
"route_ids": route_ids,
"decisive_kinds": decisive_kinds,
"policy_receipts": policy_receipts,
"labels": labels,
"timestamps": timestamps,
"decision_count": timestamps.size(),
"max_gap": maximum_gap(timestamps),
"automatic_seconds": automatic_seconds,
"recoveries": recoveries,
"reloads": reloads,
"fallen_ids": fallen_ids,
"company_before": company_before,
"company_after": Array(final_run.company).duplicate(),
"coin": int(final_run.coin),
"phase": String(final_run.phase),
"market_index": int(final_run.market_index),
"line_capacity": int(final_run.line_capacity),
"ferry": bool(final_run.reed_ferry_restored),
}
print("P102_PROFILE_RESULT profile=%s policy=%s routes=%s decisions=%s max_gap=%.1f auto=%.1f coin=%d company=%d fallen=%d reloads=%d market=%d" % [result.id, result.policy_signature, route_ids, format_timestamps(labels, timestamps), result.max_gap, result.automatic_seconds, result.coin, result.company_after.size(), fallen_ids.size(), reloads, result.market_index])
return result
func apply_policy(service, profile: Dictionary, market_index: int, route: Dictionary) -> Dictionary:
match String(profile.policy):
"hire":
for offer in service.state.current_run.market_board.recruit_offers:
if String(offer.source_offer_id) in service.state.current_run.available_recruit_offer_ids and int(offer.list_price) <= int(service.state.current_run.coin):
var hired: Dictionary = service.hire("profile-%s-hire-%d" % [profile.id, market_index], service.state.revision, String(offer.source_offer_id), int(offer.list_price))
return {"ok": hired.ok, "code": hired.code, "receipt": "hire", "service": service}
return {"ok": true, "code": "conserve", "receipt": "conserve", "service": service}
"gear":
var offer := matching_equipment(service, route)
if not offer.is_empty() and int(offer.list_price) <= int(service.state.current_run.coin):
var bought: Dictionary = service.buy_equipment("profile-gear-buy-%d" % market_index, service.state.revision, String(offer.offer_id), int(offer.list_price))
if not bought.ok:
return {"ok": false, "code": bought.code, "receipt": "gear", "service": service}
var recruit_id := String(service.state.current_run.deployed_recruit_ids[0])
var equipped: Dictionary = service.equip("profile-gear-equip-%d" % market_index, service.state.revision, recruit_id, String(bought.receipt.instance_id))
return {"ok": equipped.ok, "code": equipped.code, "receipt": "gear", "service": service}
return {"ok": true, "code": "conserve", "receipt": "conserve", "service": service}
"recover":
if market_index == 2:
var recruit: Dictionary = service.state.current_run.market_board.recruit_offers[0]
var hired: Dictionary = service.hire("profile-recover-hire", service.state.revision, String(recruit.source_offer_id), int(recruit.list_price))
return {"ok": hired.ok, "code": hired.code, "receipt": "hire_buffer", "service": service}
return {"ok": true, "code": "hold", "receipt": "hold_buffer", "service": service}
"reload":
if market_index == 2:
var offer_id := String(service.state.current_run.available_equipment_offer_ids[0])
var locked: Dictionary = service.lock_offer("profile-returning-lock", service.state.revision, offer_id, 2)
return {"ok": locked.ok, "code": locked.code, "receipt": "lock", "service": service}
return {"ok": true, "code": "carry_lock", "receipt": "carry_lock", "service": service}
return {"ok": true, "code": "conserve", "receipt": "conserve", "service": service}
func matching_equipment(service, route: Dictionary) -> Dictionary:
for offer in service.state.current_run.market_board.equipment_offers:
if String(offer.offer_id) not in service.state.current_run.available_equipment_offer_ids:
continue
var row: Dictionary = equipment_rows.get(String(offer.catalog_id), {})
if Array(row.get("response_tags", [])).any(func(tag): return tag in route.response_tags):
return Dictionary(offer).duplicate(true)
for offer in service.state.current_run.market_board.equipment_offers:
if String(offer.offer_id) in service.state.current_run.available_equipment_offer_ids:
return Dictionary(offer).duplicate(true)
return {}
func first_affordable_recruit(service) -> Dictionary:
for offer in service.state.current_run.market_board.recruit_offers:
if (
String(offer.source_offer_id) in service.state.current_run.available_recruit_offer_ids
and int(offer.list_price) <= int(service.state.current_run.coin)
):
return Dictionary(offer).duplicate(true)
return {}
func verify_results(results: Array[Dictionary], anti_dominance: 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 signatures := {}
var routes := {}
var mechanic_kinds := {}
for result in results:
signatures[String(result.policy_signature)] = true
for route_id in result.route_ids:
routes[String(route_id)] = true
for kind in result.decisive_kinds:
mechanic_kinds[String(kind)] = true
expect(int(result.decision_count) >= 15, "%s records every repeated-Market decision" % result.id)
expect(float(result.max_gap) <= 12.0, "%s nonbattle decision gap is bounded" % result.id)
expect(strictly_increasing(result.timestamps), "%s decision timestamps are strictly increasing" % result.id)
expect(int(result.coin) >= 0, "%s never has negative Coin" % result.id)
expect(result.phase == "market_5" and result.market_index == 5 and result.line_capacity == 6 and result.ferry, "%s reaches stable restored Market 5" % result.id)
expect(signatures.size() == 5, "profiles use five distinct policies")
expect(routes.size() == 9, "profiles cover all nine route IDs")
expect(mechanic_kinds.size() >= 4, "profiles expose at least four distinct decisive mechanics")
var adversarial := result_by_id(results, "adversarial")
expect(adversarial.recoveries == 1 and adversarial.fallen_ids.size() == 1, "adversarial profile proves one bounded casualty recovery")
for fallen_id in adversarial.fallen_ids:
expect(fallen_id not in adversarial.company_after, "recovery never resurrects %s" % fallen_id)
var returning := result_by_id(results, "returning")
expect(returning.reloads == 6, "returning profile reloads before and after all three Rounds")
expect(int(anti_dominance.reversals) == 9 and int(anti_dominance.max_plan_wins) < 9, "nine opposed outcomes prove no universal plan")
func verify_all_counterfactuals() -> Dictionary:
var win_counts := {"hold_line": 0, "follow_tide": 0, "focus_source": 0, "guard_center": 0}
var reversals := 0
for market_index in [2, 3, 4]:
for risk in range(3):
var service = Fixture.new().prepared_service(market_index, risk)
if service == null:
continue
var route_id := String(service.state.current_run.pending_encounter.route_id)
for plan_id in BattleService.PLAN_IDS:
var result: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal(String(plan_id))
if result.ok and String(result.snapshot.outcome) == "victory":
win_counts[String(plan_id)] += 1
var preferred: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal(String(BattleService.PREFERRED_PLAN[route_id]))
var wrong: Dictionary = BattleService.new(service.state.snapshot()).run_to_terminal(String(WRONG_PLANS[route_id]))
if preferred.ok and wrong.ok and preferred.snapshot.outcome == "victory" and wrong.snapshot.outcome == "defeat":
reversals += 1
var max_plan_wins := 0
for wins in win_counts.values():
max_plan_wins = maxi(max_plan_wins, int(wins))
return {"reversals": reversals, "max_plan_wins": max_plan_wins, "win_counts": win_counts}
func save_roundtrip(snapshot: Dictionary, sequence: int) -> Dictionary:
var encoded: Dictionary = SaveV8.new().encode_snapshot(snapshot, sequence)
if not encoded.ok:
failures.append("Save v8 encode rejected at sequence %d" % sequence)
return {}
var decoded: Dictionary = SaveV8.new().decode_text(String(encoded.text), sequence)
if not decoded.ok or decoded.snapshot != snapshot or decoded.offline_delta != SaveV8.OFFLINE_ZERO:
failures.append("Save v8 roundtrip changed state or offline value at sequence %d" % sequence)
return {}
return Dictionary(decoded.snapshot).duplicate(true)
func record_decision(labels: Array[String], timestamps: Array[float], clock: float, label: String, delta: float) -> float:
var next := clock + delta
labels.append(label)
timestamps.append(next)
return next
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 failed_result(profile_id: String, code: String) -> Dictionary:
failures.append("%s failed at %s" % [profile_id, code])
return {"id": profile_id, "failed": true}
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P10.2 five-profile: %s" % message)