1376 lines
50 KiB
GDScript
1376 lines
50 KiB
GDScript
class_name AuthorityService
|
|
extends RefCounted
|
|
|
|
## Deterministic Authority aggregate. World visibility, path finding and
|
|
## financial/sanction application are explicit callback boundaries so this
|
|
## service never scans the map or mutates another domain directly.
|
|
|
|
signal heat_changed(old_value: int, new_value: int, source_id: StringName)
|
|
signal incident_created(incident: AuthorityIncident)
|
|
signal incident_detector_added(incident_id: StringName, detector_id: StringName)
|
|
signal evidence_registered(evidence: AuthorityEvidence)
|
|
signal evidence_discovered(evidence: AuthorityEvidence, method: StringName)
|
|
signal inspection_scheduled(inspection: AuthorityInspection)
|
|
signal inspection_state_changed(inspection_id: StringName, old_state: int, new_state: int, reason: StringName)
|
|
signal resolution_applied(inspection_id: StringName, resolution: Dictionary)
|
|
|
|
const HeatStateType = preload("res://scripts/authority/heat_state.gd")
|
|
const IncidentType = preload("res://scripts/authority/incident.gd")
|
|
const EvidenceType = preload("res://scripts/authority/evidence.gd")
|
|
const InspectionType = preload("res://scripts/authority/inspection.gd")
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
|
|
const SNAPSHOT_VERSION := 1
|
|
const INSPECTION_EVALUATION_MINUTE := 360
|
|
const DEFAULT_HEAT_DECAY := 5
|
|
const DEFAULT_ACTIVE_CASE_FLOOR := 20
|
|
|
|
var current_minute := 0
|
|
var last_error := ""
|
|
|
|
var _state: HeatState = HeatStateType.new()
|
|
var _incidents: Dictionary = {}
|
|
var _evidence: Dictionary = {}
|
|
var _inspections: Dictionary = {}
|
|
var _scored_incident_points: Dictionary = {}
|
|
var _applied_resolutions: Dictionary = {}
|
|
var _evaluated_inspection_days: Dictionary = {}
|
|
var _next_inspection_sequence := 1
|
|
var _next_inspector_sequence := 1
|
|
|
|
var _line_of_sight := Callable()
|
|
var _path_query := Callable()
|
|
var _resolution_applier := Callable()
|
|
var _inspection_roll := Callable()
|
|
|
|
|
|
func configure_callbacks(
|
|
line_of_sight: Callable = Callable(),
|
|
path_query: Callable = Callable(),
|
|
resolution_applier: Callable = Callable(),
|
|
inspection_roll: Callable = Callable()
|
|
) -> AuthorityService:
|
|
_line_of_sight = line_of_sight
|
|
_path_query = path_query
|
|
_resolution_applier = resolution_applier
|
|
_inspection_roll = inspection_roll
|
|
return self
|
|
|
|
|
|
func get_authority_state() -> HeatState:
|
|
return _state
|
|
|
|
|
|
func get_incident(incident_id: StringName) -> AuthorityIncident:
|
|
return _incidents.get(incident_id) as AuthorityIncident
|
|
|
|
|
|
func get_incidents() -> Array[AuthorityIncident]:
|
|
var result: Array[AuthorityIncident] = []
|
|
for incident_id: StringName in _sorted_keys(_incidents):
|
|
result.append(_incidents[incident_id])
|
|
return result
|
|
|
|
|
|
func get_evidence(evidence_id: StringName) -> AuthorityEvidence:
|
|
return _evidence.get(evidence_id) as AuthorityEvidence
|
|
|
|
|
|
func get_all_evidence() -> Array[AuthorityEvidence]:
|
|
var result: Array[AuthorityEvidence] = []
|
|
for evidence_id: StringName in _sorted_keys(_evidence):
|
|
result.append(_evidence[evidence_id])
|
|
return result
|
|
|
|
|
|
func get_inspection(inspection_id: StringName) -> AuthorityInspection:
|
|
return _inspections.get(inspection_id) as AuthorityInspection
|
|
|
|
|
|
func get_inspections() -> Array[AuthorityInspection]:
|
|
var result: Array[AuthorityInspection] = []
|
|
for inspection_id: StringName in _sorted_keys(_inspections):
|
|
result.append(_inspections[inspection_id])
|
|
return result
|
|
|
|
|
|
func get_resolution(resolution_id: StringName) -> Dictionary:
|
|
if not _applied_resolutions.has(resolution_id):
|
|
return {}
|
|
return (_applied_resolutions[resolution_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
func apply_heat_delta(
|
|
delta: int,
|
|
source_id: StringName,
|
|
absolute_minute: int,
|
|
reason: StringName = &""
|
|
) -> CommandResult:
|
|
var details := _state.apply_heat_delta(delta, source_id, absolute_minute, reason)
|
|
if not bool(details.get(&"ok", false)):
|
|
return _invalid("Heat delta requires a non-negative minute.", details)
|
|
if int(details[&"old_value"]) != int(details[&"new_value"]):
|
|
heat_changed.emit(int(details[&"old_value"]), int(details[&"new_value"]), source_id)
|
|
last_error = ""
|
|
return _success(details)
|
|
|
|
|
|
func set_suspicion(value: int) -> CommandResult:
|
|
var old_value := _state.suspicion
|
|
_state.set_suspicion(value)
|
|
last_error = ""
|
|
return _success({&"old_value": old_value, &"new_value": _state.suspicion})
|
|
|
|
|
|
func create_incident(
|
|
incident_id: StringName,
|
|
incident_type: StringName,
|
|
absolute_minute: int,
|
|
detector_id: StringName,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
var incident := IncidentType.new().configure(
|
|
incident_id,
|
|
incident_type,
|
|
absolute_minute,
|
|
options
|
|
)
|
|
return report_incident(incident, detector_id)
|
|
|
|
|
|
## incident_id is the authoritative deduplication key. A second detector is
|
|
## retained as audit metadata, but Heat and evidence are never added again.
|
|
func report_incident(value: Variant, detector_id: StringName) -> CommandResult:
|
|
if value == null or not value is AuthorityIncident:
|
|
return _invalid("AuthorityIncident is missing or invalid.")
|
|
var incoming: AuthorityIncident = value
|
|
if not incoming.is_valid() or detector_id.is_empty():
|
|
return _invalid("AuthorityIncident fails schema validation or has no detector.")
|
|
if incoming.state != IncidentType.State.OPEN:
|
|
return _invalid("New Incident reports must begin in OPEN state.")
|
|
if not incoming.evidence_ids.is_empty():
|
|
return _invalid("New Incident reports cannot arrive with pre-attached Evidence.")
|
|
if _incidents.has(incoming.incident_id):
|
|
var existing := get_incident(incoming.incident_id)
|
|
var detector_added := existing.add_detector(detector_id)
|
|
if detector_added:
|
|
incident_detector_added.emit(existing.incident_id, detector_id)
|
|
var replay := _success({
|
|
&"incident_id": existing.incident_id,
|
|
&"detector_added": detector_added,
|
|
&"heat_applied": 0,
|
|
&"canonical_severity": existing.severity,
|
|
})
|
|
replay.is_replay = true
|
|
last_error = ""
|
|
return replay
|
|
incoming.add_detector(detector_id)
|
|
_incidents[incoming.incident_id] = incoming
|
|
if incoming.state == IncidentType.State.OPEN:
|
|
_state.add_active_case(incoming.incident_id)
|
|
var heat_result := _state.apply_heat_delta(
|
|
incoming.heat_delta,
|
|
incoming.incident_id,
|
|
incoming.created_at,
|
|
&"INCIDENT"
|
|
)
|
|
if int(heat_result[&"old_value"]) != int(heat_result[&"new_value"]):
|
|
heat_changed.emit(
|
|
int(heat_result[&"old_value"]),
|
|
int(heat_result[&"new_value"]),
|
|
incoming.incident_id
|
|
)
|
|
incident_created.emit(incoming)
|
|
last_error = ""
|
|
return _success({
|
|
&"incident_id": incoming.incident_id,
|
|
&"detector_added": true,
|
|
&"heat_applied": int(heat_result[&"applied_delta"]),
|
|
})
|
|
|
|
|
|
func dismiss_incident(
|
|
incident_id: StringName,
|
|
reason: StringName = &"DISMISSED"
|
|
) -> CommandResult:
|
|
var incident := get_incident(incident_id)
|
|
if incident == null:
|
|
return _not_found("AuthorityIncident", incident_id)
|
|
if not incident.dismiss(reason):
|
|
return _invalid("Only an open Incident can be dismissed.")
|
|
_state.remove_active_case(incident_id)
|
|
last_error = ""
|
|
return _success({&"incident_id": incident_id, &"state": &"DISMISSED"})
|
|
|
|
|
|
func register_evidence(value: Variant) -> CommandResult:
|
|
if value == null or not value is AuthorityEvidence:
|
|
return _invalid("AuthorityEvidence is missing or invalid.")
|
|
var evidence: AuthorityEvidence = value
|
|
if not evidence.is_valid():
|
|
return _invalid("AuthorityEvidence fails schema validation.")
|
|
if evidence.is_discovered():
|
|
return _invalid("Live Evidence must enter as latent and use the discovery boundary.")
|
|
if _evidence.has(evidence.evidence_id):
|
|
return _duplicate("AuthorityEvidence", evidence.evidence_id)
|
|
if not evidence.incident_id.is_empty() and not _incidents.has(evidence.incident_id):
|
|
return _invalid("AuthorityEvidence references an unknown Incident.", {
|
|
&"incident_id": evidence.incident_id,
|
|
})
|
|
_evidence[evidence.evidence_id] = evidence
|
|
if not evidence.incident_id.is_empty():
|
|
get_incident(evidence.incident_id).add_evidence(evidence.evidence_id)
|
|
evidence_registered.emit(evidence)
|
|
if evidence.is_discovered():
|
|
_recompute_authority_evidence_points(current_minute)
|
|
last_error = ""
|
|
return _success({&"evidence_id": evidence.evidence_id})
|
|
|
|
|
|
func create_evidence(
|
|
evidence_id: StringName,
|
|
source: StringName,
|
|
severity: int,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
return register_evidence(EvidenceType.new().configure(evidence_id, source, severity, options))
|
|
|
|
|
|
## No visibility callback means no line-of-sight proof. Reaching the cell,
|
|
## trusted records/testimony and an already-known illegal facility are the only
|
|
## callback-free discovery bases.
|
|
func attempt_discover_evidence(
|
|
evidence_id: StringName,
|
|
inspector_id: StringName,
|
|
inspector_cell: Vector3i,
|
|
absolute_minute: int,
|
|
context: Dictionary = {}
|
|
) -> CommandResult:
|
|
var evidence := get_evidence(evidence_id)
|
|
if evidence == null:
|
|
return _not_found("AuthorityEvidence", evidence_id)
|
|
if inspector_id.is_empty() or absolute_minute < 0:
|
|
return _invalid("Evidence discovery requires an inspector and non-negative minute.")
|
|
if evidence.is_expired(absolute_minute):
|
|
return _invalid("Evidence has expired.", {&"reason": &"EVIDENCE_EXPIRED"})
|
|
if evidence.is_discovered():
|
|
var replay := _success({
|
|
&"evidence_id": evidence_id,
|
|
&"discovery_method": EvidenceType.DiscoveryMethod.keys()[evidence.discovery_method],
|
|
&"evidence_points": _state.evidence_points,
|
|
})
|
|
replay.is_replay = true
|
|
last_error = ""
|
|
return replay
|
|
|
|
var method := EvidenceType.DiscoveryMethod.LATENT
|
|
var reason: StringName = &"NO_VISIBILITY_PROOF"
|
|
if inspector_cell == evidence.location_cell:
|
|
method = EvidenceType.DiscoveryMethod.REACHED
|
|
elif bool(_value(context, &"trusted_source", false)):
|
|
method = EvidenceType.DiscoveryMethod.TRUSTED_SOURCE
|
|
elif not evidence.facility_id.is_empty() \
|
|
and _state.known_illegal_facility_ids.has(evidence.facility_id):
|
|
method = EvidenceType.DiscoveryMethod.KNOWN_ILLEGAL
|
|
elif inspector_cell.z != evidence.location_cell.z:
|
|
reason = &"DIFFERENT_LAYER"
|
|
elif not _line_of_sight.is_valid():
|
|
reason = &"LINE_OF_SIGHT_UNAVAILABLE"
|
|
else:
|
|
var visibility_context := context.duplicate(true)
|
|
visibility_context[&"evidence_id"] = evidence.evidence_id
|
|
visibility_context[&"facility_id"] = evidence.facility_id
|
|
var visible := bool(_line_of_sight.call(
|
|
inspector_cell,
|
|
evidence.location_cell,
|
|
visibility_context
|
|
))
|
|
if visible:
|
|
method = EvidenceType.DiscoveryMethod.LINE_OF_SIGHT
|
|
else:
|
|
reason = &"LINE_OF_SIGHT_BLOCKED"
|
|
if method == EvidenceType.DiscoveryMethod.LATENT:
|
|
return _invalid("Evidence is not discoverable from the inspector's position.", {
|
|
&"reason": reason,
|
|
&"evidence_id": evidence_id,
|
|
})
|
|
if not evidence.mark_discovered(inspector_id, absolute_minute, method):
|
|
return _invalid("Evidence discovery transition failed.")
|
|
if bool(_value(context, &"illegal_confirmed", false)) and not evidence.facility_id.is_empty():
|
|
_state.add_known_illegal_facility(evidence.facility_id)
|
|
_recompute_authority_evidence_points(absolute_minute)
|
|
evidence_discovered.emit(evidence, StringName(EvidenceType.DiscoveryMethod.keys()[method]))
|
|
last_error = ""
|
|
return _success({
|
|
&"evidence_id": evidence_id,
|
|
&"discovery_method": StringName(EvidenceType.DiscoveryMethod.keys()[method]),
|
|
&"evidence_points": _state.evidence_points,
|
|
})
|
|
|
|
|
|
func advance_time(
|
|
absolute_minute: int,
|
|
daily_heat_decay: int = DEFAULT_HEAT_DECAY,
|
|
active_case_floor: int = DEFAULT_ACTIVE_CASE_FLOOR
|
|
) -> CommandResult:
|
|
if absolute_minute < current_minute or daily_heat_decay < 0:
|
|
return _invalid("Authority time cannot move backwards and decay cannot be negative.")
|
|
var old_day := current_minute / 1_440
|
|
var new_day := absolute_minute / 1_440
|
|
var decay_records: Array[Dictionary] = []
|
|
for day_index: int in range(old_day + 1, new_day + 1):
|
|
var old_heat := _state.heat
|
|
var decay := _state.apply_daily_decay(day_index, daily_heat_decay, active_case_floor)
|
|
decay_records.append(decay)
|
|
if old_heat != _state.heat:
|
|
heat_changed.emit(old_heat, _state.heat, &"daily_decay")
|
|
current_minute = absolute_minute
|
|
_recompute_authority_evidence_points(absolute_minute)
|
|
last_error = ""
|
|
return _success({
|
|
&"current_minute": current_minute,
|
|
&"decays": decay_records,
|
|
&"evidence_points": _state.evidence_points,
|
|
})
|
|
|
|
|
|
func evaluate_inspection(absolute_minute: int, terms: Dictionary = {}) -> CommandResult:
|
|
if absolute_minute < 0 or absolute_minute % 1_440 != INSPECTION_EVALUATION_MINUTE:
|
|
return _invalid("Inspection selection is evaluated only at 06:00.")
|
|
var day_index := absolute_minute / 1_440
|
|
if _evaluated_inspection_days.has(day_index):
|
|
var replay := _success((_evaluated_inspection_days[day_index] as Dictionary).duplicate(true))
|
|
replay.is_replay = true
|
|
last_error = ""
|
|
return replay
|
|
if absolute_minute < _state.next_inspection_window:
|
|
return _invalid("Inspection evaluation is outside the next Window.", {
|
|
&"next_inspection_window": _state.next_inspection_window,
|
|
})
|
|
var base_chance := int(_value(terms, &"base_chance", 5))
|
|
var heat_weight := int(_value(terms, &"heat_weight", 1))
|
|
var suspicion_weight := int(_value(terms, &"suspicion_weight", 1))
|
|
var overdue_bonus := int(_value(
|
|
terms,
|
|
&"overdue_case_bonus",
|
|
20 if not _state.active_case_ids.is_empty() else 0
|
|
))
|
|
var reputation_protection := int(_value(terms, &"reputation_protection", 0))
|
|
var chance := clampi(
|
|
base_chance
|
|
+ _state.heat * heat_weight
|
|
+ _state.suspicion * suspicion_weight
|
|
+ overdue_bonus
|
|
- reputation_protection,
|
|
0,
|
|
95
|
|
)
|
|
var roll := -1
|
|
if _has_key(terms, &"roll"):
|
|
roll = clampi(int(_value(terms, &"roll", 99)), 0, 99)
|
|
elif _inspection_roll.is_valid():
|
|
roll = clampi(int(_inspection_roll.call(day_index, chance, terms.duplicate(true))), 0, 99)
|
|
else:
|
|
roll = _stable_percent("inspection:%d:%d:%d:%d" % [
|
|
day_index,
|
|
_state.heat,
|
|
_state.suspicion,
|
|
_state.active_case_ids.size(),
|
|
])
|
|
var selected := roll < chance
|
|
var details := {
|
|
&"day_index": day_index,
|
|
&"chance": chance,
|
|
&"roll": roll,
|
|
&"selected": selected,
|
|
&"inspection_id": &"",
|
|
}
|
|
if selected and not _has_open_inspection():
|
|
var scheduled := schedule_inspection(absolute_minute, terms)
|
|
if not scheduled.is_success():
|
|
return scheduled
|
|
details[&"inspection_id"] = scheduled.details[&"inspection_id"]
|
|
elif selected:
|
|
details[&"selected"] = false
|
|
details[&"reason"] = &"INSPECTION_ALREADY_ACTIVE"
|
|
_state.next_inspection_window = (day_index + 1) * 1_440 + INSPECTION_EVALUATION_MINUTE
|
|
_state.revision += 1
|
|
_evaluated_inspection_days[day_index] = details.duplicate(true)
|
|
last_error = ""
|
|
return _success(details)
|
|
|
|
|
|
func schedule_inspection(
|
|
scheduled_at: int,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
if scheduled_at < 0:
|
|
return _invalid("Inspection schedule requires a non-negative minute.")
|
|
var inspection_id := StringName(_value(options, &"inspection_id", &""))
|
|
var generated_inspection_id := inspection_id.is_empty()
|
|
if generated_inspection_id:
|
|
inspection_id = StringName("inspection_%08d" % _next_inspection_sequence)
|
|
if _inspections.has(inspection_id):
|
|
return _duplicate("AuthorityInspection", inspection_id)
|
|
var inspector_id := StringName(_value(options, &"inspector_id", &""))
|
|
var generated_inspector_id := inspector_id.is_empty()
|
|
if generated_inspector_id:
|
|
inspector_id = StringName("authority_inspector_%08d" % _next_inspector_sequence)
|
|
var inspection := InspectionType.new().configure(
|
|
inspection_id,
|
|
inspector_id,
|
|
scheduled_at,
|
|
options
|
|
)
|
|
if not inspection.is_valid():
|
|
return _invalid("AuthorityInspection fails schema validation.")
|
|
_inspections[inspection_id] = inspection
|
|
if generated_inspection_id:
|
|
_next_inspection_sequence += 1
|
|
if generated_inspector_id:
|
|
_next_inspector_sequence += 1
|
|
inspection_scheduled.emit(inspection)
|
|
last_error = ""
|
|
return _success({
|
|
&"inspection_id": inspection_id,
|
|
&"inspector_id": inspector_id,
|
|
})
|
|
|
|
|
|
func start_inspection(inspection_id: StringName, absolute_minute: int) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.ARRIVING,
|
|
absolute_minute,
|
|
&"INSPECTION_STARTED"
|
|
)
|
|
|
|
|
|
func mark_inspector_arrived(inspection_id: StringName, absolute_minute: int) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.CHECK_IN,
|
|
absolute_minute,
|
|
&"ARRIVED"
|
|
)
|
|
|
|
|
|
func mark_inspection_blocked_entry(
|
|
inspection_id: StringName,
|
|
absolute_minute: int,
|
|
reason: StringName = &"BLOCKED_ENTRY"
|
|
) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.BLOCKED_ENTRY,
|
|
absolute_minute,
|
|
reason
|
|
)
|
|
|
|
|
|
func interrupt_inspection(
|
|
inspection_id: StringName,
|
|
absolute_minute: int,
|
|
reason: StringName = &"INTERRUPTED"
|
|
) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.INTERRUPTED,
|
|
absolute_minute,
|
|
reason
|
|
)
|
|
|
|
|
|
func mark_inspection_hostile(
|
|
inspection_id: StringName,
|
|
absolute_minute: int,
|
|
reason: StringName = &"HOSTILE"
|
|
) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.HOSTILE,
|
|
absolute_minute,
|
|
reason
|
|
)
|
|
|
|
|
|
func cancel_inspection(
|
|
inspection_id: StringName,
|
|
absolute_minute: int,
|
|
reason: StringName = &"CANCELLED"
|
|
) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.CANCELLED,
|
|
absolute_minute,
|
|
reason
|
|
)
|
|
|
|
|
|
func plan_patrol(
|
|
inspection_id: StringName,
|
|
candidates: Array,
|
|
absolute_minute: int,
|
|
time_budget_minutes: int = 120
|
|
) -> CommandResult:
|
|
var inspection := get_inspection(inspection_id)
|
|
if inspection == null:
|
|
return _not_found("AuthorityInspection", inspection_id)
|
|
if inspection.state != InspectionType.State.CHECK_IN:
|
|
return _invalid("Patrol can only be planned after CHECK_IN.")
|
|
if time_budget_minutes <= 0:
|
|
return _invalid("Patrol time budget must be positive.")
|
|
if absolute_minute < inspection.state_changed_at:
|
|
return _invalid("Patrol plan time cannot move backwards.")
|
|
var normalized: Array[Dictionary] = []
|
|
for raw_candidate: Variant in candidates:
|
|
if raw_candidate is Dictionary:
|
|
var waypoint := InspectionType.normalize_waypoint(raw_candidate)
|
|
if not waypoint.is_empty():
|
|
normalized.append(waypoint)
|
|
normalized.sort_custom(_waypoint_less)
|
|
var selected: Array[Dictionary] = []
|
|
var selected_ids: Dictionary = {}
|
|
var used_minutes := 0
|
|
for waypoint: Dictionary in normalized:
|
|
var waypoint_id: StringName = waypoint[&"waypoint_id"]
|
|
if selected_ids.has(waypoint_id):
|
|
continue
|
|
selected_ids[waypoint_id] = true
|
|
var cost := int(waypoint[&"travel_minutes"]) + int(waypoint[&"inspection_minutes"])
|
|
if used_minutes + cost > time_budget_minutes:
|
|
continue
|
|
selected.append(waypoint.duplicate(true))
|
|
used_minutes += cost
|
|
inspection.time_budget_minutes = time_budget_minutes
|
|
inspection.time_spent_minutes = 0
|
|
inspection.waypoint_route = selected
|
|
inspection.current_waypoint_index = 0
|
|
inspection.visited_waypoint_ids.clear()
|
|
inspection.inaccessible_waypoint_ids.clear()
|
|
inspection.revision += 1
|
|
var state_result := _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.PATROLLING,
|
|
absolute_minute,
|
|
&"PATROL_PLANNED"
|
|
)
|
|
if not state_result.is_success():
|
|
return state_result
|
|
last_error = ""
|
|
return _success({
|
|
&"inspection_id": inspection_id,
|
|
&"waypoint_ids": _waypoint_ids(selected),
|
|
&"planned_minutes": used_minutes,
|
|
&"time_budget_minutes": time_budget_minutes,
|
|
})
|
|
|
|
|
|
## Moves only to the next chosen waypoint. It never scans other cells. A
|
|
## failed path immediately records a reason and starts a safe departure.
|
|
func advance_patrol(
|
|
inspection_id: StringName,
|
|
absolute_minute: int,
|
|
context: Dictionary = {}
|
|
) -> CommandResult:
|
|
var inspection := get_inspection(inspection_id)
|
|
if inspection == null:
|
|
return _not_found("AuthorityInspection", inspection_id)
|
|
if inspection.state != InspectionType.State.PATROLLING:
|
|
return _invalid("Inspection is not PATROLLING.")
|
|
if absolute_minute < inspection.state_changed_at:
|
|
return _invalid("Patrol time cannot move backwards.")
|
|
if inspection.current_waypoint_index >= inspection.waypoint_route.size():
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.REVIEWING,
|
|
absolute_minute,
|
|
&"PATROL_COMPLETE"
|
|
)
|
|
var waypoint: Dictionary = inspection.waypoint_route[inspection.current_waypoint_index]
|
|
var from_cell := inspection.entrance_cell
|
|
if inspection.current_waypoint_index > 0:
|
|
from_cell = inspection.waypoint_route[inspection.current_waypoint_index - 1][&"cell"]
|
|
var to_cell: Vector3i = waypoint[&"cell"]
|
|
var path_result := {
|
|
&"reachable": false,
|
|
&"reason": &"PATH_QUERY_UNAVAILABLE",
|
|
&"travel_minutes": int(waypoint[&"travel_minutes"]),
|
|
}
|
|
if _path_query.is_valid():
|
|
var path_context := context.duplicate(true)
|
|
path_context[&"inspection_id"] = inspection_id
|
|
path_context[&"waypoint_id"] = waypoint[&"waypoint_id"]
|
|
var raw_result: Variant = _path_query.call(from_cell, to_cell, path_context)
|
|
path_result = _normalize_path_result(raw_result, int(waypoint[&"travel_minutes"]))
|
|
if not bool(path_result[&"reachable"]):
|
|
var waypoint_id: StringName = waypoint[&"waypoint_id"]
|
|
inspection.add_inaccessible_waypoint(waypoint_id)
|
|
inspection.exit_reason = &"PATH_UNREACHABLE"
|
|
inspection.result_reason = StringName(path_result.get(&"reason", &"PATH_UNREACHABLE"))
|
|
var departure := _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.DEPARTING,
|
|
absolute_minute,
|
|
inspection.result_reason
|
|
)
|
|
if not departure.is_success():
|
|
return departure
|
|
return _success({
|
|
&"inspection_id": inspection_id,
|
|
&"reachable": false,
|
|
&"waypoint_id": waypoint_id,
|
|
&"exit_reason": inspection.exit_reason,
|
|
&"path_reason": inspection.result_reason,
|
|
})
|
|
var travel_minutes := maxi(int(path_result[&"travel_minutes"]), 0)
|
|
inspection.add_visited_waypoint(waypoint[&"waypoint_id"], travel_minutes)
|
|
last_error = ""
|
|
return _success({
|
|
&"inspection_id": inspection_id,
|
|
&"reachable": true,
|
|
&"waypoint_id": waypoint[&"waypoint_id"],
|
|
&"cell": to_cell,
|
|
&"travel_minutes": travel_minutes,
|
|
})
|
|
|
|
|
|
func inspect_current_waypoint(
|
|
inspection_id: StringName,
|
|
evidence_ids: Array,
|
|
absolute_minute: int,
|
|
context: Dictionary = {}
|
|
) -> CommandResult:
|
|
var inspection := get_inspection(inspection_id)
|
|
if inspection == null:
|
|
return _not_found("AuthorityInspection", inspection_id)
|
|
if inspection.state != InspectionType.State.PATROLLING or inspection.current_waypoint_index <= 0:
|
|
return _invalid("Inspector must first reach a patrol waypoint.")
|
|
if absolute_minute < inspection.state_changed_at:
|
|
return _invalid("Evidence inspection time cannot move backwards.")
|
|
var waypoint: Dictionary = inspection.waypoint_route[inspection.current_waypoint_index - 1]
|
|
var inspector_cell: Vector3i = waypoint[&"cell"]
|
|
var discovered: Array[StringName] = []
|
|
var rejected: Array[Dictionary] = []
|
|
for evidence_id: StringName in _sorted_unique_names(evidence_ids):
|
|
var result := attempt_discover_evidence(
|
|
evidence_id,
|
|
inspection.inspector_id,
|
|
inspector_cell,
|
|
absolute_minute,
|
|
context
|
|
)
|
|
if result.is_success():
|
|
inspection.add_discovered_evidence(evidence_id)
|
|
discovered.append(evidence_id)
|
|
else:
|
|
rejected.append({
|
|
&"evidence_id": String(evidence_id),
|
|
&"message": result.message,
|
|
&"details": result.details.duplicate(true),
|
|
})
|
|
inspection.time_spent_minutes += int(waypoint[&"inspection_minutes"])
|
|
inspection.evidence_score = _calculate_inspection_evidence_score(inspection, absolute_minute)
|
|
inspection.revision += 1
|
|
last_error = ""
|
|
return _success({
|
|
&"inspection_id": inspection_id,
|
|
&"waypoint_id": waypoint[&"waypoint_id"],
|
|
&"discovered_evidence_ids": discovered,
|
|
&"rejected": rejected,
|
|
&"evidence_score": inspection.evidence_score,
|
|
})
|
|
|
|
|
|
func finish_patrol(inspection_id: StringName, absolute_minute: int) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.REVIEWING,
|
|
absolute_minute,
|
|
&"PATROL_COMPLETE"
|
|
)
|
|
|
|
|
|
func begin_inspection_departure(
|
|
inspection_id: StringName,
|
|
absolute_minute: int,
|
|
reason: StringName = &"DEPARTING"
|
|
) -> CommandResult:
|
|
return _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.DEPARTING,
|
|
absolute_minute,
|
|
reason
|
|
)
|
|
|
|
|
|
func resolve_inspection(
|
|
inspection_id: StringName,
|
|
resolution_id: StringName,
|
|
absolute_minute: int,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
if resolution_id.is_empty():
|
|
return _invalid("Inspection resolution requires a resolution_id.")
|
|
if _applied_resolutions.has(resolution_id):
|
|
var existing: Dictionary = _applied_resolutions[resolution_id]
|
|
var replay := _success(existing.duplicate(true))
|
|
replay.is_replay = true
|
|
last_error = ""
|
|
return replay
|
|
var inspection := get_inspection(inspection_id)
|
|
if inspection == null:
|
|
return _not_found("AuthorityInspection", inspection_id)
|
|
if inspection.state != InspectionType.State.REVIEWING:
|
|
return _invalid("Inspection can only resolve from REVIEWING.")
|
|
if not inspection.resolution_id.is_empty():
|
|
return _invalid("Inspection already has a different resolution.")
|
|
inspection.evidence_score = _calculate_inspection_evidence_score(inspection, absolute_minute)
|
|
var outcome := _determine_resolution(inspection.evidence_score, options)
|
|
var fine_per_point := maxi(int(_value(options, &"fine_per_point", 10)), 0)
|
|
var fine_amount := 0
|
|
if outcome >= InspectionType.Result.FINE:
|
|
fine_amount = inspection.evidence_score * fine_per_point
|
|
var sanction := _sanction_for_result(outcome)
|
|
var payload := {
|
|
&"resolution_id": resolution_id,
|
|
&"inspection_id": inspection_id,
|
|
&"absolute_minute": absolute_minute,
|
|
&"result": StringName(InspectionType.Result.keys()[outcome]),
|
|
&"evidence_score": inspection.evidence_score,
|
|
&"fine_amount": fine_amount,
|
|
&"sanction": sanction,
|
|
&"evidence_ids": _names_to_strings(inspection.discovered_evidence_ids),
|
|
}
|
|
if absolute_minute < inspection.state_changed_at:
|
|
return _invalid("Inspection resolution time cannot move backwards.")
|
|
if _resolution_applier.is_valid():
|
|
var validation := _normalize_callback_result(
|
|
_resolution_applier.call(payload.duplicate(true), &"validate", null)
|
|
)
|
|
if not bool(validation[&"ok"]):
|
|
return _invalid("Inspection resolution validation failed.", validation)
|
|
var applied := _normalize_callback_result(
|
|
_resolution_applier.call(
|
|
payload.duplicate(true),
|
|
&"apply",
|
|
validation.get(&"token", null)
|
|
)
|
|
)
|
|
if not bool(applied[&"ok"]):
|
|
var rollback := _normalize_callback_result(_resolution_applier.call(
|
|
payload.duplicate(true),
|
|
&"rollback",
|
|
applied.get(&"token", validation.get(&"token", null))
|
|
))
|
|
var failure_details := applied.duplicate(true)
|
|
failure_details[&"rollback_ok"] = bool(rollback[&"ok"])
|
|
return _invalid("Inspection resolution application failed.", failure_details)
|
|
var transition := _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.RESOLVING,
|
|
absolute_minute,
|
|
&"RESOLUTION_STARTED"
|
|
)
|
|
if not transition.is_success():
|
|
return transition
|
|
if fine_amount > 0:
|
|
_state.add_fine_due(fine_amount)
|
|
inspection.result = outcome
|
|
inspection.resolution_id = resolution_id
|
|
inspection.result_reason = &"RESOLUTION_APPLIED"
|
|
inspection.revision += 1
|
|
_applied_resolutions[resolution_id] = payload.duplicate(true)
|
|
for evidence_id: StringName in inspection.discovered_evidence_ids:
|
|
var evidence := get_evidence(evidence_id)
|
|
if evidence == null or evidence.incident_id.is_empty():
|
|
continue
|
|
var incident := get_incident(evidence.incident_id)
|
|
if incident != null and incident.state == IncidentType.State.OPEN:
|
|
incident.resolve(resolution_id, &"INSPECTION_RESOLVED")
|
|
_state.remove_active_case(incident.incident_id)
|
|
_change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.DEPARTING,
|
|
absolute_minute,
|
|
&"RESOLUTION_APPLIED"
|
|
)
|
|
resolution_applied.emit(inspection_id, payload.duplicate(true))
|
|
last_error = ""
|
|
return _success(payload)
|
|
|
|
|
|
func depart_inspection(inspection_id: StringName, absolute_minute: int) -> CommandResult:
|
|
var inspection := get_inspection(inspection_id)
|
|
if inspection == null:
|
|
return _not_found("AuthorityInspection", inspection_id)
|
|
var departure_reason := inspection.result_reason
|
|
if departure_reason.is_empty():
|
|
departure_reason = &"DEPARTED"
|
|
var result := _change_inspection_state(
|
|
inspection_id,
|
|
InspectionType.State.CLOSED,
|
|
absolute_minute,
|
|
departure_reason
|
|
)
|
|
if not result.is_success():
|
|
return result
|
|
last_error = ""
|
|
return _success({
|
|
&"inspection_id": inspection_id,
|
|
&"state": &"CLOSED",
|
|
&"exit_reason": inspection.exit_reason,
|
|
&"result_reason": inspection.result_reason,
|
|
})
|
|
|
|
|
|
func validate_invariants() -> Dictionary:
|
|
return _validate_candidate_invariants(
|
|
_state,
|
|
_incidents,
|
|
_evidence,
|
|
_inspections,
|
|
_scored_incident_points,
|
|
_applied_resolutions,
|
|
current_minute
|
|
)
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var incidents: Array[Dictionary] = []
|
|
for incident: AuthorityIncident in get_incidents():
|
|
incidents.append(incident.create_snapshot())
|
|
var evidence_snapshots: Array[Dictionary] = []
|
|
for evidence: AuthorityEvidence in get_all_evidence():
|
|
evidence_snapshots.append(evidence.create_snapshot())
|
|
var inspections: Array[Dictionary] = []
|
|
for inspection: AuthorityInspection in get_inspections():
|
|
inspections.append(inspection.create_snapshot())
|
|
var scored_points: Array[Dictionary] = []
|
|
for score_key: StringName in _sorted_keys(_scored_incident_points):
|
|
scored_points.append({
|
|
&"score_key": String(score_key),
|
|
&"points": int(_scored_incident_points[score_key]),
|
|
})
|
|
var resolutions: Array[Dictionary] = []
|
|
for resolution_id: StringName in _sorted_keys(_applied_resolutions):
|
|
resolutions.append({
|
|
&"resolution_id": String(resolution_id),
|
|
&"payload": (_applied_resolutions[resolution_id] as Dictionary).duplicate(true),
|
|
})
|
|
var evaluated_days: Array[int] = []
|
|
for day_value: Variant in _evaluated_inspection_days.keys():
|
|
evaluated_days.append(int(day_value))
|
|
evaluated_days.sort()
|
|
var evaluations: Array[Dictionary] = []
|
|
for day_index: int in evaluated_days:
|
|
evaluations.append({
|
|
&"day_index": day_index,
|
|
&"result": (_evaluated_inspection_days[day_index] as Dictionary).duplicate(true),
|
|
})
|
|
return {
|
|
&"version": SNAPSHOT_VERSION,
|
|
&"current_minute": current_minute,
|
|
&"authority_state": _state.create_snapshot(),
|
|
&"incidents": incidents,
|
|
&"evidence": evidence_snapshots,
|
|
&"inspections": inspections,
|
|
&"scored_incident_points": scored_points,
|
|
&"applied_resolutions": resolutions,
|
|
&"evaluated_inspection_days": evaluations,
|
|
&"next_inspection_sequence": _next_inspection_sequence,
|
|
&"next_inspector_sequence": _next_inspector_sequence,
|
|
}
|
|
|
|
|
|
## Parses every object and cross-reference before replacing any live field.
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
var parsed := _parse_snapshot(snapshot)
|
|
if not bool(parsed.get(&"ok", false)):
|
|
last_error = String(parsed.get(&"error", "Invalid Authority snapshot."))
|
|
return false
|
|
_state = parsed[&"authority_state"]
|
|
_incidents = parsed[&"incidents"]
|
|
_evidence = parsed[&"evidence"]
|
|
_inspections = parsed[&"inspections"]
|
|
_scored_incident_points = parsed[&"scored_incident_points"]
|
|
_applied_resolutions = parsed[&"applied_resolutions"]
|
|
_evaluated_inspection_days = parsed[&"evaluated_inspection_days"]
|
|
current_minute = int(parsed[&"current_minute"])
|
|
_next_inspection_sequence = int(parsed[&"next_inspection_sequence"])
|
|
_next_inspector_sequence = int(parsed[&"next_inspector_sequence"])
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
|
if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION:
|
|
return {&"ok": false, &"error": "Unsupported Authority snapshot version."}
|
|
for collection_key: StringName in [
|
|
&"incidents",
|
|
&"evidence",
|
|
&"inspections",
|
|
&"scored_incident_points",
|
|
&"applied_resolutions",
|
|
&"evaluated_inspection_days",
|
|
]:
|
|
if not _value(snapshot, collection_key, null) is Array:
|
|
return {&"ok": false, &"error": "Malformed Authority collection: %s" % collection_key}
|
|
var raw_state: Variant = _value(snapshot, &"authority_state", null)
|
|
if not raw_state is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed AuthorityState snapshot."}
|
|
var next_state := HeatStateType.from_snapshot(raw_state)
|
|
if next_state == null:
|
|
return {&"ok": false, &"error": "Invalid AuthorityState snapshot."}
|
|
var next_incidents: Dictionary = {}
|
|
for raw_incident: Variant in _value(snapshot, &"incidents", []):
|
|
if not raw_incident is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed Incident snapshot."}
|
|
var incident := IncidentType.from_snapshot(raw_incident)
|
|
if incident == null or next_incidents.has(incident.incident_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate Incident snapshot."}
|
|
next_incidents[incident.incident_id] = incident
|
|
var next_evidence: Dictionary = {}
|
|
for raw_evidence: Variant in _value(snapshot, &"evidence", []):
|
|
if not raw_evidence is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed Evidence snapshot."}
|
|
var evidence := EvidenceType.from_snapshot(raw_evidence)
|
|
if evidence == null or next_evidence.has(evidence.evidence_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate Evidence snapshot."}
|
|
next_evidence[evidence.evidence_id] = evidence
|
|
var next_inspections: Dictionary = {}
|
|
for raw_inspection: Variant in _value(snapshot, &"inspections", []):
|
|
if not raw_inspection is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed Inspection snapshot."}
|
|
var inspection := InspectionType.from_snapshot(raw_inspection)
|
|
if inspection == null or next_inspections.has(inspection.inspection_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate Inspection snapshot."}
|
|
next_inspections[inspection.inspection_id] = inspection
|
|
var next_scored_points: Dictionary = {}
|
|
for raw_score: Variant in _value(snapshot, &"scored_incident_points", []):
|
|
if not raw_score is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed Evidence score snapshot."}
|
|
var score_key := StringName(_value(raw_score, &"score_key", &""))
|
|
var points := int(_value(raw_score, &"points", -1))
|
|
if score_key.is_empty() or points < 0 or next_scored_points.has(score_key):
|
|
return {&"ok": false, &"error": "Invalid or duplicate Evidence score snapshot."}
|
|
next_scored_points[score_key] = points
|
|
var next_resolutions: Dictionary = {}
|
|
for raw_resolution: Variant in _value(snapshot, &"applied_resolutions", []):
|
|
if not raw_resolution is Dictionary or not _value(raw_resolution, &"payload", null) is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed resolution snapshot."}
|
|
var resolution_id := StringName(_value(raw_resolution, &"resolution_id", &""))
|
|
if resolution_id.is_empty() or next_resolutions.has(resolution_id):
|
|
return {&"ok": false, &"error": "Invalid or duplicate resolution snapshot."}
|
|
next_resolutions[resolution_id] = (_value(raw_resolution, &"payload", {}) as Dictionary).duplicate(true)
|
|
var next_evaluations: Dictionary = {}
|
|
for raw_evaluation: Variant in _value(snapshot, &"evaluated_inspection_days", []):
|
|
if not raw_evaluation is Dictionary or not _value(raw_evaluation, &"result", null) is Dictionary:
|
|
return {&"ok": false, &"error": "Malformed inspection evaluation snapshot."}
|
|
var day_index := int(_value(raw_evaluation, &"day_index", -1))
|
|
if day_index < 0 or next_evaluations.has(day_index):
|
|
return {&"ok": false, &"error": "Invalid or duplicate inspection evaluation day."}
|
|
next_evaluations[day_index] = (_value(raw_evaluation, &"result", {}) as Dictionary).duplicate(true)
|
|
var next_current_minute := int(_value(snapshot, &"current_minute", -1))
|
|
var next_inspection_sequence := int(_value(snapshot, &"next_inspection_sequence", 0))
|
|
var next_inspector_sequence := int(_value(snapshot, &"next_inspector_sequence", 0))
|
|
if next_current_minute < 0 or next_inspection_sequence < 1 or next_inspector_sequence < 1:
|
|
return {&"ok": false, &"error": "Authority snapshot counters are invalid."}
|
|
var invariant_report := _validate_candidate_invariants(
|
|
next_state,
|
|
next_incidents,
|
|
next_evidence,
|
|
next_inspections,
|
|
next_scored_points,
|
|
next_resolutions,
|
|
next_current_minute
|
|
)
|
|
if not bool(invariant_report[&"ok"]):
|
|
return {
|
|
&"ok": false,
|
|
&"error": "Authority snapshot violates invariants: %s" % [str(invariant_report[&"errors"])],
|
|
}
|
|
return {
|
|
&"ok": true,
|
|
&"authority_state": next_state,
|
|
&"incidents": next_incidents,
|
|
&"evidence": next_evidence,
|
|
&"inspections": next_inspections,
|
|
&"scored_incident_points": next_scored_points,
|
|
&"applied_resolutions": next_resolutions,
|
|
&"evaluated_inspection_days": next_evaluations,
|
|
&"current_minute": next_current_minute,
|
|
&"next_inspection_sequence": next_inspection_sequence,
|
|
&"next_inspector_sequence": next_inspector_sequence,
|
|
}
|
|
|
|
|
|
func _validate_candidate_invariants(
|
|
state: HeatState,
|
|
incidents: Dictionary,
|
|
evidence_by_id: Dictionary,
|
|
inspections: Dictionary,
|
|
scored_points: Dictionary,
|
|
resolutions: Dictionary,
|
|
reference_minute: int
|
|
) -> Dictionary:
|
|
var errors: Array[String] = []
|
|
if state == null or not state.is_valid():
|
|
errors.append("AuthorityState is invalid.")
|
|
return {&"ok": false, &"errors": errors}
|
|
if state.last_decay_day > reference_minute / 1_440:
|
|
errors.append("AuthorityState daily decay is ahead of current time.")
|
|
for incident_id: StringName in _sorted_keys(incidents):
|
|
var incident: AuthorityIncident = incidents[incident_id]
|
|
if incident == null or incident.incident_id != incident_id or not incident.is_valid():
|
|
errors.append("Invalid Incident: %s" % incident_id)
|
|
continue
|
|
if incident.detector_ids.is_empty():
|
|
errors.append("Incident has no reporting detector: %s" % incident_id)
|
|
var should_be_active := incident.state == IncidentType.State.OPEN
|
|
if state.active_case_ids.has(incident_id) != should_be_active:
|
|
errors.append("Incident active-case mismatch: %s" % incident_id)
|
|
for evidence_id: StringName in incident.evidence_ids:
|
|
if not evidence_by_id.has(evidence_id):
|
|
errors.append("Incident references missing Evidence: %s" % evidence_id)
|
|
if incident.state == IncidentType.State.RESOLVED and not resolutions.has(incident.resolution_id):
|
|
errors.append("Resolved Incident references missing resolution: %s" % incident_id)
|
|
for case_id: StringName in state.active_case_ids:
|
|
if not incidents.has(case_id):
|
|
errors.append("AuthorityState references missing active case: %s" % case_id)
|
|
for evidence_id: StringName in _sorted_keys(evidence_by_id):
|
|
var evidence: AuthorityEvidence = evidence_by_id[evidence_id]
|
|
if evidence == null or evidence.evidence_id != evidence_id or not evidence.is_valid():
|
|
errors.append("Invalid Evidence: %s" % evidence_id)
|
|
continue
|
|
if not evidence.incident_id.is_empty():
|
|
if not incidents.has(evidence.incident_id):
|
|
errors.append("Evidence references missing Incident: %s" % evidence_id)
|
|
elif not (incidents[evidence.incident_id] as AuthorityIncident).evidence_ids.has(evidence_id):
|
|
errors.append("Incident/Evidence reverse reference mismatch: %s" % evidence_id)
|
|
var expected_scores: Dictionary = {}
|
|
for evidence_id: StringName in _sorted_keys(evidence_by_id):
|
|
var scored_evidence: AuthorityEvidence = evidence_by_id[evidence_id]
|
|
if scored_evidence == null or not scored_evidence.is_discovered() \
|
|
or scored_evidence.is_expired(reference_minute):
|
|
continue
|
|
var expected_key := scored_evidence.incident_id
|
|
if expected_key.is_empty():
|
|
expected_key = StringName("evidence:%s" % evidence_id)
|
|
expected_scores[expected_key] = maxi(
|
|
int(expected_scores.get(expected_key, 0)),
|
|
scored_evidence.severity
|
|
)
|
|
for score_key: StringName in _sorted_keys(scored_points):
|
|
if int(scored_points[score_key]) < 0 or int(scored_points[score_key]) > 100:
|
|
errors.append("Invalid Evidence score: %s" % score_key)
|
|
if scored_points != expected_scores:
|
|
errors.append("Evidence score index does not match discovered, unexpired evidence.")
|
|
var score_sum := 0
|
|
for points: Variant in scored_points.values():
|
|
score_sum += int(points)
|
|
if score_sum != state.evidence_points:
|
|
errors.append("Authority evidence_points cache mismatch.")
|
|
for inspection_id: StringName in _sorted_keys(inspections):
|
|
var inspection: AuthorityInspection = inspections[inspection_id]
|
|
if inspection == null or inspection.inspection_id != inspection_id or not inspection.is_valid():
|
|
errors.append("Invalid Inspection: %s" % inspection_id)
|
|
continue
|
|
for evidence_id: StringName in inspection.discovered_evidence_ids:
|
|
if not evidence_by_id.has(evidence_id) or not (evidence_by_id[evidence_id] as AuthorityEvidence).is_discovered():
|
|
errors.append("Inspection references unavailable Evidence: %s" % evidence_id)
|
|
if not inspection.resolution_id.is_empty() and not resolutions.has(inspection.resolution_id):
|
|
errors.append("Inspection resolution reference is missing: %s" % inspection.resolution_id)
|
|
var expected_fines_due := 0
|
|
for resolution_id: StringName in _sorted_keys(resolutions):
|
|
var payload: Dictionary = resolutions[resolution_id]
|
|
if StringName(_value(payload, &"resolution_id", &"")) != resolution_id:
|
|
errors.append("Resolution payload ID mismatch: %s" % resolution_id)
|
|
var inspection_id := StringName(_value(payload, &"inspection_id", &""))
|
|
var fine_amount := int(_value(payload, &"fine_amount", -1))
|
|
if fine_amount < 0:
|
|
errors.append("Resolution has an invalid fine amount: %s" % resolution_id)
|
|
else:
|
|
expected_fines_due += fine_amount
|
|
if not inspections.has(inspection_id):
|
|
errors.append("Resolution references missing Inspection: %s" % resolution_id)
|
|
else:
|
|
var resolved_inspection: AuthorityInspection = inspections[inspection_id]
|
|
if resolved_inspection.resolution_id != resolution_id:
|
|
errors.append("Resolution/Inspection reverse reference mismatch: %s" % resolution_id)
|
|
if String(_value(payload, &"result", "")) != InspectionType.Result.keys()[resolved_inspection.result]:
|
|
errors.append("Resolution result cache mismatch: %s" % resolution_id)
|
|
if int(_value(payload, &"evidence_score", -1)) != resolved_inspection.evidence_score:
|
|
errors.append("Resolution Evidence score cache mismatch: %s" % resolution_id)
|
|
if expected_fines_due != state.fines_due:
|
|
errors.append("Authority fines_due cache mismatch.")
|
|
return {&"ok": errors.is_empty(), &"errors": errors}
|
|
|
|
|
|
func _recompute_authority_evidence_points(absolute_minute: int) -> void:
|
|
var next_scores: Dictionary = {}
|
|
for evidence_id: StringName in _sorted_keys(_evidence):
|
|
var evidence := get_evidence(evidence_id)
|
|
if evidence == null or not evidence.is_discovered() or evidence.is_expired(absolute_minute):
|
|
continue
|
|
var score_key := evidence.incident_id
|
|
if score_key.is_empty():
|
|
score_key = StringName("evidence:%s" % evidence.evidence_id)
|
|
next_scores[score_key] = maxi(int(next_scores.get(score_key, 0)), evidence.severity)
|
|
_scored_incident_points = next_scores
|
|
var total := 0
|
|
for points: Variant in _scored_incident_points.values():
|
|
total += int(points)
|
|
_state.set_evidence_points(total)
|
|
|
|
|
|
func _calculate_inspection_evidence_score(
|
|
inspection: AuthorityInspection,
|
|
absolute_minute: int
|
|
) -> int:
|
|
var incident_scores: Dictionary = {}
|
|
for evidence_id: StringName in inspection.discovered_evidence_ids:
|
|
var evidence := get_evidence(evidence_id)
|
|
if evidence == null or not evidence.is_discovered() or evidence.is_expired(absolute_minute):
|
|
continue
|
|
var score_key := evidence.incident_id
|
|
if score_key.is_empty():
|
|
score_key = StringName("evidence:%s" % evidence.evidence_id)
|
|
incident_scores[score_key] = maxi(int(incident_scores.get(score_key, 0)), evidence.severity)
|
|
var total := 0
|
|
for points: Variant in incident_scores.values():
|
|
total += int(points)
|
|
return total
|
|
|
|
|
|
func _change_inspection_state(
|
|
inspection_id: StringName,
|
|
new_state: AuthorityInspection.State,
|
|
absolute_minute: int,
|
|
reason: StringName
|
|
) -> CommandResult:
|
|
var inspection := get_inspection(inspection_id)
|
|
if inspection == null:
|
|
return _not_found("AuthorityInspection", inspection_id)
|
|
if absolute_minute < inspection.state_changed_at:
|
|
return _invalid("Inspection state time cannot move backwards.")
|
|
if not _transition_is_allowed(inspection.state, new_state):
|
|
return _invalid("Illegal Inspection state transition.", {
|
|
&"old_state": InspectionType.State.keys()[inspection.state],
|
|
&"new_state": InspectionType.State.keys()[new_state],
|
|
})
|
|
var old_state := inspection.state
|
|
if not inspection.set_state(new_state, absolute_minute, reason):
|
|
return _invalid("Inspection state transition did not change state.")
|
|
inspection_state_changed.emit(inspection_id, old_state, new_state, reason)
|
|
last_error = ""
|
|
return _success({
|
|
&"inspection_id": inspection_id,
|
|
&"old_state": StringName(InspectionType.State.keys()[old_state]),
|
|
&"new_state": StringName(InspectionType.State.keys()[new_state]),
|
|
&"reason": reason,
|
|
})
|
|
|
|
|
|
static func _transition_is_allowed(
|
|
old_state: AuthorityInspection.State,
|
|
new_state: AuthorityInspection.State
|
|
) -> bool:
|
|
match old_state:
|
|
InspectionType.State.SCHEDULED:
|
|
return new_state in [InspectionType.State.ARRIVING, InspectionType.State.CANCELLED]
|
|
InspectionType.State.ARRIVING:
|
|
return new_state in [
|
|
InspectionType.State.CHECK_IN,
|
|
InspectionType.State.BLOCKED_ENTRY,
|
|
InspectionType.State.INTERRUPTED,
|
|
InspectionType.State.HOSTILE,
|
|
InspectionType.State.CANCELLED,
|
|
]
|
|
InspectionType.State.CHECK_IN:
|
|
return new_state in [
|
|
InspectionType.State.PATROLLING,
|
|
InspectionType.State.DEPARTING,
|
|
InspectionType.State.INTERRUPTED,
|
|
InspectionType.State.HOSTILE,
|
|
InspectionType.State.CANCELLED,
|
|
]
|
|
InspectionType.State.PATROLLING:
|
|
return new_state in [
|
|
InspectionType.State.REVIEWING,
|
|
InspectionType.State.DEPARTING,
|
|
InspectionType.State.INTERRUPTED,
|
|
InspectionType.State.HOSTILE,
|
|
InspectionType.State.CANCELLED,
|
|
]
|
|
InspectionType.State.REVIEWING:
|
|
return new_state in [
|
|
InspectionType.State.RESOLVING,
|
|
InspectionType.State.DEPARTING,
|
|
InspectionType.State.CANCELLED,
|
|
]
|
|
InspectionType.State.RESOLVING:
|
|
return new_state in [InspectionType.State.REVIEWING, InspectionType.State.DEPARTING]
|
|
InspectionType.State.BLOCKED_ENTRY, InspectionType.State.INTERRUPTED, InspectionType.State.HOSTILE:
|
|
return new_state == InspectionType.State.DEPARTING
|
|
InspectionType.State.DEPARTING:
|
|
return new_state == InspectionType.State.CLOSED
|
|
return false
|
|
|
|
|
|
static func _determine_resolution(score: int, options: Dictionary) -> AuthorityInspection.Result:
|
|
if _has_key(options, &"result"):
|
|
var requested := String(_value(options, &"result", "PENDING"))
|
|
var index := InspectionType.Result.keys().find(requested)
|
|
if index > InspectionType.Result.PENDING:
|
|
return index as AuthorityInspection.Result
|
|
var warning_threshold := maxi(int(_value(options, &"warning_threshold", 1)), 1)
|
|
var fine_threshold := maxi(int(_value(options, &"fine_threshold", 20)), warning_threshold)
|
|
var closure_threshold := maxi(int(_value(options, &"closure_threshold", 50)), fine_threshold)
|
|
var confiscation_threshold := maxi(int(_value(options, &"confiscation_threshold", 80)), closure_threshold)
|
|
var arrest_threshold := maxi(int(_value(options, &"arrest_threshold", 110)), confiscation_threshold)
|
|
var raid_threshold := maxi(int(_value(options, &"raid_threshold", 140)), arrest_threshold)
|
|
if score >= raid_threshold:
|
|
return InspectionType.Result.RAID_ESCALATION
|
|
if score >= arrest_threshold:
|
|
return InspectionType.Result.ARREST_ATTEMPT
|
|
if score >= confiscation_threshold:
|
|
return InspectionType.Result.CONFISCATION
|
|
if score >= closure_threshold:
|
|
return InspectionType.Result.TEMPORARY_CLOSURE
|
|
if score >= fine_threshold:
|
|
return InspectionType.Result.FINE
|
|
if score >= warning_threshold:
|
|
return InspectionType.Result.WARNING
|
|
return InspectionType.Result.NO_ACTION
|
|
|
|
|
|
static func _sanction_for_result(result: AuthorityInspection.Result) -> StringName:
|
|
match result:
|
|
InspectionType.Result.TEMPORARY_CLOSURE:
|
|
return &"TEMPORARY_CLOSURE"
|
|
InspectionType.Result.CONFISCATION:
|
|
return &"CONFISCATION"
|
|
InspectionType.Result.ARREST_ATTEMPT:
|
|
return &"ARREST_ATTEMPT"
|
|
InspectionType.Result.RAID_ESCALATION:
|
|
return &"RAID_ESCALATION"
|
|
return &"NONE"
|
|
|
|
|
|
static func _normalize_path_result(value: Variant, fallback_minutes: int) -> Dictionary:
|
|
if value is bool:
|
|
return {
|
|
&"reachable": value,
|
|
&"reason": &"OK" if value else &"PATH_UNREACHABLE",
|
|
&"travel_minutes": fallback_minutes,
|
|
}
|
|
if value is Dictionary:
|
|
return {
|
|
&"reachable": bool(_value(value, &"reachable", false)),
|
|
&"reason": StringName(_value(value, &"reason", &"OK")),
|
|
&"travel_minutes": maxi(int(_value(value, &"travel_minutes", fallback_minutes)), 0),
|
|
}
|
|
return {
|
|
&"reachable": false,
|
|
&"reason": &"PATH_QUERY_INVALID",
|
|
&"travel_minutes": fallback_minutes,
|
|
}
|
|
|
|
|
|
static func _normalize_callback_result(value: Variant) -> Dictionary:
|
|
if value is bool:
|
|
return {&"ok": value}
|
|
if value is Dictionary:
|
|
var result: Dictionary = value.duplicate(true)
|
|
result[&"ok"] = bool(_value(value, &"ok", false))
|
|
return result
|
|
return {&"ok": false, &"reason": &"INVALID_CALLBACK_RESULT"}
|
|
|
|
|
|
func _has_open_inspection() -> bool:
|
|
for inspection: AuthorityInspection in get_inspections():
|
|
if not inspection.is_terminal():
|
|
return true
|
|
return false
|
|
|
|
|
|
static func _waypoint_less(a: Dictionary, b: Dictionary) -> bool:
|
|
var utility_a := int(a.get(&"utility", 0))
|
|
var utility_b := int(b.get(&"utility", 0))
|
|
if utility_a != utility_b:
|
|
return utility_a > utility_b
|
|
return String(a.get(&"waypoint_id", "")) < String(b.get(&"waypoint_id", ""))
|
|
|
|
|
|
static func _waypoint_ids(waypoints: Array[Dictionary]) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for waypoint: Dictionary in waypoints:
|
|
result.append(StringName(waypoint[&"waypoint_id"]))
|
|
return result
|
|
|
|
|
|
static func _stable_percent(text: String) -> int:
|
|
var value := 17
|
|
for index: int in range(text.length()):
|
|
value = (value * 131 + text.unicode_at(index)) % 10_000_019
|
|
return value % 100
|
|
|
|
|
|
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for raw_key: Variant in source.keys():
|
|
result.append(StringName(raw_key))
|
|
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return result
|
|
|
|
|
|
static func _sorted_unique_names(values: Variant) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
if values is Array:
|
|
for raw_value: Variant in values:
|
|
var value := StringName(raw_value)
|
|
if not value.is_empty() and not result.has(value):
|
|
result.append(value)
|
|
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return result
|
|
|
|
|
|
static func _names_to_strings(values: Array[StringName]) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for value: StringName in values:
|
|
result.append(String(value))
|
|
return result
|
|
|
|
|
|
static func _has_key(source: Dictionary, key: StringName) -> bool:
|
|
return source.has(key) or source.has(String(key))
|
|
|
|
|
|
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
|
if source.has(key):
|
|
return source[key]
|
|
return source.get(String(key), fallback)
|
|
|
|
|
|
func _success(details: Dictionary = {}) -> CommandResult:
|
|
return CommandResultType.succeeded(0, details)
|
|
|
|
|
|
func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
|
|
last_error = message
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.VALIDATION_FAILED,
|
|
0,
|
|
message,
|
|
details
|
|
)
|
|
|
|
|
|
func _not_found(kind: String, entity_id: StringName) -> CommandResult:
|
|
return _invalid("%s not found: %s" % [kind, entity_id], {&"entity_id": entity_id})
|
|
|
|
|
|
func _duplicate(kind: String, entity_id: StringName) -> CommandResult:
|
|
return _invalid("Duplicate %s ID: %s" % [kind, entity_id], {&"entity_id": entity_id})
|