2130 lines
93 KiB
GDScript
2130 lines
93 KiB
GDScript
class_name DefenseService
|
|
extends RefCounted
|
|
|
|
## Deterministic, round-based Raid and defense aggregate.
|
|
##
|
|
## The service owns combat state only. Resource costs and final consequences are
|
|
## explicit callback boundaries, which lets SimulationRuntime include them in a
|
|
## larger transaction without giving this service access to another domain.
|
|
|
|
signal raid_created(raid_id: StringName)
|
|
signal raid_state_changed(raid_id: StringName, old_state: int, new_state: int, reason: StringName)
|
|
signal active_skill_applied(raid_id: StringName, action_id: StringName, round_number: int)
|
|
signal round_resolved(raid_id: StringName, resolution: Dictionary)
|
|
signal raid_resolved(raid_id: StringName, outcome: Dictionary)
|
|
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
|
|
const SNAPSHOT_VERSION := 1
|
|
const DEFAULT_ROUND_MINUTES := 5
|
|
const OPERATION_ROUND := &"ROUND"
|
|
const OPERATION_RETREAT := &"RETREAT"
|
|
const OPERATION_CANCEL := &"CANCEL"
|
|
const VALID_OPERATION_KINDS := [OPERATION_ROUND, OPERATION_RETREAT, OPERATION_CANCEL]
|
|
const MAX_METADATA_DEPTH := 32
|
|
|
|
enum RaidState {
|
|
PREPARING,
|
|
ACTIVE,
|
|
BETWEEN_WAVES,
|
|
DEFENDED,
|
|
BREACHED,
|
|
DEFENDERS_RETREATED,
|
|
CANCELLED,
|
|
}
|
|
|
|
enum CombatantState {
|
|
ACTIVE,
|
|
INCAPACITATED,
|
|
RETREATED,
|
|
}
|
|
|
|
enum AssetKind {
|
|
DOOR,
|
|
DEFENSE_DEVICE,
|
|
OBJECTIVE,
|
|
}
|
|
|
|
const TERMINAL_STATES := [
|
|
RaidState.DEFENDED,
|
|
RaidState.BREACHED,
|
|
RaidState.DEFENDERS_RETREATED,
|
|
RaidState.CANCELLED,
|
|
]
|
|
|
|
const VALID_RETREAT_POLICIES := [
|
|
&"HOLD",
|
|
&"MANUAL",
|
|
&"THRESHOLD",
|
|
&"PROTECT_AND_RETREAT",
|
|
]
|
|
|
|
const VALID_SKILL_KINDS := [
|
|
&"RALLY",
|
|
&"POWER_STRIKE",
|
|
&"HEAL",
|
|
&"FORTIFY",
|
|
]
|
|
|
|
var current_minute := 0
|
|
var last_error := ""
|
|
|
|
var _raids: Dictionary = {}
|
|
var _round_roll := Callable()
|
|
var _skill_boundary := Callable()
|
|
var _outcome_boundary := Callable()
|
|
var _rng_boundary := Callable()
|
|
|
|
|
|
func configure_callbacks(
|
|
round_roll: Callable = Callable(),
|
|
skill_boundary: Callable = Callable(),
|
|
outcome_boundary: Callable = Callable(),
|
|
rng_boundary: Callable = Callable()
|
|
) -> DefenseService:
|
|
# `round_roll(key, context)` is a pure keyed function by default: the same
|
|
# key must always return the same value. Stateful RNG providers must also
|
|
# supply `rng_boundary(context, phase, token)` so failed resolutions restore
|
|
# their cursor. Boundary phases are snapshot, commit and rollback.
|
|
_round_roll = round_roll
|
|
_skill_boundary = skill_boundary
|
|
_outcome_boundary = outcome_boundary
|
|
_rng_boundary = rng_boundary
|
|
return self
|
|
|
|
|
|
func get_raid(raid_id: StringName) -> Dictionary:
|
|
if not _raids.has(raid_id):
|
|
return {}
|
|
return (_raids[raid_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
func get_raid_ids() -> Array[StringName]:
|
|
return _sorted_keys(_raids)
|
|
|
|
|
|
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
|
|
|
|
|
|
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 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", &"")),
|
|
&"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"]),
|
|
&"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:
|
|
for collection_key: StringName in [&"waves", &"combatants", &"assets", &"queued_skills", &"applied_skill_ids", &"resolved_rounds"]:
|
|
if not _value(raw, collection_key, null) is Array:
|
|
return {&"ok": false, &"error": "Malformed Raid collection: %s" % collection_key}
|
|
var raid_id := StringName(_value(raw, &"raid_id", &""))
|
|
var state_index := RaidState.keys().find(String(_value(raw, &"state", &"")))
|
|
if raid_id.is_empty() or state_index < 0:
|
|
return {&"ok": false, &"error": "Raid snapshot ID or state is invalid."}
|
|
var raid := {
|
|
&"raid_id": raid_id,
|
|
&"source_id": StringName(_value(raw, &"source_id", &"")),
|
|
&"state": state_index,
|
|
&"created_at": int(_value(raw, &"created_at", -1)),
|
|
&"started_at": int(_value(raw, &"started_at", -1)),
|
|
&"finished_at": int(_value(raw, &"finished_at", -1)),
|
|
&"state_changed_at": int(_value(raw, &"state_changed_at", -1)),
|
|
&"current_wave_index": int(_value(raw, &"current_wave_index", -1)),
|
|
&"current_round": int(_value(raw, &"current_round", -1)),
|
|
&"elapsed_minutes": int(_value(raw, &"elapsed_minutes", -1)),
|
|
&"max_duration_minutes": int(_value(raw, &"max_duration_minutes", 0)),
|
|
&"round_minutes": int(_value(raw, &"round_minutes", 0)),
|
|
&"retreat_policy": StringName(_value(raw, &"retreat_policy", &"")),
|
|
&"defender_retreat_threshold": int(_value(raw, &"defender_retreat_threshold", -1)),
|
|
&"terrain_bonus": int(_value(raw, &"terrain_bonus", 0)),
|
|
&"command_bonus": int(_value(raw, &"command_bonus", 0)),
|
|
&"surprise_penalty": int(_value(raw, &"surprise_penalty", -1)),
|
|
&"multiple_entry_penalty": int(_value(raw, &"multiple_entry_penalty", -1)),
|
|
&"entry_points": _sorted_unique_names(_value(raw, &"entry_points", [])),
|
|
&"objective_ids": _sorted_unique_names(_value(raw, &"objective_ids", [])),
|
|
&"waves": [],
|
|
&"combatants": {},
|
|
&"assets": {},
|
|
&"queued_skills": {},
|
|
&"applied_skill_ids": {},
|
|
&"resolved_rounds": {},
|
|
&"result": StringName(_value(raw, &"result", &"")),
|
|
&"result_reason": StringName(_value(raw, &"result_reason", &"")),
|
|
&"revision": int(_value(raw, &"revision", -1)),
|
|
}
|
|
for raw_wave: Variant in _value(raw, &"waves", []):
|
|
if not raw_wave is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed Raid wave snapshot."}
|
|
var wave: Dictionary = raw_wave.duplicate(true)
|
|
wave[&"wave_id"] = StringName(_value(wave, &"wave_id", &""))
|
|
wave[&"threat_strength"] = int(_value(wave, &"threat_strength", 0))
|
|
wave[&"remaining_threat"] = int(_value(wave, &"remaining_threat", -1))
|
|
wave[&"retreat_threshold"] = int(_value(wave, &"retreat_threshold", -1))
|
|
wave[&"max_rounds"] = int(_value(wave, &"max_rounds", 0))
|
|
wave[&"rounds_completed"] = int(_value(wave, &"rounds_completed", -1))
|
|
wave[&"entry_points"] = _sorted_unique_names(_value(wave, &"entry_points", []))
|
|
wave[&"objective_ids"] = _sorted_unique_names(_value(wave, &"objective_ids", []))
|
|
wave[&"state"] = StringName(_value(wave, &"state", &""))
|
|
wave[&"result_reason"] = StringName(_value(wave, &"result_reason", &""))
|
|
(raid[&"waves"] as Array).append(wave)
|
|
for raw_combatant: Variant in _value(raw, &"combatants", []):
|
|
if not raw_combatant is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed defense combatant snapshot."}
|
|
var combatant: Dictionary = (raw_combatant as Dictionary).duplicate(true)
|
|
var actor_id := StringName(_value(combatant, &"actor_id", &""))
|
|
var combatant_state := CombatantState.keys().find(String(_value(combatant, &"state", &"")))
|
|
if actor_id.is_empty() or combatant_state < 0 or (raid[&"combatants"] as Dictionary).has(actor_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate defense combatant snapshot."}
|
|
combatant[&"actor_id"] = actor_id
|
|
combatant[&"side"] = StringName(_value(combatant, &"side", &""))
|
|
combatant[&"wave_id"] = StringName(_value(combatant, &"wave_id", &""))
|
|
combatant[&"entry_point_id"] = StringName(_value(combatant, &"entry_point_id", &""))
|
|
combatant[&"state"] = combatant_state
|
|
for numeric_key: StringName in [
|
|
&"combat", &"armor", &"max_health", &"health", &"initial_health",
|
|
&"fatigue", &"target_order", &"damage_dealt", &"damage_taken", &"revision",
|
|
]:
|
|
combatant[numeric_key] = int(_value(combatant, numeric_key, -1))
|
|
(raid[&"combatants"] as Dictionary)[actor_id] = combatant
|
|
for raw_asset: Variant in _value(raw, &"assets", []):
|
|
if not raw_asset is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed defense asset snapshot."}
|
|
var asset: Dictionary = (raw_asset as Dictionary).duplicate(true)
|
|
var asset_id := StringName(_value(asset, &"asset_id", &""))
|
|
var kind_index := AssetKind.keys().find(String(_value(asset, &"kind", &"")))
|
|
if asset_id.is_empty() or kind_index < 0 or (raid[&"assets"] as Dictionary).has(asset_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate defense asset snapshot."}
|
|
asset[&"asset_id"] = asset_id
|
|
asset[&"kind"] = kind_index
|
|
asset[&"entry_points"] = _sorted_unique_names(_value(asset, &"entry_points", []))
|
|
for numeric_key: StringName in [
|
|
&"max_integrity", &"integrity", &"initial_integrity", &"defense",
|
|
&"armor", &"terrain_bonus", &"target_order", &"revision",
|
|
]:
|
|
asset[numeric_key] = int(_value(asset, numeric_key, -1))
|
|
(raid[&"assets"] as Dictionary)[asset_id] = asset
|
|
for raw_skill: Variant in _value(raw, &"queued_skills", []):
|
|
if not raw_skill is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed queued defense skill snapshot."}
|
|
var skill: Dictionary = (raw_skill as Dictionary).duplicate(true)
|
|
var action_id := StringName(_value(skill, &"action_id", &""))
|
|
if action_id.is_empty() or (raid[&"queued_skills"] as Dictionary).has(action_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate queued defense skill snapshot."}
|
|
skill[&"action_id"] = action_id
|
|
skill[&"actor_id"] = StringName(_value(skill, &"actor_id", &""))
|
|
skill[&"kind"] = StringName(_value(skill, &"kind", &""))
|
|
skill[&"target_id"] = StringName(_value(skill, &"target_id", &""))
|
|
skill[&"potency"] = int(_value(skill, &"potency", -1))
|
|
skill[&"round_number"] = int(_value(skill, &"round_number", -1))
|
|
if not _value(skill, &"metadata", null) is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed queued defense skill metadata."}
|
|
var metadata_result := _canonicalize_metadata(_value(skill, &"metadata", {}))
|
|
if not bool(metadata_result[&"ok"]):
|
|
return {&"ok": false, &"error": "Unsupported queued defense skill metadata: %s" % str(metadata_result)}
|
|
skill[&"metadata"] = metadata_result[&"value"]
|
|
(raid[&"queued_skills"] as Dictionary)[action_id] = skill
|
|
for raw_applied: Variant in _value(raw, &"applied_skill_ids", []):
|
|
if not raw_applied is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed applied defense skill snapshot."}
|
|
var action_id := StringName(_value(raw_applied, &"action_id", &""))
|
|
if action_id.is_empty() or (raid[&"applied_skill_ids"] as Dictionary).has(action_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate applied defense skill snapshot."}
|
|
(raid[&"applied_skill_ids"] as Dictionary)[action_id] = int(_value(raw_applied, &"round_number", -1))
|
|
for raw_resolution: Variant in _value(raw, &"resolved_rounds", []):
|
|
if not raw_resolution is Dictionary or not _value(raw_resolution, &"payload", null) is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed defense round resolution snapshot."}
|
|
var resolution_id := StringName(_value(raw_resolution, &"resolution_id", &""))
|
|
if resolution_id.is_empty() or (raid[&"resolved_rounds"] as Dictionary).has(resolution_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate defense resolution snapshot."}
|
|
var canonical_payload: Variant = _canonical_snapshot_value(_value(raw_resolution, &"payload", {}))
|
|
if not canonical_payload is Dictionary:
|
|
return {&"ok": false, &"error": "Defense resolution payload is not saveable."}
|
|
(raid[&"resolved_rounds"] as Dictionary)[resolution_id] = canonical_payload
|
|
return {&"ok": true, &"raid": raid}
|
|
|
|
|
|
func _validate_raid(raid: Dictionary) -> Dictionary:
|
|
var errors: Array[String] = []
|
|
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)
|
|
if state != RaidState.PREPARING 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}
|
|
)
|