Files
TheTree_mock/tests/s5/test_defense_service.gd
Takeshi 5dde0c5ec2 Record original-alignment implementation and traceability checkpoint
Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
2026-09-30 23:52:24 +09:00

1017 lines
74 KiB
GDScript

extends SceneTree
const DefenseServiceType = preload("res://scripts/defense/defense_service.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("S5: deterministic wave-by-wave Raid defense")
_test_threat_summary()
_test_definition_and_registration_validation()
_test_scores_penalties_and_stable_order()
_test_layered_assets_and_breach()
_test_explicit_and_offscreen_attackers_share_one_formula()
_test_active_skills_and_successful_boundaries()
_test_skill_boundary_validation_rollback()
_test_skill_boundary_apply_and_outcome_rollback()
_test_rng_transaction_retry_stability()
_test_retreat_policies_and_manual_retreat_replay()
_test_cancel_transaction_and_reconciliation()
_test_cancel_incomplete_preparation()
_test_resolution_operation_namespace_and_legacy_v1()
_test_multiple_waves_require_explicit_transition()
_test_raid_duration_and_wave_round_limits()
_test_round_resolution_replay()
_test_round_clock_contract()
_test_wave_entry_and_metadata_validation()
_test_armor_pool_accounting()
_test_snapshot_round_trip_and_corrupt_atomicity()
_test_cross_time_snapshot_rejection()
_test_input_order_determinism()
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _test_threat_summary() -> void:
var service := DefenseServiceType.new()
_expect(service.get_threat_summary()[&"total"] == 0, "empty defense has no HUD threat")
_must(service.create_raid(&"preparing", 0, _definition()), "HUD preparing raid created")
_prepare_basic(service, &"active")
var before := service.create_snapshot()
var summary := service.get_threat_summary()
_expect(summary == {&"preparing": 1, &"active": 1, &"between_waves": 0, &"total": 2}, "HUD counts preparing and active raids separately")
summary[&"total"] = 99
_expect(service.get_threat_summary()[&"total"] == 2 and service.create_snapshot() == before, "HUD projection is owned and read only")
_must(service.cancel_raid(&"preparing", 0), "HUD incomplete preparation can be cancelled")
_expect(service.get_threat_summary()[&"preparing"] == 0 and service.get_threat_summary()[&"total"] == 1, "cancelled history is not an ongoing threat")
var restored := DefenseServiceType.new()
_expect(restored.restore_snapshot(service.create_snapshot()) and restored.get_threat_summary() == service.get_threat_summary(), "HUD projection follows restored authoritative state")
func _test_definition_and_registration_validation() -> void:
var service := DefenseServiceType.new()
_expect(not service.create_raid(&"", 0, _definition()).is_success(), "Raid ID is required")
_expect(not service.create_raid(&"bad_minute", -1, _definition()).is_success(), "creation minute cannot be negative")
_expect(not service.create_raid(&"no_entries", 0, _definition({&"entry_points": []})).is_success(), "at least one entry point is required")
_expect(not service.create_raid(&"no_objective", 0, _definition({&"objective_ids": []})).is_success(), "at least one objective is required")
_expect(not service.create_raid(&"no_waves", 0, _definition({&"waves": []})).is_success(), "at least one wave is required")
_expect(not service.create_raid(&"bad_threat", 0, _definition({&"waves": [{&"wave_id": &"w", &"threat_strength": 0}]})).is_success(), "wave strength must be positive")
_expect(not service.create_raid(&"duplicate_wave", 0, _definition({&"waves": [
{&"wave_id": &"same", &"threat_strength": 10},
{&"wave_id": &"same", &"threat_strength": 20},
]})).is_success(), "wave IDs are unique")
_expect(not service.create_raid(&"unknown_entry", 0, _definition({&"waves": [{
&"wave_id": &"w", &"threat_strength": 10, &"entry_points": [&"not_declared"],
}]})).is_success(), "wave entry points must belong to the Raid")
_expect(not service.create_raid(&"unknown_objective", 0, _definition({&"waves": [{
&"wave_id": &"w", &"threat_strength": 10, &"objective_ids": [&"not_declared"],
}]})).is_success(), "wave objectives must belong to the Raid")
_expect(not service.create_raid(&"bad_policy", 0, _definition({&"retreat_policy": &"PANIC"})).is_success(), "retreat policy is enumerated")
_expect(not service.create_raid(&"bad_duration", 0, _definition({&"round_minutes": 0})).is_success(), "round duration must be positive")
_expect(not service.create_raid(&"bad_threshold", 0, _definition({&"defender_retreat_threshold": 101})).is_success(), "defender retreat threshold is bounded")
var created = service.create_raid(&"valid", 10, _definition({
&"entry_points": [&"west", &"east", &"west"],
&"objective_ids": [&"vault", &"core", &"vault"],
&"waves": [{
&"wave_id": &"wave_a",
&"threat_strength": 40,
&"entry_points": [&"west", &"east"],
&"objective_ids": [&"vault", &"core"],
}],
}))
_expect(created.is_success() and created.details[&"wave_count"] == 1, "valid Raid definition is accepted")
var canonical := service.get_raid(&"valid")
_expect(canonical[&"entry_points"] == [&"east", &"west"], "entry point IDs are canonicalized")
_expect(canonical[&"objective_ids"] == [&"core", &"vault"], "objective IDs are canonicalized")
_expect(not service.create_raid(&"valid", 10, _definition()).is_success(), "duplicate Raid ID is rejected")
_expect(not service.register_combatant(&"valid", &"bad_side", &"NEUTRAL", 10, 10).is_success(), "combatant side is validated")
_expect(not service.register_combatant(&"valid", &"bad_health", &"DEFENDER", 10, 0).is_success(), "combatant health is validated")
_expect(not service.register_combatant(&"valid", &"bad_wave", &"ATTACKER", 10, 10, {&"wave_id": &"missing"}).is_success(), "attacker wave reference is validated")
_expect(not service.register_combatant(&"valid", &"bad_defender_wave", &"DEFENDER", 10, 10, {&"wave_id": &"wave_a"}).is_success(), "defenders cannot be wave-bound")
_expect(service.register_combatant(&"valid", &"guard", &"DEFENDER", 20, 100, {&"health": 60, &"entry_point_id": &"east"}).is_success(), "valid wounded defender is registered")
_expect(service.get_raid(&"valid")[&"combatants"][&"guard"][&"initial_health"] == 100, "retreat baseline uses maximum health")
_expect(not service.register_asset(&"valid", &"core", &"DOOR", 100, 10).is_success(), "declared objective cannot be registered as a door")
_expect(not service.register_asset(&"valid", &"ordinary", &"OBJECTIVE", 100, 10).is_success(), "undeclared asset cannot be registered as an objective")
_expect(not service.register_asset(&"valid", &"door_bad_entry", &"DOOR", 100, 10, {&"entry_points": [&"north"]}).is_success(), "asset entry coverage is validated")
_expect(service.register_asset(&"valid", &"core", &"OBJECTIVE", 100, 5, {&"integrity": 40}).is_success(), "first objective asset is registered")
_expect(service.get_raid(&"valid")[&"assets"][&"core"][&"initial_integrity"] == 100, "objective retreat baseline uses maximum integrity")
_expect(not service.start_raid(&"valid", 10).is_success(), "Raid cannot start until every objective is registered")
_expect(service.register_asset(&"valid", &"vault", &"OBJECTIVE", 80, 5).is_success(), "second objective asset is registered")
_expect(service.start_raid(&"valid", 10).is_success(), "complete Raid can start")
_expect(service.get_raid(&"valid")[&"waves"][0][&"state"] == &"ACTIVE", "starting marks the first wave ACTIVE")
_expect(not service.register_asset(&"valid", &"late", &"DOOR", 10, 1).is_success(), "assets cannot be added after start")
_expect(not service.register_combatant(&"valid", &"late", &"DEFENDER", 1, 1).is_success(), "combatants cannot be added after start")
_expect(service.validate_invariants()[&"ok"], "validated registration state satisfies all invariants")
func _test_scores_penalties_and_stable_order() -> void:
var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(service.create_raid(&"scores", 0, _definition({
&"entry_points": [&"west", &"east"],
&"terrain_bonus": 3,
&"command_bonus": 4,
&"surprise_penalty": 7,
&"multiple_entry_penalty": 5,
&"waves": [{
&"wave_id": &"wave_1", &"threat_strength": 40,
&"entry_points": [&"west", &"east"], &"objective_ids": [&"core"],
&"retreat_threshold": 0, &"max_rounds": 10,
}],
})), "score fixture Raid is created")
_must(service.register_combatant(&"scores", &"z_guard", &"DEFENDER", 20, 100, {&"fatigue": 25}), "fatigued defender is registered")
_must(service.register_combatant(&"scores", &"a_guard", &"DEFENDER", 10, 100), "fresh defender is registered")
_must(service.register_combatant(&"scores", &"raider", &"ATTACKER", 20, 100, {&"wave_id": &"wave_1"}), "explicit attacker is registered")
_must(service.register_asset(&"scores", &"core", &"OBJECTIVE", 100, 5), "score objective is registered")
_must(service.register_asset(&"scores", &"tower", &"DEFENSE_DEVICE", 100, 12, {&"terrain_bonus": 2}), "score device is registered")
_must(service.register_asset(&"scores", &"gate", &"DOOR", 100, 8), "score door is registered")
_must(service.start_raid(&"scores", 0), "score Raid starts")
var first = service.resolve_next_round(&"scores", &"score_round_1", 5)
_expect(first.is_success(), "first score round resolves")
var components: Dictionary = first.details[&"score_components"]
_expect(components[&"defender_combat"] == 30, "defender score sums effective combat")
_expect(components[&"asset_defense"] == 25, "door, device and objective all contribute defense")
_expect(components[&"terrain_bonus"] == 5 and components[&"command_bonus"] == 4, "terrain and command bonuses are explicit score components")
_expect(components[&"fatigue_penalty"] == 5, "fatigue scales the affected defender's effective combat")
_expect(components[&"multiple_entry_penalty"] == 5, "second entry point applies one deterministic penalty")
_expect(components[&"surprise_penalty"] == 7, "surprise applies on the first wave round")
_expect(components[&"attacker_combat"] == 20 and components[&"aggregate_attacker_combat"] == 40, "explicit and aggregate attackers are visible score components")
_expect(first.details[&"defense_score"] == 47 and first.details[&"attack_score"] == 60, "integer score formula has the expected result")
var raid_after_first := service.get_raid(&"scores")
_expect(raid_after_first[&"combatants"][&"a_guard"][&"fatigue"] == 5, "fresh defender gains five fatigue per round")
_expect(raid_after_first[&"combatants"][&"z_guard"][&"fatigue"] == 30, "existing fatigue increases by the same fixed amount")
var second = service.resolve_next_round(&"scores", &"score_round_2", 10)
_expect(second.is_success() and second.details[&"score_components"][&"surprise_penalty"] == 0, "surprise never repeats after round one")
_expect(second.details[&"defense_roll"] == 50 and second.details[&"attack_roll"] == 50, "injected rolls are deterministic")
_expect(service.validate_invariants()[&"ok"], "multi-entry and fatigue score state remains valid")
func _test_layered_assets_and_breach() -> void:
var outcomes := {&"count": 0, &"payload": {}}
var service := DefenseServiceType.new().configure_callbacks(
_fixed_roll(50), Callable(),
func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
if phase == &"apply":
outcomes[&"count"] = int(outcomes[&"count"]) + 1
outcomes[&"payload"] = _payload.duplicate(true)
return {&"ok": true, &"token": token}
)
_must(service.create_raid(&"layers", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "layered Raid is created")
_must(service.register_asset(&"layers", &"core", &"OBJECTIVE", 3, 0), "fragile objective is registered")
_must(service.register_asset(&"layers", &"z_door", &"DOOR", 3, 0), "door is registered")
_must(service.register_asset(&"layers", &"a_device", &"DEFENSE_DEVICE", 3, 0), "device is registered")
_must(service.start_raid(&"layers", 0), "layered Raid starts")
var door_round = service.resolve_next_round(&"layers", &"layer_1", 5)
_expect(door_round.is_success() and door_round.details[&"defense_damage"][&"target_id"] == &"z_door", "lowest target-order door is damaged first")
_expect(door_round.details[&"defense_damage"][&"destroyed"], "door integrity reaches zero in its round")
var device_round = service.resolve_next_round(&"layers", &"layer_2", 10)
_expect(device_round.is_success() and device_round.details[&"defense_damage"][&"target_id"] == &"a_device", "device is targeted after destroyed doors")
_expect(device_round.details[&"score_components"][&"asset_defense"] == 0, "destroyed door no longer contributes defense")
var objective_round = service.resolve_next_round(&"layers", &"layer_3", 15)
_expect(objective_round.is_success() and objective_round.details[&"defense_damage"][&"target_id"] == &"core", "objective is reached after devices")
_expect(objective_round.details[&"state"] == &"BREACHED" and objective_round.details[&"reason"] == &"OBJECTIVE_DESTROYED", "destroyed objective breaches the Raid")
var raid := service.get_raid(&"layers")
_expect(raid[&"assets"][&"z_door"][&"integrity"] == 0 and raid[&"assets"][&"a_device"][&"integrity"] == 0 and raid[&"assets"][&"core"][&"integrity"] == 0, "all three defense layers retain their per-round integrity updates")
_expect(raid[&"waves"][0][&"state"] == &"BREACHED" and raid[&"result"] == &"BREACHED", "wave and Raid terminal states agree")
_expect(outcomes[&"count"] == 1, "terminal outcome boundary applies exactly once")
var asset_results: Array = outcomes[&"payload"][&"asset_results"]
_expect([asset_results[0][&"asset_id"], asset_results[1][&"asset_id"], asset_results[2][&"asset_id"]] == [&"a_device", &"core", &"z_door"], "terminal asset reconciliation payload uses stable ID order")
_expect(asset_results.all(func(asset: Dictionary) -> bool: return bool(asset[&"destroyed"])), "terminal payload exposes final integrity for every destroyed defense layer")
_expect(service.validate_invariants()[&"ok"], "breached layered Raid remains valid")
func _test_explicit_and_offscreen_attackers_share_one_formula() -> void:
var aggregate_only := _attack_mix_service(&"aggregate_only", false)
var mixed := _attack_mix_service(&"mixed", true)
var aggregate_round = aggregate_only.resolve_next_round(&"aggregate_only", &"round", 5)
var mixed_round = mixed.resolve_next_round(&"mixed", &"round", 5)
_expect(aggregate_round.is_success() and mixed_round.is_success(), "aggregate-only and mixed rounds resolve")
_expect(aggregate_round.details[&"attack_score"] == mixed_round.details[&"attack_score"], "equal explicit/offscreen effective power produces the same attack score")
_expect(aggregate_round.details[&"damage_to_threat"] == mixed_round.details[&"damage_to_threat"], "equal attack power uses the same damage formula")
_expect(mixed_round.details[&"attacker_damage"].size() == 1, "mixed damage is allocated to the stable explicit attacker first")
_expect(mixed_round.details[&"attacker_damage"][0][&"target_id"] == &"visible_raider" and mixed_round.details[&"attacker_damage"][0][&"damage"] == 2, "explicit attacker consumes only its actual remaining durability")
_expect(mixed_round.details[&"aggregate_damage"] == mixed_round.details[&"damage_applied_to_threat"] - 2, "only leftover damage reaches the offscreen aggregate")
_expect(mixed.get_raid(&"mixed")[&"waves"][0][&"remaining_threat"] == 10 - mixed_round.details[&"aggregate_damage"], "aggregate threat is reduced once, never in parallel with explicit damage")
_expect(mixed.get_raid(&"mixed")[&"combatants"][&"visible_raider"][&"state"] == DefenseServiceType.CombatantState.INCAPACITATED, "explicit attacker state follows its health")
_expect(mixed.validate_invariants()[&"ok"], "mixed on/offscreen attacker state remains valid")
func _test_active_skills_and_successful_boundaries() -> void:
var calls: Array[String] = []
var service := DefenseServiceType.new().configure_callbacks(
_fixed_roll(50),
func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
calls.append("%s:%s" % [phase, payload[&"action_id"]])
return {&"ok": true, &"token": token if token != null else String(payload[&"action_id"])}
)
_must(service.create_raid(&"skills", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 50, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "skill Raid is created")
_must(service.register_combatant(&"skills", &"caster", &"DEFENDER", 20, 100, {&"health": 50}), "skill caster is registered")
_must(service.register_asset(&"skills", &"core", &"OBJECTIVE", 100, 5), "skill objective is registered")
_must(service.register_asset(&"skills", &"door", &"DOOR", 100, 5, {&"integrity": 40}), "fortify target is registered")
_must(service.start_raid(&"skills", 0), "skill Raid starts")
_must(service.queue_active_skill(&"skills", &"z_rally", &"caster", &"RALLY", {&"potency": 10}), "RALLY is queued")
_must(service.queue_active_skill(&"skills", &"a_power", &"caster", &"POWER_STRIKE", {&"potency": 5}), "POWER_STRIKE is queued")
_must(service.queue_active_skill(&"skills", &"m_heal", &"caster", &"HEAL", {&"potency": 7, &"target_id": &"caster"}), "HEAL is queued")
_must(service.queue_active_skill(&"skills", &"n_fortify", &"caster", &"FORTIFY", {&"potency": 6, &"target_id": &"door"}), "FORTIFY is queued")
var queued_replay = service.queue_active_skill(&"skills", &"z_rally", &"caster", &"RALLY", {&"potency": 99})
_expect(queued_replay.is_success() and queued_replay.is_replay, "duplicate queued action ID is an idempotent replay")
var result = service.resolve_next_round(&"skills", &"skill_round", 5)
_expect(result.is_success(), "round with all active skills resolves")
_expect(result.details[&"skill_ids"] == [&"a_power", &"m_heal", &"n_fortify", &"z_rally"], "skills resolve in stable action-ID order")
_expect(result.details[&"score_components"][&"skill_bonus"] == 10, "RALLY contributes its score bonus")
_expect(result.details[&"damage_to_threat"] >= 6, "POWER_STRIKE adds direct threat damage")
var raid := service.get_raid(&"skills")
_expect(raid[&"combatants"][&"caster"][&"health"] == 57, "HEAL updates defender health before damage")
var fortify_effect: Dictionary = result.details[&"skill_effects"][2]
_expect(fortify_effect[&"action_id"] == &"n_fortify" and fortify_effect[&"repaired"] == 6, "FORTIFY repairs its target before the attack")
_expect(raid[&"assets"][&"door"][&"integrity"] == 46 - int(result.details[&"defense_damage"][&"damage"]), "round damage is applied after the FORTIFY repair")
_expect(raid[&"queued_skills"].is_empty() and raid[&"applied_skill_ids"].size() == 4, "applied skills move from queue to idempotency history")
_expect(calls.slice(0, 4) == ["validate:a_power", "validate:m_heal", "validate:n_fortify", "validate:z_rally"], "all boundary validations use stable action-ID order")
_expect(calls.slice(4, 8) == ["apply:a_power", "apply:m_heal", "apply:n_fortify", "apply:z_rally"], "all boundary applications follow the validated order")
var applied_replay = service.queue_active_skill(&"skills", &"z_rally", &"caster", &"RALLY")
_expect(applied_replay.is_success() and applied_replay.is_replay, "applied skill action ID also replays without recharging")
_expect(service.validate_invariants()[&"ok"], "active skill state satisfies invariants")
func _test_skill_boundary_validation_rollback() -> void:
var calls: Array[String] = []
var service := DefenseServiceType.new().configure_callbacks(
_fixed_roll(50),
func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
calls.append("%s:%s" % [phase, payload[&"action_id"]])
if phase == &"validate" and payload[&"action_id"] == &"b_reject":
return {&"ok": false, &"reason": &"NO_RESOURCE"}
return {&"ok": true, &"token": token if token != null else String(payload[&"action_id"])}
)
_prepare_basic(service, &"validation_rollback")
_must(service.queue_active_skill(&"validation_rollback", &"a_reserved", &"guard", &"RALLY", {&"potency": 3}), "first reserved skill is queued")
_must(service.queue_active_skill(&"validation_rollback", &"b_reject", &"guard", &"RALLY", {&"potency": 3}), "rejecting skill is queued")
var before := service.create_snapshot()
var failed = service.resolve_next_round(&"validation_rollback", &"rejected_round", 5)
_expect(not failed.is_success(), "skill validation rejection aborts the round")
_expect(service.create_snapshot() == before, "validation rejection leaves all local Raid state unchanged")
_expect(calls == ["validate:a_reserved", "validate:b_reject", "rollback:a_reserved"], "earlier validation reservations roll back in reverse order")
_expect(service.validate_invariants()[&"ok"], "validation rollback preserves invariants")
func _test_skill_boundary_apply_and_outcome_rollback() -> void:
var apply_calls: Array[String] = []
var applying := DefenseServiceType.new().configure_callbacks(
_fixed_roll(50),
func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
apply_calls.append("%s:%s" % [phase, payload[&"action_id"]])
if phase == &"apply" and payload[&"action_id"] == &"b_fail":
return {&"ok": false, &"token": "failed_token"}
return {&"ok": true, &"token": token if token != null else String(payload[&"action_id"])}
)
_prepare_basic(applying, &"apply_rollback")
for action_id: StringName in [&"a_apply", &"b_fail", &"c_validated"]:
_must(applying.queue_active_skill(&"apply_rollback", action_id, &"guard", &"RALLY", {&"potency": 2}), "apply rollback skill queues: %s" % action_id)
var apply_before := applying.create_snapshot()
var apply_failed = applying.resolve_next_round(&"apply_rollback", &"apply_failed", 5)
_expect(not apply_failed.is_success(), "skill application rejection aborts the round")
_expect(applying.create_snapshot() == apply_before, "application rejection leaves local state atomic")
_expect(apply_calls == [
"validate:a_apply", "validate:b_fail", "validate:c_validated",
"apply:a_apply", "apply:b_fail",
"rollback:c_validated", "rollback:b_fail", "rollback:a_apply",
], "applied, failed and not-yet-applied validation tokens all roll back once")
var skill_calls: Array[String] = []
var outcome_calls: Array[String] = []
var skill_boundary := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
skill_calls.append("%s:%s" % [phase, payload[&"action_id"]])
return {&"ok": true, &"token": token if token != null else &"skill_token"}
var outcome_boundary := func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
outcome_calls.append(String(phase))
if phase == &"apply":
return {&"ok": false, &"token": &"outcome_token"}
return {&"ok": true, &"token": token if token != null else &"outcome_token"}
var terminal := DefenseServiceType.new().configure_callbacks(
_fixed_roll(50), skill_boundary, outcome_boundary
)
_must(terminal.create_raid(&"outcome_rollback", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}],
})), "terminal rollback Raid is created")
_must(terminal.register_combatant(&"outcome_rollback", &"guard", &"DEFENDER", 10, 100), "terminal rollback guard is registered")
_must(terminal.register_asset(&"outcome_rollback", &"core", &"OBJECTIVE", 100, 0), "terminal rollback objective is registered")
_must(terminal.start_raid(&"outcome_rollback", 0), "terminal rollback Raid starts")
_must(terminal.queue_active_skill(&"outcome_rollback", &"skill", &"guard", &"RALLY", {&"potency": 10}), "terminal rollback skill is queued")
var terminal_before := terminal.create_snapshot()
var outcome_failed = terminal.resolve_next_round(&"outcome_rollback", &"outcome_failed", 5)
_expect(not outcome_failed.is_success(), "rejected terminal outcome aborts the round")
_expect(terminal.create_snapshot() == terminal_before, "outcome rejection restores round, skill and wave state atomically")
_expect(skill_calls == ["validate:skill", "apply:skill", "rollback:skill"], "successful external skill application is rolled back after outcome failure")
_expect(outcome_calls == ["validate", "apply", "rollback"], "failed outcome apply invokes its rollback boundary")
_expect(terminal.validate_invariants()[&"ok"], "cross-boundary rollback leaves a valid Raid")
func _test_rng_transaction_retry_stability() -> void:
var rng_state := {&"cursor": 0, &"values": [17, 83, 41, 59], &"phases": []}
var outcome_state := {&"apply_attempts": 0}
var roll_callback := func(_key: StringName, _context: Dictionary) -> int:
var cursor := int(rng_state[&"cursor"])
var value := int(rng_state[&"values"][cursor])
rng_state[&"cursor"] = cursor + 1
return value
var rng_boundary := func(_context: Dictionary, phase: StringName, token: Variant) -> Dictionary:
(rng_state[&"phases"] as Array).append(phase)
if phase == &"snapshot":
return {&"ok": true, &"token": int(rng_state[&"cursor"])}
if phase == &"rollback":
rng_state[&"cursor"] = int(token)
return {&"ok": true, &"token": token}
var outcome_boundary := func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
if phase == &"apply":
outcome_state[&"apply_attempts"] = int(outcome_state[&"apply_attempts"]) + 1
if int(outcome_state[&"apply_attempts"]) == 1:
return {&"ok": false, &"token": &"first_attempt"}
return {&"ok": true, &"token": token}
var service := DefenseServiceType.new().configure_callbacks(
roll_callback, Callable(), outcome_boundary, rng_boundary
)
_must(service.create_raid(&"rng_retry", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}],
})), "RNG retry Raid is created")
_must(service.register_asset(&"rng_retry", &"core", &"OBJECTIVE", 1_000, 0), "RNG retry objective is registered")
_must(service.start_raid(&"rng_retry", 0), "RNG retry Raid starts")
var before := service.create_snapshot()
var failed = service.resolve_next_round(&"rng_retry", &"rng_resolution", 5)
_expect(not failed.is_success(), "terminal outcome failure aborts a stateful-RNG round")
_expect(service.create_snapshot() == before, "failed stateful-RNG round leaves Defense state atomic")
_expect(rng_state[&"cursor"] == 0, "RNG rollback restores the cursor consumed by both rolls")
_expect(rng_state[&"phases"] == [&"snapshot", &"rollback"], "failed round snapshots then rolls back RNG state")
var retried = service.resolve_next_round(&"rng_retry", &"rng_resolution", 5)
_expect(retried.is_success() and retried.details[&"defense_roll"] == 17 and retried.details[&"attack_roll"] == 83, "retry consumes the exact same RNG values")
_expect(rng_state[&"cursor"] == 2, "successful retry commits exactly two RNG draws")
_expect(rng_state[&"phases"] == [&"snapshot", &"rollback", &"snapshot", &"commit"], "successful retry commits its RNG transaction")
var replay = service.resolve_next_round(&"rng_retry", &"rng_resolution", 999)
_expect(replay.is_success() and replay.is_replay and replay.details == retried.details, "RNG-backed resolution replays its exact committed payload")
_expect(rng_state[&"cursor"] == 2 and (rng_state[&"phases"] as Array).size() == 4, "resolution replay consumes no RNG or boundary phase")
_expect(service.validate_invariants()[&"ok"], "RNG retry result satisfies invariants")
func _test_retreat_policies_and_manual_retreat_replay() -> void:
var threshold := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(threshold.create_raid(&"threshold", 0, _definition({&"retreat_policy": &"THRESHOLD", &"defender_retreat_threshold": 50})), "threshold Raid is created")
_must(threshold.register_combatant(&"threshold", &"guard", &"DEFENDER", 10, 100, {&"health": 40}), "wounded threshold guard is registered")
_must(threshold.register_asset(&"threshold", &"core", &"OBJECTIVE", 100, 5), "threshold objective is registered")
_must(threshold.start_raid(&"threshold", 0), "threshold Raid starts")
var threshold_round = threshold.resolve_next_round(&"threshold", &"threshold_round", 5)
_expect(threshold_round.is_success() and threshold_round.details[&"state"] == &"DEFENDERS_RETREATED", "THRESHOLD retreats at or below configured health percentage")
_expect(threshold.get_raid(&"threshold")[&"combatants"][&"guard"][&"state"] == DefenseServiceType.CombatantState.RETREATED, "automatic retreat updates active defenders")
var protect := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(protect.create_raid(&"protect", 0, _definition({&"retreat_policy": &"PROTECT_AND_RETREAT", &"defender_retreat_threshold": 50})), "protect Raid is created")
_must(protect.register_combatant(&"protect", &"guard", &"DEFENDER", 10, 100), "protect guard is registered")
_must(protect.register_asset(&"protect", &"core", &"OBJECTIVE", 100, 5, {&"integrity": 40}), "pre-damaged protected objective is registered")
_must(protect.start_raid(&"protect", 0), "protect Raid starts")
var protect_round = protect.resolve_next_round(&"protect", &"protect_round", 5)
_expect(protect_round.is_success() and protect_round.details[&"reason"] == &"RETREAT_POLICY_TRIGGERED", "PROTECT_AND_RETREAT observes objective integrity against full capacity")
for policy: StringName in [&"HOLD", &"MANUAL"]:
var holding := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(holding.create_raid(policy, 0, _definition({&"retreat_policy": policy, &"defender_retreat_threshold": 90})), "%s Raid is created" % policy)
_must(holding.register_combatant(policy, &"guard", &"DEFENDER", 10, 100, {&"health": 10}), "%s wounded guard is registered" % policy)
_must(holding.register_asset(policy, &"core", &"OBJECTIVE", 100, 5), "%s objective is registered" % policy)
_must(holding.start_raid(policy, 0), "%s Raid starts" % policy)
var round = holding.resolve_next_round(policy, StringName("%s_round" % policy), 5)
_expect(round.is_success() and round.details[&"state"] == &"ACTIVE", "%s never triggers automatic retreat" % policy)
var outcome_count := {&"apply": 0}
var manual := DefenseServiceType.new().configure_callbacks(
_fixed_roll(50), Callable(),
func(_payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
if phase == &"apply":
outcome_count[&"apply"] = int(outcome_count[&"apply"]) + 1
return {&"ok": true, &"token": token}
)
_prepare_basic(manual, &"manual")
_must(manual.queue_active_skill(&"manual", &"unused_skill", &"guard", &"RALLY", {&"potency": 2}), "manual retreat fixture queues an unused skill")
var retreated = manual.retreat_defenders(&"manual", &"manual_resolution", 3)
var replay = manual.retreat_defenders(&"manual", &"manual_resolution", 99)
_expect(retreated.is_success() and retreated.details[&"result"] == &"DEFENDERS_RETREATED", "manual retreat reaches its terminal state")
_expect(replay.is_success() and replay.is_replay and replay.details == retreated.details, "manual retreat resolution ID replays exact stored outcome")
_expect(outcome_count[&"apply"] == 1, "manual retreat consequence applies once across replay")
_expect(retreated.details[&"retreated_defender_ids"] == [&"guard"] and retreated.details[&"retreated_attacker_ids"].is_empty(), "outcome separates defender and attacker retreat IDs for reconciliation")
_expect(manual.get_raid(&"manual")[&"waves"][0][&"state"] == &"RETREATED", "manual retreat updates current wave state")
_expect(manual.get_raid(&"manual")[&"queued_skills"].is_empty(), "manual retreat discards skills that never crossed the apply boundary")
_expect(manual.validate_invariants()[&"ok"], "all retreat policy outcomes preserve invariants")
func _test_cancel_transaction_and_reconciliation() -> void:
var boundary_state := {&"apply_attempts": 0, &"rollbacks": 0, &"last_payload": {}}
var resolved_signals := {&"count": 0, &"payload": {}}
var outcome_boundary := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
if phase == &"apply":
boundary_state[&"apply_attempts"] = int(boundary_state[&"apply_attempts"]) + 1
boundary_state[&"last_payload"] = payload.duplicate(true)
if int(boundary_state[&"apply_attempts"]) == 1:
return {&"ok": false, &"token": &"cancel_attempt"}
elif phase == &"rollback":
boundary_state[&"rollbacks"] = int(boundary_state[&"rollbacks"]) + 1
return {&"ok": true, &"token": token}
var service := DefenseServiceType.new().configure_callbacks(
_fixed_roll(50), Callable(), outcome_boundary
)
service.raid_resolved.connect(func(_raid_id: StringName, payload: Dictionary) -> void:
resolved_signals[&"count"] = int(resolved_signals[&"count"]) + 1
resolved_signals[&"payload"] = payload.duplicate(true)
)
_must(service.create_raid(&"cancel_tx", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "cancel transaction Raid is created")
_must(service.register_combatant(&"cancel_tx", &"guard", &"DEFENDER", 10, 100), "cancel transaction guard is registered")
_must(service.register_asset(&"cancel_tx", &"core", &"OBJECTIVE", 1_000, 0), "cancel transaction objective is registered")
_must(service.register_asset(&"cancel_tx", &"door", &"DOOR", 30, 0), "cancel transaction door is registered")
_must(service.start_raid(&"cancel_tx", 0), "cancel transaction Raid starts")
_must(service.resolve_next_round(&"cancel_tx", &"damage_round", 5), "cancel transaction accumulates one round of damage")
_must(service.queue_active_skill(&"cancel_tx", &"pending_on_cancel", &"guard", &"RALLY", {
&"potency": 3, &"metadata": {&"source": "cancel-test"},
}), "cancel transaction retains a pending skill before cancellation")
var before_cancel := service.create_snapshot()
var rejected = service.cancel_raid(&"cancel_tx", 6, &"PLAYER_ABORT", &"cancel_resolution")
_expect(not rejected.is_success(), "cancel outcome rejection aborts cancellation")
_expect(service.create_snapshot() == before_cancel, "rejected cancellation preserves accumulated Raid state atomically")
_expect(boundary_state[&"apply_attempts"] == 1 and boundary_state[&"rollbacks"] == 1, "failed cancel apply invokes outcome rollback")
_expect(resolved_signals[&"count"] == 0, "rejected cancellation emits no raid_resolved signal")
var cancelled = service.cancel_raid(&"cancel_tx", 6, &"PLAYER_ABORT", &"cancel_resolution")
_expect(cancelled.is_success() and cancelled.details[&"result"] == &"CANCELLED", "accepted cancellation reaches CANCELLED through the outcome boundary")
_expect(cancelled.details[&"operation_kind"] == &"CANCEL", "cancel result stores its operation namespace")
_expect(cancelled.details[&"asset_results"] == boundary_state[&"last_payload"][&"asset_results"], "cancel boundary receives final accumulated asset integrity")
_expect(cancelled.details[&"combatant_results"] == boundary_state[&"last_payload"][&"combatant_results"], "cancel boundary receives final accumulated combatant state")
_expect(service.get_raid(&"cancel_tx")[&"queued_skills"].is_empty(), "accepted cancel discards skills that never crossed the apply boundary")
_expect(resolved_signals[&"count"] == 1 and resolved_signals[&"payload"] == cancelled.details, "accepted cancel emits one exact raid_resolved payload")
var replay = service.cancel_raid(&"cancel_tx", 999, &"DIFFERENT_REASON", &"cancel_resolution")
_expect(replay.is_success() and replay.is_replay and replay.details == cancelled.details, "cancel resolution replays canonically after terminal state")
_expect(boundary_state[&"apply_attempts"] == 2 and resolved_signals[&"count"] == 1, "cancel replay cannot reapply consequences or signals")
_expect(service.validate_invariants()[&"ok"], "transactional cancellation satisfies invariants")
func _test_resolution_operation_namespace_and_legacy_v1() -> void:
var round_service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_prepare_basic(round_service, &"namespace")
var round_result = round_service.resolve_next_round(&"namespace", &"shared_id", 5)
_expect(round_result.is_success() and round_result.details[&"operation_kind"] == &"ROUND", "round history stores ROUND operation kind")
var after_round := round_service.create_snapshot()
var wrong_retreat = round_service.retreat_defenders(&"namespace", &"shared_id", 6)
_expect(not wrong_retreat.is_success() and not wrong_retreat.is_replay, "round resolution ID cannot replay as manual retreat")
_expect(round_service.create_snapshot() == after_round, "cross-operation retreat collision is atomic")
var wrong_cancel = round_service.cancel_raid(&"namespace", 6, &"CANCELLED", &"shared_id")
_expect(not wrong_cancel.is_success() and not wrong_cancel.is_replay, "round resolution ID cannot replay as cancellation")
_expect(round_service.create_snapshot() == after_round, "cross-operation cancel collision is atomic")
var retreat_service := DefenseServiceType.new()
_prepare_basic(retreat_service, &"retreat_namespace")
var retreat = retreat_service.retreat_defenders(&"retreat_namespace", &"terminal_id", 3)
_expect(retreat.is_success() and retreat.details[&"operation_kind"] == &"RETREAT", "manual retreat history stores RETREAT operation kind")
var wrong_round = retreat_service.resolve_next_round(&"retreat_namespace", &"terminal_id", 500)
_expect(not wrong_round.is_success() and not wrong_round.is_replay, "retreat resolution ID cannot replay as a round")
var legacy_round_snapshot := after_round.duplicate(true)
legacy_round_snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"].erase("operation_kind")
var legacy_round := DefenseServiceType.new()
_expect(legacy_round.restore_snapshot(legacy_round_snapshot), "version-1 round snapshot without operation_kind restores")
_expect(legacy_round.create_snapshot() == legacy_round_snapshot, "legacy round snapshot round-trips without forced schema rewrite")
var legacy_round_replay = legacy_round.resolve_next_round(&"namespace", &"shared_id", 999)
_expect(legacy_round_replay.is_success() and legacy_round_replay.is_replay, "legacy round operation kind is inferred for exact replay")
_expect(not legacy_round.retreat_defenders(&"namespace", &"shared_id", 999).is_success(), "legacy inferred round still rejects cross-operation replay")
var legacy_retreat_snapshot := retreat_service.create_snapshot()
legacy_retreat_snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"].erase("operation_kind")
var legacy_retreat := DefenseServiceType.new()
_expect(legacy_retreat.restore_snapshot(legacy_retreat_snapshot), "version-1 retreat snapshot without operation_kind restores")
var legacy_retreat_replay = legacy_retreat.retreat_defenders(&"retreat_namespace", &"terminal_id", 999)
_expect(legacy_retreat_replay.is_success() and legacy_retreat_replay.is_replay, "legacy retreat operation kind is inferred for exact replay")
var cancel_service := DefenseServiceType.new()
_prepare_basic(cancel_service, &"cancel_namespace")
_must(cancel_service.cancel_raid(&"cancel_namespace", 3, &"LEGACY_CANCEL", &"cancel_terminal_id"), "legacy cancel fixture reaches its terminal state")
var legacy_cancel_snapshot := cancel_service.create_snapshot()
legacy_cancel_snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"].erase("operation_kind")
var legacy_cancel := DefenseServiceType.new()
_expect(legacy_cancel.restore_snapshot(legacy_cancel_snapshot), "version-1 cancel snapshot without operation_kind restores")
_expect(legacy_cancel.create_snapshot() == legacy_cancel_snapshot, "legacy cancel snapshot round-trips without forced schema rewrite")
var legacy_cancel_replay = legacy_cancel.cancel_raid(&"cancel_namespace", 999, &"DIFFERENT", &"cancel_terminal_id")
_expect(legacy_cancel_replay.is_success() and legacy_cancel_replay.is_replay, "legacy cancel operation kind is inferred for exact replay")
_expect(not legacy_cancel.retreat_defenders(&"cancel_namespace", &"cancel_terminal_id", 999).is_success(), "legacy inferred cancel rejects cross-operation replay")
_expect(round_service.validate_invariants()[&"ok"] and retreat_service.validate_invariants()[&"ok"] and cancel_service.validate_invariants()[&"ok"], "operation namespaces preserve valid aggregates")
func _test_multiple_waves_require_explicit_transition() -> void:
var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(service.create_raid(&"multi", 0, _definition({
&"waves": [
{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1},
{&"wave_id": &"wave_2", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1},
],
})), "two-wave Raid is created")
_must(service.register_asset(&"multi", &"core", &"OBJECTIVE", 1_000, 0), "durable multi-wave objective is registered")
_must(service.start_raid(&"multi", 0), "multi-wave Raid starts")
var wave_one = service.resolve_next_round(&"multi", &"wave_one_round", 5)
_expect(wave_one.is_success() and wave_one.details[&"state"] == &"BETWEEN_WAVES", "first max-round wave ends BETWEEN_WAVES")
_expect(service.get_threat_summary()[&"between_waves"] == 1 and service.get_threat_summary()[&"active"] == 0, "HUD distinguishes waiting for next wave from combat")
var between_snapshot := service.create_snapshot()
_expect(not service.resolve_next_round(&"multi", &"illegal_between_round", 6).is_success(), "round cannot resolve while waiting between waves")
_expect(service.create_snapshot() == between_snapshot, "failed between-wave round is atomic")
_expect(service.get_raid(&"multi")[&"current_wave_index"] == 0, "next wave never starts implicitly")
var begun = service.begin_next_wave(&"multi", 7)
_expect(begun.is_success() and begun.details[&"wave_id"] == &"wave_2" and begun.details[&"wave_index"] == 1, "explicit command starts exactly the next wave")
_expect(service.get_raid(&"multi")[&"waves"][1][&"state"] == &"ACTIVE", "next wave state becomes ACTIVE")
_expect(not service.begin_next_wave(&"multi", 7).is_success(), "active wave cannot be skipped")
var wave_two = service.resolve_next_round(&"multi", &"wave_two_round", 12)
_expect(wave_two.is_success() and wave_two.details[&"state"] == &"DEFENDED", "last wave completion defends the Raid")
_expect(service.get_threat_summary()[&"total"] == 0, "defended raid is removed from ongoing HUD threats")
var raid := service.get_raid(&"multi")
_expect(raid[&"current_round"] == 2 and raid[&"elapsed_minutes"] == 10, "round and elapsed counters span all waves")
_expect(raid[&"waves"][0][&"state"] == &"RESOLVED" and raid[&"waves"][1][&"state"] == &"RESOLVED", "each completed wave retains its own state")
_expect(not service.begin_next_wave(&"multi", 13).is_success(), "terminal Raid has no phantom next wave")
_expect(service.validate_invariants()[&"ok"], "multi-wave terminal state satisfies invariants")
func _test_raid_duration_and_wave_round_limits() -> void:
var duration := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(duration.create_raid(&"duration", 0, _definition({
&"max_duration_minutes": 5,
&"round_minutes": 5,
&"waves": [
{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10},
{&"wave_id": &"wave_2", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10},
],
})), "duration-limited Raid is created")
_must(duration.register_asset(&"duration", &"core", &"OBJECTIVE", 1_000, 0), "duration objective is registered")
_must(duration.start_raid(&"duration", 0), "duration Raid starts")
var duration_result = duration.resolve_next_round(&"duration", &"duration_round", 5)
_expect(duration_result.is_success() and duration_result.details[&"state"] == &"DEFENDED", "Raid-wide duration is a hard terminal boundary")
_expect(duration_result.details[&"reason"] == &"RAID_DURATION_EXHAUSTED", "duration terminal reason is explicit")
_expect(duration.get_raid(&"duration")[&"current_wave_index"] == 0, "duration never opens a later wave")
var max_round := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(max_round.create_raid(&"max_round", 0, _definition({
&"max_duration_minutes": 100,
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 1}],
})), "max-round Raid is created")
_must(max_round.register_asset(&"max_round", &"core", &"OBJECTIVE", 1_000, 0), "max-round objective is registered")
_must(max_round.start_raid(&"max_round", 0), "max-round Raid starts")
var round_result = max_round.resolve_next_round(&"max_round", &"max_round_resolution", 5)
_expect(round_result.is_success() and round_result.details[&"reason"] == &"WAVE_DURATION_EXHAUSTED", "wave max-round limit has a stable terminal reason")
_expect(max_round.get_raid(&"max_round")[&"result"] == &"DEFENDED", "surviving the final wave round limit defends the Raid")
_expect(duration.validate_invariants()[&"ok"] and max_round.validate_invariants()[&"ok"], "both duration boundaries remain valid")
func _test_round_resolution_replay() -> void:
var roll_calls := {&"count": 0}
var roll_keys: Array[StringName] = []
var service := DefenseServiceType.new().configure_callbacks(
func(key: StringName, _context: Dictionary) -> int:
roll_calls[&"count"] = int(roll_calls[&"count"]) + 1
roll_keys.append(key)
return 50
)
_prepare_basic(service, &"replay")
var first = service.resolve_next_round(&"replay", &"same_resolution", 5)
var snapshot_after_first := service.create_snapshot()
var replay = service.resolve_next_round(&"replay", &"same_resolution", 500)
_expect(first.is_success() and replay.is_success() and replay.is_replay, "duplicate resolution ID returns an idempotent replay")
_expect(replay.details == first.details, "resolution replay returns the exact canonical payload")
_expect(service.create_snapshot() == snapshot_after_first, "resolution replay mutates no Raid or clock state")
_expect(roll_calls[&"count"] == 2, "defense and attack rolls are consumed only on first resolution")
_expect(roll_keys == [&"replay:wave_1:1:DEFENSE", &"replay:wave_1:1:ATTACK"], "round roll keys and call order are stable")
_expect(service.get_raid(&"replay")[&"current_round"] == 1, "replay cannot advance the round twice")
_expect(service.validate_invariants()[&"ok"], "replayed Raid remains valid")
var default_a := DefenseServiceType.new()
var default_b := DefenseServiceType.new()
_prepare_basic(default_a, &"default_roll")
_prepare_basic(default_b, &"default_roll")
var default_result_a = default_a.resolve_next_round(&"default_roll", &"default_round", 5)
var default_result_b = default_b.resolve_next_round(&"default_roll", &"default_round", 5)
_expect(default_result_a.details[&"defense_roll"] == default_result_b.details[&"defense_roll"] and default_result_a.details[&"attack_roll"] == default_result_b.details[&"attack_roll"], "built-in fallback rolls are deterministic from stable keys")
_expect(default_result_a.details == default_result_b.details and default_a.create_snapshot() == default_b.create_snapshot(), "built-in deterministic roll reproduces the full round")
func _test_round_clock_contract() -> void:
var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_prepare_basic(service, &"clock_contract")
var initial := service.create_snapshot()
var too_early = service.resolve_next_round(&"clock_contract", &"too_early", 4)
_expect(not too_early.is_success() and too_early.details[&"earliest_minute"] == 5, "first round waits one full configured interval after start")
_expect(service.create_snapshot() == initial, "early first round rejection is atomic")
_must(service.resolve_next_round(&"clock_contract", &"round_1", 5), "first round resolves at its exact boundary")
var after_first := service.create_snapshot()
_expect(not service.resolve_next_round(&"clock_contract", &"same_minute_other_id", 5).is_success(), "different resolution ID cannot create a second round in the same minute")
_expect(not service.resolve_next_round(&"clock_contract", &"before_interval", 9).is_success(), "later round cannot violate previous-round interval")
_expect(service.create_snapshot() == after_first, "same-minute and interval failures preserve round history")
_must(service.resolve_next_round(&"clock_contract", &"round_2", 10), "second round resolves after the full interval")
_must(service.advance_time(20), "Defense clock advances independently")
var after_clock := service.create_snapshot()
_expect(not service.resolve_next_round(&"clock_contract", &"past_global_clock", 19).is_success(), "round cannot be inserted behind the current Defense clock")
_expect(service.create_snapshot() == after_clock, "past-global-clock rejection is atomic")
_must(service.resolve_next_round(&"clock_contract", &"round_3", 20), "round at current clock succeeds when interval is satisfied")
var historical := DefenseServiceType.new()
_must(historical.advance_time(10), "historical fixture clock advances")
_expect(not historical.create_raid(&"past_create", 9, _definition()).is_success(), "Raid cannot be created in the past of the Defense clock")
_must(historical.create_raid(&"future_start", 10, _definition()), "Raid can be created at the current clock")
_must(historical.register_asset(&"future_start", &"core", &"OBJECTIVE", 100, 0), "future-start objective is registered")
_must(historical.advance_time(15), "clock advances before Raid start")
_expect(not historical.start_raid(&"future_start", 14).is_success(), "Raid cannot start behind the current Defense clock")
_must(historical.start_raid(&"future_start", 15), "Raid starts at the current Defense clock")
_expect(not historical.resolve_next_round(&"future_start", &"early_after_start", 19).is_success(), "first round interval is measured from actual start time")
_must(historical.resolve_next_round(&"future_start", &"on_time_after_start", 20), "first round resolves exactly one interval after late start")
_expect(service.validate_invariants()[&"ok"] and historical.validate_invariants()[&"ok"], "clock-contract fixtures satisfy invariants")
func _test_wave_entry_and_metadata_validation() -> void:
var entry_service := DefenseServiceType.new()
_must(entry_service.create_raid(&"entry_scope", 0, _definition({
&"entry_points": [&"east", &"west"],
&"waves": [{
&"wave_id": &"east_wave", &"threat_strength": 20,
&"entry_points": [&"east"], &"objective_ids": [&"core"],
}],
})), "entry-scope Raid is created")
_expect(not entry_service.register_combatant(&"entry_scope", &"wrong_gate", &"ATTACKER", 10, 10, {
&"wave_id": &"east_wave", &"entry_point_id": &"west",
}).is_success(), "attacker cannot use a global entry omitted by its assigned wave")
_expect(entry_service.register_combatant(&"entry_scope", &"east_raider", &"ATTACKER", 10, 10, {
&"wave_id": &"east_wave", &"entry_point_id": &"east",
}).is_success(), "attacker can use an entry included by its assigned wave")
var metadata_service := DefenseServiceType.new()
_prepare_basic(metadata_service, &"metadata")
var metadata_before := metadata_service.create_snapshot()
var invalid_metadata: Array = [
{&"label": "non-dictionary root", &"value": 7},
{&"label": "Object", &"value": {"bad": RefCounted.new()}},
{&"label": "Callable", &"value": {"bad": Callable(self, "_run")}},
{&"label": "RID", &"value": {"bad": RID()}},
{&"label": "NaN", &"value": {"bad": NAN}},
{&"label": "positive infinity", &"value": {"nested": [1, INF]}},
{&"label": "non-string dictionary key", &"value": {1: "bad"}},
]
for fixture: Dictionary in invalid_metadata:
var result = metadata_service.queue_active_skill(&"metadata", &"invalid_meta", &"guard", &"RALLY", {
&"potency": 1, &"metadata": fixture[&"value"],
})
_expect(not result.is_success(), "skill metadata rejects %s" % fixture[&"label"])
_expect(metadata_service.create_snapshot() == metadata_before, "rejected %s metadata is atomic" % fixture[&"label"])
var metadata_a := {"zeta": [&"name", 2, true, null], "alpha": {"b": 2.5, "a": "first"}}
var metadata_b := {"alpha": {"a": "first", "b": 2.5}, "zeta": [&"name", 2, true, null]}
_must(metadata_service.queue_active_skill(&"metadata", &"canonical", &"guard", &"RALLY", {
&"potency": 1, &"metadata": metadata_a,
}), "nested saveable metadata is accepted")
var stored_metadata: Dictionary = metadata_service.get_raid(&"metadata")[&"queued_skills"][&"canonical"][&"metadata"]
_expect(stored_metadata.keys() == ["alpha", "zeta"], "metadata dictionary keys are stored in lexical order")
_expect(stored_metadata["zeta"][0] == "name" and stored_metadata["alpha"].keys() == ["a", "b"], "metadata recursively canonicalizes StringName values and nested keys")
var metadata_peer := DefenseServiceType.new()
_prepare_basic(metadata_peer, &"metadata")
_must(metadata_peer.queue_active_skill(&"metadata", &"canonical", &"guard", &"RALLY", {
&"potency": 1, &"metadata": metadata_b,
}), "reverse-order metadata fixture is accepted")
_expect(metadata_service.create_snapshot() == metadata_peer.create_snapshot(), "metadata snapshot is independent of dictionary insertion order")
var corrupt_metadata := metadata_service.create_snapshot()
corrupt_metadata[&"raids"][0][&"queued_skills"][0][&"metadata"] = {"bad": NAN}
var restored := DefenseServiceType.new()
_prepare_basic(restored, &"restored_baseline")
var restored_before := restored.create_snapshot()
_expect(not restored.restore_snapshot(corrupt_metadata), "snapshot restore rejects non-finite queued metadata")
_expect(restored.create_snapshot() == restored_before, "corrupt metadata snapshot rejection is atomic")
_expect(metadata_service.validate_invariants()[&"ok"] and metadata_peer.validate_invariants()[&"ok"], "canonical metadata fixtures satisfy invariants")
func _test_armor_pool_accounting() -> void:
var blocked := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(blocked.create_raid(&"armor_block", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 20, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "armor-block Raid is created")
_must(blocked.register_combatant(&"armor_block", &"guard", &"DEFENDER", 100, 100), "armor-block guard is registered")
_must(blocked.register_combatant(&"armor_block", &"armored_raider", &"ATTACKER", 1, 100, {
&"wave_id": &"wave_1", &"armor": 100,
}), "heavily armored attacker is registered")
_must(blocked.register_asset(&"armor_block", &"core", &"OBJECTIVE", 1_000, 0), "armor-block objective is registered")
_must(blocked.start_raid(&"armor_block", 0), "armor-block Raid starts")
var blocked_round = blocked.resolve_next_round(&"armor_block", &"blocked_round", 5)
_expect(blocked_round.is_success() and blocked_round.details[&"damage_to_threat"] > 0, "armored target receives a positive raw damage budget")
_expect(blocked_round.details[&"damage_applied_to_threat"] == 0, "armor uses max(0, damage - armor) without forced chip damage")
_expect(blocked_round.details[&"aggregate_damage"] == 0, "damage absorbed by explicit armor cannot leak into aggregate threat")
_expect(blocked_round.details[&"damage_mitigated_by_attackers"] == blocked_round.details[&"damage_to_threat"], "mitigation accounts for the entire blocked damage pool")
_expect(blocked.get_raid(&"armor_block")[&"waves"][0][&"remaining_threat"] == 20 and blocked.get_raid(&"armor_block")[&"combatants"][&"armored_raider"][&"health"] == 100, "full armor block changes neither explicit nor aggregate durability")
var overflow := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(overflow.create_raid(&"armor_overflow", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 10, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "armor-overflow Raid is created")
_must(overflow.register_combatant(&"armor_overflow", &"guard", &"DEFENDER", 200, 100), "armor-overflow guard is registered")
_must(overflow.register_combatant(&"armor_overflow", &"front_raider", &"ATTACKER", 2, 2, {
&"wave_id": &"wave_1", &"armor": 5,
}), "front armored attacker is registered")
_must(overflow.register_asset(&"armor_overflow", &"core", &"OBJECTIVE", 1_000, 0), "armor-overflow objective is registered")
_must(overflow.start_raid(&"armor_overflow", 0), "armor-overflow Raid starts")
var overflow_round = overflow.resolve_next_round(&"armor_overflow", &"overflow_round", 5)
_expect(overflow_round.details[&"damage_to_threat"] == 12, "raw damage is capped by combined explicit and aggregate durability")
_expect(overflow_round.details[&"attacker_damage"][0][&"damage"] == 2 and overflow_round.details[&"attacker_damage"][0][&"mitigated"] == 5, "front attacker consumes armor plus remaining health from the pool")
_expect(overflow_round.details[&"aggregate_damage"] == 5, "only true raw overflow reaches aggregate threat")
_expect(overflow_round.details[&"damage_applied_to_threat"] == 7 and overflow_round.details[&"damage_mitigated_by_attackers"] == 5, "applied plus mitigated damage reconciles the complete pool")
_expect(overflow.get_raid(&"armor_overflow")[&"waves"][0][&"remaining_threat"] == 5, "aggregate durability decreases by overflow exactly once")
var defended := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(defended.create_raid(&"defense_armor", 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "defense-armor Raid is created")
_must(defended.register_asset(&"defense_armor", &"core", &"OBJECTIVE", 1_000, 0), "defense-armor objective is registered")
_must(defended.register_asset(&"defense_armor", &"door", &"DOOR", 100, 0, {&"armor": 100}), "fully armored defense door is registered")
_must(defended.start_raid(&"defense_armor", 0), "defense-armor Raid starts")
var defense_round = defended.resolve_next_round(&"defense_armor", &"defense_armor_round", 5)
_expect(defense_round.details[&"damage_to_defense"] > 0 and defense_round.details[&"defense_damage"][&"damage"] == 0, "defense armor also applies max(0, damage - armor)")
_expect(defense_round.details[&"defense_damage"][&"mitigated"] == defense_round.details[&"damage_to_defense"] and defended.get_raid(&"defense_armor")[&"assets"][&"door"][&"integrity"] == 100, "fully mitigated defense damage leaves integrity unchanged")
_expect(blocked.validate_invariants()[&"ok"] and overflow.validate_invariants()[&"ok"] and defended.validate_invariants()[&"ok"], "armor pool fixtures satisfy invariants")
func _test_snapshot_round_trip_and_corrupt_atomicity() -> void:
var source := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_must(source.create_raid(&"snapshot_raid", 0, _definition({
&"entry_points": [&"west", &"east"],
&"waves": [
{&"wave_id": &"wave_1", &"threat_strength": 60, &"entry_points": [&"east", &"west"], &"max_rounds": 10},
{&"wave_id": &"wave_2", &"threat_strength": 30, &"entry_points": [&"west"], &"max_rounds": 10},
],
})), "snapshot Raid is created")
_must(source.register_combatant(&"snapshot_raid", &"guard_b", &"DEFENDER", 20, 100), "snapshot defender B is registered")
_must(source.register_combatant(&"snapshot_raid", &"guard_a", &"DEFENDER", 10, 80), "snapshot defender A is registered")
_must(source.register_combatant(&"snapshot_raid", &"raider", &"ATTACKER", 15, 30, {&"wave_id": &"wave_1", &"entry_point_id": &"east"}), "snapshot attacker is registered")
_must(source.register_asset(&"snapshot_raid", &"core", &"OBJECTIVE", 200, 20), "snapshot objective is registered")
_must(source.register_asset(&"snapshot_raid", &"door", &"DOOR", 50, 10, {&"entry_points": [&"east"]}), "snapshot door is registered")
_must(source.start_raid(&"snapshot_raid", 0), "snapshot Raid starts")
_must(source.queue_active_skill(&"snapshot_raid", &"applied", &"guard_a", &"RALLY", {&"potency": 4, &"metadata": {&"source": "test"}}), "snapshot applied skill is queued")
_must(source.resolve_next_round(&"snapshot_raid", &"snapshot_round", 5), "snapshot round is resolved")
_must(source.queue_active_skill(&"snapshot_raid", &"queued", &"guard_b", &"POWER_STRIKE", {&"potency": 2, &"metadata": {&"source": "test"}}), "snapshot pending skill is queued")
var snapshot := source.create_snapshot()
var restored := DefenseServiceType.new()
_expect(restored.restore_snapshot(snapshot), "valid Defense snapshot restores")
_expect(restored.create_snapshot() == snapshot, "Defense snapshot has an exact round trip")
_expect(restored.validate_invariants()[&"ok"], "restored Defense snapshot satisfies invariants")
_expect(restored.get_raid_ids() == [&"snapshot_raid"] and restored.get_active_raid_ids() == [&"snapshot_raid"], "restored indexes expose canonical Raid IDs")
var restored_replay = restored.resolve_next_round(&"snapshot_raid", &"snapshot_round", 999)
_expect(restored_replay.is_success() and restored_replay.is_replay and restored.get_raid(&"snapshot_raid")[&"current_round"] == 1, "restored resolution history remains idempotent")
_expect(JSON.stringify(restored_replay.details) == JSON.stringify(snapshot[&"raids"][0][&"resolved_rounds"][0][&"payload"]), "restored replay returns its exact canonical payload")
var json_snapshot: Variant = JSON.parse_string(JSON.stringify(snapshot))
var json_restored := DefenseServiceType.new()
_expect(json_snapshot is Dictionary and json_restored.restore_snapshot(json_snapshot), "JSON-serialized Defense snapshot restores")
var expected_json := JSON.stringify(snapshot)
var actual_json := JSON.stringify(json_restored.create_snapshot())
_expect(actual_json == expected_json, "JSON transport preserves the canonical Defense snapshot")
# Each corrupt restore is attempted against a non-empty live aggregate. A
# rejection must preserve the previously restored snapshot byte-for-value.
var corruptions: Array[Dictionary] = []
var duplicate := snapshot.duplicate(true)
duplicate[&"raids"].append(duplicate[&"raids"][0].duplicate(true))
corruptions.append({&"name": "duplicate Raid ID", &"snapshot": duplicate})
var bad_remaining := snapshot.duplicate(true)
bad_remaining[&"raids"][0][&"waves"][0][&"remaining_threat"] = 9_999
corruptions.append({&"name": "remaining threat above maximum", &"snapshot": bad_remaining})
var bad_wave_state := snapshot.duplicate(true)
bad_wave_state[&"raids"][0][&"waves"][0][&"state"] = "TELEPORTING"
corruptions.append({&"name": "unknown wave state", &"snapshot": bad_wave_state})
var bad_clock := snapshot.duplicate(true)
bad_clock[&"current_minute"] = -1
corruptions.append({&"name": "negative Defense clock", &"snapshot": bad_clock})
var future_clock := snapshot.duplicate(true)
future_clock[&"current_minute"] = 0
future_clock[&"raids"][0][&"state_changed_at"] = 1
corruptions.append({&"name": "Raid timestamp beyond clock", &"snapshot": future_clock})
var bad_health := snapshot.duplicate(true)
bad_health[&"raids"][0][&"combatants"][0][&"health"] = -1
corruptions.append({&"name": "negative combatant health", &"snapshot": bad_health})
var bad_asset_entry := snapshot.duplicate(true)
bad_asset_entry[&"raids"][0][&"assets"][0][&"entry_points"] = ["not_declared"]
corruptions.append({&"name": "unknown asset entry", &"snapshot": bad_asset_entry})
var bad_attacker_wave_entry := snapshot.duplicate(true)
bad_attacker_wave_entry[&"raids"][0][&"waves"][0][&"entry_points"] = ["west"]
corruptions.append({
&"name": "attacker entry omitted by assigned wave",
&"snapshot": bad_attacker_wave_entry,
})
var bad_skill := snapshot.duplicate(true)
bad_skill[&"raids"][0][&"queued_skills"][0][&"potency"] = 101
corruptions.append({&"name": "invalid queued skill potency", &"snapshot": bad_skill})
for corruption: Dictionary in corruptions:
var before := restored.create_snapshot()
_expect(not restored.restore_snapshot(corruption[&"snapshot"]), "corrupt snapshot rejects %s" % corruption[&"name"])
_expect(restored.create_snapshot() == before, "rejected %s is atomic" % corruption[&"name"])
_expect(not restored.last_error.is_empty(), "corrupt restore exposes a diagnostic error")
_expect(restored.validate_invariants()[&"ok"], "all corrupt snapshot attempts preserve live invariants")
func _test_cross_time_snapshot_rejection() -> void:
var source := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
_prepare_basic(source, &"time_snapshot")
_must(source.resolve_next_round(&"time_snapshot", &"time_round_1", 5), "time snapshot first round resolves")
_must(source.resolve_next_round(&"time_snapshot", &"time_round_2", 10), "time snapshot second round resolves")
var valid := source.create_snapshot()
var restored := DefenseServiceType.new()
_expect(restored.restore_snapshot(valid), "valid cross-time snapshot restores")
var corruptions: Array[Dictionary] = []
var start_after_state := valid.duplicate(true)
start_after_state[&"raids"][0][&"started_at"] = 11
corruptions.append({&"name": "start after state change", &"snapshot": start_after_state})
var first_too_early := valid.duplicate(true)
first_too_early[&"raids"][0][&"resolved_rounds"][0][&"payload"]["absolute_minute"] = 4
corruptions.append({&"name": "first round before start interval", &"snapshot": first_too_early})
var same_minute_rounds := valid.duplicate(true)
same_minute_rounds[&"raids"][0][&"resolved_rounds"][1][&"payload"]["absolute_minute"] = 5
corruptions.append({&"name": "two rounds in one minute", &"snapshot": same_minute_rounds})
var resolution_after_state := valid.duplicate(true)
resolution_after_state[&"raids"][0][&"resolved_rounds"][1][&"payload"]["absolute_minute"] = 11
corruptions.append({&"name": "resolution after state change", &"snapshot": resolution_after_state})
var missing_history := valid.duplicate(true)
missing_history[&"raids"][0][&"resolved_rounds"].remove_at(0)
corruptions.append({&"name": "missing round history", &"snapshot": missing_history})
var invalid_operation := valid.duplicate(true)
invalid_operation[&"raids"][0][&"resolved_rounds"][0][&"payload"]["operation_kind"] = "DUEL"
corruptions.append({&"name": "invalid explicit operation kind", &"snapshot": invalid_operation})
var clock_before_state := valid.duplicate(true)
clock_before_state[&"current_minute"] = 9
corruptions.append({&"name": "clock before latest state", &"snapshot": clock_before_state})
var live_finished := valid.duplicate(true)
live_finished[&"raids"][0][&"finished_at"] = 10
corruptions.append({&"name": "live Raid with finish time", &"snapshot": live_finished})
for corruption: Dictionary in corruptions:
var before := restored.create_snapshot()
_expect(not restored.restore_snapshot(corruption[&"snapshot"]), "cross-time snapshot rejects %s" % corruption[&"name"])
_expect(restored.create_snapshot() == before, "rejected %s remains atomic" % corruption[&"name"])
var terminal := DefenseServiceType.new()
_prepare_basic(terminal, &"terminal_time")
_must(terminal.cancel_raid(&"terminal_time", 2, &"TEST_CANCEL", &"terminal_cancel"), "terminal time fixture cancels")
var bad_finish := terminal.create_snapshot()
bad_finish[&"raids"][0][&"finished_at"] = 1
var before_terminal_restore := restored.create_snapshot()
_expect(not restored.restore_snapshot(bad_finish), "terminal snapshot rejects finish time different from state change")
_expect(restored.create_snapshot() == before_terminal_restore, "terminal finish-time rejection is atomic")
var bad_terminal_resolution_time := terminal.create_snapshot()
bad_terminal_resolution_time[&"raids"][0][&"resolved_rounds"][0][&"payload"]["absolute_minute"] = 1
_expect(not restored.restore_snapshot(bad_terminal_resolution_time), "terminal command resolution must occur at the Raid finish time")
_expect(restored.create_snapshot() == before_terminal_restore, "terminal resolution-time rejection is atomic")
var wrong_terminal_operation := terminal.create_snapshot()
wrong_terminal_operation[&"raids"][0][&"resolved_rounds"][0][&"payload"]["operation_kind"] = "RETREAT"
_expect(not restored.restore_snapshot(wrong_terminal_operation), "terminal operation kind must agree with its result and Raid state")
_expect(restored.create_snapshot() == before_terminal_restore, "terminal operation mismatch rejection is atomic")
_expect(restored.validate_invariants()[&"ok"], "cross-time corruptions preserve restored invariants")
func _test_input_order_determinism() -> void:
var forward := _ordered_fixture(false)
var reverse := _ordered_fixture(true)
_expect(forward.create_snapshot() == reverse.create_snapshot(), "canonical snapshot is independent of registration and collection input order")
var forward_round = forward.resolve_next_round(&"ordered", &"ordered_round", 5)
var reverse_round = reverse.resolve_next_round(&"ordered", &"ordered_round", 5)
_expect(forward_round.is_success() and reverse_round.is_success(), "both input-order variants resolve")
_expect(forward_round.details == reverse_round.details, "round payload is input-order deterministic")
_expect(forward.create_snapshot() == reverse.create_snapshot(), "post-round aggregate is input-order deterministic")
_expect(forward_round.details[&"defense_damage"][&"target_id"] == &"a_door", "equal-priority asset targeting uses lexical stable ID")
_expect(forward_round.details[&"attacker_damage"][0][&"target_id"] == &"a_attacker", "explicit attacker damage uses lexical stable ID")
_expect(forward.validate_invariants()[&"ok"] and reverse.validate_invariants()[&"ok"], "both deterministic orderings preserve invariants")
func _definition(overrides: Dictionary = {}) -> Dictionary:
var definition := {
&"source_id": &"fixture",
&"entry_points": [&"gate"],
&"objective_ids": [&"core"],
&"waves": [{
&"wave_id": &"wave_1",
&"threat_strength": 30,
&"retreat_threshold": 0,
&"max_rounds": 12,
}],
&"max_duration_minutes": 120,
&"round_minutes": 5,
&"retreat_policy": &"HOLD",
&"defender_retreat_threshold": 20,
&"terrain_bonus": 0,
&"command_bonus": 0,
&"surprise_penalty": 0,
&"multiple_entry_penalty": 5,
}
for key: Variant in overrides.keys():
definition[key] = overrides[key]
return definition
func _fixed_roll(value: int) -> Callable:
return func(_key: StringName, _context: Dictionary) -> int: return value
func _prepare_basic(service: DefenseService, raid_id: StringName) -> void:
_must(service.create_raid(raid_id, 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": 100, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "basic Raid is created: %s" % raid_id)
_must(service.register_combatant(raid_id, &"guard", &"DEFENDER", 20, 100), "basic guard is registered: %s" % raid_id)
_must(service.register_asset(raid_id, &"core", &"OBJECTIVE", 1_000, 10), "basic objective is registered: %s" % raid_id)
_must(service.start_raid(raid_id, 0), "basic Raid starts: %s" % raid_id)
func _attack_mix_service(raid_id: StringName, include_explicit: bool) -> DefenseService:
var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
var aggregate_strength := 10 if include_explicit else 12
_must(service.create_raid(raid_id, 0, _definition({
&"waves": [{&"wave_id": &"wave_1", &"threat_strength": aggregate_strength, &"retreat_threshold": 0, &"max_rounds": 10}],
})), "attack mix Raid is created: %s" % raid_id)
_must(service.register_combatant(raid_id, &"guard", &"DEFENDER", 100, 100), "attack mix guard is registered: %s" % raid_id)
if include_explicit:
_must(service.register_combatant(raid_id, &"visible_raider", &"ATTACKER", 2, 2, {&"wave_id": &"wave_1"}), "visible attacker is registered")
_must(service.register_asset(raid_id, &"core", &"OBJECTIVE", 1_000, 0), "attack mix objective is registered: %s" % raid_id)
_must(service.start_raid(raid_id, 0), "attack mix Raid starts: %s" % raid_id)
return service
func _ordered_fixture(reverse: bool) -> DefenseService:
var service := DefenseServiceType.new().configure_callbacks(_fixed_roll(50))
var entries: Array[StringName] = [&"east", &"west"]
if reverse:
entries.reverse()
_must(service.create_raid(&"ordered", 0, _definition({
&"entry_points": entries,
&"waves": [{
&"wave_id": &"wave_1", &"threat_strength": 40,
&"entry_points": entries.duplicate(), &"objective_ids": [&"core"],
&"retreat_threshold": 0, &"max_rounds": 10,
}],
})), "ordered Raid definition is created")
var combatants := [
[&"z_guard", &"DEFENDER", 10, 100, {}],
[&"a_guard", &"DEFENDER", 20, 100, {}],
[&"z_attacker", &"ATTACKER", 5, 20, {&"wave_id": &"wave_1"}],
[&"a_attacker", &"ATTACKER", 5, 20, {&"wave_id": &"wave_1"}],
]
var assets := [
[&"core", &"OBJECTIVE", 1_000, 10, {}],
[&"z_door", &"DOOR", 50, 5, {&"target_order": 10}],
[&"a_door", &"DOOR", 50, 5, {&"target_order": 10}],
]
if reverse:
combatants.reverse()
assets.reverse()
for row: Array in combatants:
_must(service.register_combatant(&"ordered", row[0], row[1], row[2], row[3], row[4]), "ordered combatant registers: %s" % row[0])
for row: Array in assets:
_must(service.register_asset(&"ordered", row[0], row[1], row[2], row[3], row[4]), "ordered asset registers: %s" % row[0])
_must(service.start_raid(&"ordered", 0), "ordered Raid starts")
return service
func _must(result: CommandResult, message: String) -> bool:
return _expect(result != null and result.is_success(), message)
func _expect(condition: bool, message: String) -> bool:
_checks += 1
if condition:
return true
_failures += 1
push_error(" CHECK FAILED: %s" % message)
return false
func _test_cancel_incomplete_preparation() -> void:
for partial: bool in [false, true]:
var service := DefenseServiceType.new()
_must(service.create_raid(&"unprepared", 0, _definition({&"objective_ids": [&"core", &"annex"]})), "create raid with incomplete objective registration")
if partial:
_must(service.register_asset(&"unprepared", &"core", &"OBJECTIVE", 1000, 0), "register only one of two objectives")
var before := service.create_snapshot()
_expect(not service.start_raid(&"unprepared", 0).is_success() and service.create_snapshot() == before, "incomplete preparation still cannot start combat")
var cancelled = service.cancel_raid(&"unprepared", 1, &"PLAYER_ABORT", &"cancel_unprepared")
_expect(cancelled.is_success(), "incomplete preparation can be cancelled")
if not cancelled.is_success():
continue
var raid := service.get_raid(&"unprepared")
_expect(raid[&"state"] == DefenseServiceType.RaidState.CANCELLED and raid[&"started_at"] == -1 and raid[&"current_round"] == 0, "preparation cancellation does not invent combat")
_expect(raid[&"assets"].size() == (1 if partial else 0), "cancellation does not invent unregistered assets")
var saved := service.create_snapshot()
var restored := DefenseServiceType.new()
_expect(restored.restore_snapshot(JSON.parse_string(JSON.stringify(saved))), "cancelled preparation survives JSON save transport")
var replay = restored.cancel_raid(&"unprepared", 99, &"DIFFERENT", &"cancel_unprepared")
_expect(replay.is_success() and replay.is_replay and restored.create_snapshot() == saved, "cancel replay after restore preserves state")
var corrupt := saved.duplicate(true)
corrupt[&"raids"][0][&"started_at"] = 0
_expect(not restored.restore_snapshot(corrupt) and restored.create_snapshot() == saved, "started cancelled raid still requires all objective assets and rejects corruption atomically")
_expect(restored.validate_invariants()[&"ok"], "cancelled incomplete preparation remains valid")