249 lines
13 KiB
GDScript
249 lines
13 KiB
GDScript
extends SceneTree
|
|
|
|
const BattleService = preload("res://fg3/contract2_battle_service.gd")
|
|
const ExecutionService = preload("res://fg3/contract2_execution_service.gd")
|
|
const Fixture = preload("res://tests/fg3/contract1_fixture.gd")
|
|
const SaveV10 = preload("res://fg3/save_v10_service.gd")
|
|
|
|
var failures: Array[String] = []
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run_test")
|
|
|
|
|
|
func run_test() -> void:
|
|
verify_twelve_round_execution()
|
|
verify_permanent_casualty_and_recovery()
|
|
verify_replay_and_operation_atomicity()
|
|
verify_route_and_artifact_counterfactuals()
|
|
if failures.is_empty():
|
|
print("ABG_FG3_CONTRACT2_EXECUTION_OK rounds=12 routes=36 responses=2 state_reversals=2 artifacts=7 nondominant_plans=4 rewards=exact xp=exact history=24 pulse=event_bound casualty=permanent recovery=same_board replay=true duplicate=true save_v10=recovery_roundtrip offline=zero")
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
func verify_twelve_round_execution() -> void:
|
|
var start := Fixture.new().contract2_market_1_snapshot()
|
|
var service := ExecutionService.new(start)
|
|
expect(service.valid, "real first-Contract handoff activates Contract 2")
|
|
if not service.valid:
|
|
return
|
|
var expected_coin := int(start.current_run.coin)
|
|
var contract2_route_ids: Array = []
|
|
for market_index in range(1, 13):
|
|
expect(service.state.current_run.phase == "contract2_market", "Market %d is open" % market_index)
|
|
expect(service.state.current_run.market_index == market_index, "Market %d index is exact" % market_index)
|
|
var route: Dictionary = service.state.current_run.market_board.battle_offers[0]
|
|
var board_hash := String(service.state.current_run.market_board.board_hash)
|
|
var revision_before := int(service.state.revision)
|
|
var operation_id := "fg3-contract2-round-%02d-prepare" % market_index
|
|
var prepared := service.commit_route(operation_id, revision_before, String(route.offer_id), board_hash)
|
|
expect(prepared.ok and service.state.current_run.phase == "contract2_prepared", "Market %d route prepares" % market_index)
|
|
var duplicate_prepare := service.commit_route(operation_id, revision_before, String(route.offer_id), board_hash)
|
|
expect(duplicate_prepare.ok and duplicate_prepare.duplicate, "Market %d prepare retry is inert" % market_index)
|
|
if not prepared.ok:
|
|
return
|
|
var plan_id := String(BattleService.PRIMARY_PLAN[market_index])
|
|
var battle := BattleService.new(service.state.snapshot()).run_to_terminal(plan_id)
|
|
expect(battle.ok and battle.snapshot.outcome == "victory", "Market %d primary response wins" % market_index)
|
|
if not battle.ok:
|
|
return
|
|
expect(event_count(battle.snapshot.event_log, "pulse_linked") == 1, "Market %d emits one disclosed pulse" % market_index)
|
|
expect(event_count(battle.snapshot.event_log, "plan_response") == 1, "Market %d emits one bound plan response" % market_index)
|
|
var coin_before := int(service.state.current_run.coin)
|
|
var settled := service.settle_route(
|
|
"fg3-contract2-round-%02d-settle" % market_index,
|
|
service.state.revision,
|
|
battle.snapshot
|
|
)
|
|
expect(settled.ok, "Market %d settlement applies" % market_index)
|
|
if not settled.ok:
|
|
return
|
|
expected_coin += int(route.reward_coin)
|
|
contract2_route_ids.append(String(route.route_id))
|
|
expect(service.state.current_run.coin == coin_before + int(route.reward_coin), "Market %d pays exact reward" % market_index)
|
|
expect(settled.receipt.xp_award == int(battle.snapshot.xp_grant), "Market %d pays exact XP" % market_index)
|
|
expect(service.state.current_run.cleared_rounds == 12 + market_index, "Market %d increments global clears once" % market_index)
|
|
expect(service.state.current_run.phase == "contract2_boss_muster", "Round 12 opens the second Boss Muster")
|
|
expect(service.state.current_run.cleared_rounds == 24 and service.state.current_run.route_history.size() == 24, "both Contracts retain 24 route records")
|
|
expect(service.state.current_run.route_history.slice(12).map(func(row): return String(row.route_id)) == contract2_route_ids, "second Record input retains the twelve exact routes")
|
|
expect(service.state.current_run.coin == expected_coin, "all ordinary rewards sum exactly")
|
|
expect(service.state.current_run.boss_muster.boss_id == "BO-R2-02" and service.state.current_run.boss_muster.plan_ids.size() == 2, "Drowned Bell Muster exposes two plans")
|
|
|
|
|
|
func verify_permanent_casualty_and_recovery() -> void:
|
|
var start := Fixture.new().contract2_market_1_snapshot()
|
|
var service := ExecutionService.new(start)
|
|
if not service.valid:
|
|
expect(false, "recovery fixture validates")
|
|
return
|
|
var board_before: Dictionary = service.state.current_run.market_board.duplicate(true)
|
|
var company_before: Array = Array(service.state.current_run.company).duplicate()
|
|
var coin_before := int(service.state.current_run.coin)
|
|
var route: Dictionary = service.state.current_run.market_board.battle_offers[2]
|
|
var primary := String(BattleService.PRIMARY_PLAN[1])
|
|
var secondary := String(BattleService.SECONDARY_PLAN[1])
|
|
var failing_plan := "interrupt_pulse"
|
|
if failing_plan in [primary, secondary]:
|
|
failing_plan = "reorder_line"
|
|
var prepared := service.commit_route(
|
|
"fg3-contract2-defeat-prepare",
|
|
service.state.revision,
|
|
String(route.offer_id),
|
|
String(board_before.board_hash)
|
|
)
|
|
expect(prepared.ok, "risk-two recovery route prepares")
|
|
if not prepared.ok:
|
|
return
|
|
var battle := BattleService.new(service.state.snapshot()).run_to_terminal(failing_plan)
|
|
expect(battle.ok and battle.snapshot.outcome == "defeat" and battle.snapshot.fallen_recruit_ids.size() == 1, "unanswered pulse causes one permanent casualty")
|
|
if not battle.ok:
|
|
return
|
|
var fallen_id := String(battle.snapshot.fallen_recruit_ids[0])
|
|
var equipment_id := String(start.current_run.equipped_by_recruit.get(fallen_id, ""))
|
|
var settled := service.settle_route("fg3-contract2-defeat-settle", service.state.revision, battle.snapshot)
|
|
expect(settled.ok and service.state.current_run.phase == "contract2_recovery", "defeat enters explicit recovery")
|
|
expect(fallen_id not in service.state.current_run.company and service.state.current_run.company.size() == company_before.size() - 1, "Fallen Recruit is removed permanently")
|
|
expect(service.state.current_run.coin == coin_before and service.state.current_run.cleared_rounds == 12, "defeat pays no Coin and no clear")
|
|
expect(service.state.current_run.market_board == board_before, "defeat preserves the exact disclosed board")
|
|
if not equipment_id.is_empty():
|
|
expect(service.state.current_run.inventory[equipment_id].wearer_id == "", "Fallen Recruit equipment returns to inventory")
|
|
var recovery_snapshot := service.state.snapshot()
|
|
var encoded := SaveV10.new().encode_snapshot(recovery_snapshot, 120)
|
|
var decoded := SaveV10.new().decode_text(String(encoded.text), 120)
|
|
expect(encoded.ok and decoded.ok and decoded.snapshot == recovery_snapshot, "recovery state roundtrips byte-equivalent")
|
|
expect(decoded.offline_delta == SaveV10.OFFLINE_ZERO, "recovery reload invents no offline value")
|
|
var recovery_revision := int(service.state.revision)
|
|
var recovered := service.recover_route(
|
|
"fg3-contract2-defeat-recover",
|
|
recovery_revision,
|
|
String(battle.snapshot.snapshot_hash),
|
|
int(route.recovery_coin)
|
|
)
|
|
expect(recovered.ok and service.state.current_run.phase == "contract2_market", "recovery reopens the same Market")
|
|
expect(service.state.current_run.market_board == board_before, "recovery never rerolls route or pulse facts")
|
|
expect(fallen_id not in service.state.current_run.company and service.state.current_run.company.size() == company_before.size() - 1, "recovery never resurrects or substitutes")
|
|
expect(service.state.current_run.coin == coin_before - int(route.recovery_coin), "recovery charges the exact disclosed cost")
|
|
var after_recovery := service.state.snapshot()
|
|
var duplicate_recovery := service.recover_route(
|
|
"fg3-contract2-defeat-recover",
|
|
recovery_revision,
|
|
String(battle.snapshot.snapshot_hash),
|
|
int(route.recovery_coin)
|
|
)
|
|
expect(duplicate_recovery.ok and duplicate_recovery.duplicate and service.state.snapshot() == after_recovery, "recovery retry cannot charge twice")
|
|
|
|
|
|
func verify_replay_and_operation_atomicity() -> void:
|
|
var service := ExecutionService.new(Fixture.new().contract2_market_1_snapshot())
|
|
if not service.valid:
|
|
expect(false, "atomicity fixture validates")
|
|
return
|
|
var route: Dictionary = service.state.current_run.market_board.battle_offers[0]
|
|
var prepared := service.commit_route(
|
|
"fg3-contract2-atomic-prepare",
|
|
service.state.revision,
|
|
String(route.offer_id),
|
|
String(service.state.current_run.market_board.board_hash)
|
|
)
|
|
expect(prepared.ok, "atomicity route prepares")
|
|
if not prepared.ok:
|
|
return
|
|
var battle := BattleService.new(service.state.snapshot()).run_to_terminal(String(BattleService.PRIMARY_PLAN[1]))
|
|
var prepared_snapshot := service.state.snapshot()
|
|
var tampered: Dictionary = battle.snapshot.duplicate(true)
|
|
tampered.score = int(tampered.score) + 1
|
|
var rejected := service.settle_route("fg3-contract2-tampered", service.state.revision, tampered)
|
|
expect(not rejected.ok and rejected.code == "battle_snapshot_mismatch" and service.state.snapshot() == prepared_snapshot, "tampered replay rejects without mutation")
|
|
var settle_revision := int(service.state.revision)
|
|
var settled := service.settle_route("fg3-contract2-atomic-settle", settle_revision, battle.snapshot)
|
|
expect(settled.ok, "untampered replay settles")
|
|
var settled_snapshot := service.state.snapshot()
|
|
var duplicate := service.settle_route("fg3-contract2-atomic-settle", settle_revision, battle.snapshot)
|
|
expect(duplicate.ok and duplicate.duplicate and service.state.snapshot() == settled_snapshot, "settlement retry cannot duplicate reward or XP")
|
|
|
|
|
|
func verify_route_and_artifact_counterfactuals() -> void:
|
|
var support_artifact := {
|
|
1: "AR-011", 2: "AR-012", 3: "AR-010", 4: "AR-009",
|
|
5: "AR-011", 6: "AR-010", 7: "AR-012", 8: "AR-011",
|
|
9: "AR-012", 10: "AR-012", 11: "AR-009", 12: "AR-009",
|
|
}
|
|
var primary_counts := {}
|
|
var secondary_counts := {}
|
|
for plan_id in BattleService.PLAN_IDS:
|
|
primary_counts[plan_id] = 0
|
|
secondary_counts[plan_id] = 0
|
|
for market_index in range(1, 13):
|
|
var primary := String(BattleService.PRIMARY_PLAN[market_index])
|
|
var secondary := String(BattleService.SECONDARY_PLAN[market_index])
|
|
var artifact_id := String(support_artifact[market_index])
|
|
primary_counts[primary] += 1
|
|
secondary_counts[secondary] += 1
|
|
var wrong_plan := ""
|
|
for plan_id in BattleService.PLAN_IDS:
|
|
if plan_id not in [primary, secondary]:
|
|
wrong_plan = String(plan_id)
|
|
break
|
|
for risk in range(3):
|
|
var primary_score := BattleService.route_plan_score(
|
|
market_index, risk, primary, artifact_id, 3, 1.0
|
|
)
|
|
var secondary_score := BattleService.route_plan_score(
|
|
market_index, risk, secondary, artifact_id, 3, 1.0
|
|
)
|
|
expect(primary_score >= 15, "Market %d risk %d retains a viable primary response" % [market_index, risk])
|
|
expect(secondary_score >= 15, "Market %d risk %d gains a viable secondary response in a matching state" % [market_index, risk])
|
|
var wrong_score := BattleService.route_plan_score(
|
|
market_index, 0, wrong_plan, artifact_id, 3, 1.0
|
|
)
|
|
expect(wrong_score < 15, "Market %d does not reduce all four plans to equivalent wins" % market_index)
|
|
for plan_id in BattleService.PLAN_IDS:
|
|
expect(primary_counts[plan_id] > 0 and secondary_counts[plan_id] > 0, "%s changes rank across Markets" % plan_id)
|
|
for artifact_id in BattleService.ARTIFACT_PLAN_BONUS:
|
|
var values: Array = []
|
|
for plan_id in BattleService.PLAN_IDS:
|
|
values.append(int(BattleService.ARTIFACT_PLAN_BONUS[artifact_id].get(plan_id, 0)))
|
|
expect(values.max() > values.min(), "%s changes at least one plan comparison" % artifact_id)
|
|
var m3_primary := String(BattleService.PRIMARY_PLAN[3])
|
|
var m3_secondary := String(BattleService.SECONDARY_PLAN[3])
|
|
expect(
|
|
BattleService.route_plan_score(3, 1, m3_primary, "AR-011", 0, 1.0)
|
|
> BattleService.route_plan_score(3, 1, m3_secondary, "AR-011", 0, 1.0),
|
|
"AR-011 favors fortifying the first triple link"
|
|
)
|
|
expect(
|
|
BattleService.route_plan_score(3, 1, m3_primary, "AR-014", 0, 1.0)
|
|
< BattleService.route_plan_score(3, 1, m3_secondary, "AR-014", 0, 1.0),
|
|
"AR-014 reverses the first triple-link plan toward reordering"
|
|
)
|
|
var m11_primary := String(BattleService.PRIMARY_PLAN[11])
|
|
var m11_secondary := String(BattleService.SECONDARY_PLAN[11])
|
|
expect(
|
|
BattleService.route_plan_score(11, 1, m11_primary, "AR-011", 0, 1.0)
|
|
> BattleService.route_plan_score(11, 1, m11_secondary, "AR-011", 0, 1.0),
|
|
"AR-011 favors durable links late in the Contract"
|
|
)
|
|
expect(
|
|
BattleService.route_plan_score(11, 1, m11_primary, "AR-009", 0, 1.0)
|
|
< BattleService.route_plan_score(11, 1, m11_secondary, "AR-009", 0, 1.0),
|
|
"AR-009 reverses the late link plan toward interruption"
|
|
)
|
|
|
|
|
|
func event_count(events: Array, kind: String) -> int:
|
|
var count := 0
|
|
for event in events:
|
|
if String(event.get("kind", "")) == kind:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func expect(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append("FG-3 Contract-2 execution: %s" % message)
|