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

367 lines
19 KiB
GDScript

extends SceneTree
const MarketService = preload("res://p10_0/market_service.gd")
const BattleService = preload("res://p10_0/wave_battle_service.gd")
const ResolutionService = preload("res://p10_0/resolution_service.gd")
const ContractService = preload("res://p10_0/contract_service.gd")
const BossBattleService = preload("res://p10_0/boss_battle_service.gd")
const SaveService = preload("res://p10_0/save_service.gd")
const Content = preload("res://p10_0/content_registry.gd")
const OfferGenerator = preload("res://p10_0/offer_generator.gd")
const PROFILES := [
{"id": "normal", "difficulty": "standard", "risks": [0, 1, 0, 1], "target_company": 5, "reverse": false},
{"id": "active", "difficulty": "standard", "risks": [1, 2, 1, 0], "target_company": 6, "reverse": false},
{"id": "efficient", "difficulty": "wayfinder", "risks": [0, 0, 1, 0], "target_company": 5, "reverse": false},
{"id": "adversarial", "difficulty": "iron_oath", "risks": [2, 2, 1, 0], "target_company": 6, "reverse": true},
{"id": "returning", "difficulty": "standard", "risks": [0, 1, 2, 0], "target_company": 5, "reverse": true},
]
const ROOT_SEEDS := [
"11111111111111111111111111111111",
"22222222222222222222222222222222",
"33333333333333333333333333333333",
"44444444444444444444444444444444",
"55555555555555555555555555555555",
]
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var results: Array[Dictionary] = []
for index in range(PROFILES.size()):
results.append(simulate_profile(PROFILES[index], ROOT_SEEDS[index], index))
verify_profile_results(results)
verify_catalog_reachability()
if failures.is_empty():
var decisions := 0
var max_gap := 0.0
var casualties := 0
for result in results:
decisions += int(result.decisions)
max_gap = maxf(max_gap, float(result.max_gap_seconds))
casualties += int(result.casualties)
print("ABG_P10_0_FIVE_PROFILE_OK profiles=normal,active,efficient,adversarial,returning contracts=10 rounds=120 bosses=10 decisions=%d max_gap=%.1f casualties=%d risks=0,1,2 save_reload=true policies=market,order,risk,reward,recovery,boss anti_dominance=true content=12base,24advanced,36traits,40equipment,12enemies,2bosses" % [decisions, max_gap, casualties])
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func simulate_profile(profile: Dictionary, root_seed: String, profile_index: int) -> Dictionary:
var market := MarketService.new({}, root_seed, _lineage(profile_index))
var result := {
"id": String(profile.id),
"contracts": 0,
"rounds": 0,
"decisions": 0,
"elapsed_seconds": 0.0,
"max_gap_seconds": 0.0,
"casualties": 0,
"recoveries": 0,
"risks_used": [],
"rewards": [],
"boss_plans": [],
"dismissals": 0,
"save_reloads": 0,
"failed": false,
}
for contract_id in ["CTR-R1-01", "CTR-R1-02"]:
if not start_run(market, contract_id, profile, profile_index):
failures.append("%s start run rejected contract=%s revision=%d clears=%s" % [profile.id, contract_id, market.state.revision, str(market.state.guild_profile.contract_clear_ids)])
result.failed = true
break
var safety := 0
while not market.state.current_run.is_empty() and int(market.state.current_run.cleared_rounds) < 12 and safety < 60:
safety += 1
prepare_market(market, profile, result, safety)
if bool(result.failed):
break
if market.state.current_run.deployed_recruit_ids.is_empty():
failures.append("%s has no deployed Company contract=%s attempt=%d phase=%s company=%d recovery=%s" % [profile.id, contract_id, safety, market.state.current_run.phase, market.state.current_run.company.size(), str(market.state.current_run.recovery_required)])
result.failed = true
break
var battle_offer := choose_available_battle(market, profile, int(market.state.current_run.cleared_rounds), safety)
if battle_offer.is_empty():
failures.append("%s has no battle offer contract=%s attempt=%d market=%d available=%s" % [profile.id, contract_id, safety, market.state.current_run.market_index, str(market.state.current_run.available_battle_offer_ids)])
result.failed = true
break
var risk := int(battle_offer.risk)
var offer_id := String(battle_offer.offer_id)
var committed := market.commit_battle_offer("%s-%s-battle-%d" % [profile.id, contract_id, safety], market.state.revision, offer_id, String(market.state.current_run.market_board.board_hash))
if not committed.ok:
failures.append("%s battle commit rejected contract=%s attempt=%d risk=%d code=%s" % [profile.id, contract_id, safety, risk, committed.code])
result.failed = true
break
var battle: Dictionary = BattleService.new(market.state.snapshot()).run_to_terminal().snapshot
var gap := float(battle.tick) / 10.0
result.elapsed_seconds = float(result.elapsed_seconds) + gap + 6.0
result.max_gap_seconds = maxf(float(result.max_gap_seconds), gap)
result.decisions = int(result.decisions) + 2
if risk not in result.risks_used:
result.risks_used.append(risk)
var resolver := ResolutionService.new(market.state.snapshot())
var settled := resolver.settle_battle("%s-%s-settle-%d" % [profile.id, contract_id, safety], resolver.state.revision, String(battle.snapshot_hash), battle)
if not settled.ok:
failures.append("%s battle settlement rejected contract=%s attempt=%d outcome=%s code=%s" % [profile.id, contract_id, safety, battle.outcome, settled.code])
result.failed = true
break
result.casualties = int(result.casualties) + settled.receipt.fallen_recruit_ids.size()
if not resolver.state.current_run.pending_reward.is_empty():
if not claim_profile_reward(resolver, profile, result, safety):
result.failed = true
break
var next_snapshot: Dictionary = resolver.state.snapshot()
var next_market := MarketService.new()
if not next_market.restore_snapshot(next_snapshot):
failures.append("%s restore rejected after round=%d phase=%s market=%d clears=%d capacity=%d reward=%s" % [profile.id, int(result.rounds), String(next_snapshot.current_run.phase), int(next_snapshot.current_run.market_index), int(next_snapshot.current_run.cleared_rounds), int(next_snapshot.current_run.line_capacity), str(not next_snapshot.current_run.pending_reward.is_empty())])
result.failed = true
break
market = next_market
if String(profile.id) == "returning":
var reloaded = reload_market(market, int(result.save_reloads) + 1)
if reloaded == null:
failures.append("%s save reload rejected contract=%s attempt=%d sequence=%d" % [profile.id, contract_id, safety, int(result.save_reloads) + 1])
result.failed = true
break
market = reloaded
result.save_reloads = int(result.save_reloads) + 1
if battle.outcome == "victory":
result.rounds = int(result.rounds) + 1
if bool(result.failed) or market.state.current_run.is_empty() or int(market.state.current_run.cleared_rounds) != 12:
if not bool(result.failed):
failures.append("%s ordinary rounds incomplete contract=%s attempts=%d current_run=%s clears=%d company=%d" % [profile.id, contract_id, safety, str(not market.state.current_run.is_empty()), int(market.state.current_run.get("cleared_rounds", -1)), market.state.current_run.get("company", []).size()])
result.failed = true
break
if not complete_boss(market, profile, result, contract_id):
failures.append("%s boss flow rejected contract=%s company=%d coin=%d" % [profile.id, contract_id, market.state.current_run.get("company", []).size(), int(market.state.current_run.get("coin", -1))])
result.failed = true
break
result.contracts = int(result.contracts) + 1
return result
func start_run(market, contract_id: String, profile: Dictionary, profile_index: int) -> bool:
var prefix := "%s-%s" % [profile.id, contract_id]
if not market.begin_run_creation("%s-begin" % prefix, market.state.revision, contract_id, String(profile.difficulty)).ok:
return false
var draft_id := String(market.state.run_creation_draft.draft_id)
if not market.materialize_opening_offers("%s-materialize" % prefix, market.state.revision, draft_id).ok:
return false
var offer_ids: Array[String] = []
for offer in market.state.run_creation_draft.opening_offers:
offer_ids.append(String(offer.source_offer_id))
var first := profile_index % 3
var selected := [offer_ids[first], offer_ids[first + 1]]
if not market.set_opening_selection("%s-select" % prefix, market.state.revision, selected).ok:
return false
return market.commit_run_creation("%s-commit" % prefix, market.state.revision, draft_id, int(market.state.run_creation_draft.selection_revision), selected, String(market.state.run_creation_draft.opening_offer_set_hash)).ok
func prepare_market(market, profile: Dictionary, result: Dictionary, safety: int) -> void:
var run_id := String(market.state.current_run.run_id)
var operation_prefix := "%s-%s-%d" % [profile.id, run_id, safety]
if bool(market.state.current_run.recovery_required):
var protected_id := String(market.state.current_run.protected_recovery_offer_id)
var offer: Dictionary = market.inspect_recruit_offer(protected_id).receipt
var affordable := int(market.state.current_run.coin) + int(market.state.current_run.replacement_credit) >= int(offer.list_price)
if affordable and market.state.current_run.company.size() < int(profile.target_company):
var recovered = market.hire("%s-recovery-hire" % operation_prefix, market.state.revision, protected_id, int(offer.list_price), true)
if recovered.ok:
result.recoveries = int(result.recoveries) + 1
result.decisions = int(result.decisions) + 1
else:
failures.append("%s recovery hire rejected run=%s attempt=%d code=%s" % [profile.id, run_id, safety, recovered.code])
result.failed = true
return
else:
var declined = market.decline_protected_recovery("%s-recovery-decline" % operation_prefix, market.state.revision, protected_id)
if not declined.ok:
failures.append("%s recovery decline rejected run=%s attempt=%d code=%s" % [profile.id, run_id, safety, declined.code])
result.failed = true
return
while market.state.current_run.company.size() < int(profile.target_company):
var choice := cheapest_affordable_recruit(market)
if choice.is_empty():
break
var hired = market.hire("%s-hire-%d" % [operation_prefix, market.state.current_run.company.size()], market.state.revision, String(choice.source_offer_id), int(choice.list_price), true)
if not hired.ok:
failures.append("%s Market hire rejected run=%s attempt=%d company=%d code=%s" % [profile.id, run_id, safety, market.state.current_run.company.size(), hired.code])
result.failed = true
return
result.decisions = int(result.decisions) + 1
if String(profile.id) == "active" and int(result.dismissals) == 0 and market.state.current_run.company.size() > 5:
var target := String(market.state.current_run.reserve_recruit_ids[-1]) if not market.state.current_run.reserve_recruit_ids.is_empty() else ""
if not target.is_empty():
var preview = market.preview_dismissal(target)
if preview.ok and market.confirm_dismissal("active-dismiss", market.state.revision, target, int(preview.receipt.rebate), int(preview.receipt.live_recruit_count), String(preview.receipt.equipment_instance_id)).ok:
result.dismissals = 1
var proposal = market.automatic_formation_candidate()
if bool(profile.reverse):
proposal.reverse()
if proposal != market.state.current_run.deployed_recruit_ids:
var deployed = market.commit_deployment("%s-deploy" % operation_prefix, market.state.revision, proposal)
if not deployed.ok:
failures.append("%s deployment rejected run=%s attempt=%d company=%d proposal=%d code=%s" % [profile.id, run_id, safety, market.state.current_run.company.size(), proposal.size(), deployed.code])
result.failed = true
return
result.decisions = int(result.decisions) + 1
func cheapest_affordable_recruit(market) -> Dictionary:
var choices: Array[Dictionary] = []
for offer in market.state.current_run.market_board.recruit_offers:
if String(offer.source_offer_id) in market.state.current_run.available_recruit_offer_ids:
choices.append(Dictionary(offer))
choices.sort_custom(func(left: Dictionary, right: Dictionary): return int(left.list_price) < int(right.list_price))
for offer in choices:
if int(market.state.current_run.coin) + int(market.state.current_run.replacement_credit) >= int(offer.list_price):
return offer
return {}
func choose_available_battle(market, profile: Dictionary, cleared: int, attempt: int) -> Dictionary:
var desired := int(profile.risks[(cleared + attempt - 1) % profile.risks.size()])
for risk in [desired, 0, 1, 2]:
for offer in market.state.current_run.market_board.battle_offers:
if int(offer.risk) == risk and String(offer.offer_id) in market.state.current_run.available_battle_offer_ids:
return Dictionary(offer).duplicate(true)
return {}
func claim_profile_reward(resolver, profile: Dictionary, result: Dictionary, safety: int) -> bool:
var draft: Dictionary = resolver.state.current_run.pending_reward
var card: Dictionary = {}
var operation_prefix := "%s-%s-reward-%d" % [profile.id, resolver.state.current_run.run_id, safety]
if int(draft.risk) == 2 and resolver.state.current_run.active_artifact_ids.is_empty():
card = draft.cards.filter(func(row): return row.kind == "artifact")[0]
var artifact_id := String(card.artifact_ids.filter(func(candidate): return candidate not in resolver.state.current_run.active_artifact_ids)[0])
var artifact_claim = resolver.claim_reward(operation_prefix, resolver.state.revision, String(draft.draft_hash), String(card.card_id), artifact_id)
if not artifact_claim.ok:
failures.append("%s reward claim rejected run=%s round=%d kind=artifact code=%s" % [profile.id, resolver.state.current_run.run_id, int(draft.round), artifact_claim.code])
return false
result.rewards.append("artifact")
return true
for preferred in ["coin", "credit", "equipment"]:
var matches: Array = draft.cards.filter(func(row): return row.kind == preferred)
if not matches.is_empty():
card = matches[0]
break
if card.is_empty():
failures.append("%s reward draft has no supported card run=%s round=%d" % [profile.id, resolver.state.current_run.run_id, int(draft.round)])
return false
var claim = resolver.claim_reward(operation_prefix, resolver.state.revision, String(draft.draft_hash), String(card.card_id))
if not claim.ok:
failures.append("%s reward claim rejected run=%s round=%d kind=%s code=%s" % [profile.id, resolver.state.current_run.run_id, int(draft.round), card.kind, claim.code])
return false
result.rewards.append(String(card.kind))
return true
func complete_boss(market, profile: Dictionary, result: Dictionary, contract_id: String) -> bool:
var output_market = market
var attempts := 0
while attempts < 4:
attempts += 1
prepare_market(market, profile, result, 100 + attempts)
if bool(result.failed):
return false
var contract := ContractService.new(market.state.snapshot())
var entered := contract.enter_boss_muster("%s-%s-muster-%d" % [profile.id, contract_id, attempts], contract.state.revision)
if not entered.ok:
return false
var plans: Array = entered.receipt.plans
var plan_id := String(plans[(attempts - 1 + (1 if bool(profile.reverse) else 0)) % plans.size()])
if not contract.commit_boss_plan("%s-%s-plan-%d" % [profile.id, contract_id, attempts], contract.state.revision, String(contract.state.current_run.boss_muster.muster_hash), plan_id).ok:
return false
result.boss_plans.append(plan_id)
result.decisions = int(result.decisions) + 2
var battle: Dictionary = BossBattleService.new(contract.state.snapshot()).run_to_terminal().snapshot
var gap := float(battle.tick) / 10.0
result.elapsed_seconds = float(result.elapsed_seconds) + gap
result.max_gap_seconds = maxf(float(result.max_gap_seconds), gap)
var settled := contract.settle_boss("%s-%s-boss-settle-%d" % [profile.id, contract_id, attempts], contract.state.revision, String(battle.snapshot_hash), battle)
if not settled.ok:
return false
result.casualties = int(result.casualties) + settled.receipt.fallen_recruit_ids.size()
if battle.outcome == "victory":
var close_id := String(contract.state.current_run.pending_close.close_id)
var closed := contract.close_contract("%s-%s-close" % [profile.id, contract_id], contract.state.revision, close_id)
if not closed.ok:
return false
if not output_market.restore_snapshot(closed.snapshot):
return false
return true
market = MarketService.new(contract.state.snapshot())
return false
func reload_market(market, sequence: int):
var save := SaveService.new()
var encoded := save.encode_snapshot(market.state.snapshot(), sequence)
if not encoded.ok:
return null
var loaded := save.decode_text(String(encoded.text), sequence)
return MarketService.new(loaded.snapshot) if loaded.ok else null
func verify_profile_results(results: Array[Dictionary]) -> void:
var all_risks: Array = []
var all_plans: Array = []
for result in results:
expect(not bool(result.failed), "%s profile completes without simulation abort" % result.id)
expect(int(result.contracts) == 2 and int(result.rounds) == 24, "%s completes two Contracts and 24 Rounds" % result.id)
expect(float(result.max_gap_seconds) <= 150.0 and int(result.decisions) >= 50, "%s keeps decision gaps bounded" % result.id)
for risk in result.risks_used:
if risk not in all_risks:
all_risks.append(risk)
for plan in result.boss_plans:
if plan not in all_plans:
all_plans.append(plan)
expect(all_risks.has(0) and all_risks.has(1) and all_risks.has(2), "profiles collectively use all three disclosed risks")
expect(all_plans.size() == 4, "profiles collectively use both plans for both bosses")
expect(results.any(func(row): return int(row.casualties) > 0) and results.any(func(row): return int(row.casualties) == 0), "casualty and preservation policies both remain reachable")
func verify_catalog_reachability() -> void:
var catalog: Dictionary = Content.snapshot()
var professions := {}
var traits := {}
var equipment := {}
var enemies := {}
for seed in ROOT_SEEDS:
var generator := OfferGenerator.new(catalog)
for contract_id in ["CTR-R1-01", "CTR-R1-02"]:
for market_index in range(1, 13):
for refresh in range(20):
var board: Dictionary = generator.market_board(seed, contract_id, market_index, refresh).board
for recruit in board.recruit_offers:
professions[String(recruit.profession_id)] = true
traits[String(recruit.trait_id)] = true
for item in board.equipment_offers:
equipment[String(item.catalog_id)] = true
for battle in board.battle_offers:
for enemy_id in battle.enemy_ids:
enemies[String(enemy_id)] = true
for row in catalog.professions:
if String(row.family) == "advanced":
professions[String(row.id)] = true
expect(professions.size() == 36, "all 12 base and 24 advanced Professions are reachable")
expect(traits.size() == 36, "all 36 Traits are generated across fixed seeds")
expect(equipment.size() == 40, "all 40 Windglass equipment identities are offered across fixed seeds")
expect(enemies.size() == 12, "all 12 Windglass enemies appear across fixed routes")
func _lineage(index: int) -> String:
return ("%032x" % (index + 101)).substr(0, 32)
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P10.0 five-profile: %s" % message)