1026 lines
39 KiB
GDScript
1026 lines
39 KiB
GDScript
extends SceneTree
|
|
|
|
const PLAYER_CASH := &"PLAYER_CASH"
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
var _clock: Variant
|
|
var _state: Variant
|
|
var _runtime: Variant
|
|
var _command_sequence := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S5: integrated Defense runtime cycle")
|
|
_test_all_commands_multiwave_and_terminal_replay()
|
|
_test_scheduled_command_time_alignment()
|
|
_test_manual_retreat_cancel_and_long_horizon()
|
|
_test_rng_skill_and_outcome_atomicity()
|
|
_test_cross_raid_locks()
|
|
_test_snapshot_replay_corruption_and_legacy_migration()
|
|
_cleanup()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _reset_runtime(seed: int, hour := 6, minute := 0) -> void:
|
|
_cleanup()
|
|
_command_sequence = 0
|
|
_clock = load("res://autoload/game_clock.gd").new()
|
|
_state = load("res://autoload/game_state.gd").new()
|
|
_runtime = load("res://autoload/simulation_runtime.gd").new()
|
|
_clock.reset(1, hour, minute)
|
|
_state.reset()
|
|
var report: Dictionary = _runtime.initialize(_clock, _state, seed)
|
|
_clock.set_paused(true)
|
|
_expect(report[&"valid"], "runtime initializes with the Defense aggregate")
|
|
_expect(
|
|
_runtime.defense_service.current_minute == _clock.absolute_minute,
|
|
"Defense time begins aligned with SimulationKernel"
|
|
)
|
|
_expect(
|
|
_runtime.get_rng_stream(&"defense") != null
|
|
and _runtime.get_rng_stream(&"defense").get_stream_id() == &"defense",
|
|
"runtime owns a dedicated Defense RNG stream"
|
|
)
|
|
|
|
|
|
func _cleanup() -> void:
|
|
if _runtime != null:
|
|
_runtime.free()
|
|
if _clock != null:
|
|
_clock.free()
|
|
if _state != null:
|
|
_state.free()
|
|
_runtime = null
|
|
_clock = null
|
|
_state = null
|
|
|
|
|
|
func _test_all_commands_multiwave_and_terminal_replay() -> void:
|
|
_reset_runtime(7_101)
|
|
_create_staff_and_facility(&"guard.multi", &"objective.multi", 94, 12, 9_750)
|
|
var created := _submit(&"create_defense_raid", {
|
|
&"raid_id": &"raid.multi",
|
|
&"definition": _raid_definition(&"objective.multi", [
|
|
_wave(&"wave.alpha", 15, 1, [&"gate.north"]),
|
|
_wave(&"wave.beta", 20, 1, [&"gate.south"]),
|
|
]),
|
|
})
|
|
var defender := _submit(&"register_defense_combatant", {
|
|
&"raid_id": &"raid.multi",
|
|
&"actor_id": &"guard.multi",
|
|
&"side": &"defender",
|
|
&"combat": 90,
|
|
&"max_health": 100,
|
|
&"options": {&"armor": 3},
|
|
})
|
|
var attacker_alpha := _submit(&"register_defense_combatant", {
|
|
&"raid_id": &"raid.multi",
|
|
&"actor_id": &"raider.alpha",
|
|
&"side": &"ATTACKER",
|
|
&"combat": 15,
|
|
&"max_health": 20,
|
|
&"options": {
|
|
&"wave_id": &"wave.alpha",
|
|
&"entry_point_id": &"gate.north",
|
|
},
|
|
})
|
|
var attacker_beta := _submit(&"register_defense_combatant", {
|
|
&"raid_id": &"raid.multi",
|
|
&"actor_id": &"raider.beta",
|
|
&"side": &"ATTACKER",
|
|
&"combat": 20,
|
|
&"max_health": 20,
|
|
&"options": {
|
|
&"wave_id": &"wave.beta",
|
|
&"entry_point_id": &"gate.south",
|
|
},
|
|
})
|
|
var asset := _submit(&"register_defense_asset", {
|
|
&"raid_id": &"raid.multi",
|
|
&"asset_id": &"objective.multi",
|
|
&"kind": &"objective",
|
|
&"max_integrity": 10_000,
|
|
&"defense": 40,
|
|
&"options": {&"target_order": 1, &"armor": 2},
|
|
})
|
|
var policy := _submit(&"set_defense_retreat_policy", {
|
|
&"raid_id": &"raid.multi",
|
|
&"policy": &"threshold",
|
|
&"threshold": 5,
|
|
})
|
|
var started := _submit(&"start_defense_raid", {&"raid_id": &"raid.multi"})
|
|
var skill := _submit(&"queue_defense_skill", {
|
|
&"raid_id": &"raid.multi",
|
|
&"action_id": &"skill.multi.rally",
|
|
&"actor_id": &"guard.multi",
|
|
&"kind": &"rally",
|
|
&"options": {&"potency": 7, &"metadata": {&"label": "free"}},
|
|
})
|
|
_expect(
|
|
created.is_success() and defender.is_success() and attacker_alpha.is_success()
|
|
and attacker_beta.is_success() and asset.is_success() and policy.is_success()
|
|
and started.is_success() and skill.is_success(),
|
|
"create, register, policy, start and skill Commands complete through Runtime"
|
|
)
|
|
_expect(
|
|
created.command_id > 0 and created.target_id == &"raid.multi"
|
|
and defender.command_id > created.command_id,
|
|
"Defense Command results preserve Runtime command_id and target conventions"
|
|
)
|
|
var early := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.multi", &"resolution_id": &"round.multi.early",
|
|
})
|
|
_expect(not early.is_success(), "a round before its configured interval is rejected")
|
|
_advance(5)
|
|
var first_round := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.multi", &"resolution_id": &"round.multi.1",
|
|
})
|
|
var after_first: Dictionary = _runtime.defense_service.get_raid(&"raid.multi")
|
|
_expect(
|
|
first_round.is_success()
|
|
and int(after_first[&"state"]) == DefenseService.RaidState.BETWEEN_WAVES
|
|
and int(after_first[&"current_round"]) == 1,
|
|
"first max-round Wave resolves and waits for an explicit transition"
|
|
)
|
|
_expect(
|
|
_runtime.facility_service.get_instance(&"objective.multi").durability == 9_750,
|
|
"non-terminal round damage stays inside Defense until terminal reconciliation"
|
|
)
|
|
var next_wave := _submit(&"begin_next_defense_wave", {&"raid_id": &"raid.multi"})
|
|
_expect(
|
|
next_wave.is_success()
|
|
and int(_runtime.defense_service.get_raid(&"raid.multi")[&"state"]) == DefenseService.RaidState.ACTIVE,
|
|
"begin_next_defense_wave is the required explicit Wave transition"
|
|
)
|
|
var same_minute := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.multi", &"resolution_id": &"round.multi.same-minute",
|
|
})
|
|
_expect(not same_minute.is_success(), "a different round ID cannot resolve again in the same minute")
|
|
_advance(5)
|
|
var terminal := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.multi", &"resolution_id": &"round.multi.2",
|
|
})
|
|
var terminal_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.multi")
|
|
var linked_staff: StaffRuntime = _runtime.staff_service.get_staff(&"guard.multi")
|
|
var linked_facility: FacilityInstance = _runtime.facility_service.get_instance(&"objective.multi")
|
|
_expect(
|
|
terminal.is_success()
|
|
and int(terminal_raid[&"state"]) == DefenseService.RaidState.DEFENDED,
|
|
"second Wave reaches a terminal Raid result through resolve_defense_round"
|
|
)
|
|
_expect(
|
|
linked_staff.health == int(terminal_raid[&"combatants"][&"guard.multi"][&"health"])
|
|
and linked_staff.fatigue == int(terminal_raid[&"combatants"][&"guard.multi"][&"fatigue"])
|
|
and linked_staff.state == StaffRuntime.State.AVAILABLE,
|
|
"terminal combatant outcome reconciles Staff health, fatigue and state"
|
|
)
|
|
_expect(
|
|
linked_facility.durability == int(terminal_raid[&"assets"][&"objective.multi"][&"integrity"]),
|
|
"terminal asset outcome reconciles Facility durability"
|
|
)
|
|
var rng_after: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var staff_after: Dictionary = _runtime.staff_service.create_snapshot()
|
|
var facility_after: Dictionary = _runtime.facility_service.create_snapshot()
|
|
var resolved_events := _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.multi")
|
|
var replay := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.multi", &"resolution_id": &"round.multi.2",
|
|
})
|
|
_expect(replay.is_success() and replay.is_replay, "duplicate round resolution replays its canonical result")
|
|
_expect(
|
|
_runtime.get_rng_stream(&"defense").snapshot() == rng_after
|
|
and _runtime.staff_service.create_snapshot() == staff_after
|
|
and _runtime.facility_service.create_snapshot() == facility_after,
|
|
"resolution replay does not consume RNG or reapply external outcomes"
|
|
)
|
|
_expect(
|
|
_count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.multi") == resolved_events,
|
|
"resolution replay emits no duplicate Defense resolved event"
|
|
)
|
|
var wrong_operation := _submit(&"retreat_defenders", {
|
|
&"raid_id": &"raid.multi", &"resolution_id": &"round.multi.2",
|
|
})
|
|
_expect(not wrong_operation.is_success(), "an explicit resolution ID cannot cross operation namespaces")
|
|
_expect(
|
|
_defense_terminal_event_order(&"raid.multi", &"round.multi.2"),
|
|
"terminal Defense DomainEvents are structured and emitted in causal order"
|
|
)
|
|
|
|
|
|
func _test_manual_retreat_cancel_and_long_horizon() -> void:
|
|
_reset_runtime(7_202)
|
|
_create_staff_and_facility(&"guard.retreat", &"objective.retreat")
|
|
_prepare_started_raid(
|
|
&"raid.retreat", &"guard.retreat", &"objective.retreat",
|
|
[_wave(&"wave.retreat", 150, 8, [&"gate.north"])]
|
|
)
|
|
var retreat := _submit(&"retreat_defenders", {&"raid_id": &"raid.retreat"})
|
|
_expect(
|
|
retreat.is_success()
|
|
and String(retreat.details.get(&"resolution_id", "")).begins_with("defense_retreat_resolution.")
|
|
and int(_runtime.defense_service.get_raid(&"raid.retreat")[&"state"])
|
|
== DefenseService.RaidState.DEFENDERS_RETREATED,
|
|
"manual retreat uses its stable operation-specific default resolution ID"
|
|
)
|
|
_expect(
|
|
_runtime.staff_service.get_staff(&"guard.retreat").state == StaffRuntime.State.FLEEING,
|
|
"manual retreat projects the Defender state to Staff"
|
|
)
|
|
|
|
_create_staff_and_facility(&"guard.cancel", &"objective.cancel", 100, 3, 9_900)
|
|
_prepare_started_raid(
|
|
&"raid.cancel", &"guard.cancel", &"objective.cancel",
|
|
[_wave(&"wave.cancel", 600, 10, [&"gate.north"])],
|
|
25,
|
|
0,
|
|
{&"target_order": 1}
|
|
)
|
|
_advance(5)
|
|
var partial := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.cancel", &"resolution_id": &"round.cancel.partial",
|
|
})
|
|
var partial_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.cancel")
|
|
_expect(
|
|
partial.is_success()
|
|
and int(partial_raid[&"state"]) == DefenseService.RaidState.ACTIVE,
|
|
"a long Wave can retain accumulated round state before cancellation"
|
|
)
|
|
_expect(
|
|
_runtime.facility_service.get_instance(&"objective.cancel").durability == 9_900,
|
|
"accumulated active-Raid damage is not prematurely projected"
|
|
)
|
|
var cancel := _submit(&"cancel_defense_raid", {&"raid_id": &"raid.cancel"})
|
|
var cancelled_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.cancel")
|
|
_expect(
|
|
cancel.is_success()
|
|
and String(cancel.details.get(&"resolution_id", "")).begins_with("defense_cancel_resolution.")
|
|
and int(cancelled_raid[&"state"]) == DefenseService.RaidState.CANCELLED,
|
|
"active Raid cancellation uses the terminal transaction boundary"
|
|
)
|
|
_expect(
|
|
_runtime.staff_service.get_staff(&"guard.cancel").health
|
|
== int(cancelled_raid[&"combatants"][&"guard.cancel"][&"health"])
|
|
and _runtime.facility_service.get_instance(&"objective.cancel").durability
|
|
== int(cancelled_raid[&"assets"][&"objective.cancel"][&"integrity"]),
|
|
"cancel reconciles all damage accumulated in earlier rounds"
|
|
)
|
|
|
|
_create_staff_and_facility(&"guard.long", &"objective.long")
|
|
var long_definition := _raid_definition(
|
|
&"objective.long",
|
|
[_wave(&"wave.long", 20_000, 400, [&"gate.north"])],
|
|
5,
|
|
5
|
|
)
|
|
_prepare_started_raid(
|
|
&"raid.long", &"guard.long", &"objective.long",
|
|
long_definition[&"waves"], 1, 0, {}, 100, long_definition
|
|
)
|
|
var long_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
_advance(1_440)
|
|
var long_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.long")
|
|
_expect(
|
|
_runtime.defense_service.current_minute == _clock.absolute_minute
|
|
and int(long_raid[&"current_round"]) == 0
|
|
and int(long_raid[&"state"]) == DefenseService.RaidState.ACTIVE,
|
|
"24-hour ticking advances Defense time without automatically resolving rounds"
|
|
)
|
|
_expect(
|
|
_runtime.get_rng_stream(&"defense").snapshot() == long_rng_before,
|
|
"Defense ticking alone consumes no round RNG"
|
|
)
|
|
var duration_terminal := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.long", &"resolution_id": &"round.long.duration",
|
|
})
|
|
_expect(
|
|
duration_terminal.is_success()
|
|
and int(_runtime.defense_service.get_raid(&"raid.long")[&"state"])
|
|
== DefenseService.RaidState.DEFENDED
|
|
and duration_terminal.details.get(&"reason", &"") == &"RAID_DURATION_EXHAUSTED",
|
|
"the next explicit round applies max-duration terminal behavior"
|
|
)
|
|
|
|
_create_staff_and_facility(&"guard.breach", &"objective.breach", 100, 0, 1)
|
|
_prepare_started_raid(
|
|
&"raid.breach", &"guard.breach", &"objective.breach",
|
|
[_wave(&"wave.breach", 1_000, 1, [&"gate.north"])],
|
|
0,
|
|
0,
|
|
{&"target_order": 1},
|
|
1_000
|
|
)
|
|
_advance(5)
|
|
var breached := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.breach", &"resolution_id": &"round.breach.terminal",
|
|
})
|
|
_expect(
|
|
breached.is_success()
|
|
and int(_runtime.defense_service.get_raid(&"raid.breach")[&"state"])
|
|
== DefenseService.RaidState.BREACHED
|
|
and _runtime.facility_service.get_instance(&"objective.breach").durability == 0
|
|
and _runtime.facility_service.get_instance(&"objective.breach").state
|
|
== FacilityInstance.State.BROKEN,
|
|
"destroyed Defense objective atomically projects Facility durability and state"
|
|
)
|
|
|
|
|
|
func _test_scheduled_command_time_alignment() -> void:
|
|
_reset_runtime(7_202)
|
|
_create_staff_and_facility(&"guard.scheduled", &"objective.scheduled")
|
|
_prepare_started_raid(
|
|
&"raid.scheduled", &"guard.scheduled", &"objective.scheduled",
|
|
[_wave(&"wave.scheduled", 1, 1, [&"gate.north"])],
|
|
100,
|
|
100,
|
|
{&"target_order": 1},
|
|
0
|
|
)
|
|
var execute_at: int = int(_clock.absolute_minute) + 5
|
|
var enqueued: CommandResult = _runtime.submit_command(
|
|
&"resolve_defense_round",
|
|
{&"raid_id": &"raid.scheduled", &"resolution_id": &"round.scheduled.1"},
|
|
&"defense_controller",
|
|
&"raid.scheduled",
|
|
80,
|
|
execute_at,
|
|
&"runtime.defense.scheduled.round"
|
|
)
|
|
_clock.set_paused(false)
|
|
_clock.advance_minutes(4)
|
|
_expect(
|
|
enqueued.is_success()
|
|
and _runtime.command_queue.get_processed_result(enqueued.command_id) == null
|
|
and _runtime.defense_service.current_minute == execute_at - 1,
|
|
"scheduled Defense round remains pending until its exact configured minute"
|
|
)
|
|
_clock.advance_minutes(1)
|
|
_clock.set_paused(true)
|
|
var completed: CommandResult = _runtime.command_queue.get_processed_result(enqueued.command_id)
|
|
_expect(
|
|
completed != null and completed.is_success()
|
|
and int(completed.details.get(&"absolute_minute", -1)) == execute_at
|
|
and _clock.absolute_minute == execute_at
|
|
and _runtime.defense_service.current_minute == execute_at,
|
|
"scheduled Defense command resolves against the same absolute minute as the completed tick"
|
|
)
|
|
var event_minutes: Dictionary = {}
|
|
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
|
if &"raid.scheduled" not in event.entity_ids:
|
|
continue
|
|
if event.event_type in [
|
|
&"DEFENSE_ROUND_RESOLVED",
|
|
&"DEFENSE_RAID_STATE_CHANGED",
|
|
&"DEFENSE_RAID_RESOLVED",
|
|
&"COMMAND_COMPLETED",
|
|
]:
|
|
event_minutes[event.event_type] = event.absolute_minute
|
|
_expect(
|
|
event_minutes.size() == 4
|
|
and event_minutes.values().all(func(value: Variant) -> bool: return int(value) == execute_at),
|
|
"scheduled Defense outcome and Command DomainEvents share the canonical execution minute"
|
|
)
|
|
|
|
|
|
func _test_rng_skill_and_outcome_atomicity() -> void:
|
|
_reset_runtime(7_303)
|
|
_create_staff_and_facility(&"guard.atomic", &"objective.atomic", 100, 4, 9_800)
|
|
_expect(
|
|
bool(_runtime.inventory_service.add_stack(
|
|
&"defense.inventory", &"mana_bomb", 5, 100, -1, _clock.absolute_minute, 1, &"stack.atomic"
|
|
).get(&"ok", false)),
|
|
"skill-cost Inventory fixture is available"
|
|
)
|
|
_prepare_started_raid(
|
|
&"raid.atomic", &"guard.atomic", &"objective.atomic",
|
|
[_wave(&"wave.atomic", 500, 1, [&"gate.north"])],
|
|
10,
|
|
0,
|
|
{&"target_order": 1}
|
|
)
|
|
var queued := _submit(&"queue_defense_skill", {
|
|
&"raid_id": &"raid.atomic",
|
|
&"actor_id": &"guard.atomic",
|
|
&"kind": &"POWER_STRIKE",
|
|
&"options": {
|
|
&"potency": 10,
|
|
&"metadata": {&"cost": {
|
|
&"gold": 125,
|
|
&"mana": 9,
|
|
&"items": [{
|
|
&"inventory_id": &"defense.inventory",
|
|
&"item_id": &"mana_bomb",
|
|
&"quantity": 2,
|
|
}],
|
|
}},
|
|
},
|
|
})
|
|
_expect(
|
|
queued.is_success()
|
|
and String(queued.details.get(&"action_id", "")).begins_with("defense_skill_action."),
|
|
"queued skill receives a stable generated action namespace"
|
|
)
|
|
_advance(5)
|
|
var defense_before: Dictionary = _runtime.defense_service.create_snapshot()
|
|
var defense_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var authority_rng_before: Dictionary = _runtime.get_rng_stream(&"authority").snapshot()
|
|
var economy_before: Dictionary = _runtime.economy_service.create_snapshot()
|
|
var state_before: Dictionary = _state.create_snapshot()
|
|
var inventory_before: Dictionary = _runtime.inventory_service.create_snapshot()
|
|
var staff_before: Dictionary = _runtime.staff_service.create_snapshot()
|
|
var facilities_before: Dictionary = _runtime.facility_service.create_snapshot()
|
|
var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng
|
|
expected_rng.restore(defense_rng_before)
|
|
var expected_defense_roll := expected_rng.randi_range(0, 100)
|
|
var expected_attack_roll := expected_rng.randi_range(0, 100)
|
|
var failure_switch := {&"armed": true}
|
|
var runtime_outcome := Callable(_runtime, "_apply_defense_outcome_boundary")
|
|
var failing_outcome := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
|
|
var report: Dictionary = runtime_outcome.call(payload, phase, token)
|
|
if phase == &"apply" and bool(report.get(&"ok", false)) and bool(failure_switch[&"armed"]):
|
|
failure_switch[&"armed"] = false
|
|
report = report.duplicate(true)
|
|
report[&"ok"] = false
|
|
report[&"error"] = "Injected post-apply outcome failure."
|
|
return report
|
|
_runtime.defense_service.configure_callbacks(
|
|
Callable(_runtime, "_roll_defense_round"),
|
|
Callable(_runtime, "_apply_defense_skill_boundary"),
|
|
failing_outcome,
|
|
Callable(_runtime, "_apply_defense_rng_boundary")
|
|
)
|
|
var failed := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.atomic", &"resolution_id": &"round.atomic.retry",
|
|
})
|
|
_expect(not failed.is_success(), "an injected post-apply terminal failure rejects the round")
|
|
_expect(
|
|
_runtime.defense_service.create_snapshot() == defense_before
|
|
and _runtime.get_rng_stream(&"defense").snapshot() == defense_rng_before
|
|
and _runtime.get_rng_stream(&"authority").snapshot() == authority_rng_before,
|
|
"failed outcome restores Defense state and its RNG without touching other streams"
|
|
)
|
|
_expect(
|
|
_runtime.economy_service.create_snapshot() == economy_before
|
|
and _state.create_snapshot() == state_before
|
|
and _runtime.inventory_service.create_snapshot() == inventory_before,
|
|
"failed outcome restores Gold, Mana, item reservations and consumption"
|
|
)
|
|
_expect(
|
|
_runtime.staff_service.create_snapshot() == staff_before
|
|
and _runtime.facility_service.create_snapshot() == facilities_before,
|
|
"failed outcome restores Staff and Facility changes atomically"
|
|
)
|
|
_expect(
|
|
_count_domain_events(&"LEDGER_TRANSACTION_POSTED") == 0
|
|
and _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.atomic") == 0,
|
|
"rolled-back skill and outcome effects leave no committed success events"
|
|
)
|
|
_runtime._configure_defense_boundaries()
|
|
var retried := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.atomic", &"resolution_id": &"round.atomic.retry",
|
|
})
|
|
_expect(
|
|
retried.is_success()
|
|
and int(retried.details[&"defense_roll"]) == expected_defense_roll
|
|
and int(retried.details[&"attack_roll"]) == expected_attack_roll,
|
|
"retry consumes the exact rolls predicted before the failed transaction"
|
|
)
|
|
_expect(
|
|
_runtime.economy_service.get_balance(PLAYER_CASH) == 5_000 - 125
|
|
and _state.get_resource(&"gold") == 5_000 - 125
|
|
and _state.get_resource(&"mana") == 100 - 9
|
|
and _runtime.inventory_service.available_quantity(
|
|
&"defense.inventory", &"mana_bomb", _clock.absolute_minute
|
|
) == 3,
|
|
"successful retry commits explicit Gold, Mana and item costs once"
|
|
)
|
|
_expect(
|
|
_runtime.get_rng_stream(&"authority").snapshot() == authority_rng_before
|
|
and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(),
|
|
"successful round advances only the Defense RNG to its exact predicted state"
|
|
)
|
|
|
|
_reset_runtime(7_304)
|
|
_create_staff_and_facility(&"guard.reserve", &"objective.reserve")
|
|
_expect(
|
|
bool(_runtime.inventory_service.add_stack(
|
|
&"reserve.inventory", &"a_available", 1, 100, -1,
|
|
_clock.absolute_minute, 1, &"stack.reserve.available"
|
|
).get(&"ok", false)),
|
|
"partial-reservation rollback fixture is available"
|
|
)
|
|
_prepare_started_raid(
|
|
&"raid.reserve", &"guard.reserve", &"objective.reserve",
|
|
[_wave(&"wave.reserve", 100, 1, [&"gate.north"])]
|
|
)
|
|
var reservation_skill := _submit(&"queue_defense_skill", {
|
|
&"raid_id": &"raid.reserve",
|
|
&"action_id": &"skill.reserve.partial",
|
|
&"actor_id": &"guard.reserve",
|
|
&"kind": &"RALLY",
|
|
&"options": {&"potency": 1, &"metadata": {&"cost": {&"items": [
|
|
{&"inventory_id": &"reserve.inventory", &"item_id": &"a_available", &"quantity": 1},
|
|
{&"inventory_id": &"reserve.inventory", &"item_id": &"z_missing", &"quantity": 1},
|
|
]}}},
|
|
})
|
|
_expect(reservation_skill.is_success(), "multi-item skill cost queues before boundary validation")
|
|
_advance(5)
|
|
var reserve_defense_before: Dictionary = _runtime.defense_service.create_snapshot()
|
|
var reserve_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot()
|
|
var reserve_inventory_before: Dictionary = _runtime.inventory_service.create_snapshot()
|
|
var reserve_economy_before: Dictionary = _runtime.economy_service.create_snapshot()
|
|
var reserve_state_before: Dictionary = _state.create_snapshot()
|
|
var reservation_failed := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.reserve", &"resolution_id": &"round.reserve.failed",
|
|
})
|
|
_expect(not reservation_failed.is_success(), "a later unavailable item rejects the whole skill-cost batch")
|
|
_expect(
|
|
_runtime.inventory_service.create_snapshot() == reserve_inventory_before
|
|
and _runtime.economy_service.create_snapshot() == reserve_economy_before
|
|
and _state.create_snapshot() == reserve_state_before,
|
|
"partial item reservation failure rolls every external resource back"
|
|
)
|
|
_expect(
|
|
_runtime.defense_service.create_snapshot() == reserve_defense_before
|
|
and _runtime.get_rng_stream(&"defense").snapshot() == reserve_rng_before,
|
|
"skill validation failure leaves Defense and round RNG untouched"
|
|
)
|
|
|
|
|
|
func _test_cross_raid_locks() -> void:
|
|
_reset_runtime(7_404)
|
|
_create_staff_and_facility(&"guard.lock", &"objective.lock")
|
|
for raid_id: StringName in [&"raid.lock.a", &"raid.lock.b"]:
|
|
var created := _submit(&"create_defense_raid", {
|
|
&"raid_id": raid_id,
|
|
&"definition": _raid_definition(
|
|
&"objective.lock", [_wave(StringName("wave.%s" % raid_id), 50, 4, [&"gate.north"])]
|
|
),
|
|
})
|
|
_expect(created.is_success(), "lock fixture Raid is created: %s" % raid_id)
|
|
var mismatched_defender := _submit(&"register_defense_combatant", {
|
|
&"raid_id": &"raid.lock.a",
|
|
&"actor_id": &"guard.lock",
|
|
&"side": &"DEFENDER",
|
|
&"combat": 50,
|
|
&"max_health": 100,
|
|
&"options": {&"health": 99},
|
|
})
|
|
var mismatched_asset := _submit(&"register_defense_asset", {
|
|
&"raid_id": &"raid.lock.a",
|
|
&"asset_id": &"objective.lock",
|
|
&"kind": &"OBJECTIVE",
|
|
&"max_integrity": 9_999,
|
|
&"defense": 20,
|
|
&"options": {},
|
|
})
|
|
_expect(
|
|
not mismatched_defender.is_success() and not mismatched_asset.is_success(),
|
|
"registration rejects values that differ from Staff and Facility baselines"
|
|
)
|
|
var first_defender := _register_defender(&"raid.lock.a", &"guard.lock", 50)
|
|
var first_asset := _register_objective(&"raid.lock.a", &"objective.lock", 20)
|
|
var second_defender := _register_defender(&"raid.lock.b", &"guard.lock", 50)
|
|
var second_asset := _register_objective(&"raid.lock.b", &"objective.lock", 20)
|
|
_expect(first_defender.is_success() and first_asset.is_success(), "first non-terminal Raid acquires Staff and Facility locks")
|
|
_expect(
|
|
not second_defender.is_success() and not second_asset.is_success(),
|
|
"a second non-terminal Raid cannot reuse locked Defender or Facility IDs"
|
|
)
|
|
var demolish := _submit(&"demolish_facility", {&"instance_id": &"objective.lock"})
|
|
_expect(not demolish.is_success(), "a Defense-locked Facility cannot be demolished")
|
|
var cancelled := _submit(&"cancel_defense_raid", {
|
|
&"raid_id": &"raid.lock.a", &"resolution_id": &"cancel.lock.release",
|
|
})
|
|
_expect(cancelled.is_success(), "terminal cancellation releases derived Raid locks")
|
|
second_defender = _register_defender(&"raid.lock.b", &"guard.lock", 50)
|
|
second_asset = _register_objective(&"raid.lock.b", &"objective.lock", 20)
|
|
_expect(
|
|
second_defender.is_success() and second_asset.is_success(),
|
|
"released locks are deterministically acquired by the remaining Raid"
|
|
)
|
|
var snapshot: Dictionary = _runtime.create_snapshot()
|
|
_expect(_runtime.restore_snapshot(snapshot), "snapshot restore rebuilds Defense locks without a mutable lock table")
|
|
var health_corruption: Dictionary = snapshot.duplicate(true)
|
|
var health_raids: Array = health_corruption[&"systems"][&"runtime"][&"defense"][&"raids"]
|
|
for raw_raid: Variant in health_raids:
|
|
if String(raw_raid.get(&"raid_id", "")) != "raid.lock.b":
|
|
continue
|
|
for raw_combatant: Variant in raw_raid[&"combatants"]:
|
|
if String(raw_combatant.get(&"actor_id", "")) == "guard.lock":
|
|
raw_combatant[&"health"] = 99
|
|
_expect(
|
|
not _runtime.restore_snapshot(health_corruption)
|
|
and _runtime.create_snapshot() == snapshot,
|
|
"restore rejects a PREPARING Defender health baseline mismatch atomically"
|
|
)
|
|
var integrity_corruption: Dictionary = snapshot.duplicate(true)
|
|
var integrity_raids: Array = integrity_corruption[&"systems"][&"runtime"][&"defense"][&"raids"]
|
|
for raw_raid: Variant in integrity_raids:
|
|
if String(raw_raid.get(&"raid_id", "")) != "raid.lock.b":
|
|
continue
|
|
for raw_asset: Variant in raw_raid[&"assets"]:
|
|
if String(raw_asset.get(&"asset_id", "")) == "objective.lock":
|
|
raw_asset[&"integrity"] = 9_999
|
|
_expect(
|
|
not _runtime.restore_snapshot(integrity_corruption)
|
|
and _runtime.create_snapshot() == snapshot,
|
|
"restore rejects a PREPARING Facility integrity baseline mismatch atomically"
|
|
)
|
|
var duplicate_after_restore := _submit(&"create_defense_raid", {
|
|
&"raid_id": &"raid.lock.c",
|
|
&"definition": _raid_definition(
|
|
&"objective.lock", [_wave(&"wave.lock.c", 30, 3, [&"gate.north"])]
|
|
),
|
|
})
|
|
var locked_after_restore := _register_defender(&"raid.lock.c", &"guard.lock", 20)
|
|
_expect(
|
|
duplicate_after_restore.is_success() and not locked_after_restore.is_success(),
|
|
"restored non-terminal ownership continues to enforce Staff exclusivity"
|
|
)
|
|
var second_cancel := _submit(&"cancel_defense_raid", {
|
|
&"raid_id": &"raid.lock.b", &"resolution_id": &"cancel.lock.second",
|
|
})
|
|
_runtime.staff_service.get_staff(&"guard.lock").fatigue = 17
|
|
var repaired: CommandResult = _runtime.facility_service.set_operational_status(
|
|
&"objective.lock", {&"durability": 9_850}, _clock.absolute_minute
|
|
)
|
|
var reused_defender := _register_defender(&"raid.lock.c", &"guard.lock", 30)
|
|
var reused_asset := _register_objective(&"raid.lock.c", &"objective.lock", 10)
|
|
var evolved_snapshot: Dictionary = _runtime.create_snapshot()
|
|
_expect(
|
|
second_cancel.is_success() and repaired.is_success()
|
|
and reused_defender.is_success() and reused_asset.is_success(),
|
|
"terminal Raid history does not block later Staff reuse or Facility repair"
|
|
)
|
|
_expect(
|
|
_runtime.restore_snapshot(evolved_snapshot),
|
|
"terminal outcomes remain historical while the newer live ownership restores"
|
|
)
|
|
|
|
|
|
func _test_snapshot_replay_corruption_and_legacy_migration() -> void:
|
|
_reset_runtime(7_505)
|
|
_create_staff_and_facility(&"guard.save", &"objective.save", 97, 6, 9_600)
|
|
_prepare_started_raid(
|
|
&"raid.save", &"guard.save", &"objective.save",
|
|
[_wave(&"wave.save", 2_000, 5, [&"gate.north"])],
|
|
80,
|
|
0,
|
|
{&"target_order": 1}
|
|
)
|
|
_advance(5)
|
|
var first := _submit(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.save", &"resolution_id": &"round.save.1",
|
|
})
|
|
_expect(
|
|
first.is_success()
|
|
and int(_runtime.defense_service.get_raid(&"raid.save")[&"state"])
|
|
== DefenseService.RaidState.ACTIVE,
|
|
"mid-Raid fixture preserves an active Wave after its first round"
|
|
)
|
|
var queued := _submit(&"queue_defense_skill", {
|
|
&"raid_id": &"raid.save",
|
|
&"action_id": &"skill.save.second",
|
|
&"actor_id": &"guard.save",
|
|
&"kind": &"FORTIFY",
|
|
&"options": {
|
|
&"potency": 8,
|
|
&"target_id": &"objective.save",
|
|
&"metadata": {&"cost": {&"gold": 0, &"mana": 0}},
|
|
},
|
|
})
|
|
_expect(queued.is_success(), "queued next-round skill is included in the mid-Raid snapshot")
|
|
var origin: Dictionary = _runtime.create_snapshot()
|
|
_advance(5)
|
|
var first_branch := _submit_with_key(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.save", &"resolution_id": &"round.save.2",
|
|
}, &"runtime.defense.save.resolve")
|
|
var branch_snapshot: Dictionary = _runtime.create_snapshot()
|
|
_expect(first_branch.is_success(), "first post-save Defense branch resolves")
|
|
_expect(_runtime.restore_snapshot(origin), "mid-round Runtime snapshot restores atomically")
|
|
_advance(5)
|
|
var replay_branch := _submit_with_key(&"resolve_defense_round", {
|
|
&"raid_id": &"raid.save", &"resolution_id": &"round.save.2",
|
|
}, &"runtime.defense.save.resolve")
|
|
_expect(
|
|
replay_branch.is_success() and replay_branch.details == first_branch.details,
|
|
"restored mid-round branch reproduces the exact Defense resolution"
|
|
)
|
|
_expect(
|
|
_runtime.create_snapshot() == branch_snapshot,
|
|
"save/load reproduces Defense, RNG, costs, events and command state exactly"
|
|
)
|
|
var retiring_service: DefenseService = _runtime.defense_service
|
|
var detach_origin: Dictionary = _runtime.create_snapshot()
|
|
_expect(_runtime.restore_snapshot(detach_origin), "same-state restore replaces the public Defense aggregate")
|
|
var runtime_before_stale_mutation: Dictionary = _runtime.create_snapshot()
|
|
var ghost_created := retiring_service.create_raid(
|
|
&"raid.stale.ghost",
|
|
retiring_service.current_minute,
|
|
_raid_definition(&"objective.ghost", [_wave(&"wave.ghost", 10, 1, [&"gate.north"])])
|
|
)
|
|
retiring_service.advance_time(retiring_service.current_minute + 5)
|
|
var stale_round := retiring_service.resolve_next_round(
|
|
&"raid.save", &"round.save.stale", retiring_service.current_minute
|
|
)
|
|
_expect(
|
|
ghost_created.is_success() and stale_round.is_success()
|
|
and _runtime.create_snapshot() == runtime_before_stale_mutation,
|
|
"retired Defense references cannot consume Runtime boundaries or emit ghost events"
|
|
)
|
|
|
|
var live_before_corruption: Dictionary = _runtime.create_snapshot()
|
|
var missing_current_defense: Dictionary = live_before_corruption.duplicate(true)
|
|
missing_current_defense[&"systems"][&"runtime"].erase(&"defense")
|
|
missing_current_defense[&"systems"][&"runtime"][&"rng_streams"].erase("defense")
|
|
_expect(
|
|
not _runtime.restore_snapshot(missing_current_defense)
|
|
and _runtime.create_snapshot() == live_before_corruption,
|
|
"current Runtime format requires Defense state and rejects silent data loss"
|
|
)
|
|
var corrupt_minute: Dictionary = live_before_corruption.duplicate(true)
|
|
corrupt_minute[&"systems"][&"runtime"][&"defense"][&"current_minute"] = _clock.absolute_minute - 1
|
|
_expect(
|
|
not _runtime.restore_snapshot(corrupt_minute)
|
|
and _runtime.create_snapshot() == live_before_corruption,
|
|
"corrupt Defense clock is rejected without changing any live Domain"
|
|
)
|
|
var corrupt_rng_stream: Dictionary = live_before_corruption.duplicate(true)
|
|
corrupt_rng_stream[&"systems"][&"runtime"][&"rng_streams"]["defense"]["stream_id"] = "authority"
|
|
_expect(
|
|
not _runtime.restore_snapshot(corrupt_rng_stream)
|
|
and _runtime.create_snapshot() == live_before_corruption,
|
|
"Defense RNG stream identity mismatch is rejected atomically"
|
|
)
|
|
var corrupt_rng_seed: Dictionary = live_before_corruption.duplicate(true)
|
|
corrupt_rng_seed[&"systems"][&"runtime"][&"rng_streams"]["defense"]["root_seed"] = 999_999
|
|
_expect(
|
|
not _runtime.restore_snapshot(corrupt_rng_seed)
|
|
and _runtime.create_snapshot() == live_before_corruption,
|
|
"Defense RNG root-seed mismatch is rejected atomically"
|
|
)
|
|
var corrupt_staff: Dictionary = live_before_corruption.duplicate(true)
|
|
var saved_staff: Array = corrupt_staff[&"systems"][&"runtime"][&"staff"][&"staff"]
|
|
for index: int in range(saved_staff.size()):
|
|
if String(saved_staff[index].get(&"staff_id", "")) == "guard.save":
|
|
saved_staff.remove_at(index)
|
|
break
|
|
_expect(
|
|
not _runtime.restore_snapshot(corrupt_staff)
|
|
and _runtime.create_snapshot() == live_before_corruption,
|
|
"missing external Staff crosslink rejects the snapshot atomically"
|
|
)
|
|
var corrupt_facility: Dictionary = live_before_corruption.duplicate(true)
|
|
var saved_facilities: Array = corrupt_facility[&"systems"][&"runtime"][&"facilities"][&"instances"]
|
|
for index: int in range(saved_facilities.size()):
|
|
if String(saved_facilities[index].get(&"instance_id", "")) == "objective.save":
|
|
saved_facilities.remove_at(index)
|
|
break
|
|
_expect(
|
|
not _runtime.restore_snapshot(corrupt_facility)
|
|
and _runtime.create_snapshot() == live_before_corruption,
|
|
"missing external Facility crosslink rejects the snapshot atomically"
|
|
)
|
|
|
|
_reset_runtime(7_606, 9, 17)
|
|
var legacy: Dictionary = _runtime.create_snapshot()
|
|
legacy[&"systems"][&"runtime"].erase(&"runtime_schema_version")
|
|
legacy[&"systems"][&"runtime"].erase(&"kitchen_production")
|
|
legacy[&"systems"][&"runtime"].erase(&"operations")
|
|
legacy[&"systems"][&"runtime"].erase(&"defense")
|
|
legacy[&"systems"][&"runtime"][&"rng_streams"].erase("defense")
|
|
for infrastructure_key: StringName in [
|
|
&"excavation", &"spatial_subslots", &"route_projection",
|
|
&"facility_infrastructure",
|
|
]:
|
|
legacy[&"systems"][&"runtime"].erase(infrastructure_key)
|
|
_expect(_runtime.restore_snapshot(legacy), "pre-Defense Runtime snapshot migrates without synthetic Raid state")
|
|
_expect(
|
|
_runtime.defense_service.get_raid_ids().is_empty()
|
|
and _runtime.defense_service.current_minute == _clock.absolute_minute,
|
|
"legacy migration installs an empty clock-aligned Defense aggregate"
|
|
)
|
|
var migrated_rng: DeterministicRng = _runtime.get_rng_stream(&"defense")
|
|
var expected_rng := DeterministicRng.from_root_seed(7_606, &"defense") as DeterministicRng
|
|
_expect(
|
|
migrated_rng.snapshot() == expected_rng.snapshot(),
|
|
"legacy migration derives the missing Defense RNG from the saved world seed"
|
|
)
|
|
var after_legacy: Dictionary = _runtime.create_snapshot()
|
|
_expect(
|
|
after_legacy[&"systems"][&"runtime"].has(&"defense")
|
|
and after_legacy[&"systems"][&"runtime"][&"rng_streams"].has("defense"),
|
|
"the next current-format snapshot persists migrated Defense state"
|
|
)
|
|
|
|
|
|
func _create_staff_and_facility(
|
|
staff_id: StringName,
|
|
facility_id: StringName,
|
|
health := 100,
|
|
fatigue := 0,
|
|
durability := 10_000
|
|
) -> void:
|
|
var staff := _submit(&"register_staff", {
|
|
&"staff_id": staff_id,
|
|
&"role_id": &"security",
|
|
&"options": {
|
|
&"state": StaffRuntime.State.AVAILABLE,
|
|
&"health": health,
|
|
&"fatigue": fatigue,
|
|
&"skills": {&"combat": 100},
|
|
},
|
|
})
|
|
var facility: CommandResult = _runtime.facility_service.create_instance({
|
|
&"instance_id": facility_id,
|
|
&"definition_id": &"defense_fixture",
|
|
&"durability": durability,
|
|
&"created_at": _clock.absolute_minute,
|
|
})
|
|
_expect(staff.is_success(), "external Staff fixture is registered: %s" % staff_id)
|
|
_expect(facility.is_success(), "external Facility fixture is registered: %s" % facility_id)
|
|
|
|
|
|
func _prepare_started_raid(
|
|
raid_id: StringName,
|
|
staff_id: StringName,
|
|
facility_id: StringName,
|
|
waves: Array,
|
|
defender_combat := 50,
|
|
asset_defense := 20,
|
|
asset_options: Dictionary = {},
|
|
attacker_combat := 0,
|
|
definition_override: Dictionary = {}
|
|
) -> void:
|
|
var definition := definition_override.duplicate(true) if not definition_override.is_empty() \
|
|
else _raid_definition(facility_id, waves)
|
|
var created := _submit(&"create_defense_raid", {
|
|
&"raid_id": raid_id,
|
|
&"definition": definition,
|
|
})
|
|
var defender := _register_defender(raid_id, staff_id, defender_combat)
|
|
var asset := _register_objective(raid_id, facility_id, asset_defense, asset_options)
|
|
var wave_index := 0
|
|
for wave_value: Variant in waves:
|
|
var wave: Dictionary = wave_value
|
|
var wave_entries: Array = wave.get(&"entry_points", [&"gate.north"])
|
|
var attacker := _submit(&"register_defense_combatant", {
|
|
&"raid_id": raid_id,
|
|
&"actor_id": StringName("attacker.%s.%d" % [raid_id, wave_index]),
|
|
&"side": &"ATTACKER",
|
|
&"combat": attacker_combat,
|
|
&"max_health": 100,
|
|
&"options": {
|
|
&"wave_id": StringName(wave[&"wave_id"]),
|
|
&"entry_point_id": StringName(wave_entries[0]),
|
|
},
|
|
})
|
|
_expect(attacker.is_success(), "synthetic attacker is registered for %s" % String(wave[&"wave_id"]))
|
|
wave_index += 1
|
|
var started := _submit(&"start_defense_raid", {&"raid_id": raid_id})
|
|
_expect(
|
|
created.is_success() and defender.is_success() and asset.is_success() and started.is_success(),
|
|
"Raid fixture starts with validated external links: %s" % raid_id
|
|
)
|
|
|
|
|
|
func _register_defender(raid_id: StringName, staff_id: StringName, combat: int) -> CommandResult:
|
|
return _submit(&"register_defense_combatant", {
|
|
&"raid_id": raid_id,
|
|
&"actor_id": staff_id,
|
|
&"side": &"DEFENDER",
|
|
&"combat": combat,
|
|
&"max_health": 100,
|
|
&"options": {&"armor": 2},
|
|
})
|
|
|
|
|
|
func _register_objective(
|
|
raid_id: StringName,
|
|
facility_id: StringName,
|
|
defense: int,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
return _submit(&"register_defense_asset", {
|
|
&"raid_id": raid_id,
|
|
&"asset_id": facility_id,
|
|
&"kind": &"OBJECTIVE",
|
|
&"max_integrity": 10_000,
|
|
&"defense": defense,
|
|
&"options": options.duplicate(true),
|
|
})
|
|
|
|
|
|
func _raid_definition(
|
|
objective_id: StringName,
|
|
waves: Array,
|
|
round_minutes := 5,
|
|
max_duration_minutes := 180
|
|
) -> Dictionary:
|
|
return {
|
|
&"source_id": &"runtime_test",
|
|
&"entry_points": [&"gate.north", &"gate.south"],
|
|
&"objective_ids": [objective_id],
|
|
&"waves": waves.duplicate(true),
|
|
&"round_minutes": round_minutes,
|
|
&"max_duration_minutes": max_duration_minutes,
|
|
&"retreat_policy": &"HOLD",
|
|
&"defender_retreat_threshold": 0,
|
|
}
|
|
|
|
|
|
func _wave(
|
|
wave_id: StringName,
|
|
threat_strength: int,
|
|
max_rounds: int,
|
|
entry_points: Array
|
|
) -> Dictionary:
|
|
return {
|
|
&"wave_id": wave_id,
|
|
&"threat_strength": threat_strength,
|
|
&"retreat_threshold": 0,
|
|
&"max_rounds": max_rounds,
|
|
&"entry_points": entry_points.duplicate(),
|
|
}
|
|
|
|
|
|
func _submit(command_type: StringName, payload: Dictionary) -> CommandResult:
|
|
_command_sequence += 1
|
|
return _submit_with_key(
|
|
command_type,
|
|
payload,
|
|
StringName("runtime.defense.%04d" % _command_sequence)
|
|
)
|
|
|
|
|
|
func _submit_with_key(
|
|
command_type: StringName,
|
|
payload: Dictionary,
|
|
idempotency_key: StringName
|
|
) -> CommandResult:
|
|
return _runtime.submit_command(
|
|
command_type,
|
|
payload,
|
|
&"defense_controller",
|
|
StringName(payload.get(&"raid_id", payload.get("raid_id", ""))),
|
|
80,
|
|
-1,
|
|
idempotency_key
|
|
)
|
|
|
|
|
|
func _advance(minutes: int) -> void:
|
|
_clock.advance_minutes(minutes)
|
|
_clock.set_paused(true)
|
|
|
|
|
|
func _count_domain_events(event_type: StringName, entity_id: StringName = &"") -> int:
|
|
var count := 0
|
|
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
|
if event.event_type != event_type:
|
|
continue
|
|
if entity_id.is_empty() or entity_id in event.entity_ids:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func _defense_terminal_event_order(raid_id: StringName, resolution_id: StringName) -> bool:
|
|
var ordered_types: Array[StringName] = []
|
|
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
|
if raid_id not in event.entity_ids:
|
|
continue
|
|
if event.event_type in [
|
|
&"DEFENSE_SKILL_APPLIED",
|
|
&"DEFENSE_ROUND_RESOLVED",
|
|
&"DEFENSE_RAID_STATE_CHANGED",
|
|
&"DEFENSE_RAID_RESOLVED",
|
|
&"COMMAND_COMPLETED",
|
|
]:
|
|
if event.event_type == &"DEFENSE_ROUND_RESOLVED" \
|
|
and event.payload.get(&"resolution_id", &"") != resolution_id:
|
|
continue
|
|
ordered_types.append(event.event_type)
|
|
var round_index := ordered_types.rfind(&"DEFENSE_ROUND_RESOLVED")
|
|
var state_index := ordered_types.rfind(&"DEFENSE_RAID_STATE_CHANGED")
|
|
var resolved_index := ordered_types.rfind(&"DEFENSE_RAID_RESOLVED")
|
|
var command_index := ordered_types.rfind(&"COMMAND_COMPLETED")
|
|
return round_index >= 0 and state_index > round_index \
|
|
and resolved_index > state_index and command_index > resolved_index
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|