Files
TheTree_mock/scripts/defense/defense_service.gd
T
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

2264 lines
99 KiB
GDScript

class_name DefenseService
extends RefCounted
## Deterministic, round-based Raid and defense aggregate.
##
## The service owns combat state only. Resource costs and final consequences are
## explicit callback boundaries, which lets SimulationRuntime include them in a
## larger transaction without giving this service access to another domain.
signal raid_created(raid_id: StringName)
signal raid_state_changed(raid_id: StringName, old_state: int, new_state: int, reason: StringName)
signal active_skill_applied(raid_id: StringName, action_id: StringName, round_number: int)
signal round_resolved(raid_id: StringName, resolution: Dictionary)
signal raid_resolved(raid_id: StringName, outcome: Dictionary)
const CommandResultType = preload("res://scripts/core/command_result.gd")
const SNAPSHOT_VERSION := 1
const DEFAULT_ROUND_MINUTES := 5
const OPERATION_ROUND := &"ROUND"
const OPERATION_RETREAT := &"RETREAT"
const OPERATION_CANCEL := &"CANCEL"
const VALID_OPERATION_KINDS := [OPERATION_ROUND, OPERATION_RETREAT, OPERATION_CANCEL]
const MAX_METADATA_DEPTH := 32
enum RaidState {
PREPARING,
ACTIVE,
BETWEEN_WAVES,
DEFENDED,
BREACHED,
DEFENDERS_RETREATED,
CANCELLED,
}
enum CombatantState {
ACTIVE,
INCAPACITATED,
RETREATED,
}
enum AssetKind {
DOOR,
DEFENSE_DEVICE,
OBJECTIVE,
}
const TERMINAL_STATES := [
RaidState.DEFENDED,
RaidState.BREACHED,
RaidState.DEFENDERS_RETREATED,
RaidState.CANCELLED,
]
const VALID_RETREAT_POLICIES := [
&"HOLD",
&"MANUAL",
&"THRESHOLD",
&"PROTECT_AND_RETREAT",
]
const VALID_SKILL_KINDS := [
&"RALLY",
&"POWER_STRIKE",
&"HEAL",
&"FORTIFY",
]
var current_minute := 0
var last_error := ""
var _raids: Dictionary = {}
var _round_roll := Callable()
var _skill_boundary := Callable()
var _outcome_boundary := Callable()
var _rng_boundary := Callable()
func configure_callbacks(
round_roll: Callable = Callable(),
skill_boundary: Callable = Callable(),
outcome_boundary: Callable = Callable(),
rng_boundary: Callable = Callable()
) -> DefenseService:
# `round_roll(key, context)` is a pure keyed function by default: the same
# key must always return the same value. Stateful RNG providers must also
# supply `rng_boundary(context, phase, token)` so failed resolutions restore
# their cursor. Boundary phases are snapshot, commit and rollback.
_round_roll = round_roll
_skill_boundary = skill_boundary
_outcome_boundary = outcome_boundary
_rng_boundary = rng_boundary
return self
func get_raid(raid_id: StringName) -> Dictionary:
if not _raids.has(raid_id):
return {}
return (_raids[raid_id] as Dictionary).duplicate(true)
func get_raid_ids() -> Array[StringName]:
return _sorted_keys(_raids)
## Explicit IDs in valid older saves need not have advanced Runtime's allocator.
## Query the existing history without exposing or copying its mutable payloads.
func has_resolution(raid_id: StringName, resolution_id: StringName) -> bool:
return _raids.has(raid_id) and (_raids[raid_id][&"resolved_rounds"] as Dictionary).has(resolution_id)
## Detached primitives only; ordinary ticks must not clone growing round history.
## The interval is already part of each persisted raid, not a new balance value.
func get_due_rounds(absolute_minute: int) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for raid_id: StringName in _sorted_keys(_raids):
var raid: Dictionary = _raids[raid_id]
if int(raid[&"state"]) != RaidState.ACTIVE:
continue
var earliest := int(raid[&"state_changed_at"]) + int(raid[&"round_minutes"])
if earliest <= absolute_minute:
result.append({
&"raid_id": raid_id,
&"earliest_minute": earliest,
&"round_number": int(raid[&"current_round"]) + 1,
})
return result
func get_active_raid_ids() -> Array[StringName]:
var result: Array[StringName] = []
for raid_id: StringName in _sorted_keys(_raids):
var raid: Dictionary = _raids[raid_id]
if int(raid[&"state"]) in [RaidState.ACTIVE, RaidState.BETWEEN_WAVES]:
result.append(raid_id)
return result
## Compact, owned projection: never copy combatants or accumulated round history
## just to display the current threats. Preparing raids also lock employees.
func get_threat_summary() -> Dictionary:
var counts := {&"preparing": 0, &"active": 0, &"between_waves": 0, &"total": 0}
for raid: Dictionary in _raids.values():
match int(raid[&"state"]):
RaidState.PREPARING:
counts[&"preparing"] += 1
RaidState.ACTIVE:
counts[&"active"] += 1
RaidState.BETWEEN_WAVES:
counts[&"between_waves"] += 1
counts[&"total"] = counts[&"preparing"] + counts[&"active"] + counts[&"between_waves"]
return counts
## Owned UI projections. Deliberately omit resolved rounds and skill history.
func get_management_rows() -> Array[Dictionary]:
var rows: Array[Dictionary] = []
for raid_id: StringName in _sorted_keys(_raids):
var raid: Dictionary = _raids[raid_id]
var missing: Array[StringName] = []
for objective_id: StringName in raid[&"objective_ids"]:
if not (raid[&"assets"] as Dictionary).has(objective_id):
missing.append(objective_id)
var defender_ids: Array[StringName] = []
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
if raid[&"combatants"][actor_id][&"side"] == &"DEFENDER": defender_ids.append(actor_id)
rows.append({
&"raid_id": raid_id, &"state": int(raid[&"state"]),
&"source_id": raid[&"source_id"], &"current_round": int(raid[&"current_round"]),
&"source_kind": String((raid.get(&"event_source", {}) as Dictionary).get("source_kind", "")),
&"created_at": int(raid[&"created_at"]),
&"current_wave": int(raid[&"current_wave_index"]) + 1,
&"wave_count": (raid[&"waves"] as Array).size(),
&"retreat_policy": raid[&"retreat_policy"],
&"defender_retreat_threshold": int(raid[&"defender_retreat_threshold"]),
&"objective_ids": (raid[&"objective_ids"] as Array).duplicate(),
&"missing_objectives": missing,
&"defender_ids": defender_ids, &"asset_ids": _sorted_keys(raid[&"assets"]),
&"result": raid[&"result"], &"result_reason": raid[&"result_reason"],
})
return rows
func get_management_detail(raid_id: StringName) -> Dictionary:
if not _raids.has(raid_id):
return {}
var raid: Dictionary = _raids[raid_id]
var defenders: Array[Dictionary] = []
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var actor: Dictionary = raid[&"combatants"][actor_id]
if actor[&"side"] == &"DEFENDER":
defenders.append({
&"actor_id": actor_id, &"health": actor[&"health"],
&"max_health": actor[&"max_health"], &"combat": actor[&"combat"],
&"fatigue": actor[&"fatigue"], &"state": actor[&"state"],
})
var assets: Array[Dictionary] = []
for asset_id: StringName in _sorted_keys(raid[&"assets"]):
var asset: Dictionary = raid[&"assets"][asset_id]
assets.append({
&"asset_id": asset_id, &"kind": asset[&"kind"],
&"integrity": asset[&"integrity"], &"max_integrity": asset[&"max_integrity"],
&"defense": asset[&"defense"],
})
return {&"defenders": defenders, &"assets": assets}
func create_raid(
raid_id: StringName,
absolute_minute: int,
definition: Dictionary
) -> CommandResult:
if raid_id.is_empty() or absolute_minute < 0:
return _invalid("Raid requires an ID and non-negative creation minute.")
if absolute_minute < current_minute:
return _invalid("Raid creation time cannot precede the Defense clock.")
if _raids.has(raid_id):
return _duplicate("Raid", raid_id)
var event_source_value: Variant = _value(definition, &"event_source", {})
if not _is_valid_event_source(event_source_value):
return _invalid("Event threat provenance is malformed.")
var event_source: Dictionary = event_source_value.duplicate(true)
var entry_points := _sorted_unique_names(_value(definition, &"entry_points", []))
var objective_ids := _sorted_unique_names(_value(definition, &"objective_ids", []))
if entry_points.is_empty() or objective_ids.is_empty():
return _invalid("Raid requires at least one entry point and objective.")
var waves_result := _normalize_waves(raid_id, definition, entry_points, objective_ids)
if not bool(waves_result.get(&"ok", false)):
return _invalid(String(waves_result.get(&"error", "Raid waves are invalid.")))
var retreat_policy := StringName(_value(definition, &"retreat_policy", &"HOLD"))
if not VALID_RETREAT_POLICIES.has(retreat_policy):
return _invalid("Raid retreat policy is invalid.")
var max_duration_minutes := int(_value(definition, &"max_duration_minutes", 120))
var round_minutes := int(_value(definition, &"round_minutes", DEFAULT_ROUND_MINUTES))
var defender_retreat_threshold := int(_value(definition, &"defender_retreat_threshold", 20))
if max_duration_minutes <= 0 or round_minutes <= 0:
return _invalid("Raid duration and round duration must be positive.")
if defender_retreat_threshold < 0 or defender_retreat_threshold > 100:
return _invalid("Defender retreat threshold must be within 0..100.")
var raid := {
&"raid_id": raid_id,
&"source_id": StringName(_value(definition, &"source_id", &"")),
&"event_source": event_source,
&"state": RaidState.PREPARING,
&"created_at": absolute_minute,
&"started_at": -1,
&"finished_at": -1,
&"state_changed_at": absolute_minute,
&"current_wave_index": 0,
&"current_round": 0,
&"elapsed_minutes": 0,
&"max_duration_minutes": max_duration_minutes,
&"round_minutes": round_minutes,
&"retreat_policy": retreat_policy,
&"defender_retreat_threshold": defender_retreat_threshold,
&"terrain_bonus": int(_value(definition, &"terrain_bonus", 0)),
&"command_bonus": int(_value(definition, &"command_bonus", 0)),
&"surprise_penalty": maxi(int(_value(definition, &"surprise_penalty", 0)), 0),
&"multiple_entry_penalty": maxi(int(_value(definition, &"multiple_entry_penalty", 5)), 0),
&"entry_points": entry_points,
&"objective_ids": objective_ids,
&"waves": waves_result[&"waves"],
&"combatants": {},
&"assets": {},
&"queued_skills": {},
&"applied_skill_ids": {},
&"resolved_rounds": {},
&"result": &"PENDING",
&"result_reason": &"",
&"revision": 0,
}
var report := _validate_raid(raid)
if not bool(report[&"ok"]):
return _invalid("Raid definition violates invariants.", report)
_raids[raid_id] = raid
current_minute = maxi(current_minute, absolute_minute)
last_error = ""
raid_created.emit(raid_id)
return _success({&"raid_id": raid_id, &"wave_count": (raid[&"waves"] as Array).size()})
func register_combatant(
raid_id: StringName,
actor_id: StringName,
side: StringName,
combat: int,
max_health: int,
options: Dictionary = {}
) -> CommandResult:
var raid := _raid_ref(raid_id)
if raid.is_empty():
return _not_found("Raid", raid_id)
if int(raid[&"state"]) != RaidState.PREPARING:
return _invalid("Combatants can only be registered while a Raid is PREPARING.")
if actor_id.is_empty() or (raid[&"combatants"] as Dictionary).has(actor_id):
return _duplicate("Defense combatant", actor_id)
if side not in [&"DEFENDER", &"ATTACKER"] or combat < 0 or max_health <= 0:
return _invalid("Defense combatant schema is invalid.")
var health := int(_value(options, &"health", max_health))
var armor := int(_value(options, &"armor", 0))
var fatigue := int(_value(options, &"fatigue", 0))
if health <= 0 or health > max_health or armor < 0 or fatigue < 0 or fatigue > 100:
return _invalid("Defense combatant health, armor or fatigue is invalid.")
var wave_id := StringName(_value(options, &"wave_id", &""))
if side == &"ATTACKER" and not _wave_exists(raid, wave_id):
return _invalid("Attacker must reference an existing Raid wave.")
if side == &"DEFENDER" and not wave_id.is_empty():
return _invalid("Defenders cannot be bound to a single attacker wave.")
var entry_point_id := StringName(_value(options, &"entry_point_id", &""))
if not entry_point_id.is_empty() and not (raid[&"entry_points"] as Array).has(entry_point_id):
return _invalid("Combatant entry point is outside the Raid definition.")
if side == &"ATTACKER" and not entry_point_id.is_empty():
var attacker_wave := _wave_by_id(raid, wave_id)
if attacker_wave.is_empty() or not (attacker_wave[&"entry_points"] as Array).has(entry_point_id):
return _invalid("Attacker entry point is outside its assigned wave.")
(raid[&"combatants"] as Dictionary)[actor_id] = {
&"actor_id": actor_id,
&"side": side,
&"wave_id": wave_id,
&"entry_point_id": entry_point_id,
&"combat": combat,
&"armor": armor,
&"max_health": max_health,
&"health": health,
# Baseline percentages (retreat policy and reports) are relative to the
# combatant's full capacity, even when a wounded actor enters the Raid.
&"initial_health": max_health,
&"fatigue": fatigue,
&"target_order": int(_value(options, &"target_order", 75)),
&"state": CombatantState.ACTIVE,
&"damage_dealt": 0,
&"damage_taken": 0,
&"revision": 0,
}
raid[&"revision"] = int(raid[&"revision"]) + 1
last_error = ""
return _success({&"raid_id": raid_id, &"actor_id": actor_id, &"side": side})
func register_asset(
raid_id: StringName,
asset_id: StringName,
kind: StringName,
max_integrity: int,
defense: int,
options: Dictionary = {}
) -> CommandResult:
var raid := _raid_ref(raid_id)
if raid.is_empty():
return _not_found("Raid", raid_id)
if int(raid[&"state"]) != RaidState.PREPARING:
return _invalid("Defense assets can only be registered while a Raid is PREPARING.")
if asset_id.is_empty() or (raid[&"assets"] as Dictionary).has(asset_id):
return _duplicate("Defense asset", asset_id)
var kind_index := AssetKind.keys().find(String(kind))
if kind_index < 0 or max_integrity <= 0 or defense < 0:
return _invalid("Defense asset schema is invalid.")
var is_objective := (raid[&"objective_ids"] as Array).has(asset_id)
if is_objective != (kind_index == AssetKind.OBJECTIVE):
return _invalid("Raid objective IDs must be registered as OBJECTIVE assets.")
var integrity := int(_value(options, &"integrity", max_integrity))
if integrity <= 0 or integrity > max_integrity:
return _invalid("Defense asset integrity is invalid.")
var covered_entries := _sorted_unique_names(_value(options, &"entry_points", []))
for entry_id: StringName in covered_entries:
if not (raid[&"entry_points"] as Array).has(entry_id):
return _invalid("Defense asset references an unknown entry point.")
var default_order := 100
if kind_index == AssetKind.DOOR:
default_order = 10
elif kind_index == AssetKind.DEFENSE_DEVICE:
default_order = 50
(raid[&"assets"] as Dictionary)[asset_id] = {
&"asset_id": asset_id,
&"kind": kind_index,
&"max_integrity": max_integrity,
&"integrity": integrity,
# Keep the full-capacity baseline so pre-damaged objectives can correctly
# trigger PROTECT_AND_RETREAT.
&"initial_integrity": max_integrity,
&"defense": defense,
&"armor": maxi(int(_value(options, &"armor", 0)), 0),
&"terrain_bonus": int(_value(options, &"terrain_bonus", 0)),
&"target_order": int(_value(options, &"target_order", default_order)),
&"entry_points": covered_entries,
&"revision": 0,
}
raid[&"revision"] = int(raid[&"revision"]) + 1
last_error = ""
return _success({&"raid_id": raid_id, &"asset_id": asset_id, &"kind": AssetKind.keys()[kind_index]})
func start_raid(raid_id: StringName, absolute_minute: int) -> CommandResult:
var raid := _raid_ref(raid_id)
if raid.is_empty():
return _not_found("Raid", raid_id)
if int(raid[&"state"]) != RaidState.PREPARING:
return _invalid("Only a PREPARING Raid can start.")
if absolute_minute < int(raid[&"created_at"]):
return _invalid("Raid start time cannot precede creation.")
if absolute_minute < current_minute:
return _invalid("Raid start time cannot precede the Defense clock.")
for objective_id: StringName in raid[&"objective_ids"]:
if not (raid[&"assets"] as Dictionary).has(objective_id):
return _invalid("Every Raid objective needs a registered OBJECTIVE asset.", {
&"objective_id": objective_id,
})
var old_state := int(raid[&"state"])
raid[&"state"] = RaidState.ACTIVE
var first_wave := _active_wave(raid)
first_wave[&"state"] = &"ACTIVE"
raid[&"started_at"] = absolute_minute
raid[&"state_changed_at"] = absolute_minute
raid[&"revision"] = int(raid[&"revision"]) + 1
current_minute = maxi(current_minute, absolute_minute)
last_error = ""
raid_state_changed.emit(raid_id, old_state, RaidState.ACTIVE, &"RAID_STARTED")
return _success({&"raid_id": raid_id, &"state": &"ACTIVE", &"wave_id": _active_wave(raid)[&"wave_id"]})
func set_retreat_policy(
raid_id: StringName,
policy: StringName,
threshold: int = -1
) -> CommandResult:
var raid := _raid_ref(raid_id)
if raid.is_empty():
return _not_found("Raid", raid_id)
if _is_terminal(int(raid[&"state"])):
return _invalid("A completed Raid cannot change retreat policy.")
if not VALID_RETREAT_POLICIES.has(policy):
return _invalid("Retreat policy is invalid.")
if threshold >= 0 and threshold > 100:
return _invalid("Retreat threshold must be within 0..100.")
raid[&"retreat_policy"] = policy
if threshold >= 0:
raid[&"defender_retreat_threshold"] = threshold
raid[&"revision"] = int(raid[&"revision"]) + 1
last_error = ""
return _success({
&"raid_id": raid_id,
&"retreat_policy": policy,
&"defender_retreat_threshold": raid[&"defender_retreat_threshold"],
})
func queue_active_skill(
raid_id: StringName,
action_id: StringName,
actor_id: StringName,
kind: StringName,
options: Dictionary = {}
) -> CommandResult:
var raid := _raid_ref(raid_id)
if raid.is_empty():
return _not_found("Raid", raid_id)
if int(raid[&"state"]) != RaidState.ACTIVE:
return _invalid("Active skills can only be queued during an active wave.")
if action_id.is_empty() or actor_id.is_empty() or not VALID_SKILL_KINDS.has(kind):
return _invalid("Active skill schema is invalid.")
if (raid[&"applied_skill_ids"] as Dictionary).has(action_id):
var applied_replay := _success({&"raid_id": raid_id, &"action_id": action_id})
applied_replay.is_replay = true
return applied_replay
if (raid[&"queued_skills"] as Dictionary).has(action_id):
var queued_replay := _success({&"raid_id": raid_id, &"action_id": action_id})
queued_replay.is_replay = true
return queued_replay
var combatant: Dictionary = (raid[&"combatants"] as Dictionary).get(actor_id, {})
if combatant.is_empty() or combatant[&"side"] != &"DEFENDER" \
or int(combatant[&"state"]) != CombatantState.ACTIVE:
return _invalid("Active skill actor must be an active defender.")
var potency := int(_value(options, &"potency", 0))
if potency < 0 or potency > 100:
return _invalid("Active skill potency must be within 0..100.")
var target_id := StringName(_value(options, &"target_id", &""))
if kind == &"HEAL":
var target_combatant: Dictionary = (raid[&"combatants"] as Dictionary).get(target_id, {})
if target_combatant.is_empty() or target_combatant[&"side"] != &"DEFENDER":
return _invalid("HEAL requires a defender target.")
elif kind == &"FORTIFY":
var target_asset: Dictionary = (raid[&"assets"] as Dictionary).get(target_id, {})
if target_asset.is_empty() or int(target_asset[&"integrity"]) <= 0:
return _invalid("FORTIFY requires an intact defense asset target.")
var raw_metadata: Variant = _value(options, &"metadata", {})
if not raw_metadata is Dictionary:
return _invalid("Active skill metadata must be a Dictionary.")
var metadata_result := _canonicalize_metadata(raw_metadata)
if not bool(metadata_result[&"ok"]):
return _invalid("Active skill metadata is not deterministic or saveable.", metadata_result)
(raid[&"queued_skills"] as Dictionary)[action_id] = {
&"action_id": action_id,
&"actor_id": actor_id,
&"kind": kind,
&"potency": potency,
&"target_id": target_id,
&"round_number": int(raid[&"current_round"]) + 1,
&"metadata": metadata_result[&"value"],
}
raid[&"revision"] = int(raid[&"revision"]) + 1
last_error = ""
return _success({&"raid_id": raid_id, &"action_id": action_id, &"round_number": int(raid[&"current_round"]) + 1})
func resolve_next_round(
raid_id: StringName,
resolution_id: StringName,
absolute_minute: int
) -> CommandResult:
var live := _raid_ref(raid_id)
if live.is_empty():
return _not_found("Raid", raid_id)
if resolution_id.is_empty():
return _invalid("Defense round requires a resolution_id.")
if (live[&"resolved_rounds"] as Dictionary).has(resolution_id):
return _replay_resolution(live, resolution_id, OPERATION_ROUND)
if int(live[&"state"]) != RaidState.ACTIVE:
return _invalid("Defense round can only resolve during an active wave.")
if absolute_minute < current_minute:
return _invalid("Defense round time cannot precede the Defense clock.")
var earliest_round_minute := int(live[&"state_changed_at"]) + int(live[&"round_minutes"])
if absolute_minute < earliest_round_minute:
return _invalid("Defense round must observe its configured round interval.", {
&"earliest_minute": earliest_round_minute,
})
var candidate := live.duplicate(true)
var wave := _active_wave(candidate)
if wave.is_empty():
return _invalid("Raid has no active wave.")
var round_number := int(candidate[&"current_round"]) + 1
var skills := _skills_for_round(candidate, round_number)
var external_skills := _validate_and_apply_skill_boundaries(raid_id, skills, round_number)
if not bool(external_skills[&"ok"]):
return _invalid("Active skill boundary rejected the round.", external_skills)
var rng_context := {
&"raid_id": raid_id,
&"resolution_id": resolution_id,
&"wave_id": wave[&"wave_id"],
&"round_number": round_number,
}
var rng_transaction := _begin_rng_transaction(rng_context)
if not bool(rng_transaction[&"ok"]):
_rollback_skill_boundaries(external_skills.get(&"applied", []))
return _invalid("Defense RNG snapshot boundary rejected the round.", rng_transaction)
var skill_effects := _apply_skills(candidate, skills)
var score := _calculate_scores(candidate, wave, skill_effects)
var defense_roll := _deterministic_roll(raid_id, wave[&"wave_id"], round_number, &"DEFENSE")
var attack_roll := _deterministic_roll(raid_id, wave[&"wave_id"], round_number, &"ATTACK")
var defense_margin := int(score[&"defense_score"]) - int(score[&"attack_score"]) \
+ int(floor(float(defense_roll - 50) / 5.0))
var attack_margin := int(score[&"attack_score"]) - int(score[&"defense_score"]) \
+ int(floor(float(attack_roll - 50) / 5.0))
var damage_to_threat := clampi(
5 + int(floor(float(defense_margin) / 8.0)) + int(skill_effects[&"damage_bonus"]),
1,
maxi(_remaining_attacker_durability(candidate, wave), 1)
)
var damage_to_defense := clampi(
5 + int(floor(float(attack_margin) / 8.0)),
0,
50
)
# Explicit attackers and the off-screen aggregate are a single damage pool.
# Damage is allocated to stable-ID ordered explicit actors first and only the
# remainder reduces the aggregate. Applying the full amount to both would
# double-count casualties and make on-screen/off-screen simulation diverge.
var attacker_damage := _apply_damage_to_attackers(candidate, wave, damage_to_threat)
var defense_damage := _apply_damage_to_defense(candidate, wave, damage_to_defense)
_apply_round_fatigue(candidate)
wave[&"rounds_completed"] = int(wave[&"rounds_completed"]) + 1
candidate[&"current_round"] = round_number
candidate[&"elapsed_minutes"] = int(candidate[&"elapsed_minutes"]) + int(candidate[&"round_minutes"])
candidate[&"state_changed_at"] = absolute_minute
_mark_skills_applied(candidate, skills, round_number)
var old_state := int(candidate[&"state"])
var resolution_reason := _evaluate_round_outcome(candidate, wave, absolute_minute)
var new_state := int(candidate[&"state"])
var round_payload := {
&"operation_kind": OPERATION_ROUND,
&"resolution_id": resolution_id,
&"raid_id": raid_id,
&"wave_id": wave[&"wave_id"],
&"round_number": round_number,
&"absolute_minute": absolute_minute,
&"defense_score": score[&"defense_score"],
&"attack_score": score[&"attack_score"],
&"score_components": score[&"components"],
&"defense_roll": defense_roll,
&"attack_roll": attack_roll,
&"damage_to_threat": damage_to_threat,
&"remaining_threat": wave[&"remaining_threat"],
&"damage_applied_to_threat": attacker_damage[&"applied_total"],
&"damage_mitigated_by_attackers": attacker_damage[&"mitigated_total"],
&"unapplied_damage_to_threat": attacker_damage[&"unapplied_damage"],
&"attacker_damage": attacker_damage[&"explicit_damage"],
&"aggregate_damage": attacker_damage[&"aggregate_damage"],
&"damage_to_defense": damage_to_defense,
&"defense_damage": defense_damage,
&"skill_ids": _skill_ids(skills),
&"skill_effects": (skill_effects[&"changes"] as Array).duplicate(true),
&"state": StringName(RaidState.keys()[new_state]),
&"reason": resolution_reason,
}
var outcome_boundary := {&"ok": true}
if _is_terminal(new_state):
var outcome_payload := _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_ROUND)
outcome_boundary = _apply_outcome_boundary(outcome_payload)
if not bool(outcome_boundary[&"ok"]):
_rollback_rng_transaction(rng_transaction)
_rollback_skill_boundaries(external_skills.get(&"applied", []))
return _invalid("Raid outcome boundary rejected the round.", outcome_boundary)
(candidate[&"resolved_rounds"] as Dictionary)[resolution_id] = round_payload.duplicate(true)
candidate[&"revision"] = int(candidate[&"revision"]) + 1
var invariant_report := _validate_raid(candidate)
if not bool(invariant_report[&"ok"]):
_rollback_outcome_boundary(outcome_boundary)
_rollback_rng_transaction(rng_transaction)
_rollback_skill_boundaries(external_skills.get(&"applied", []))
return _invalid("Resolved defense round violates invariants.", invariant_report)
var rng_commit := _commit_rng_transaction(rng_transaction)
if not bool(rng_commit[&"ok"]):
_rollback_outcome_boundary(outcome_boundary)
_rollback_rng_transaction(rng_transaction)
_rollback_skill_boundaries(external_skills.get(&"applied", []))
return _invalid("Defense RNG commit boundary rejected the round.", rng_commit)
_raids[raid_id] = candidate
current_minute = maxi(current_minute, absolute_minute)
last_error = ""
for skill: Dictionary in skills:
active_skill_applied.emit(raid_id, skill[&"action_id"], round_number)
round_resolved.emit(raid_id, round_payload.duplicate(true))
if new_state != old_state:
raid_state_changed.emit(raid_id, old_state, new_state, resolution_reason)
if _is_terminal(new_state):
raid_resolved.emit(raid_id, _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_ROUND))
return _success(round_payload)
func begin_next_wave(raid_id: StringName, absolute_minute: int) -> CommandResult:
var raid := _raid_ref(raid_id)
if raid.is_empty():
return _not_found("Raid", raid_id)
if int(raid[&"state"]) != RaidState.BETWEEN_WAVES:
return _invalid("Raid is not waiting between waves.")
if absolute_minute < int(raid[&"state_changed_at"]):
return _invalid("Wave start time cannot move backwards.")
if absolute_minute < current_minute:
return _invalid("Wave start time cannot precede the Defense clock.")
var next_index := int(raid[&"current_wave_index"]) + 1
if next_index >= (raid[&"waves"] as Array).size():
return _invalid("Raid has no remaining wave.")
var old_state := int(raid[&"state"])
raid[&"current_wave_index"] = next_index
raid[&"state"] = RaidState.ACTIVE
var next_wave := _active_wave(raid)
next_wave[&"state"] = &"ACTIVE"
raid[&"state_changed_at"] = absolute_minute
raid[&"revision"] = int(raid[&"revision"]) + 1
current_minute = maxi(current_minute, absolute_minute)
last_error = ""
raid_state_changed.emit(raid_id, old_state, RaidState.ACTIVE, &"NEXT_WAVE_STARTED")
return _success({
&"raid_id": raid_id,
&"wave_id": _active_wave(raid)[&"wave_id"],
&"wave_index": next_index,
})
func retreat_defenders(
raid_id: StringName,
resolution_id: StringName,
absolute_minute: int,
reason: StringName = &"PLAYER_RETREAT"
) -> CommandResult:
var live := _raid_ref(raid_id)
if live.is_empty():
return _not_found("Raid", raid_id)
if resolution_id.is_empty():
return _invalid("Defender retreat requires a resolution_id.")
if (live[&"resolved_rounds"] as Dictionary).has(resolution_id):
return _replay_resolution(live, resolution_id, OPERATION_RETREAT)
if int(live[&"state"]) not in [RaidState.ACTIVE, RaidState.BETWEEN_WAVES]:
return _invalid("Defenders can only retreat from an active Raid.")
if absolute_minute < int(live[&"state_changed_at"]):
return _invalid("Retreat time cannot move backwards.")
if absolute_minute < current_minute:
return _invalid("Retreat time cannot precede the Defense clock.")
var candidate := live.duplicate(true)
for actor_id: StringName in _sorted_keys(candidate[&"combatants"]):
var combatant: Dictionary = candidate[&"combatants"][actor_id]
if combatant[&"side"] == &"DEFENDER" and int(combatant[&"state"]) == CombatantState.ACTIVE:
combatant[&"state"] = CombatantState.RETREATED
combatant[&"revision"] = int(combatant[&"revision"]) + 1
var wave := _active_wave(candidate)
if not wave.is_empty():
wave[&"state"] = &"RETREATED"
wave[&"result_reason"] = reason
_set_terminal_state(candidate, RaidState.DEFENDERS_RETREATED, absolute_minute, reason)
var outcome := _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_RETREAT)
var boundary := _apply_outcome_boundary(outcome)
if not bool(boundary[&"ok"]):
return _invalid("Raid retreat outcome boundary rejected the command.", boundary)
var payload := outcome.duplicate(true)
payload[&"resolution_id"] = resolution_id
(candidate[&"resolved_rounds"] as Dictionary)[resolution_id] = payload
candidate[&"revision"] = int(candidate[&"revision"]) + 1
var report := _validate_raid(candidate)
if not bool(report[&"ok"]):
_rollback_outcome_boundary(boundary)
return _invalid("Defender retreat violates invariants.", report)
_raids[raid_id] = candidate
current_minute = maxi(current_minute, absolute_minute)
last_error = ""
raid_state_changed.emit(raid_id, int(live[&"state"]), RaidState.DEFENDERS_RETREATED, reason)
raid_resolved.emit(raid_id, outcome)
return _success(payload)
func cancel_raid(
raid_id: StringName,
absolute_minute: int,
reason: StringName = &"CANCELLED",
resolution_id: StringName = &""
) -> CommandResult:
var live := _raid_ref(raid_id)
if live.is_empty():
return _not_found("Raid", raid_id)
if resolution_id.is_empty():
resolution_id = StringName("%s:cancel" % raid_id)
if (live[&"resolved_rounds"] as Dictionary).has(resolution_id):
return _replay_resolution(live, resolution_id, OPERATION_CANCEL)
if _is_terminal(int(live[&"state"])):
return _invalid("Raid is already terminal.")
if absolute_minute < int(live[&"state_changed_at"]):
return _invalid("Raid cancellation time cannot move backwards.")
if absolute_minute < current_minute:
return _invalid("Raid cancellation time cannot precede the Defense clock.")
var candidate := live.duplicate(true)
var old_state := int(candidate[&"state"])
var wave := _active_wave(candidate)
if not wave.is_empty():
wave[&"state"] = &"CANCELLED"
wave[&"result_reason"] = reason
_set_terminal_state(candidate, RaidState.CANCELLED, absolute_minute, reason)
var outcome := _make_outcome_payload(candidate, resolution_id, absolute_minute, OPERATION_CANCEL)
var boundary := _apply_outcome_boundary(outcome)
if not bool(boundary[&"ok"]):
return _invalid("Raid cancellation outcome boundary rejected the command.", boundary)
(candidate[&"resolved_rounds"] as Dictionary)[resolution_id] = outcome.duplicate(true)
candidate[&"revision"] = int(candidate[&"revision"]) + 1
var report := _validate_raid(candidate)
if not bool(report[&"ok"]):
_rollback_outcome_boundary(boundary)
return _invalid("Raid cancellation violates invariants.", report)
_raids[raid_id] = candidate
current_minute = maxi(current_minute, absolute_minute)
last_error = ""
raid_state_changed.emit(raid_id, old_state, RaidState.CANCELLED, reason)
raid_resolved.emit(raid_id, outcome.duplicate(true))
return _success(outcome)
func advance_time(absolute_minute: int) -> CommandResult:
if absolute_minute < current_minute:
return _invalid("Defense time cannot move backwards.")
current_minute = absolute_minute
last_error = ""
return _success({&"current_minute": current_minute})
func validate_invariants() -> Dictionary:
var errors: Array[String] = []
if current_minute < 0:
errors.append("Defense current minute is negative.")
for raid_id: StringName in _sorted_keys(_raids):
var raid: Dictionary = _raids[raid_id]
if raid.get(&"raid_id", &"") != raid_id:
errors.append("Raid dictionary key mismatch: %s" % raid_id)
var report := _validate_raid(raid)
if not bool(report[&"ok"]):
for error: String in report[&"errors"]:
errors.append("%s: %s" % [raid_id, error])
var clock_report := _validate_raid_clock(raid, current_minute)
if not bool(clock_report[&"ok"]):
for error: String in clock_report[&"errors"]:
errors.append("%s: %s" % [raid_id, error])
return {&"ok": errors.is_empty(), &"errors": errors}
func create_snapshot() -> Dictionary:
var raids: Array[Dictionary] = []
for raid_id: StringName in _sorted_keys(_raids):
raids.append(_raid_to_snapshot(_raids[raid_id]))
return {
&"version": SNAPSHOT_VERSION,
&"current_minute": current_minute,
&"raids": raids,
}
func restore_snapshot(snapshot: Dictionary) -> bool:
if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION:
last_error = "Unsupported Defense snapshot version."
return false
var next_minute := int(_value(snapshot, &"current_minute", -1))
var raw_raids: Variant = _value(snapshot, &"raids", null)
if next_minute < 0 or not raw_raids is Array:
last_error = "Malformed Defense snapshot root."
return false
var parsed_raids: Dictionary = {}
for raw_raid: Variant in raw_raids:
if not raw_raid is Dictionary:
last_error = "Malformed Raid snapshot entry."
return false
var parsed := _raid_from_snapshot(raw_raid)
if not bool(parsed.get(&"ok", false)):
last_error = String(parsed.get(&"error", "Invalid Raid snapshot."))
return false
var raid: Dictionary = parsed[&"raid"]
var raid_id: StringName = raid[&"raid_id"]
if parsed_raids.has(raid_id):
last_error = "Duplicate Raid snapshot ID."
return false
var report := _validate_raid(raid)
if not bool(report[&"ok"]):
last_error = "Raid snapshot violates invariants: %s" % str(report[&"errors"])
return false
var clock_report := _validate_raid_clock(raid, next_minute)
if not bool(clock_report[&"ok"]):
last_error = "Raid snapshot exceeds Defense current minute: %s" % str(clock_report[&"errors"])
return false
parsed_raids[raid_id] = raid
_raids = parsed_raids
current_minute = next_minute
last_error = ""
return true
func _normalize_waves(
raid_id: StringName,
definition: Dictionary,
entry_points: Array[StringName],
objective_ids: Array[StringName]
) -> Dictionary:
var raw_waves: Variant = _value(definition, &"waves", [])
if not raw_waves is Array:
return {&"ok": false, &"error": "Raid waves must be an Array."}
if raw_waves.is_empty() and int(_value(definition, &"threat_strength", 0)) > 0:
raw_waves = [{
&"wave_id": StringName("%s_wave_01" % raid_id),
&"threat_strength": int(_value(definition, &"threat_strength", 0)),
}]
if raw_waves.is_empty():
return {&"ok": false, &"error": "Raid requires at least one wave."}
var result: Array[Dictionary] = []
var seen: Dictionary = {}
for index: int in range(raw_waves.size()):
var raw: Variant = raw_waves[index]
if not raw is Dictionary:
return {&"ok": false, &"error": "Raid wave entry must be a Dictionary."}
var wave_id := StringName(_value(raw, &"wave_id", StringName("%s_wave_%02d" % [raid_id, index + 1])))
var threat := int(_value(raw, &"threat_strength", 0))
var retreat_threshold := int(_value(raw, &"retreat_threshold", 0))
var max_rounds := int(_value(raw, &"max_rounds", 12))
if wave_id.is_empty() or seen.has(wave_id) or threat <= 0 or max_rounds <= 0 \
or retreat_threshold < 0 or retreat_threshold > 100:
return {&"ok": false, &"error": "Raid wave ID, strength, retreat threshold or duration is invalid."}
var wave_entries := _sorted_unique_names(_value(raw, &"entry_points", entry_points))
var wave_objectives := _sorted_unique_names(_value(raw, &"objective_ids", objective_ids))
if wave_entries.is_empty() or wave_objectives.is_empty():
return {&"ok": false, &"error": "Every Raid wave requires entry points and objectives."}
for entry_id: StringName in wave_entries:
if not entry_points.has(entry_id):
return {&"ok": false, &"error": "Raid wave references an unknown entry point."}
for objective_id: StringName in wave_objectives:
if not objective_ids.has(objective_id):
return {&"ok": false, &"error": "Raid wave references an unknown objective."}
seen[wave_id] = true
result.append({
&"wave_id": wave_id,
&"threat_strength": threat,
&"remaining_threat": threat,
&"retreat_threshold": retreat_threshold,
&"max_rounds": max_rounds,
&"rounds_completed": 0,
&"entry_points": wave_entries,
&"objective_ids": wave_objectives,
&"state": &"PENDING",
&"result_reason": &"",
})
return {&"ok": true, &"waves": result}
func _calculate_scores(raid: Dictionary, wave: Dictionary, skill_effects: Dictionary) -> Dictionary:
var defender_combat := 0
var fatigue_penalty := 0
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var combatant: Dictionary = raid[&"combatants"][actor_id]
if combatant[&"side"] != &"DEFENDER" or int(combatant[&"state"]) != CombatantState.ACTIVE:
continue
var effective_combat := _effective_combat_power(
int(combatant[&"combat"]),
int(combatant[&"health"]),
int(combatant[&"max_health"])
)
defender_combat += effective_combat
fatigue_penalty += int(floor(float(effective_combat * int(combatant[&"fatigue"])) / 100.0))
var asset_defense := 0
var terrain_bonus := int(raid[&"terrain_bonus"])
for asset_id: StringName in _sorted_keys(raid[&"assets"]):
var asset: Dictionary = raid[&"assets"][asset_id]
if int(asset[&"integrity"]) <= 0 or not _asset_relevant_to_wave(asset, wave):
continue
asset_defense += int(floor(float(int(asset[&"defense"]) * int(asset[&"integrity"])) / float(int(asset[&"max_integrity"]))))
terrain_bonus += int(asset[&"terrain_bonus"])
var attacker_combat := 0
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var attacker: Dictionary = raid[&"combatants"][actor_id]
if attacker[&"side"] != &"ATTACKER" or attacker[&"wave_id"] != wave[&"wave_id"] \
or int(attacker[&"state"]) != CombatantState.ACTIVE:
continue
attacker_combat += _effective_combat_power(
int(attacker[&"combat"]),
int(attacker[&"health"]),
int(attacker[&"max_health"])
)
# Off-screen attackers use the exact same effective-power formula as explicit
# actors. Their max health and combat are both the wave's initial threat, so
# the result is the remaining aggregate threat without a separate rule path.
var aggregate_attacker_combat := _effective_combat_power(
int(wave[&"threat_strength"]),
int(wave[&"remaining_threat"]),
int(wave[&"threat_strength"])
)
var multi_entry_penalty := maxi((wave[&"entry_points"] as Array).size() - 1, 0) * int(raid[&"multiple_entry_penalty"])
var surprise_penalty := int(raid[&"surprise_penalty"]) if int(wave[&"rounds_completed"]) == 0 else 0
var defense_score := defender_combat + asset_defense + terrain_bonus + int(raid[&"command_bonus"]) \
+ int(skill_effects[&"score_bonus"]) - fatigue_penalty - multi_entry_penalty - surprise_penalty
var attack_score := aggregate_attacker_combat + attacker_combat
return {
&"defense_score": maxi(defense_score, 0),
&"attack_score": maxi(attack_score, 0),
&"components": {
&"defender_combat": defender_combat,
&"asset_defense": asset_defense,
&"terrain_bonus": terrain_bonus,
&"command_bonus": int(raid[&"command_bonus"]),
&"skill_bonus": int(skill_effects[&"score_bonus"]),
&"fatigue_penalty": fatigue_penalty,
&"multiple_entry_penalty": multi_entry_penalty,
&"surprise_penalty": surprise_penalty,
&"attacker_combat": attacker_combat,
&"aggregate_attacker_combat": aggregate_attacker_combat,
&"remaining_threat": int(wave[&"remaining_threat"]),
},
}
func _effective_combat_power(combat: int, health: int, max_health: int) -> int:
if combat <= 0 or health <= 0 or max_health <= 0:
return 0
return int(floor(float(combat * health) / float(max_health)))
func _remaining_attacker_durability(raid: Dictionary, wave: Dictionary) -> int:
var result := maxi(int(wave[&"remaining_threat"]), 0)
for actor_id: StringName in _wave_attacker_ids(raid, wave[&"wave_id"]):
var actor: Dictionary = raid[&"combatants"][actor_id]
if int(actor[&"state"]) == CombatantState.ACTIVE:
result += maxi(int(actor[&"health"]), 0)
return result
func _apply_damage_to_attackers(raid: Dictionary, wave: Dictionary, damage: int) -> Dictionary:
var remaining := maxi(damage, 0)
var applied_total := 0
var mitigated_total := 0
var changes: Array[Dictionary] = []
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
if remaining <= 0:
break
var actor: Dictionary = raid[&"combatants"][actor_id]
if actor[&"side"] != &"ATTACKER" or actor[&"wave_id"] != wave[&"wave_id"] \
or int(actor[&"state"]) != CombatantState.ACTIVE:
continue
var mitigated := mini(remaining, int(actor[&"armor"]))
var penetrating := maxi(remaining - mitigated, 0)
var actual := mini(penetrating, int(actor[&"health"]))
remaining -= mitigated + actual
mitigated_total += mitigated
applied_total += actual
if actual > 0:
actor[&"health"] = int(actor[&"health"]) - actual
actor[&"damage_taken"] = int(actor[&"damage_taken"]) + actual
actor[&"revision"] = int(actor[&"revision"]) + 1
if int(actor[&"health"]) <= 0:
actor[&"state"] = CombatantState.INCAPACITATED
changes.append({
&"target_id": actor_id,
&"damage": actual,
&"mitigated": mitigated,
&"state": StringName(CombatantState.keys()[actor[&"state"]]),
})
var aggregate_damage := mini(remaining, int(wave[&"remaining_threat"]))
wave[&"remaining_threat"] = int(wave[&"remaining_threat"]) - aggregate_damage
remaining -= aggregate_damage
applied_total += aggregate_damage
return {
&"requested": maxi(damage, 0),
&"applied_total": applied_total,
&"mitigated_total": mitigated_total,
&"explicit_damage": changes,
&"aggregate_damage": aggregate_damage,
&"unapplied_damage": remaining,
}
func _apply_damage_to_defense(raid: Dictionary, wave: Dictionary, damage: int) -> Dictionary:
if damage <= 0:
return {&"target_type": &"NONE", &"target_id": &"", &"damage": 0}
var targets: Array[Dictionary] = []
for asset_id: StringName in _sorted_keys(raid[&"assets"]):
var asset: Dictionary = raid[&"assets"][asset_id]
if int(asset[&"integrity"]) > 0 and _asset_relevant_to_wave(asset, wave):
targets.append({
&"target_type": &"ASSET",
&"target_id": asset_id,
&"target_order": int(asset[&"target_order"]),
})
# Guards are a real defense layer rather than an unreachable fallback after
# objective destruction. Their default order (75) places them after doors
# and devices but before objectives; callers can override it per actor.
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var actor: Dictionary = raid[&"combatants"][actor_id]
if actor[&"side"] == &"DEFENDER" and int(actor[&"state"]) == CombatantState.ACTIVE:
targets.append({
&"target_type": &"COMBATANT",
&"target_id": actor_id,
&"target_order": int(actor[&"target_order"]),
})
targets.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if int(a[&"target_order"]) != int(b[&"target_order"]):
return int(a[&"target_order"]) < int(b[&"target_order"])
if a[&"target_type"] != b[&"target_type"]:
return String(a[&"target_type"]) < String(b[&"target_type"])
return String(a[&"target_id"]) < String(b[&"target_id"])
)
if not targets.is_empty():
var target_ref: Dictionary = targets[0]
if target_ref[&"target_type"] == &"ASSET":
var target_asset: Dictionary = raid[&"assets"][target_ref[&"target_id"]]
var asset_mitigated := mini(damage, int(target_asset[&"armor"]))
var asset_damage := mini(maxi(damage - asset_mitigated, 0), int(target_asset[&"integrity"]))
if asset_damage > 0:
target_asset[&"integrity"] = int(target_asset[&"integrity"]) - asset_damage
target_asset[&"revision"] = int(target_asset[&"revision"]) + 1
return {
&"target_type": &"ASSET",
&"target_id": target_asset[&"asset_id"],
&"damage": asset_damage,
&"mitigated": asset_mitigated,
&"destroyed": int(target_asset[&"integrity"]) <= 0,
&"remaining_integrity": target_asset[&"integrity"],
}
var target_actor: Dictionary = raid[&"combatants"][target_ref[&"target_id"]]
var actor_mitigated := mini(damage, int(target_actor[&"armor"]))
var actor_damage := mini(maxi(damage - actor_mitigated, 0), int(target_actor[&"health"]))
if actor_damage > 0:
target_actor[&"health"] = int(target_actor[&"health"]) - actor_damage
target_actor[&"damage_taken"] = int(target_actor[&"damage_taken"]) + actor_damage
target_actor[&"revision"] = int(target_actor[&"revision"]) + 1
if int(target_actor[&"health"]) <= 0:
target_actor[&"state"] = CombatantState.INCAPACITATED
return {
&"target_type": &"COMBATANT",
&"target_id": target_actor[&"actor_id"],
&"damage": actor_damage,
&"mitigated": actor_mitigated,
&"state": StringName(CombatantState.keys()[target_actor[&"state"]]),
&"remaining_health": target_actor[&"health"],
}
return {&"target_type": &"NONE", &"target_id": &"", &"damage": 0}
func _apply_round_fatigue(raid: Dictionary) -> void:
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var actor: Dictionary = raid[&"combatants"][actor_id]
if actor[&"side"] == &"DEFENDER" and int(actor[&"state"]) == CombatantState.ACTIVE:
actor[&"fatigue"] = mini(int(actor[&"fatigue"]) + 5, 100)
actor[&"revision"] = int(actor[&"revision"]) + 1
func _evaluate_round_outcome(raid: Dictionary, wave: Dictionary, absolute_minute: int) -> StringName:
for objective_id: StringName in wave[&"objective_ids"]:
var objective: Dictionary = (raid[&"assets"] as Dictionary).get(objective_id, {})
if objective.is_empty() or int(objective[&"integrity"]) <= 0:
_set_terminal_state(raid, RaidState.BREACHED, absolute_minute, &"OBJECTIVE_DESTROYED")
wave[&"state"] = &"BREACHED"
wave[&"result_reason"] = &"OBJECTIVE_DESTROYED"
return &"OBJECTIVE_DESTROYED"
if _should_defenders_retreat(raid):
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var defender: Dictionary = raid[&"combatants"][actor_id]
if defender[&"side"] == &"DEFENDER" and int(defender[&"state"]) == CombatantState.ACTIVE:
defender[&"state"] = CombatantState.RETREATED
defender[&"revision"] = int(defender[&"revision"]) + 1
_set_terminal_state(raid, RaidState.DEFENDERS_RETREATED, absolute_minute, &"RETREAT_POLICY_TRIGGERED")
wave[&"state"] = &"RETREATED"
wave[&"result_reason"] = &"RETREAT_POLICY_TRIGGERED"
return &"RETREAT_POLICY_TRIGGERED"
var attacker_ids := _wave_attacker_ids(raid, wave[&"wave_id"])
var initial_total_threat := int(wave[&"threat_strength"])
var remaining_total_threat := int(wave[&"remaining_threat"])
var all_attackers_down := true
for actor_id: StringName in attacker_ids:
var attacker: Dictionary = raid[&"combatants"][actor_id]
initial_total_threat += int(attacker[&"combat"])
remaining_total_threat += _effective_combat_power(
int(attacker[&"combat"]),
int(attacker[&"health"]),
int(attacker[&"max_health"])
)
if int(attacker[&"state"]) == CombatantState.ACTIVE:
all_attackers_down = false
var threat_percent := 0
if initial_total_threat > 0:
threat_percent = int(floor(float(remaining_total_threat * 100) / float(initial_total_threat)))
all_attackers_down = all_attackers_down and int(wave[&"remaining_threat"]) <= 0
# The Raid-wide duration is a hard terminal boundary, including in a
# multi-wave Raid. It takes precedence over opening another wave.
if int(raid[&"elapsed_minutes"]) >= int(raid[&"max_duration_minutes"]):
_retreat_active_attackers(raid, wave[&"wave_id"])
wave[&"state"] = &"RESOLVED"
wave[&"result_reason"] = &"RAID_DURATION_EXHAUSTED"
_set_terminal_state(raid, RaidState.DEFENDED, absolute_minute, &"RAID_DURATION_EXHAUSTED")
return &"RAID_DURATION_EXHAUSTED"
var wave_timed_out := int(wave[&"rounds_completed"]) >= int(wave[&"max_rounds"])
if threat_percent <= int(wave[&"retreat_threshold"]) or all_attackers_down or wave_timed_out:
_retreat_active_attackers(raid, wave[&"wave_id"])
wave[&"state"] = &"RESOLVED"
var reason := &"THREAT_RETREATED"
if all_attackers_down:
reason = &"ATTACKERS_INCAPACITATED"
elif wave_timed_out:
reason = &"WAVE_DURATION_EXHAUSTED"
wave[&"result_reason"] = reason
if int(raid[&"current_wave_index"]) + 1 >= (raid[&"waves"] as Array).size():
_set_terminal_state(raid, RaidState.DEFENDED, absolute_minute, reason)
else:
raid[&"state"] = RaidState.BETWEEN_WAVES
raid[&"state_changed_at"] = absolute_minute
return reason
wave[&"state"] = &"ACTIVE"
return &"ROUND_COMPLETE"
func _should_defenders_retreat(raid: Dictionary) -> bool:
var policy: StringName = raid[&"retreat_policy"]
if policy in [&"HOLD", &"MANUAL"]:
return false
var total_initial := 0
var total_health := 0
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var actor: Dictionary = raid[&"combatants"][actor_id]
if actor[&"side"] != &"DEFENDER":
continue
total_initial += int(actor[&"initial_health"])
total_health += int(actor[&"health"])
var health_percent := 0
if total_initial > 0:
health_percent = int(floor(float(total_health * 100) / float(total_initial)))
if health_percent <= int(raid[&"defender_retreat_threshold"]):
return true
if policy == &"PROTECT_AND_RETREAT":
for objective_id: StringName in raid[&"objective_ids"]:
var objective: Dictionary = raid[&"assets"][objective_id]
var integrity_percent := int(floor(float(int(objective[&"integrity"]) * 100) / float(int(objective[&"initial_integrity"]))))
if integrity_percent <= int(raid[&"defender_retreat_threshold"]):
return true
return false
func _set_terminal_state(raid: Dictionary, state: int, absolute_minute: int, reason: StringName) -> void:
raid[&"state"] = state
raid[&"state_changed_at"] = absolute_minute
raid[&"finished_at"] = absolute_minute
raid[&"result"] = StringName(RaidState.keys()[state])
raid[&"result_reason"] = reason
(raid[&"queued_skills"] as Dictionary).clear()
func _retreat_active_attackers(raid: Dictionary, wave_id: StringName) -> void:
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var actor: Dictionary = raid[&"combatants"][actor_id]
if actor[&"side"] == &"ATTACKER" and actor[&"wave_id"] == wave_id \
and int(actor[&"state"]) == CombatantState.ACTIVE:
actor[&"state"] = CombatantState.RETREATED
actor[&"revision"] = int(actor[&"revision"]) + 1
func _skills_for_round(raid: Dictionary, round_number: int) -> Array[Dictionary]:
var skills: Array[Dictionary] = []
for action_id: StringName in _sorted_keys(raid[&"queued_skills"]):
var skill: Dictionary = raid[&"queued_skills"][action_id]
if int(skill[&"round_number"]) == round_number:
skills.append(skill.duplicate(true))
return skills
func _apply_skills(raid: Dictionary, skills: Array[Dictionary]) -> Dictionary:
var score_bonus := 0
var damage_bonus := 0
var changes: Array[Dictionary] = []
for skill: Dictionary in skills:
var kind: StringName = skill[&"kind"]
var potency := int(skill[&"potency"])
if kind == &"RALLY":
score_bonus += potency
changes.append({&"action_id": skill[&"action_id"], &"score_bonus": potency})
elif kind == &"POWER_STRIKE":
damage_bonus += potency
changes.append({&"action_id": skill[&"action_id"], &"damage_bonus": potency})
elif kind == &"HEAL":
var target: Dictionary = raid[&"combatants"][skill[&"target_id"]]
var old_health := int(target[&"health"])
target[&"health"] = mini(old_health + potency, int(target[&"max_health"]))
if int(target[&"health"]) > 0 and int(target[&"state"]) == CombatantState.INCAPACITATED:
target[&"state"] = CombatantState.ACTIVE
target[&"revision"] = int(target[&"revision"]) + 1
changes.append({&"action_id": skill[&"action_id"], &"healed": int(target[&"health"]) - old_health})
elif kind == &"FORTIFY":
var asset: Dictionary = raid[&"assets"][skill[&"target_id"]]
var old_integrity := int(asset[&"integrity"])
asset[&"integrity"] = mini(old_integrity + potency, int(asset[&"max_integrity"]))
asset[&"revision"] = int(asset[&"revision"]) + 1
changes.append({&"action_id": skill[&"action_id"], &"repaired": int(asset[&"integrity"]) - old_integrity})
return {&"score_bonus": score_bonus, &"damage_bonus": damage_bonus, &"changes": changes}
func _mark_skills_applied(raid: Dictionary, skills: Array[Dictionary], round_number: int) -> void:
for skill: Dictionary in skills:
var action_id: StringName = skill[&"action_id"]
(raid[&"queued_skills"] as Dictionary).erase(action_id)
(raid[&"applied_skill_ids"] as Dictionary)[action_id] = round_number
func _validate_and_apply_skill_boundaries(
raid_id: StringName,
skills: Array[Dictionary],
round_number: int
) -> Dictionary:
if not _skill_boundary.is_valid() or skills.is_empty():
return {&"ok": true, &"applied": []}
var validated: Array[Dictionary] = []
for skill: Dictionary in skills:
var payload := skill.duplicate(true)
payload[&"raid_id"] = raid_id
payload[&"round_number"] = round_number
var validation := _normalize_boundary_result(_skill_boundary.call(payload, &"validate", null))
if not bool(validation[&"ok"]):
# Validation callbacks may reserve resources. Release every earlier
# validation token when a later skill rejects the batch. A rejecting
# validator may also supply a token for work it needs undone.
if validation.has(&"token") or validation.has("token"):
validated.append({&"payload": payload, &"token": validation.get(&"token", validation.get("token", null))})
_rollback_skill_boundaries(validated)
return {&"ok": false, &"phase": &"validate", &"action_id": skill[&"action_id"], &"details": validation}
validated.append({&"payload": payload, &"token": validation.get(&"token", null)})
for index: int in range(validated.size()):
var entry: Dictionary = validated[index]
var application := _normalize_boundary_result(_skill_boundary.call(entry[&"payload"], &"apply", entry[&"token"]))
if not bool(application[&"ok"]):
entry[&"token"] = application.get(&"token", entry[&"token"])
validated[index] = entry
# All validations were completed before apply. Roll back applied,
# failed, and not-yet-applied reservation tokens exactly once.
_rollback_skill_boundaries(validated)
return {&"ok": false, &"phase": &"apply", &"action_id": entry[&"payload"][&"action_id"], &"details": application}
entry[&"token"] = application.get(&"token", entry[&"token"])
validated[index] = entry
return {&"ok": true, &"applied": validated}
func _rollback_skill_boundaries(raw_applied: Variant) -> void:
if not _skill_boundary.is_valid() or not raw_applied is Array:
return
var applied: Array = raw_applied
for index: int in range(applied.size() - 1, -1, -1):
var entry: Dictionary = applied[index]
_skill_boundary.call(entry[&"payload"], &"rollback", entry.get(&"token", null))
func _apply_outcome_boundary(payload: Dictionary) -> Dictionary:
if not _outcome_boundary.is_valid():
return {&"ok": true, &"applied": false}
var validation := _normalize_boundary_result(_outcome_boundary.call(payload.duplicate(true), &"validate", null))
if not bool(validation[&"ok"]):
if validation.has(&"token") or validation.has("token"):
_outcome_boundary.call(payload.duplicate(true), &"rollback", validation.get(&"token", validation.get("token", null)))
return {&"ok": false, &"phase": &"validate", &"details": validation}
var application := _normalize_boundary_result(_outcome_boundary.call(payload.duplicate(true), &"apply", validation.get(&"token", null)))
if not bool(application[&"ok"]):
_outcome_boundary.call(payload.duplicate(true), &"rollback", application.get(&"token", validation.get(&"token", null)))
return {&"ok": false, &"phase": &"apply", &"details": application}
return {
&"ok": true,
&"applied": true,
&"payload": payload.duplicate(true),
&"token": application.get(&"token", validation.get(&"token", null)),
}
func _rollback_outcome_boundary(boundary: Dictionary) -> void:
if _outcome_boundary.is_valid() and bool(boundary.get(&"applied", false)):
_outcome_boundary.call(boundary[&"payload"], &"rollback", boundary.get(&"token", null))
func _begin_rng_transaction(context: Dictionary) -> Dictionary:
if not _rng_boundary.is_valid():
return {&"ok": true, &"enabled": false, &"context": context.duplicate(true), &"token": null}
var snapshot := _normalize_boundary_result(
_rng_boundary.call(context.duplicate(true), &"snapshot", null)
)
if not bool(snapshot[&"ok"]):
if snapshot.has(&"token") or snapshot.has("token"):
_rng_boundary.call(
context.duplicate(true),
&"rollback",
snapshot.get(&"token", snapshot.get("token", null))
)
return {&"ok": false, &"phase": &"snapshot", &"details": snapshot}
return {
&"ok": true,
&"enabled": true,
&"context": context.duplicate(true),
&"token": snapshot.get(&"token", snapshot.get("token", null)),
}
func _commit_rng_transaction(transaction: Dictionary) -> Dictionary:
if not bool(transaction.get(&"enabled", false)):
return {&"ok": true}
var result := _normalize_boundary_result(_rng_boundary.call(
(transaction[&"context"] as Dictionary).duplicate(true),
&"commit",
transaction.get(&"token", null)
))
if not bool(result[&"ok"]):
return {&"ok": false, &"phase": &"commit", &"details": result}
return {&"ok": true}
func _rollback_rng_transaction(transaction: Dictionary) -> void:
if _rng_boundary.is_valid() and bool(transaction.get(&"enabled", false)):
_rng_boundary.call(
(transaction[&"context"] as Dictionary).duplicate(true),
&"rollback",
transaction.get(&"token", null)
)
func _make_outcome_payload(
raid: Dictionary,
resolution_id: StringName,
absolute_minute: int,
operation_kind: StringName
) -> Dictionary:
var destroyed_assets: Array[StringName] = []
var asset_results: Array[Dictionary] = []
for asset_id: StringName in _sorted_keys(raid[&"assets"]):
var asset: Dictionary = raid[&"assets"][asset_id]
if int(asset[&"integrity"]) <= 0:
destroyed_assets.append(asset_id)
asset_results.append({
&"asset_id": asset_id,
&"kind": StringName(AssetKind.keys()[int(asset[&"kind"])]),
&"integrity": int(asset[&"integrity"]),
&"max_integrity": int(asset[&"max_integrity"]),
&"destroyed": int(asset[&"integrity"]) <= 0,
})
var incapacitated: Array[StringName] = []
var retreated: Array[StringName] = []
var incapacitated_defenders: Array[StringName] = []
var incapacitated_attackers: Array[StringName] = []
var retreated_defenders: Array[StringName] = []
var retreated_attackers: Array[StringName] = []
var combatant_results: Array[Dictionary] = []
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var combatant: Dictionary = raid[&"combatants"][actor_id]
var state := int(combatant[&"state"])
if state == CombatantState.INCAPACITATED:
incapacitated.append(actor_id)
if combatant[&"side"] == &"DEFENDER":
incapacitated_defenders.append(actor_id)
else:
incapacitated_attackers.append(actor_id)
elif state == CombatantState.RETREATED:
retreated.append(actor_id)
if combatant[&"side"] == &"DEFENDER":
retreated_defenders.append(actor_id)
else:
retreated_attackers.append(actor_id)
combatant_results.append({
&"actor_id": actor_id,
&"side": combatant[&"side"],
&"state": StringName(CombatantState.keys()[state]),
&"health": int(combatant[&"health"]),
&"max_health": int(combatant[&"max_health"]),
&"fatigue": int(combatant[&"fatigue"]),
&"damage_taken": int(combatant[&"damage_taken"]),
})
return {
&"operation_kind": operation_kind,
&"resolution_id": resolution_id,
&"raid_id": raid[&"raid_id"],
&"result": raid[&"result"],
&"reason": raid[&"result_reason"],
&"absolute_minute": absolute_minute,
&"rounds_completed": raid[&"current_round"],
&"destroyed_asset_ids": destroyed_assets,
&"incapacitated_actor_ids": incapacitated,
&"retreated_actor_ids": retreated,
&"incapacitated_defender_ids": incapacitated_defenders,
&"incapacitated_attacker_ids": incapacitated_attackers,
&"retreated_defender_ids": retreated_defenders,
&"retreated_attacker_ids": retreated_attackers,
&"combatant_results": combatant_results,
&"asset_results": asset_results,
}
func _deterministic_roll(
raid_id: StringName,
wave_id: StringName,
round_number: int,
side: StringName
) -> int:
var key := StringName("%s:%s:%d:%s" % [raid_id, wave_id, round_number, side])
var context := {
&"raid_id": raid_id,
&"wave_id": wave_id,
&"round_number": round_number,
&"side": side,
}
if _round_roll.is_valid():
var raw: Variant = _round_roll.call(key, context)
if raw is Dictionary:
raw = raw.get(&"value", raw.get("value", 50))
if raw is int or raw is float:
return clampi(int(raw), 0, 100)
var accumulator := 17
for character_index: int in range(String(key).length()):
accumulator = (accumulator * 131 + String(key).unicode_at(character_index)) % 1_000_003
return accumulator % 101
func _raid_to_snapshot(raid: Dictionary) -> Dictionary:
var snapshot := {
&"raid_id": String(raid[&"raid_id"]),
&"source_id": String(raid[&"source_id"]),
&"event_source": _canonical_snapshot_value(raid.get(&"event_source", {})),
&"state": RaidState.keys()[int(raid[&"state"])],
&"created_at": raid[&"created_at"],
&"started_at": raid[&"started_at"],
&"finished_at": raid[&"finished_at"],
&"state_changed_at": raid[&"state_changed_at"],
&"current_wave_index": raid[&"current_wave_index"],
&"current_round": raid[&"current_round"],
&"elapsed_minutes": raid[&"elapsed_minutes"],
&"max_duration_minutes": raid[&"max_duration_minutes"],
&"round_minutes": raid[&"round_minutes"],
&"retreat_policy": String(raid[&"retreat_policy"]),
&"defender_retreat_threshold": raid[&"defender_retreat_threshold"],
&"terrain_bonus": raid[&"terrain_bonus"],
&"command_bonus": raid[&"command_bonus"],
&"surprise_penalty": raid[&"surprise_penalty"],
&"multiple_entry_penalty": raid[&"multiple_entry_penalty"],
&"entry_points": _names_to_strings(raid[&"entry_points"]),
&"objective_ids": _names_to_strings(raid[&"objective_ids"]),
&"waves": (raid[&"waves"] as Array).duplicate(true),
&"combatants": [],
&"assets": [],
&"queued_skills": [],
&"applied_skill_ids": [],
&"resolved_rounds": [],
&"result": String(raid[&"result"]),
&"result_reason": String(raid[&"result_reason"]),
&"revision": raid[&"revision"],
}
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var combatant: Dictionary = raid[&"combatants"][actor_id]
var saved_combatant := combatant.duplicate(true)
saved_combatant[&"actor_id"] = String(actor_id)
saved_combatant[&"side"] = String(combatant[&"side"])
saved_combatant[&"wave_id"] = String(combatant[&"wave_id"])
saved_combatant[&"entry_point_id"] = String(combatant[&"entry_point_id"])
saved_combatant[&"state"] = CombatantState.keys()[int(combatant[&"state"])]
(snapshot[&"combatants"] as Array).append(saved_combatant)
for asset_id: StringName in _sorted_keys(raid[&"assets"]):
var asset: Dictionary = raid[&"assets"][asset_id]
var saved_asset := asset.duplicate(true)
saved_asset[&"asset_id"] = String(asset_id)
saved_asset[&"kind"] = AssetKind.keys()[int(asset[&"kind"])]
saved_asset[&"entry_points"] = _names_to_strings(asset[&"entry_points"])
(snapshot[&"assets"] as Array).append(saved_asset)
for action_id: StringName in _sorted_keys(raid[&"queued_skills"]):
(snapshot[&"queued_skills"] as Array).append((raid[&"queued_skills"][action_id] as Dictionary).duplicate(true))
for action_id: StringName in _sorted_keys(raid[&"applied_skill_ids"]):
(snapshot[&"applied_skill_ids"] as Array).append({
&"action_id": String(action_id),
&"round_number": raid[&"applied_skill_ids"][action_id],
})
for resolution_id: StringName in _sorted_keys(raid[&"resolved_rounds"]):
(snapshot[&"resolved_rounds"] as Array).append({
&"resolution_id": String(resolution_id),
&"payload": _canonical_snapshot_value(raid[&"resolved_rounds"][resolution_id]),
})
return snapshot
func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
var event_source_value: Variant = _value(raw, &"event_source", {})
if not _is_valid_event_source(event_source_value):
return {&"ok": false, &"error": "Event threat provenance snapshot is malformed."}
for collection_key: StringName in [&"waves", &"combatants", &"assets", &"queued_skills", &"applied_skill_ids", &"resolved_rounds"]:
if not _value(raw, collection_key, null) is Array:
return {&"ok": false, &"error": "Malformed Raid collection: %s" % collection_key}
var raid_id := StringName(_value(raw, &"raid_id", &""))
var state_index := RaidState.keys().find(String(_value(raw, &"state", &"")))
if raid_id.is_empty() or state_index < 0:
return {&"ok": false, &"error": "Raid snapshot ID or state is invalid."}
var raid := {
&"raid_id": raid_id,
&"source_id": StringName(_value(raw, &"source_id", &"")),
&"event_source": event_source_value.duplicate(true),
&"state": state_index,
&"created_at": int(_value(raw, &"created_at", -1)),
&"started_at": int(_value(raw, &"started_at", -1)),
&"finished_at": int(_value(raw, &"finished_at", -1)),
&"state_changed_at": int(_value(raw, &"state_changed_at", -1)),
&"current_wave_index": int(_value(raw, &"current_wave_index", -1)),
&"current_round": int(_value(raw, &"current_round", -1)),
&"elapsed_minutes": int(_value(raw, &"elapsed_minutes", -1)),
&"max_duration_minutes": int(_value(raw, &"max_duration_minutes", 0)),
&"round_minutes": int(_value(raw, &"round_minutes", 0)),
&"retreat_policy": StringName(_value(raw, &"retreat_policy", &"")),
&"defender_retreat_threshold": int(_value(raw, &"defender_retreat_threshold", -1)),
&"terrain_bonus": int(_value(raw, &"terrain_bonus", 0)),
&"command_bonus": int(_value(raw, &"command_bonus", 0)),
&"surprise_penalty": int(_value(raw, &"surprise_penalty", -1)),
&"multiple_entry_penalty": int(_value(raw, &"multiple_entry_penalty", -1)),
&"entry_points": _sorted_unique_names(_value(raw, &"entry_points", [])),
&"objective_ids": _sorted_unique_names(_value(raw, &"objective_ids", [])),
&"waves": [],
&"combatants": {},
&"assets": {},
&"queued_skills": {},
&"applied_skill_ids": {},
&"resolved_rounds": {},
&"result": StringName(_value(raw, &"result", &"")),
&"result_reason": StringName(_value(raw, &"result_reason", &"")),
&"revision": int(_value(raw, &"revision", -1)),
}
for raw_wave: Variant in _value(raw, &"waves", []):
if not raw_wave is Dictionary:
return {&"ok": false, &"error": "Malformed Raid wave snapshot."}
var wave: Dictionary = raw_wave.duplicate(true)
wave[&"wave_id"] = StringName(_value(wave, &"wave_id", &""))
wave[&"threat_strength"] = int(_value(wave, &"threat_strength", 0))
wave[&"remaining_threat"] = int(_value(wave, &"remaining_threat", -1))
wave[&"retreat_threshold"] = int(_value(wave, &"retreat_threshold", -1))
wave[&"max_rounds"] = int(_value(wave, &"max_rounds", 0))
wave[&"rounds_completed"] = int(_value(wave, &"rounds_completed", -1))
wave[&"entry_points"] = _sorted_unique_names(_value(wave, &"entry_points", []))
wave[&"objective_ids"] = _sorted_unique_names(_value(wave, &"objective_ids", []))
wave[&"state"] = StringName(_value(wave, &"state", &""))
wave[&"result_reason"] = StringName(_value(wave, &"result_reason", &""))
(raid[&"waves"] as Array).append(wave)
for raw_combatant: Variant in _value(raw, &"combatants", []):
if not raw_combatant is Dictionary:
return {&"ok": false, &"error": "Malformed defense combatant snapshot."}
var combatant: Dictionary = (raw_combatant as Dictionary).duplicate(true)
var actor_id := StringName(_value(combatant, &"actor_id", &""))
var combatant_state := CombatantState.keys().find(String(_value(combatant, &"state", &"")))
if actor_id.is_empty() or combatant_state < 0 or (raid[&"combatants"] as Dictionary).has(actor_id):
return {&"ok": false, &"error": "Invalid or duplicate defense combatant snapshot."}
combatant[&"actor_id"] = actor_id
combatant[&"side"] = StringName(_value(combatant, &"side", &""))
combatant[&"wave_id"] = StringName(_value(combatant, &"wave_id", &""))
combatant[&"entry_point_id"] = StringName(_value(combatant, &"entry_point_id", &""))
combatant[&"state"] = combatant_state
for numeric_key: StringName in [
&"combat", &"armor", &"max_health", &"health", &"initial_health",
&"fatigue", &"target_order", &"damage_dealt", &"damage_taken", &"revision",
]:
combatant[numeric_key] = int(_value(combatant, numeric_key, -1))
(raid[&"combatants"] as Dictionary)[actor_id] = combatant
for raw_asset: Variant in _value(raw, &"assets", []):
if not raw_asset is Dictionary:
return {&"ok": false, &"error": "Malformed defense asset snapshot."}
var asset: Dictionary = (raw_asset as Dictionary).duplicate(true)
var asset_id := StringName(_value(asset, &"asset_id", &""))
var kind_index := AssetKind.keys().find(String(_value(asset, &"kind", &"")))
if asset_id.is_empty() or kind_index < 0 or (raid[&"assets"] as Dictionary).has(asset_id):
return {&"ok": false, &"error": "Invalid or duplicate defense asset snapshot."}
asset[&"asset_id"] = asset_id
asset[&"kind"] = kind_index
asset[&"entry_points"] = _sorted_unique_names(_value(asset, &"entry_points", []))
for numeric_key: StringName in [
&"max_integrity", &"integrity", &"initial_integrity", &"defense",
&"armor", &"terrain_bonus", &"target_order", &"revision",
]:
asset[numeric_key] = int(_value(asset, numeric_key, -1))
(raid[&"assets"] as Dictionary)[asset_id] = asset
for raw_skill: Variant in _value(raw, &"queued_skills", []):
if not raw_skill is Dictionary:
return {&"ok": false, &"error": "Malformed queued defense skill snapshot."}
var skill: Dictionary = (raw_skill as Dictionary).duplicate(true)
var action_id := StringName(_value(skill, &"action_id", &""))
if action_id.is_empty() or (raid[&"queued_skills"] as Dictionary).has(action_id):
return {&"ok": false, &"error": "Invalid or duplicate queued defense skill snapshot."}
skill[&"action_id"] = action_id
skill[&"actor_id"] = StringName(_value(skill, &"actor_id", &""))
skill[&"kind"] = StringName(_value(skill, &"kind", &""))
skill[&"target_id"] = StringName(_value(skill, &"target_id", &""))
skill[&"potency"] = int(_value(skill, &"potency", -1))
skill[&"round_number"] = int(_value(skill, &"round_number", -1))
if not _value(skill, &"metadata", null) is Dictionary:
return {&"ok": false, &"error": "Malformed queued defense skill metadata."}
var metadata_result := _canonicalize_metadata(_value(skill, &"metadata", {}))
if not bool(metadata_result[&"ok"]):
return {&"ok": false, &"error": "Unsupported queued defense skill metadata: %s" % str(metadata_result)}
skill[&"metadata"] = metadata_result[&"value"]
(raid[&"queued_skills"] as Dictionary)[action_id] = skill
for raw_applied: Variant in _value(raw, &"applied_skill_ids", []):
if not raw_applied is Dictionary:
return {&"ok": false, &"error": "Malformed applied defense skill snapshot."}
var action_id := StringName(_value(raw_applied, &"action_id", &""))
if action_id.is_empty() or (raid[&"applied_skill_ids"] as Dictionary).has(action_id):
return {&"ok": false, &"error": "Invalid or duplicate applied defense skill snapshot."}
(raid[&"applied_skill_ids"] as Dictionary)[action_id] = int(_value(raw_applied, &"round_number", -1))
for raw_resolution: Variant in _value(raw, &"resolved_rounds", []):
if not raw_resolution is Dictionary or not _value(raw_resolution, &"payload", null) is Dictionary:
return {&"ok": false, &"error": "Malformed defense round resolution snapshot."}
var resolution_id := StringName(_value(raw_resolution, &"resolution_id", &""))
if resolution_id.is_empty() or (raid[&"resolved_rounds"] as Dictionary).has(resolution_id):
return {&"ok": false, &"error": "Invalid or duplicate defense resolution snapshot."}
var canonical_payload: Variant = _canonical_snapshot_value(_value(raw_resolution, &"payload", {}))
if not canonical_payload is Dictionary:
return {&"ok": false, &"error": "Defense resolution payload is not saveable."}
(raid[&"resolved_rounds"] as Dictionary)[resolution_id] = canonical_payload
return {&"ok": true, &"raid": raid}
func _validate_raid(raid: Dictionary) -> Dictionary:
var errors: Array[String] = []
if not _is_valid_event_source(raid.get(&"event_source", {})):
errors.append("Event threat provenance is invalid.")
var raid_id := StringName(raid.get(&"raid_id", &""))
var state := int(raid.get(&"state", -1))
var created_at := int(raid.get(&"created_at", -1))
var started_at := int(raid.get(&"started_at", -1))
var state_changed_at := int(raid.get(&"state_changed_at", -1))
var finished_at := int(raid.get(&"finished_at", -1))
if raid_id.is_empty() or state < 0 or state >= RaidState.size():
errors.append("Raid ID or state is invalid.")
if created_at < 0 or state_changed_at < created_at:
errors.append("Raid timestamps are invalid.")
if started_at >= 0 and (started_at < created_at or state_changed_at < started_at):
errors.append("Raid start timestamp is outside created/state-changed bounds.")
if int(raid.get(&"max_duration_minutes", 0)) <= 0 or int(raid.get(&"round_minutes", 0)) <= 0:
errors.append("Raid durations are invalid.")
if int(raid.get(&"current_round", -1)) < 0 or int(raid.get(&"elapsed_minutes", -1)) < 0:
errors.append("Raid progress counters are invalid.")
elif int(raid.get(&"elapsed_minutes", -1)) \
!= int(raid.get(&"current_round", -1)) * int(raid.get(&"round_minutes", 0)):
errors.append("Raid elapsed time does not match its resolved round count.")
if int(raid.get(&"revision", -1)) < 0:
errors.append("Raid revision is invalid.")
if int(raid.get(&"surprise_penalty", -1)) < 0 \
or int(raid.get(&"multiple_entry_penalty", -1)) < 0:
errors.append("Raid entry or surprise penalties are invalid.")
if not VALID_RETREAT_POLICIES.has(StringName(raid.get(&"retreat_policy", &""))):
errors.append("Raid retreat policy is invalid.")
var retreat_threshold := int(raid.get(&"defender_retreat_threshold", -1))
if retreat_threshold < 0 or retreat_threshold > 100:
errors.append("Raid defender retreat threshold is invalid.")
var entry_points: Variant = raid.get(&"entry_points", null)
var objective_ids: Variant = raid.get(&"objective_ids", null)
if not entry_points is Array or not objective_ids is Array \
or entry_points.is_empty() or objective_ids.is_empty():
errors.append("Raid entry points or objectives are invalid.")
else:
if entry_points != _sorted_unique_names(entry_points):
errors.append("Raid entry points are not canonical.")
if objective_ids != _sorted_unique_names(objective_ids):
errors.append("Raid objective IDs are not canonical.")
var raw_waves: Variant = raid.get(&"waves", null)
var wave_ids: Dictionary = {}
var resolved_round_total := 0
const VALID_WAVE_STATES := [&"PENDING", &"ACTIVE", &"RESOLVED", &"BREACHED", &"RETREATED", &"CANCELLED"]
if not raw_waves is Array or raw_waves.is_empty():
errors.append("Raid waves are missing.")
else:
for raw_wave: Variant in raw_waves:
if not raw_wave is Dictionary:
errors.append("Raid wave entry is malformed.")
continue
var wave: Dictionary = raw_wave
var wave_id := StringName(wave.get(&"wave_id", &""))
var threat := int(wave.get(&"threat_strength", 0))
var remaining := int(wave.get(&"remaining_threat", -1))
var wave_retreat := int(wave.get(&"retreat_threshold", -1))
var wave_state := StringName(wave.get(&"state", &""))
if wave_id.is_empty() or wave_ids.has(wave_id):
errors.append("Raid wave ID is empty or duplicated.")
else:
wave_ids[wave_id] = true
if threat <= 0 or remaining < 0 or remaining > threat:
errors.append("Raid wave threat values are invalid: %s" % wave_id)
if wave_retreat < 0 or wave_retreat > 100 or int(wave.get(&"max_rounds", 0)) <= 0 \
or int(wave.get(&"rounds_completed", -1)) < 0 \
or int(wave.get(&"rounds_completed", 0)) > int(wave.get(&"max_rounds", 0)):
errors.append("Raid wave round limits are invalid: %s" % wave_id)
resolved_round_total += maxi(int(wave.get(&"rounds_completed", -1)), 0)
if not VALID_WAVE_STATES.has(wave_state):
errors.append("Raid wave state is invalid: %s" % wave_id)
var wave_entries: Variant = wave.get(&"entry_points", null)
var wave_objectives: Variant = wave.get(&"objective_ids", null)
if not wave_entries is Array or wave_entries.is_empty() \
or wave_entries != _sorted_unique_names(wave_entries):
errors.append("Raid wave entry points are malformed: %s" % wave_id)
if not wave_objectives is Array or wave_objectives.is_empty() \
or wave_objectives != _sorted_unique_names(wave_objectives):
errors.append("Raid wave objectives are malformed: %s" % wave_id)
for wave_entry: StringName in _sorted_unique_names(wave_entries):
if not entry_points is Array or not entry_points.has(wave_entry):
errors.append("Raid wave has unknown entry point: %s" % wave_entry)
for wave_objective: StringName in _sorted_unique_names(wave_objectives):
if not objective_ids is Array or not objective_ids.has(wave_objective):
errors.append("Raid wave has unknown objective: %s" % wave_objective)
var wave_index := int(raid.get(&"current_wave_index", -1))
if raw_waves is Array and (wave_index < 0 or wave_index >= raw_waves.size()):
errors.append("Raid current wave index is invalid.")
if resolved_round_total != int(raid.get(&"current_round", -1)):
errors.append("Raid current round differs from the sum of wave rounds.")
if raw_waves is Array and wave_index >= 0 and wave_index < raw_waves.size() \
and raw_waves[wave_index] is Dictionary:
var current_wave_state := StringName((raw_waves[wave_index] as Dictionary).get(&"state", &""))
if state == RaidState.PREPARING and current_wave_state != &"PENDING":
errors.append("Preparing Raid current wave is not pending.")
elif state == RaidState.ACTIVE and current_wave_state != &"ACTIVE":
errors.append("Active Raid current wave is not active.")
elif state == RaidState.BETWEEN_WAVES and current_wave_state != &"RESOLVED":
errors.append("Between-wave Raid current wave is not resolved.")
elif state == RaidState.DEFENDED and current_wave_state != &"RESOLVED":
errors.append("Defended Raid current wave is not resolved.")
elif state == RaidState.BREACHED and current_wave_state != &"BREACHED":
errors.append("Breached Raid current wave is not breached.")
elif state == RaidState.DEFENDERS_RETREATED and current_wave_state != &"RETREATED":
errors.append("Retreated Raid current wave is not marked retreated.")
elif state == RaidState.CANCELLED and current_wave_state != &"CANCELLED":
errors.append("Cancelled Raid current wave is not cancelled.")
for index: int in range(raw_waves.size()):
if not raw_waves[index] is Dictionary:
continue
var indexed_state := StringName((raw_waves[index] as Dictionary).get(&"state", &""))
if index < wave_index and indexed_state != &"RESOLVED":
errors.append("Completed earlier Raid wave is not resolved.")
elif index > wave_index and indexed_state != &"PENDING":
errors.append("Future Raid wave is not pending.")
var combatants: Variant = raid.get(&"combatants", null)
if not combatants is Dictionary:
errors.append("Raid combatants collection is malformed.")
else:
for actor_id: StringName in _sorted_keys(combatants):
var actor: Variant = combatants[actor_id]
if not actor is Dictionary or actor.get(&"actor_id", &"") != actor_id:
errors.append("Defense combatant key mismatch: %s" % actor_id)
continue
if actor.get(&"side", &"") not in [&"DEFENDER", &"ATTACKER"]:
errors.append("Defense combatant side is invalid: %s" % actor_id)
if int(actor.get(&"state", -1)) < 0 or int(actor.get(&"state", -1)) >= CombatantState.size():
errors.append("Defense combatant state is invalid: %s" % actor_id)
var max_health := int(actor.get(&"max_health", 0))
var health := int(actor.get(&"health", -1))
if max_health <= 0 or health < 0 or health > max_health \
or int(actor.get(&"initial_health", 0)) <= 0 or int(actor.get(&"initial_health", 0)) > max_health:
errors.append("Defense combatant health is invalid: %s" % actor_id)
if health == 0 and int(actor.get(&"state", -1)) == CombatantState.ACTIVE:
errors.append("Zero-health combatant remains active: %s" % actor_id)
if int(actor.get(&"combat", -1)) < 0 or int(actor.get(&"armor", -1)) < 0 \
or int(actor.get(&"fatigue", -1)) < 0 or int(actor.get(&"fatigue", -1)) > 100:
errors.append("Defense combatant stats are invalid: %s" % actor_id)
if int(actor.get(&"damage_dealt", -1)) < 0 \
or int(actor.get(&"damage_taken", -1)) < 0 \
or int(actor.get(&"revision", -1)) < 0 \
or not actor.has(&"target_order"):
errors.append("Defense combatant counters are invalid: %s" % actor_id)
var actor_entry := StringName(actor.get(&"entry_point_id", &""))
if not actor_entry.is_empty() and (not entry_points is Array or not entry_points.has(actor_entry)):
errors.append("Defense combatant entry point is invalid: %s" % actor_id)
if actor.get(&"side", &"") == &"ATTACKER":
if not wave_ids.has(actor.get(&"wave_id", &"")):
errors.append("Attacker references a missing wave: %s" % actor_id)
elif not actor_entry.is_empty():
var referenced_wave := _wave_by_id(raid, StringName(actor.get(&"wave_id", &"")))
if referenced_wave.is_empty() or not (referenced_wave[&"entry_points"] as Array).has(actor_entry):
errors.append("Attacker entry point is outside its wave: %s" % actor_id)
elif not StringName(actor.get(&"wave_id", &"")).is_empty():
errors.append("Defender is incorrectly bound to a wave: %s" % actor_id)
var assets: Variant = raid.get(&"assets", null)
if not assets is Dictionary:
errors.append("Raid assets collection is malformed.")
else:
for asset_id: StringName in _sorted_keys(assets):
var asset: Variant = assets[asset_id]
if not asset is Dictionary or asset.get(&"asset_id", &"") != asset_id:
errors.append("Defense asset key mismatch: %s" % asset_id)
continue
var kind := int(asset.get(&"kind", -1))
var maximum := int(asset.get(&"max_integrity", 0))
var integrity := int(asset.get(&"integrity", -1))
if kind < 0 or kind >= AssetKind.size() or maximum <= 0 or integrity < 0 or integrity > maximum:
errors.append("Defense asset state is invalid: %s" % asset_id)
if int(asset.get(&"initial_integrity", 0)) <= 0 \
or int(asset.get(&"initial_integrity", 0)) > maximum:
errors.append("Defense asset initial integrity is invalid: %s" % asset_id)
if int(asset.get(&"defense", -1)) < 0 or int(asset.get(&"armor", -1)) < 0 \
or int(asset.get(&"revision", -1)) < 0:
errors.append("Defense asset stats are invalid: %s" % asset_id)
var asset_entries: Variant = asset.get(&"entry_points", null)
if not asset_entries is Array or asset_entries != _sorted_unique_names(asset_entries):
errors.append("Defense asset entry points are malformed: %s" % asset_id)
else:
for asset_entry: StringName in asset_entries:
if not entry_points is Array or not entry_points.has(asset_entry):
errors.append("Defense asset has unknown entry point: %s" % asset_id)
var listed_objective: bool = objective_ids is Array and objective_ids.has(asset_id)
if listed_objective != (kind == AssetKind.OBJECTIVE):
errors.append("Defense objective registration mismatch: %s" % asset_id)
# Preparation may be cancelled before all objective assets are registered.
# A raid that actually started must retain those assets even after cancellation.
var cancelled_before_start := state == RaidState.CANCELLED and started_at == -1
if state != RaidState.PREPARING and not cancelled_before_start and assets is Dictionary and objective_ids is Array:
for objective_id: StringName in objective_ids:
if not assets.has(objective_id):
errors.append("Started Raid is missing objective asset: %s" % objective_id)
var queued: Variant = raid.get(&"queued_skills", null)
var applied: Variant = raid.get(&"applied_skill_ids", null)
if not queued is Dictionary or not applied is Dictionary:
errors.append("Raid skill collections are malformed.")
else:
if state != RaidState.ACTIVE and not queued.is_empty():
errors.append("Only an active Raid may retain queued skills.")
for action_id: StringName in _sorted_keys(queued):
var skill: Variant = queued[action_id]
if not skill is Dictionary or skill.get(&"action_id", &"") != action_id \
or not VALID_SKILL_KINDS.has(StringName(skill.get(&"kind", &""))):
errors.append("Queued defense skill is invalid: %s" % action_id)
elif applied.has(action_id):
errors.append("Defense skill is both queued and applied: %s" % action_id)
elif not combatants is Dictionary or not combatants.has(skill.get(&"actor_id", &"")):
errors.append("Queued defense skill actor is missing: %s" % action_id)
else:
var skill_actor: Variant = combatants[skill.get(&"actor_id", &"")]
if not skill_actor is Dictionary or skill_actor.get(&"side", &"") != &"DEFENDER" \
or int(skill_actor.get(&"state", -1)) != CombatantState.ACTIVE:
errors.append("Queued defense skill actor is not an active defender: %s" % action_id)
var potency := int(skill.get(&"potency", -1))
if potency < 0 or potency > 100:
errors.append("Queued defense skill potency is invalid: %s" % action_id)
if int(skill.get(&"round_number", -1)) != int(raid.get(&"current_round", 0)) + 1:
errors.append("Queued defense skill round is invalid: %s" % action_id)
if not skill.get(&"metadata", null) is Dictionary:
errors.append("Queued defense skill metadata is invalid: %s" % action_id)
else:
var metadata_report := _canonicalize_metadata(skill.get(&"metadata", {}))
if not bool(metadata_report[&"ok"]) \
or metadata_report[&"value"] != skill.get(&"metadata", {}):
errors.append("Queued defense skill metadata is not canonical: %s" % action_id)
var skill_kind := StringName(skill.get(&"kind", &""))
var target_id := StringName(skill.get(&"target_id", &""))
if skill_kind == &"HEAL":
var heal_target: Variant = combatants.get(target_id, null) if combatants is Dictionary else null
if not heal_target is Dictionary or heal_target.get(&"side", &"") != &"DEFENDER":
errors.append("Queued HEAL target is invalid: %s" % action_id)
elif skill_kind == &"FORTIFY":
var fortify_target: Variant = assets.get(target_id, null) if assets is Dictionary else null
if not fortify_target is Dictionary or int(fortify_target.get(&"integrity", 0)) <= 0:
errors.append("Queued FORTIFY target is invalid: %s" % action_id)
for action_id: StringName in _sorted_keys(applied):
if int(applied[action_id]) <= 0 or int(applied[action_id]) > int(raid.get(&"current_round", 0)):
errors.append("Applied defense skill round is invalid: %s" % action_id)
var resolutions: Variant = raid.get(&"resolved_rounds", null)
if not resolutions is Dictionary:
errors.append("Raid resolution collection is malformed.")
else:
var round_times: Dictionary = {}
for resolution_id: StringName in _sorted_keys(resolutions):
var payload: Variant = resolutions[resolution_id]
if not payload is Dictionary or payload.get(&"resolution_id", &"") != resolution_id \
or payload.get(&"raid_id", &"") != raid_id:
errors.append("Defense resolution payload mismatch: %s" % resolution_id)
continue
var operation_kind := _resolution_operation_kind(payload)
if operation_kind.is_empty():
errors.append("Defense resolution operation kind is invalid: %s" % resolution_id)
var resolution_minute := int(payload.get(&"absolute_minute", -1))
if resolution_minute < created_at or resolution_minute > state_changed_at:
errors.append("Defense resolution timestamp is outside Raid bounds: %s" % resolution_id)
if operation_kind in [OPERATION_ROUND, OPERATION_RETREAT] \
and (started_at < 0 or resolution_minute < started_at):
errors.append("Started operation resolution precedes Raid start: %s" % resolution_id)
if operation_kind == OPERATION_RETREAT:
if StringName(payload.get(&"result", &"")) != &"DEFENDERS_RETREATED" \
or state != RaidState.DEFENDERS_RETREATED:
errors.append("Retreat resolution does not match the Raid result: %s" % resolution_id)
if resolution_minute != finished_at:
errors.append("Retreat resolution timestamp differs from Raid finish: %s" % resolution_id)
elif operation_kind == OPERATION_CANCEL:
if StringName(payload.get(&"result", &"")) != &"CANCELLED" \
or state != RaidState.CANCELLED:
errors.append("Cancel resolution does not match the Raid result: %s" % resolution_id)
if resolution_minute != finished_at:
errors.append("Cancel resolution timestamp differs from Raid finish: %s" % resolution_id)
if operation_kind == OPERATION_ROUND:
var round_number := int(payload.get(&"round_number", -1))
if round_number <= 0 or round_number > int(raid.get(&"current_round", 0)) \
or round_times.has(round_number):
errors.append("Defense round history number is invalid or duplicated: %s" % resolution_id)
else:
round_times[round_number] = resolution_minute
if round_times.size() != int(raid.get(&"current_round", 0)):
errors.append("Defense round history count differs from current round.")
var previous_round_minute := started_at
for round_number: int in range(1, int(raid.get(&"current_round", 0)) + 1):
if not round_times.has(round_number):
continue
var round_minute := int(round_times[round_number])
if previous_round_minute < 0 \
or round_minute < previous_round_minute + int(raid.get(&"round_minutes", 0)):
errors.append("Defense round history violates its configured interval: %d" % round_number)
previous_round_minute = round_minute
if _is_terminal(state):
if finished_at != state_changed_at \
or raid.get(&"result", &"PENDING") == &"PENDING":
errors.append("Terminal Raid result or finish time is invalid.")
if StringName(raid.get(&"result", &"")) != StringName(RaidState.keys()[state]):
errors.append("Terminal Raid result does not match its state.")
else:
if finished_at != -1 or raid.get(&"result", &"PENDING") != &"PENDING":
errors.append("Live Raid has a terminal result.")
if state == RaidState.PREPARING and int(raid.get(&"started_at", -1)) != -1:
errors.append("Preparing Raid already has a start time.")
if state != RaidState.PREPARING and state != RaidState.CANCELLED \
and int(raid.get(&"started_at", -1)) < int(raid.get(&"created_at", 0)):
errors.append("Started Raid has an invalid start time.")
if state == RaidState.CANCELLED and int(raid.get(&"started_at", -1)) != -1 \
and int(raid.get(&"started_at", -1)) < int(raid.get(&"created_at", 0)):
errors.append("Cancelled Raid has an invalid start time.")
return {&"ok": errors.is_empty(), &"errors": errors}
func _validate_raid_clock(raid: Dictionary, clock_minute: int) -> Dictionary:
var errors: Array[String] = []
for timestamp_key: StringName in [&"created_at", &"state_changed_at", &"started_at", &"finished_at"]:
var timestamp := int(raid.get(timestamp_key, -1))
if timestamp >= 0 and timestamp > clock_minute:
errors.append("%s exceeds the Defense clock." % timestamp_key)
var resolutions: Variant = raid.get(&"resolved_rounds", null)
if resolutions is Dictionary:
for resolution_id: StringName in _sorted_keys(resolutions):
var payload: Variant = resolutions[resolution_id]
if payload is Dictionary and int(payload.get(&"absolute_minute", -1)) > clock_minute:
errors.append("Resolution %s exceeds the Defense clock." % resolution_id)
return {&"ok": errors.is_empty(), &"errors": errors}
func _asset_relevant_to_wave(asset: Dictionary, wave: Dictionary) -> bool:
if int(asset[&"kind"]) == AssetKind.OBJECTIVE:
return (wave[&"objective_ids"] as Array).has(asset[&"asset_id"])
var entries: Array = asset[&"entry_points"]
if entries.is_empty():
return true
for entry_id: StringName in entries:
if (wave[&"entry_points"] as Array).has(entry_id):
return true
return false
func _wave_attacker_ids(raid: Dictionary, wave_id: StringName) -> Array[StringName]:
var result: Array[StringName] = []
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
var actor: Dictionary = raid[&"combatants"][actor_id]
if actor[&"side"] == &"ATTACKER" and actor[&"wave_id"] == wave_id:
result.append(actor_id)
return result
func _skill_ids(skills: Array[Dictionary]) -> Array[StringName]:
var result: Array[StringName] = []
for skill: Dictionary in skills:
result.append(skill[&"action_id"])
return result
func _active_wave(raid: Dictionary) -> Dictionary:
var waves: Array = raid.get(&"waves", [])
var index := int(raid.get(&"current_wave_index", -1))
if index < 0 or index >= waves.size() or not waves[index] is Dictionary:
return {}
return waves[index]
func _wave_exists(raid: Dictionary, wave_id: StringName) -> bool:
return not _wave_by_id(raid, wave_id).is_empty()
func _wave_by_id(raid: Dictionary, wave_id: StringName) -> Dictionary:
if wave_id.is_empty():
return {}
for wave: Dictionary in raid[&"waves"]:
if wave[&"wave_id"] == wave_id:
return wave
return {}
func _raid_ref(raid_id: StringName) -> Dictionary:
if not _raids.has(raid_id):
return {}
return _raids[raid_id]
func _is_terminal(state: int) -> bool:
return TERMINAL_STATES.has(state)
func _resolution_operation_kind(payload: Dictionary) -> StringName:
var explicit := StringName(_value(payload, &"operation_kind", &""))
if VALID_OPERATION_KINDS.has(explicit):
return explicit
if payload.has(&"operation_kind") or payload.has("operation_kind"):
return &""
# Version-1 snapshots created before operation_kind existed remain readable.
# Round payloads have scoring fields; terminal command payloads identify
# themselves by result. Ambiguous legacy entries are rejected.
if payload.has(&"defense_score") or payload.has("defense_score"):
return OPERATION_ROUND
var result := StringName(_value(payload, &"result", &""))
if result == &"DEFENDERS_RETREATED":
return OPERATION_RETREAT
if result == &"CANCELLED":
return OPERATION_CANCEL
return &""
func _replay_resolution(
raid: Dictionary,
resolution_id: StringName,
expected_kind: StringName
) -> CommandResult:
var payload: Dictionary = raid[&"resolved_rounds"][resolution_id]
var actual_kind := _resolution_operation_kind(payload)
if actual_kind != expected_kind:
return _invalid("Defense resolution ID belongs to a different operation.", {
&"resolution_id": resolution_id,
&"expected_operation_kind": expected_kind,
&"actual_operation_kind": actual_kind,
})
var replay := _success(payload.duplicate(true))
replay.is_replay = true
last_error = ""
return replay
func _normalize_boundary_result(raw: Variant) -> Dictionary:
if raw is Dictionary:
var result: Dictionary = (raw as Dictionary).duplicate(true)
result[&"ok"] = bool(raw.get(&"ok", raw.get("ok", false)))
return result
if raw is bool:
return {&"ok": raw}
return {&"ok": false, &"reason": &"INVALID_BOUNDARY_RESULT"}
func _sorted_keys(dictionary: Dictionary) -> Array[StringName]:
var result: Array[StringName] = []
for raw_key: Variant in dictionary.keys():
result.append(StringName(raw_key))
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return result
func _sorted_unique_names(raw: Variant) -> Array[StringName]:
var result: Array[StringName] = []
if raw is Array:
for value: Variant in raw:
var name := StringName(value)
if not name.is_empty() and not result.has(name):
result.append(name)
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return result
func _names_to_strings(raw: Variant) -> Array[String]:
var result: Array[String] = []
if raw is Array:
for value: Variant in raw:
result.append(String(value))
return result
func _canonicalize_metadata(raw: Variant, depth: int = 0, path: String = "$") -> Dictionary:
if depth > MAX_METADATA_DEPTH:
return {&"ok": false, &"reason": &"MAX_DEPTH_EXCEEDED", &"path": path}
match typeof(raw):
TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_STRING:
return {&"ok": true, &"value": raw}
TYPE_STRING_NAME:
return {&"ok": true, &"value": String(raw)}
TYPE_FLOAT:
var number := float(raw)
if not is_finite(number):
return {&"ok": false, &"reason": &"NON_FINITE_FLOAT", &"path": path}
if number == 0.0:
number = 0.0
return {&"ok": true, &"value": number}
TYPE_ARRAY:
var values: Array = []
var raw_array: Array = raw
for index: int in range(raw_array.size()):
var child := _canonicalize_metadata(raw_array[index], depth + 1, "%s[%d]" % [path, index])
if not bool(child[&"ok"]):
return child
values.append(child[&"value"])
return {&"ok": true, &"value": values}
TYPE_DICTIONARY:
var raw_dictionary: Dictionary = raw
var source_by_key: Dictionary = {}
var canonical_keys: Array[String] = []
for raw_key: Variant in raw_dictionary.keys():
if not (raw_key is String or raw_key is StringName):
return {&"ok": false, &"reason": &"NON_STRING_KEY", &"path": path}
var key := String(raw_key)
if source_by_key.has(key):
return {&"ok": false, &"reason": &"DUPLICATE_CANONICAL_KEY", &"path": "%s.%s" % [path, key]}
source_by_key[key] = raw_key
canonical_keys.append(key)
canonical_keys.sort()
var values: Dictionary = {}
for key: String in canonical_keys:
var child := _canonicalize_metadata(
raw_dictionary[source_by_key[key]],
depth + 1,
"%s.%s" % [path, key]
)
if not bool(child[&"ok"]):
return child
values[key] = child[&"value"]
return {&"ok": true, &"value": values}
_:
return {
&"ok": false,
&"reason": &"UNSUPPORTED_VARIANT_TYPE",
&"path": path,
&"variant_type": type_string(typeof(raw)),
}
func _canonical_snapshot_value(raw: Variant) -> Variant:
if raw is StringName:
return String(raw)
if raw is float and raw == floor(raw):
return int(raw)
if raw is Array:
var result: Array = []
for value: Variant in raw:
result.append(_canonical_snapshot_value(value))
return result
if raw is Dictionary:
var result: Dictionary = {}
for key: Variant in raw.keys():
result[String(key)] = _canonical_snapshot_value(raw[key])
return result
return raw
func _value(dictionary: Dictionary, key: StringName, default_value: Variant) -> Variant:
if dictionary.has(key):
return dictionary[key]
var string_key := String(key)
if dictionary.has(string_key):
return dictionary[string_key]
return default_value
func _success(details: Dictionary = {}) -> CommandResult:
last_error = ""
return CommandResultType.succeeded(0, details)
func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
last_error = message
return CommandResultType.failed(CommandResultType.Code.INVALID_PAYLOAD, 0, message, details)
func _duplicate(kind: String, value_id: StringName) -> CommandResult:
return _invalid("%s already exists: %s" % [kind, value_id], {&"id": value_id})
func _not_found(kind: String, value_id: StringName) -> CommandResult:
last_error = "%s not found: %s" % [kind, value_id]
return CommandResultType.failed(
CommandResultType.Code.NOT_FOUND,
0,
last_error,
{&"id": value_id}
)
## Optional provenance is absent in legacy/manual raids. Configured event raids
## retain it so a replay cannot silently replace an already-running incident.
func _is_valid_event_source(raw: Variant) -> bool:
if not raw is Dictionary:
return false
if raw.is_empty():
return true
var keys := ["event_instance_id", "event_action_id", "template_id", "template_digest", "source_kind"]
if raw.size() != keys.size():
return false
for key: String in keys:
var value: Variant = _value(raw, StringName(key), null)
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME] or String(value).is_empty():
return false
if String(_value(raw, &"source_kind", "")) not in ["THIEF", "BANKRUPTCY_RIOT", "BANK_DELINQUENCY", "AUTHORITY_ESCALATION"]:
return false
var digest := String(_value(raw, &"template_digest", ""))
if digest.length() != 64:
return false
for character: String in digest:
if character not in "0123456789abcdef":
return false
return true