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."} # Normal creation owns Array[Dictionary]; restores keep the same container type. var parsed_waves: Array[Dictionary] = [] 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": parsed_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", &"")) parsed_waves.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