class_name AuthorityInspection extends RefCounted enum State { SCHEDULED, ARRIVING, CHECK_IN, PATROLLING, REVIEWING, RESOLVING, DEPARTING, CLOSED, BLOCKED_ENTRY, INTERRUPTED, HOSTILE, CANCELLED, } enum Result { PENDING, NO_ACTION, WARNING, FINE, TEMPORARY_CLOSURE, CONFISCATION, ARREST_ATTEMPT, RAID_ESCALATION, } var inspection_id: StringName = &"" var inspector_id: StringName = &"" var scheduled_at := 0 var entrance_cell := Vector3i.ZERO var state: State = State.SCHEDULED var state_changed_at := 0 var time_budget_minutes := 120 var time_spent_minutes := 0 var waypoint_route: Array[Dictionary] = [] var current_waypoint_index := 0 var visited_waypoint_ids: Array[StringName] = [] var inaccessible_waypoint_ids: Array[StringName] = [] var discovered_evidence_ids: Array[StringName] = [] var evidence_score := 0 var result: Result = Result.PENDING var result_reason: StringName = &"" var exit_reason: StringName = &"" var resolution_id: StringName = &"" var closed_at := -1 var revision := 0 func configure( p_inspection_id: StringName, p_inspector_id: StringName, p_scheduled_at: int, options: Dictionary = {} ) -> AuthorityInspection: inspection_id = p_inspection_id inspector_id = p_inspector_id scheduled_at = maxi(p_scheduled_at, 0) entrance_cell = _vector3i(_value(options, &"entrance_cell", Vector3i.ZERO)) state = _state_from_value(_value(options, &"state", State.SCHEDULED)) state_changed_at = maxi(int(_value(options, &"state_changed_at", scheduled_at)), 0) time_budget_minutes = maxi(int(_value(options, &"time_budget_minutes", 120)), 1) time_spent_minutes = maxi(int(_value(options, &"time_spent_minutes", 0)), 0) waypoint_route.clear() var raw_route: Variant = _value(options, &"waypoint_route", []) if raw_route is Array: for raw_waypoint: Variant in raw_route: if raw_waypoint is Dictionary: var normalized := normalize_waypoint(raw_waypoint) if not normalized.is_empty(): waypoint_route.append(normalized) current_waypoint_index = maxi(int(_value(options, &"current_waypoint_index", 0)), 0) visited_waypoint_ids = _sorted_unique_names(_value(options, &"visited_waypoint_ids", [])) inaccessible_waypoint_ids = _sorted_unique_names(_value(options, &"inaccessible_waypoint_ids", [])) discovered_evidence_ids = _sorted_unique_names(_value(options, &"discovered_evidence_ids", [])) evidence_score = maxi(int(_value(options, &"evidence_score", 0)), 0) result = _result_from_value(_value(options, &"result", Result.PENDING)) result_reason = StringName(_value(options, &"result_reason", &"")) exit_reason = StringName(_value(options, &"exit_reason", &"")) resolution_id = StringName(_value(options, &"resolution_id", &"")) closed_at = int(_value(options, &"closed_at", -1)) revision = maxi(int(_value(options, &"revision", 0)), 0) return self func set_state(new_state: State, absolute_minute: int, reason: StringName = &"") -> bool: if absolute_minute < state_changed_at or state == new_state: return false state = new_state state_changed_at = absolute_minute if new_state == State.CLOSED: closed_at = absolute_minute if not reason.is_empty(): result_reason = reason revision += 1 return true func add_visited_waypoint(waypoint_id: StringName, travel_minutes: int) -> bool: if waypoint_id.is_empty() or visited_waypoint_ids.has(waypoint_id): return false visited_waypoint_ids.append(waypoint_id) visited_waypoint_ids.sort_custom(_name_less) time_spent_minutes += maxi(travel_minutes, 0) current_waypoint_index += 1 revision += 1 return true func add_inaccessible_waypoint(waypoint_id: StringName) -> bool: if waypoint_id.is_empty() or inaccessible_waypoint_ids.has(waypoint_id): return false inaccessible_waypoint_ids.append(waypoint_id) inaccessible_waypoint_ids.sort_custom(_name_less) revision += 1 return true func add_discovered_evidence(evidence_id: StringName) -> bool: if evidence_id.is_empty() or discovered_evidence_ids.has(evidence_id): return false discovered_evidence_ids.append(evidence_id) discovered_evidence_ids.sort_custom(_name_less) revision += 1 return true func is_terminal() -> bool: return state in [State.CLOSED, State.CANCELLED] func is_valid() -> bool: if inspection_id.is_empty() or inspector_id.is_empty() or scheduled_at < 0: return false if state_changed_at < scheduled_at or time_budget_minutes <= 0 or time_spent_minutes < 0: return false if current_waypoint_index < 0 or current_waypoint_index > waypoint_route.size(): return false if evidence_score < 0 or closed_at < -1 or revision < 0: return false if visited_waypoint_ids != _sorted_unique_names(visited_waypoint_ids): return false if inaccessible_waypoint_ids != _sorted_unique_names(inaccessible_waypoint_ids): return false if discovered_evidence_ids != _sorted_unique_names(discovered_evidence_ids): return false var waypoint_ids: Dictionary = {} var previous_waypoint: Dictionary = {} for waypoint: Dictionary in waypoint_route: var normalized := normalize_waypoint(waypoint) if normalized.is_empty(): return false var waypoint_id: StringName = normalized[&"waypoint_id"] if waypoint_ids.has(waypoint_id): return false if not previous_waypoint.is_empty(): var previous_utility := int(previous_waypoint[&"utility"]) var current_utility := int(normalized[&"utility"]) if current_utility > previous_utility: return false if current_utility == previous_utility \ and String(normalized[&"waypoint_id"]) < String(previous_waypoint[&"waypoint_id"]): return false waypoint_ids[waypoint_id] = true previous_waypoint = normalized if current_waypoint_index != visited_waypoint_ids.size(): return false for route_index: int in range(current_waypoint_index): if not visited_waypoint_ids.has(StringName(waypoint_route[route_index][&"waypoint_id"])): return false for waypoint_id: StringName in visited_waypoint_ids: if not waypoint_ids.has(waypoint_id): return false for waypoint_id: StringName in inaccessible_waypoint_ids: if not waypoint_ids.has(waypoint_id): return false if result == Result.PENDING and not resolution_id.is_empty(): return false if result != Result.PENDING and resolution_id.is_empty(): return false if state == State.CLOSED and closed_at < state_changed_at: return false if state != State.CLOSED and closed_at >= 0: return false return true func create_snapshot() -> Dictionary: var saved_route: Array[Dictionary] = [] for waypoint: Dictionary in waypoint_route: saved_route.append({ &"waypoint_id": String(waypoint[&"waypoint_id"]), &"cell": _cell_to_array(waypoint[&"cell"]), &"suspect_score": int(waypoint[&"suspect_score"]), &"evidence_potential": int(waypoint[&"evidence_potential"]), &"travel_minutes": int(waypoint[&"travel_minutes"]), &"inspection_minutes": int(waypoint[&"inspection_minutes"]), &"time_budget_penalty": int(waypoint[&"time_budget_penalty"]), &"utility": int(waypoint[&"utility"]), }) return { &"inspection_id": String(inspection_id), &"inspector_id": String(inspector_id), &"scheduled_at": scheduled_at, &"entrance_cell": [entrance_cell.x, entrance_cell.y, entrance_cell.z], &"state": State.keys()[state], &"state_changed_at": state_changed_at, &"time_budget_minutes": time_budget_minutes, &"time_spent_minutes": time_spent_minutes, &"waypoint_route": saved_route, &"current_waypoint_index": current_waypoint_index, &"visited_waypoint_ids": _names_to_strings(visited_waypoint_ids), &"inaccessible_waypoint_ids": _names_to_strings(inaccessible_waypoint_ids), &"discovered_evidence_ids": _names_to_strings(discovered_evidence_ids), &"evidence_score": evidence_score, &"result": Result.keys()[result], &"result_reason": String(result_reason), &"exit_reason": String(exit_reason), &"resolution_id": String(resolution_id), &"closed_at": closed_at, &"revision": revision, } static func from_snapshot(snapshot: Dictionary) -> AuthorityInspection: if not _snapshot_is_valid(snapshot): return null var inspection := AuthorityInspection.new().configure( StringName(_value(snapshot, &"inspection_id", &"")), StringName(_value(snapshot, &"inspector_id", &"")), int(_value(snapshot, &"scheduled_at", -1)), snapshot ) return inspection if inspection.is_valid() else null static func _snapshot_is_valid(snapshot: Dictionary) -> bool: for key: StringName in [ &"inspection_id", &"inspector_id", &"scheduled_at", &"entrance_cell", &"state", &"state_changed_at", &"time_budget_minutes", &"time_spent_minutes", &"waypoint_route", &"current_waypoint_index", &"visited_waypoint_ids", &"inaccessible_waypoint_ids", &"discovered_evidence_ids", &"evidence_score", &"result", &"result_reason", &"exit_reason", &"resolution_id", &"closed_at", &"revision", ]: if not _has_key(snapshot, key): return false if StringName(_value(snapshot, &"inspection_id", &"")).is_empty() \ or StringName(_value(snapshot, &"inspector_id", &"")).is_empty(): return false if not _value(snapshot, &"entrance_cell", null) is Array \ or (_value(snapshot, &"entrance_cell", []) as Array).size() != 3: return false for array_key: StringName in [ &"waypoint_route", &"visited_waypoint_ids", &"inaccessible_waypoint_ids", &"discovered_evidence_ids", ]: if not _value(snapshot, array_key, null) is Array: return false for names_key: StringName in [ &"visited_waypoint_ids", &"inaccessible_waypoint_ids", &"discovered_evidence_ids", ]: if not _name_array_is_canonical(_value(snapshot, names_key, [])): return false if not State.keys().has(String(_value(snapshot, &"state", ""))) \ or not Result.keys().has(String(_value(snapshot, &"result", ""))): return false if int(_value(snapshot, &"scheduled_at", -1)) < 0 \ or int(_value(snapshot, &"state_changed_at", -1)) < 0 \ or int(_value(snapshot, &"time_budget_minutes", 0)) <= 0 \ or int(_value(snapshot, &"time_spent_minutes", -1)) < 0 \ or int(_value(snapshot, &"current_waypoint_index", -1)) < 0 \ or int(_value(snapshot, &"evidence_score", -1)) < 0 \ or int(_value(snapshot, &"closed_at", -2)) < -1 \ or int(_value(snapshot, &"revision", -1)) < 0: return false for raw_waypoint: Variant in _value(snapshot, &"waypoint_route", []): if not raw_waypoint is Dictionary: return false for waypoint_key: StringName in [ &"waypoint_id", &"cell", &"suspect_score", &"evidence_potential", &"travel_minutes", &"inspection_minutes", &"time_budget_penalty", &"utility", ]: if not _has_key(raw_waypoint, waypoint_key): return false if not _value(raw_waypoint, &"cell", null) is Array \ or (_value(raw_waypoint, &"cell", []) as Array).size() != 3: return false var normalized := normalize_waypoint(raw_waypoint) if normalized.is_empty() or int(_value(raw_waypoint, &"utility", 2_147_483_647)) != int(normalized[&"utility"]): return false return true static func normalize_waypoint(value: Dictionary) -> Dictionary: var waypoint_id := StringName(_value(value, &"waypoint_id", &"")) if waypoint_id.is_empty(): return {} var suspect_score := clampi(int(_value(value, &"suspect_score", 0)), 0, 100) var evidence_potential := clampi(int(_value(value, &"evidence_potential", 0)), 0, 100) var travel_minutes := maxi(int(_value(value, &"travel_minutes", 0)), 0) var inspection_minutes := maxi(int(_value(value, &"inspection_minutes", 5)), 1) var time_budget_penalty := maxi(int(_value(value, &"time_budget_penalty", 0)), 0) return { &"waypoint_id": waypoint_id, &"cell": _vector3i(_value(value, &"cell", Vector3i.ZERO)), &"suspect_score": suspect_score, &"evidence_potential": evidence_potential, &"travel_minutes": travel_minutes, &"inspection_minutes": inspection_minutes, &"time_budget_penalty": time_budget_penalty, &"utility": suspect_score * 5 + evidence_potential * 4 - travel_minutes * 2 - time_budget_penalty, } static func _state_from_value(value: Variant) -> State: if value is String or value is StringName: var index := State.keys().find(String(value)) return (index as State) if index >= 0 else State.SCHEDULED var number := int(value) return (number as State) if number >= 0 and number < State.size() else State.SCHEDULED static func _result_from_value(value: Variant) -> Result: if value is String or value is StringName: var index := Result.keys().find(String(value)) return (index as Result) if index >= 0 else Result.PENDING var number := int(value) return (number as Result) if number >= 0 and number < Result.size() else Result.PENDING static func _vector3i(value: Variant) -> Vector3i: if value is Vector3i: return value if value is Array and value.size() >= 3: return Vector3i(int(value[0]), int(value[1]), int(value[2])) if value is Dictionary: return Vector3i(int(value.get(&"x", 0)), int(value.get(&"y", 0)), int(value.get(&"z", 0))) return Vector3i.ZERO static func _cell_to_array(value: Variant) -> Array[int]: var cell := _vector3i(value) return [cell.x, cell.y, cell.z] 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(_name_less) 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 _name_less(a: StringName, b: StringName) -> bool: return String(a) < String(b) static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: if source.has(key): return source[key] return source.get(String(key), fallback) static func _has_key(source: Dictionary, key: StringName) -> bool: return source.has(key) or source.has(String(key)) static func _name_array_is_canonical(value: Variant) -> bool: if not value is Array: return false var previous := "" for index: int in range(value.size()): var current := String(value[index]) if current.is_empty() or (index > 0 and current <= previous): return false previous = current return true