The formal collector's roster includes 46 suites the Makefile never ran; the first formal attempt stopped on test_required_kitchen and a sweep found: - v15 MIMI purchase saves could not be written once Runtime saves carried mana_reserve (P06-01): the format detector now validates that section and excludes it before the unchanged v15 allowlist, like special_peeping_rooms. - test_required_kitchen's legacy14 exact-load comparison now maps the two approved migrations (fresh A06 reserve at the restored boundary, elevator snapshot v1->v2) after checking each against its contract, in Runtime key order; every other byte must still match. - test_runtime_defense_autonomy allowed Mana to change only by the A06 flow. - test_p08_normal_placement_ops expects 25 palette slots (two warps); its suite-policy pin is updated with flags unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
355 lines
27 KiB
GDScript
355 lines
27 KiB
GDScript
extends "res://tests/s5/test_runtime_defense_cycle.gd"
|
|
|
|
## All threat strengths, skills,
|
|
## costs and wave limits below are disclosed fixtures, not production values.
|
|
## Autonomy is enabled in every Runtime, with no test-only opt-out switch.
|
|
|
|
|
|
func _run() -> void:
|
|
print("Defense autonomy: ordinary clock, command precedence and restart")
|
|
_test_ordinary_due_pause_two_waves_and_terminal()
|
|
_test_multiple_raids_stable_order()
|
|
_test_automatic_failure_pause_retry_and_atomicity()
|
|
_test_manual_failure_is_not_hidden_by_same_tick_auto()
|
|
_test_history_replay_does_not_skip_due_automatic_round()
|
|
_test_history_replay_preserves_prior_manual_failure_guard()
|
|
_test_resolution_history_is_scoped_per_raid()
|
|
_test_generated_ids_skip_existing_saved_history()
|
|
_test_exhausted_generated_ids_fail_atomically()
|
|
_test_restart_mid_interval_and_speed_equivalence()
|
|
_test_auto_breach_projects_facility_suspension()
|
|
_cleanup()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_ordinary_due_pause_two_waves_and_terminal() -> void:
|
|
_reset_runtime(91_001)
|
|
_create_staff_and_facility(&"guard.auto", &"objective.auto", 100, 0, 9_900)
|
|
_prepare_started_raid(&"raid.auto", &"guard.auto", &"objective.auto", [
|
|
_wave(&"wave.auto.1", 1_000, 1, [&"gate.north"]),
|
|
_wave(&"wave.auto.2", 1_000, 1, [&"gate.south"]),
|
|
])
|
|
# A second incident stays preparing even while the first battle advances.
|
|
_create_staff_and_facility(&"guard.prep", &"objective.prep")
|
|
_expect(_submit(&"create_defense_raid", {
|
|
&"raid_id": &"raid.prep", &"definition": _raid_definition(
|
|
&"objective.prep", [_wave(&"wave.prep", 100, 1, [&"gate.north"])]
|
|
),
|
|
}).is_success(), "a separate preparing incident exists without automatic kickoff")
|
|
var start := int(_clock.absolute_minute)
|
|
var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
_expect(_clock.process_real_delta(10.0) == 0 and _clock.absolute_minute == start, "real paused play advances no game minute or defense")
|
|
_advance(4)
|
|
_expect(_runtime.defense_service.get_raid(&"raid.auto")[&"current_round"] == 0 and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "deadline-minus-one neither attacks nor consumes combat RNG")
|
|
var due: Array[Dictionary] = _runtime.defense_service.get_due_rounds(start + 5)
|
|
_expect(due.size() == 1 and due[0][&"raid_id"] == &"raid.auto", "compact due projection includes only active due battles")
|
|
due[0][&"round_number"] = 999
|
|
_expect(_runtime.defense_service.get_due_rounds(start + 5)[0][&"round_number"] == 1, "due projection cannot mutate its raid")
|
|
var processed_before := int(_runtime.command_queue.processed_count())
|
|
_advance(1)
|
|
var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.auto")
|
|
_expect(raid[&"current_round"] == 1 and raid[&"state"] == DefenseService.RaidState.BETWEEN_WAVES, "exact ordinary deadline resolves one first wave")
|
|
_expect(_runtime.command_queue.processed_count() == processed_before, "automatic progression creates no fake player command")
|
|
_expect(_runtime.staff_service.get_staff(&"guard.auto").state == StaffRuntime.State.UNAVAILABLE and _runtime.facility_service.get_instance(&"objective.auto").durability == 9_900, "between-wave damage stays in Defense and employee remains reserved")
|
|
var between_rng: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
_advance(20)
|
|
_expect(_runtime.defense_service.get_raid(&"raid.auto")[&"current_round"] == 1 and _runtime.get_rng_stream(&"defense").snapshot() == between_rng, "between-wave waiting invents no cooldown or automatic next wave")
|
|
_expect(_runtime.defense_service.get_raid(&"raid.prep")[&"state"] == DefenseService.RaidState.PREPARING, "ordinary time never starts preparation automatically")
|
|
_expect(_submit(&"begin_next_defense_wave", {&"raid_id": &"raid.auto"}).is_success(), "existing player command explicitly begins the second wave")
|
|
_advance(5)
|
|
raid = _runtime.defense_service.get_raid(&"raid.auto")
|
|
_expect(raid[&"state"] == DefenseService.RaidState.DEFENDED and raid[&"current_round"] == 2, "second wave reaches terminal state from ordinary clock only")
|
|
var guard: StaffRuntime = _runtime.staff_service.get_staff(&"guard.auto")
|
|
_expect(guard.health == raid[&"combatants"][&"guard.auto"][&"health"] and guard.fatigue == raid[&"combatants"][&"guard.auto"][&"fatigue"] and guard.state == StaffRuntime.State.AVAILABLE, "automatic terminal boundary reconciles employee health, fatigue and availability")
|
|
_expect(_runtime.facility_service.get_instance(&"objective.auto").durability == raid[&"assets"][&"objective.auto"][&"integrity"], "automatic terminal boundary reconciles facility durability")
|
|
var terminal_rng: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var terminal_events := _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.auto")
|
|
_advance(10)
|
|
_expect(_runtime.get_rng_stream(&"defense").snapshot() == terminal_rng and _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.auto") == terminal_events, "terminal raids stop consuming combat RNG and emit no repeated resolution")
|
|
|
|
|
|
func _test_multiple_raids_stable_order() -> void:
|
|
_reset_runtime(91_002)
|
|
# Deliberately register in reverse order; tick order must use stable raid IDs.
|
|
for suffix: String in ["z", "a"]:
|
|
var guard := StringName("guard.order." + suffix)
|
|
var objective := StringName("objective.order." + suffix)
|
|
var raid_id := StringName("raid.order." + suffix)
|
|
_create_staff_and_facility(guard, objective)
|
|
_prepare_started_raid(raid_id, guard, objective, [_wave(StringName("wave.order." + suffix), 1_000, 1, [&"gate.north"])])
|
|
var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng
|
|
expected_rng.restore(_runtime.get_rng_stream(&"defense").snapshot())
|
|
var expected_rolls: Array[int] = []
|
|
for _draw: int in 4:
|
|
expected_rolls.append(expected_rng.randi_range(0, 100))
|
|
_advance(5)
|
|
var order: Array[StringName] = []
|
|
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
|
if event.event_type == &"DEFENSE_ROUND_RESOLVED":
|
|
order.append(StringName(event.payload[&"raid_id"]))
|
|
_expect(order == [&"raid.order.a", &"raid.order.z"], "simultaneous raids resolve in deterministic ID order regardless of creation order")
|
|
var roll_cursor := 0
|
|
for raid_id: StringName in [&"raid.order.a", &"raid.order.z"]:
|
|
var raid: Dictionary = _runtime.defense_service.get_raid(raid_id)
|
|
var payload: Dictionary = (raid[&"resolved_rounds"] as Dictionary).values()[0]
|
|
_expect(payload[&"defense_roll"] == expected_rolls[roll_cursor] and payload[&"attack_roll"] == expected_rolls[roll_cursor + 1], "each simultaneous raid receives the exact stable-order RNG pair")
|
|
_expect(String(payload[&"resolution_id"]).begins_with("defense_round_resolution."), "automatic resolution uses the existing saved ID namespace")
|
|
roll_cursor += 2
|
|
_expect(_runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "multi-raid tick consumes exactly two combat draws per round")
|
|
|
|
|
|
func _test_automatic_failure_pause_retry_and_atomicity() -> void:
|
|
_reset_runtime(91_003)
|
|
_create_staff_and_facility(&"guard.failure", &"objective.failure", 100, 0, 9_800)
|
|
_prepare_started_raid(&"raid.failure", &"guard.failure", &"objective.failure", [_wave(&"wave.failure", 500, 1, [&"gate.north"])])
|
|
_expect(bool(_runtime.inventory_service.add_stack(&"auto.inventory", &"mana_bomb", 5, 100, -1, _clock.absolute_minute, 1, &"auto.stack").get(&"ok", false)), "disclosed skill-cost inventory fixture exists")
|
|
_expect(_submit(&"queue_defense_skill", {
|
|
&"raid_id": &"raid.failure", &"actor_id": &"guard.failure", &"kind": &"POWER_STRIKE",
|
|
&"options": {&"potency": 10, &"metadata": {&"cost": {&"gold": 125, &"mana": 9, &"items": [{&"inventory_id": &"auto.inventory", &"item_id": &"mana_bomb", &"quantity": 2}]}}},
|
|
}).is_success(), "disclosed active skill queues before automatic resolution")
|
|
var before: Dictionary = _runtime.defense_service.create_snapshot()
|
|
var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var id_before := int(_runtime.id_generator.get_counter(&"defense_round_resolution"))
|
|
var failure := {&"armed": true, &"calls": 0}
|
|
var normal_outcome := Callable(_runtime, "_apply_defense_outcome_boundary")
|
|
var injected := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
|
|
var report: Dictionary = normal_outcome.call(payload, phase, token)
|
|
if phase == &"apply":
|
|
failure[&"calls"] += 1
|
|
if bool(failure[&"armed"]) and bool(report.get(&"ok", false)):
|
|
failure[&"armed"] = false
|
|
report[&"ok"] = false
|
|
report[&"error"] = "Disclosed automatic terminal post-apply failure."
|
|
return report
|
|
_runtime.defense_service.configure_callbacks(Callable(_runtime, "_roll_defense_round"), Callable(_runtime, "_apply_defense_skill_boundary"), injected, Callable(_runtime, "_apply_defense_rng_boundary"))
|
|
_clock.set_paused(false)
|
|
_clock.advance_minutes(20)
|
|
var failed_minute := int(_clock.absolute_minute)
|
|
before[&"current_minute"] = failed_minute
|
|
_expect(_clock.paused and failed_minute == 365 and failure[&"calls"] == 1, "automatic failure pauses the long catch-up batch after one due attempt")
|
|
_expect(_runtime.defense_service.create_snapshot() == before and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "failed automatic transaction retains round, queued skill and RNG cursor")
|
|
_expect(_state.get_resource(&"gold") == 5_000 and _state.get_resource(&"mana") == 100 and _runtime.inventory_service.available_quantity(&"auto.inventory", &"mana_bomb", failed_minute) == 5, "failed automatic terminal outcome rolls all skill costs back")
|
|
_expect(_runtime.staff_service.get_staff(&"guard.failure").health == 100 and _runtime.staff_service.get_staff(&"guard.failure").fatigue == 0 and _runtime.facility_service.get_instance(&"objective.failure").durability == 9_800, "failed automatic terminal outcome rolls staff and facility changes back")
|
|
_expect(_runtime.id_generator.get_counter(&"defense_round_resolution") == id_before and _count_domain_events(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"raid.failure") == 1, "failed generated round ID rolls back and one diagnostic remains")
|
|
_runtime._advance_defense(failed_minute)
|
|
_expect(failure[&"calls"] == 1 and _clock.process_real_delta(5.0) == 0, "same-minute deferred reentry and paused real frames never retry the failure")
|
|
_runtime._configure_defense_boundaries()
|
|
_clock.set_paused(false)
|
|
_clock.advance_minutes(1)
|
|
_clock.set_paused(true)
|
|
var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.failure")
|
|
_expect(raid[&"state"] == DefenseService.RaidState.DEFENDED and raid[&"current_round"] == 1 and raid[&"elapsed_minutes"] == 5 and raid[&"state_changed_at"] == failed_minute + 1, "resuming retries once at the current minute without fabricating offline combat")
|
|
_expect(_state.get_resource(&"gold") == 4_875 and _state.get_resource(&"mana") == 91 and _runtime.inventory_service.available_quantity(&"auto.inventory", &"mana_bomb", _clock.absolute_minute) == 3, "automatic retry commits skill costs exactly once")
|
|
var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng
|
|
expected_rng.restore(rng_before)
|
|
var expected_defense := expected_rng.randi_range(0, 100)
|
|
var expected_attack := expected_rng.randi_range(0, 100)
|
|
var round_payload: Dictionary = (raid[&"resolved_rounds"] as Dictionary).values()[0]
|
|
_expect(round_payload[&"defense_roll"] == expected_defense and round_payload[&"attack_roll"] == expected_attack and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "retry consumes exactly the combat pair restored after failure")
|
|
|
|
|
|
func _test_manual_failure_is_not_hidden_by_same_tick_auto() -> void:
|
|
_reset_runtime(91_004)
|
|
_create_staff_and_facility(&"guard.manual", &"objective.manual")
|
|
_prepare_started_raid(&"raid.manual", &"guard.manual", &"objective.manual", [_wave(&"wave.manual", 500, 1, [&"gate.north"])])
|
|
var failure := {&"armed": true, &"calls": 0}
|
|
var normal_outcome := Callable(_runtime, "_apply_defense_outcome_boundary")
|
|
var injected := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
|
|
var report: Dictionary = normal_outcome.call(payload, phase, token)
|
|
if phase == &"apply":
|
|
failure[&"calls"] += 1
|
|
if bool(failure[&"armed"]):
|
|
failure[&"armed"] = false
|
|
report[&"ok"] = false
|
|
report[&"error"] = "Disclosed one-shot manual failure."
|
|
return report
|
|
_runtime.defense_service.configure_callbacks(Callable(_runtime, "_roll_defense_round"), Callable(_runtime, "_apply_defense_skill_boundary"), injected, Callable(_runtime, "_apply_defense_rng_boundary"))
|
|
var attempt := _submit_round_at_due({&"raid_id": &"raid.manual"})
|
|
var failed: CommandResult = attempt[&"result"]
|
|
_expect(not failed.is_success() and failure[&"calls"] == 1 and attempt[&"before"] == attempt[&"after"], "manual due failure remains a failed command with all domain boundaries rolled back")
|
|
_expect(_runtime.defense_service.get_raid(&"raid.manual")[&"current_round"] == 0 and _count_domain_events(&"DEFENSE_ROUND_RESOLVED", &"raid.manual") == 0, "automatic phase never hides that failure by immediately retrying the same raid")
|
|
# A successful restore at the very same clock minute must discard the old
|
|
# execution guard, including when this Runtime already attempted that minute.
|
|
var retryable: Dictionary = _runtime.create_snapshot()
|
|
_expect(_runtime.restore_snapshot(retryable), "failed but atomic manual round remains a valid save")
|
|
_runtime._advance_defense(_clock.absolute_minute)
|
|
_expect(_runtime.defense_service.get_raid(&"raid.manual")[&"current_round"] == 1, "successful same-minute restore clears ephemeral attempt state")
|
|
|
|
|
|
func _test_restart_mid_interval_and_speed_equivalence() -> void:
|
|
_reset_runtime(91_005)
|
|
_create_staff_and_facility(&"guard.restart", &"objective.restart")
|
|
_prepare_started_raid(&"raid.restart", &"guard.restart", &"objective.restart", [_wave(&"wave.restart", 10_000, 100, [&"gate.north"])])
|
|
_advance(2)
|
|
_expect(_submit(&"queue_defense_skill", {&"raid_id": &"raid.restart", &"actor_id": &"guard.restart", &"kind": &"FORTIFY", &"options": {&"potency": 8, &"target_id": &"objective.restart"}}).is_success(), "mid-interval queued skill is an explicit disclosed fixture")
|
|
var origin: Dictionary = _runtime.create_snapshot()
|
|
_advance(8)
|
|
var expected: Dictionary = _runtime.create_snapshot()
|
|
_reset_runtime(91_005)
|
|
_expect(_runtime.restore_snapshot(origin), "independently initialized Runtime restores the mid-interval save")
|
|
_advance(8)
|
|
_expect(_runtime.create_snapshot() == expected, "restart reproduces full combat, RNG, queued skill, ID and event state without extra serialized timers")
|
|
_expect(_runtime.restore_snapshot(origin), "restore speed comparison origin")
|
|
_clock.process_real_delta(0.0)
|
|
_clock.set_speed(1)
|
|
_clock.set_paused(false)
|
|
var x1_ticks := 0
|
|
for frame: int in 9:
|
|
x1_ticks += _clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE * 1.01)
|
|
var x1: Dictionary = _capture_defense_views()
|
|
_expect(_runtime.restore_snapshot(origin), "restore identical x3 origin")
|
|
_clock.process_real_delta(0.0)
|
|
_clock.set_speed(3)
|
|
_clock.set_paused(false)
|
|
var x3_ticks := 0
|
|
for frame: int in 3:
|
|
x3_ticks += _clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE * 1.01)
|
|
_expect(x1_ticks == 9 and x3_ticks == 9 and _capture_defense_views() == x1, "x1 and x3 real-frame execution agree after the same nine game minutes")
|
|
_clock.set_paused(true)
|
|
|
|
|
|
func _test_history_replay_does_not_skip_due_automatic_round() -> void:
|
|
_reset_runtime(91_007)
|
|
_create_staff_and_facility(&"guard.replay.auto", &"objective.replay.auto")
|
|
_prepare_started_raid(&"raid.replay.auto", &"guard.replay.auto", &"objective.replay.auto", [_wave(&"wave.replay.auto", 10_000, 100, [&"gate.north"])])
|
|
var first_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.auto", &"resolution_id": &"round.replay.first"})
|
|
var first: CommandResult = first_attempt[&"result"]
|
|
_expect(first.is_success() and not first.is_replay, "a genuine first round establishes canonical history")
|
|
var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
# A new command key deliberately reads that old result at the second round's
|
|
# actual deadline; its command is processed before the automatic phase.
|
|
var replay_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.auto", &"resolution_id": &"round.replay.first"}, &"autonomy.replay.new.command.key")
|
|
var replay: CommandResult = replay_attempt[&"result"]
|
|
_expect(replay.is_success() and replay.is_replay and replay.command_id > first.command_id and replay.details == first.details, "new command key returns the unchanged canonical historical result")
|
|
_expect(replay_attempt[&"before"] == replay_attempt[&"after"], "history replay alone consumes no RNG, costs or combat state")
|
|
var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.replay.auto")
|
|
_expect(raid[&"current_round"] == 2 and _count_domain_events(&"DEFENSE_ROUND_RESOLVED", &"raid.replay.auto") == 2, "historical replay never skips the legitimate automatic round due in the same tick")
|
|
var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng
|
|
expected_rng.restore(rng_before)
|
|
var expected_defense := expected_rng.randi_range(0, 100)
|
|
var expected_attack := expected_rng.randi_range(0, 100)
|
|
var latest: Dictionary = {}
|
|
for payload: Dictionary in (raid[&"resolved_rounds"] as Dictionary).values():
|
|
if payload[&"round_number"] == 2:
|
|
latest = payload
|
|
_expect(not latest.is_empty() and latest[&"defense_roll"] == expected_defense and latest[&"attack_roll"] == expected_attack and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "replay plus due automatic work consumes exactly one new round RNG pair")
|
|
|
|
|
|
func _test_history_replay_preserves_prior_manual_failure_guard() -> void:
|
|
_reset_runtime(91_008)
|
|
_create_staff_and_facility(&"guard.replay.failure", &"objective.replay.failure")
|
|
_prepare_started_raid(&"raid.replay.failure", &"guard.replay.failure", &"objective.replay.failure", [_wave(&"wave.replay.failure", 10_000, 2, [&"gate.north"])])
|
|
var first_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.failure", &"resolution_id": &"round.replay.failure.first"})
|
|
_expect((first_attempt[&"result"] as CommandResult).is_success(), "first round precedes the disclosed next-round failure")
|
|
var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var failure := {&"armed": true, &"calls": 0}
|
|
var normal_outcome := Callable(_runtime, "_apply_defense_outcome_boundary")
|
|
var injected := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
|
|
var report: Dictionary = normal_outcome.call(payload, phase, token)
|
|
if phase == &"apply":
|
|
failure[&"calls"] += 1
|
|
if bool(failure[&"armed"]):
|
|
failure[&"armed"] = false
|
|
report[&"ok"] = false
|
|
report[&"error"] = "Disclosed failure before historical replay."
|
|
return report
|
|
_runtime.defense_service.configure_callbacks(Callable(_runtime, "_roll_defense_round"), Callable(_runtime, "_apply_defense_skill_boundary"), injected, Callable(_runtime, "_apply_defense_rng_boundary"))
|
|
var due := int(_clock.absolute_minute) + int(_runtime.defense_service.get_raid(&"raid.replay.failure")[&"round_minutes"])
|
|
# Stable command-ID order executes this real failing transaction before the
|
|
# helper's subsequent historical replay, at the very same canonical minute.
|
|
var accepted: CommandResult = _runtime.submit_command(&"resolve_defense_round", {&"raid_id": &"raid.replay.failure", &"resolution_id": &"round.replay.failure.next"}, &"defense_controller", &"raid.replay.failure", 80, due, &"autonomy.real.failure.first")
|
|
var replay_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.failure", &"resolution_id": &"round.replay.failure.first"}, &"autonomy.old.replay.after.failure")
|
|
var failed: CommandResult = _runtime.command_queue.get_processed_result(accepted.command_id)
|
|
var replay: CommandResult = replay_attempt[&"result"]
|
|
_expect(accepted.is_success() and failed != null and not failed.is_success() and replay.is_success() and replay.is_replay and replay.command_id > accepted.command_id, "real failure and following old replay preserve their distinct actual command results")
|
|
_expect(failure[&"calls"] == 1 and _runtime.defense_service.get_raid(&"raid.replay.failure")[&"current_round"] == 1 and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "old replay cannot erase a prior failed real-round guard or hide it with same-tick automatic success")
|
|
_runtime._configure_defense_boundaries()
|
|
_advance(1)
|
|
_expect(_runtime.defense_service.get_raid(&"raid.replay.failure")[&"current_round"] == 2, "the genuine failed round can progress automatically on a later game minute")
|
|
|
|
|
|
func _test_resolution_history_is_scoped_per_raid() -> void:
|
|
_reset_runtime(91_009)
|
|
var accepted: Array[CommandResult] = []
|
|
for suffix: String in ["a", "b"]:
|
|
var guard := StringName("guard.shared.id." + suffix)
|
|
var objective := StringName("objective.shared.id." + suffix)
|
|
var raid_id := StringName("raid.shared.id." + suffix)
|
|
_create_staff_and_facility(guard, objective)
|
|
_prepare_started_raid(raid_id, guard, objective, [_wave(StringName("wave.shared.id." + suffix), 10_000, 100, [&"gate.north"])])
|
|
accepted.append(_runtime.submit_command(&"resolve_defense_round", {&"raid_id": raid_id, &"resolution_id": &"round.shared.history.id"}, &"defense_controller", raid_id, 80, int(_clock.absolute_minute) + 5, StringName("autonomy.shared.history." + suffix)))
|
|
_advance(5)
|
|
for pending: CommandResult in accepted:
|
|
var completed: CommandResult = _runtime.command_queue.get_processed_result(pending.command_id)
|
|
_expect(pending.is_success() and completed != null and completed.is_success() and not completed.is_replay and completed.details[&"resolution_id"] == &"round.shared.history.id", "same explicit resolution ID belongs independently to each raid in the same minute")
|
|
_expect(_runtime.defense_service.get_raid(&"raid.shared.id.a")[&"current_round"] == 1 and _runtime.defense_service.get_raid(&"raid.shared.id.b")[&"current_round"] == 1, "per-raid manual guards prevent double resolution without suppressing the other raid")
|
|
|
|
|
|
func _test_generated_ids_skip_existing_saved_history() -> void:
|
|
_reset_runtime(91_010)
|
|
_create_staff_and_facility(&"guard.id.collision", &"objective.id.collision")
|
|
_prepare_started_raid(&"raid.id.collision", &"guard.id.collision", &"objective.id.collision", [_wave(&"wave.id.collision", 10_000, 100, [&"gate.north"])])
|
|
for number: int in [1, 2]:
|
|
var explicit_id := StringName("defense_round_resolution.%012d" % number)
|
|
var attempt := _submit_round_at_due({&"raid_id": &"raid.id.collision", &"resolution_id": explicit_id})
|
|
_expect((attempt[&"result"] as CommandResult).is_success(), "valid explicit resolution establishes saved namespace collision")
|
|
_expect(_runtime.id_generator.get_counter(&"defense_round_resolution") == 0, "existing explicit IDs legitimately leave the generated counter unchanged")
|
|
var origin: Dictionary = _runtime.create_snapshot()
|
|
_reset_runtime(91_010)
|
|
_expect(_runtime.restore_snapshot(origin), "valid mid-battle history with explicit namespace IDs remains loadable")
|
|
var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var due := int(_clock.absolute_minute) + 5
|
|
_advance(5)
|
|
var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.id.collision")
|
|
_expect(raid[&"current_round"] == 3 and raid[&"state_changed_at"] == due, "namespace collision never delays the attack beyond its saved deadline")
|
|
_expect(_runtime.defense_service.has_resolution(&"raid.id.collision", &"defense_round_resolution.000000000003") and _runtime.id_generator.get_counter(&"defense_round_resolution") == 3, "generated resolution skips both existing IDs and commits one unused ID")
|
|
var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng
|
|
expected_rng.restore(rng_before)
|
|
var expected_defense := expected_rng.randi_range(0, 100)
|
|
var expected_attack := expected_rng.randi_range(0, 100)
|
|
var resolved: Dictionary = raid[&"resolved_rounds"][&"defense_round_resolution.000000000003"]
|
|
_expect(resolved[&"defense_roll"] == expected_defense and resolved[&"attack_roll"] == expected_attack and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "skipping history IDs consumes exactly one attack's combat RNG pair")
|
|
_expect(raid[&"resolved_rounds"][&"defense_round_resolution.000000000001"][&"round_number"] == 1 and raid[&"resolved_rounds"][&"defense_round_resolution.000000000002"][&"round_number"] == 2, "skipping IDs preserves both canonical old results")
|
|
|
|
|
|
func _test_exhausted_generated_ids_fail_atomically() -> void:
|
|
_reset_runtime(91_011)
|
|
_create_staff_and_facility(&"guard.id.exhausted", &"objective.id.exhausted")
|
|
_prepare_started_raid(&"raid.id.exhausted", &"guard.id.exhausted", &"objective.id.exhausted", [_wave(&"wave.id.exhausted", 10_000, 100, [&"gate.north"])])
|
|
var last_id := StringName("defense_round_resolution.%012d" % StableIdGenerator.MAX_COUNTER)
|
|
var attempt := _submit_round_at_due({&"raid_id": &"raid.id.exhausted", &"resolution_id": last_id})
|
|
_expect((attempt[&"result"] as CommandResult).is_success(), "valid explicit history can occupy the last generated ID")
|
|
var allocator: Dictionary = _runtime.id_generator.create_snapshot()
|
|
allocator["counters"]["defense_round_resolution"] = StableIdGenerator.MAX_COUNTER - 1
|
|
_expect(_runtime.id_generator.restore_snapshot(allocator), "valid near-exhausted allocator is a disclosed saved-state boundary")
|
|
var before: Dictionary = _runtime.defense_service.create_snapshot()
|
|
var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var mana_before: int = _state.get_resource(&"mana")
|
|
var reserve_minute_before: int = _runtime.mana_reserve.get_minute()
|
|
_advance(5)
|
|
# A06: crossing a 10-minute boundary settles the finite reserve; that flow is
|
|
# the only Mana change allowed here.
|
|
var reserve_mana := mana_before
|
|
if _runtime.mana_reserve.get_minute() != reserve_minute_before:
|
|
reserve_mana = int(_runtime.mana_reserve.get_last_flow()[&"after"])
|
|
before[&"current_minute"] = _clock.absolute_minute
|
|
_expect(before == _runtime.defense_service.create_snapshot() and rng_before == _runtime.get_rng_stream(&"defense").snapshot() and _clock.paused, "exhausted automatic allocator fails without advancing combat or RNG")
|
|
_expect(_state.get_resource(&"gold") == 5_000 and _state.get_resource(&"mana") == reserve_mana, "allocation failure charges no skill or outcome resources (Mana moves only by the A06 reserve flow)")
|
|
_expect(_count_domain_events(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"raid.id.exhausted") == 1 and _runtime.id_generator.get_counter(&"defense_round_resolution") == StableIdGenerator.MAX_COUNTER - 1, "exhaustion records one diagnostic and rolls skipped-ID allocation back")
|
|
|
|
|
|
func _test_auto_breach_projects_facility_suspension() -> void:
|
|
_reset_runtime(91_006)
|
|
_create_staff_and_facility(&"guard.breach.auto", &"objective.breach.auto", 100, 0, 1)
|
|
_prepare_started_raid(&"raid.breach.auto", &"guard.breach.auto", &"objective.breach.auto", [_wave(&"wave.breach.auto", 1_000, 1, [&"gate.north"])], 0, 0, {&"target_order": 1}, 1_000)
|
|
_advance(5)
|
|
var facility: FacilityInstance = _runtime.facility_service.get_instance(&"objective.breach.auto")
|
|
_expect(_runtime.defense_service.get_raid(&"raid.breach.auto")[&"state"] == DefenseService.RaidState.BREACHED and facility.durability == 0 and facility.state == FacilityInstance.State.BROKEN, "ordinary automatic combat destroys the objective through the actual facility boundary")
|
|
_expect(facility.suspension_reason == &"DESTROYED", "automatic destruction keeps the user-specified suspension state without inventing recovery")
|
|
var terminal: Dictionary = _runtime.create_snapshot()
|
|
_expect(_runtime.restore_snapshot(terminal), "automatically breached terminal state round-trips through ordinary Runtime save restoration")
|