class_name FacilityInstance extends RefCounted const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd") ## Values are deliberately ordered by operational priority. enum State { BROKEN, NO_ACCESS, NO_POWER, NO_STAFF, CLOSED, DEGRADED, OPEN, } const FULL_DAY_MINUTES := 1_440 const DEFAULT_DURABILITY := 10_000 const DEGRADED_DURABILITY_THRESHOLD := 3_000 var instance_id: StringName = &"" var definition_id: StringName = &"" var origin := Vector3i.ZERO var rotation := 0 var occupied_cells: Array[Vector3i] = [] var level := 1 var state: State = State.OPEN var state_reasons: Array[StringName] = [] var durability := DEFAULT_DURABILITY var local_inventory_id: StringName = &"" var assigned_staff_by_shift: Dictionary = {} var active_staff_ids: Array[StringName] = [] var queue_entries: Array[StringName] = [] var active_services: Dictionary = {} var policy_current: Dictionary = {} var policy_pending: Dictionary = {} var policy_revision := 0 var policy_pending_revision := 0 var policy_pending_effective_at := -1 var statistics_today: Dictionary = {} var lifetime_statistics: Dictionary = {} var created_at := 0 var capacity := 1 var queue_capacity := 0 var base_service_minutes := 1 var base_quality := 0 var base_price := 0 var staff_min := 0 var staff_max := 0 var access_available := true var power_available := true var operator_open := true var opening_intervals: Array[Vector2i] = [Vector2i(0, FULL_DAY_MINUTES)] var degraded_reasons: Array[StringName] = [] func configure( p_instance_id: StringName, p_definition_id: StringName, options: Dictionary = {} ) -> FacilityInstance: instance_id = p_instance_id definition_id = p_definition_id origin = _vector3i(_value(options, &"origin", Vector3i.ZERO)) rotation = _normalize_rotation(int(_value(options, &"rotation", 0))) occupied_cells = _cell_array(_value(options, &"occupied_cells", [])) level = maxi(int(_value(options, &"level", 1)), 1) durability = clampi(int(_value(options, &"durability", DEFAULT_DURABILITY)), 0, DEFAULT_DURABILITY) local_inventory_id = StringName(_value(options, &"local_inventory_id", &"")) capacity = maxi(int(_value(options, &"capacity", 1)), 1) queue_capacity = maxi(int(_value(options, &"queue_capacity", 0)), 0) base_service_minutes = maxi(int(_value(options, &"base_service_minutes", 1)), 1) base_quality = int(_value(options, &"base_quality", 0)) base_price = maxi(int(_value(options, &"base_price", 0)), 0) staff_min = maxi(int(_value(options, &"staff_min", 0)), 0) staff_max = maxi(int(_value(options, &"staff_max", maxi(staff_min, 0))), staff_min) policy_current = _normalize_policy(_value(options, &"policy_current", {})) policy_revision = maxi(int(_value(options, &"policy_revision", 0)), 0) created_at = maxi(int(_value(options, &"created_at", 0)), 0) access_available = bool(_value(options, &"access_available", true)) power_available = bool(_value(options, &"power_available", true)) operator_open = bool(_value(options, &"operator_open", true)) var intervals := _parse_intervals(_value(options, &"opening_intervals", [])) if not intervals.is_empty(): opening_intervals = intervals active_staff_ids = _sorted_unique_names(_value(options, &"active_staff_ids", [])) degraded_reasons = _sorted_unique_names(_value(options, &"degraded_reasons", [])) evaluate_state(created_at) return self func is_valid() -> bool: return not instance_id.is_empty() \ and not definition_id.is_empty() \ and capacity > 0 \ and queue_capacity >= 0 \ and base_service_minutes > 0 \ and staff_min >= 0 \ and staff_max >= staff_min \ and _intervals_are_valid(opening_intervals) func set_opening_hours(open_minute_of_day: int, close_minute_of_day: int) -> bool: if open_minute_of_day < 0 or open_minute_of_day >= FULL_DAY_MINUTES: return false if close_minute_of_day < 0 or close_minute_of_day > FULL_DAY_MINUTES: return false if open_minute_of_day == close_minute_of_day: opening_intervals = [Vector2i(0, FULL_DAY_MINUTES)] elif open_minute_of_day < close_minute_of_day: opening_intervals = [Vector2i(open_minute_of_day, close_minute_of_day)] else: opening_intervals = [] if close_minute_of_day > 0: opening_intervals.append(Vector2i(0, close_minute_of_day)) if open_minute_of_day < FULL_DAY_MINUTES: opening_intervals.append(Vector2i(open_minute_of_day, FULL_DAY_MINUTES)) return true func set_opening_intervals(intervals: Variant) -> bool: var parsed := _parse_intervals(intervals) if parsed.is_empty() or not _intervals_are_valid(parsed): return false opening_intervals = parsed return true func is_within_opening_hours(absolute_minute: int) -> bool: var minute_of_day := posmod(absolute_minute, FULL_DAY_MINUTES) for interval: Vector2i in opening_intervals: if minute_of_day >= interval.x and minute_of_day < interval.y: return true return false func set_operational_conditions(conditions: Dictionary, absolute_minute: int) -> bool: if _has_value(conditions, &"durability"): durability = clampi(int(_value(conditions, &"durability", durability)), 0, DEFAULT_DURABILITY) if _has_value(conditions, &"access_available"): access_available = bool(_value(conditions, &"access_available", access_available)) if _has_value(conditions, &"power_available"): power_available = bool(_value(conditions, &"power_available", power_available)) if _has_value(conditions, &"operator_open"): operator_open = bool(_value(conditions, &"operator_open", operator_open)) if _has_value(conditions, &"active_staff_ids"): active_staff_ids = _sorted_unique_names(_value(conditions, &"active_staff_ids", [])) if _has_value(conditions, &"degraded_reasons"): degraded_reasons = _sorted_unique_names(_value(conditions, &"degraded_reasons", [])) return evaluate_state(absolute_minute) func evaluate_state(absolute_minute: int) -> bool: var next_reasons: Array[StringName] = [] var next_state := State.OPEN if durability <= 0: next_reasons.append(&"BROKEN") next_state = State.BROKEN if not access_available: next_reasons.append(&"NO_ACCESS") if next_state > State.NO_ACCESS: next_state = State.NO_ACCESS if not power_available: next_reasons.append(&"NO_POWER") if next_state > State.NO_POWER: next_state = State.NO_POWER if active_staff_ids.size() < staff_min: next_reasons.append(&"NO_STAFF") if next_state > State.NO_STAFF: next_state = State.NO_STAFF if not operator_open: next_reasons.append(&"CLOSED_BY_OPERATOR") if next_state > State.CLOSED: next_state = State.CLOSED if not is_within_opening_hours(absolute_minute): next_reasons.append(&"OUTSIDE_OPENING_HOURS") if next_state > State.CLOSED: next_state = State.CLOSED if durability > 0 and durability < DEGRADED_DURABILITY_THRESHOLD: next_reasons.append(&"LOW_DURABILITY") if next_state > State.DEGRADED: next_state = State.DEGRADED for reason: StringName in degraded_reasons: if not next_reasons.has(reason): next_reasons.append(reason) if next_state > State.DEGRADED: next_state = State.DEGRADED var changed := state != next_state state = next_state state_reasons = next_reasons return changed func can_start_service(absolute_minute: int) -> bool: evaluate_state(absolute_minute) return state in [State.OPEN, State.DEGRADED] and available_slots() > 0 func available_slots() -> int: return maxi(capacity - active_services.size(), 0) func has_queue_space() -> bool: return queue_entries.size() < queue_capacity func apply_policy_patch(patch: Dictionary) -> bool: var changed := false var keys: Array = patch.keys() keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) for raw_key: Variant in keys: var key := StringName(raw_key) var value: Variant = patch[raw_key] var has_existing := _has_value(policy_current, key) var existing: Variant = _value(policy_current, key, null) if not has_existing or existing != value: policy_current.erase(String(key)) policy_current[key] = _copy(value) changed = true if changed: policy_revision += 1 return changed func schedule_policy_patch(patch: Dictionary, effective_at: int) -> bool: if effective_at < 0 or patch.is_empty(): return false policy_pending = _normalize_policy(patch) policy_pending_effective_at = effective_at policy_pending_revision = policy_revision + 1 return true func apply_pending_policy(absolute_minute: int) -> bool: if policy_pending.is_empty() or policy_pending_effective_at < 0: return false if absolute_minute < policy_pending_effective_at: return false var changed := apply_policy_patch(policy_pending) policy_pending.clear() policy_pending_effective_at = -1 policy_pending_revision = 0 return changed func create_snapshot() -> Dictionary: var saved_services: Array[Dictionary] = [] var service_ids := _sorted_keys(active_services) for service_id: StringName in service_ids: var service: ServiceInstance = active_services[service_id] saved_services.append(service.create_snapshot()) return { &"instance_id": String(instance_id), &"definition_id": String(definition_id), &"origin": [origin.x, origin.y, origin.z], &"rotation": rotation, &"occupied_cells": _serialize_cells(occupied_cells), &"level": level, &"state": State.keys()[state], &"state_reasons": _names_to_strings(state_reasons), &"durability": durability, &"local_inventory_id": String(local_inventory_id), &"assigned_staff_by_shift": assigned_staff_by_shift.duplicate(true), &"active_staff_ids": _names_to_strings(active_staff_ids), &"queue_entries": _names_to_strings(queue_entries), &"active_services": saved_services, &"policy_current": policy_current.duplicate(true), &"policy_pending": policy_pending.duplicate(true), &"policy_revision": policy_revision, &"policy_pending_revision": policy_pending_revision, &"policy_pending_effective_at": policy_pending_effective_at, &"statistics_today": statistics_today.duplicate(true), &"lifetime_statistics": lifetime_statistics.duplicate(true), &"created_at": created_at, &"capacity": capacity, &"queue_capacity": queue_capacity, &"base_service_minutes": base_service_minutes, &"base_quality": base_quality, &"base_price": base_price, &"staff_min": staff_min, &"staff_max": staff_max, &"access_available": access_available, &"power_available": power_available, &"operator_open": operator_open, &"opening_intervals": _serialize_intervals(opening_intervals), &"degraded_reasons": _names_to_strings(degraded_reasons), } static func from_snapshot(snapshot: Dictionary) -> FacilityInstance: if not _snapshot_shapes_are_valid(snapshot): return null var instance := FacilityInstance.new().configure( StringName(snapshot.get(&"instance_id", "")), StringName(snapshot.get(&"definition_id", "")), { &"origin": snapshot.get(&"origin", []), &"rotation": snapshot.get(&"rotation", 0), &"occupied_cells": snapshot.get(&"occupied_cells", []), &"level": snapshot.get(&"level", 1), &"durability": snapshot.get(&"durability", DEFAULT_DURABILITY), &"local_inventory_id": snapshot.get(&"local_inventory_id", ""), &"capacity": snapshot.get(&"capacity", 1), &"queue_capacity": snapshot.get(&"queue_capacity", 0), &"base_service_minutes": snapshot.get(&"base_service_minutes", 1), &"base_quality": snapshot.get(&"base_quality", 0), &"base_price": snapshot.get(&"base_price", 0), &"staff_min": snapshot.get(&"staff_min", 0), &"staff_max": snapshot.get(&"staff_max", 0), &"policy_current": snapshot.get(&"policy_current", {}), &"policy_revision": snapshot.get(&"policy_revision", 0), &"created_at": snapshot.get(&"created_at", 0), &"access_available": snapshot.get(&"access_available", true), &"power_available": snapshot.get(&"power_available", true), &"operator_open": snapshot.get(&"operator_open", true), &"opening_intervals": snapshot.get(&"opening_intervals", []), &"active_staff_ids": snapshot.get(&"active_staff_ids", []), &"degraded_reasons": snapshot.get(&"degraded_reasons", []), } ) var state_index := State.keys().find(String(snapshot.get(&"state", "OPEN"))) if state_index < 0: return null instance.state = state_index as State instance.state_reasons = _string_name_array(snapshot.get(&"state_reasons", [])) instance.assigned_staff_by_shift = snapshot.get(&"assigned_staff_by_shift", {}).duplicate(true) instance.queue_entries = _string_name_array(snapshot.get(&"queue_entries", [])) instance.policy_pending = _normalize_policy(snapshot.get(&"policy_pending", {})) instance.policy_pending_revision = maxi(int(snapshot.get(&"policy_pending_revision", 0)), 0) instance.policy_pending_effective_at = int(snapshot.get(&"policy_pending_effective_at", -1)) instance.statistics_today = snapshot.get(&"statistics_today", {}).duplicate(true) instance.lifetime_statistics = snapshot.get(&"lifetime_statistics", {}).duplicate(true) var seen_services: Dictionary = {} for saved_service: Variant in snapshot.get(&"active_services", []): var service := ServiceInstanceType.from_snapshot(saved_service) if service == null or service.is_terminal() or service.facility_id != instance.instance_id: return null if seen_services.has(service.service_id): return null seen_services[service.service_id] = true instance.active_services[service.service_id] = service if not instance.is_valid() or instance.active_services.size() > instance.capacity: return null return instance static func _snapshot_shapes_are_valid(snapshot: Dictionary) -> bool: var array_fields := [ &"origin", &"occupied_cells", &"state_reasons", &"active_staff_ids", &"queue_entries", &"active_services", &"opening_intervals", &"degraded_reasons", ] for field: StringName in array_fields: if not snapshot.get(field, null) is Array: return false var dictionary_fields := [ &"assigned_staff_by_shift", &"policy_current", &"policy_pending", &"statistics_today", &"lifetime_statistics", ] for field: StringName in dictionary_fields: if not snapshot.get(field, null) is Dictionary: return false return true static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: return source.get(key, source.get(String(key), fallback)) static func _has_value(source: Dictionary, key: StringName) -> bool: return source.has(key) or source.has(String(key)) static func _copy(value: Variant) -> Variant: return value.duplicate(true) if value is Dictionary or value is Array else value static func _dictionary_copy(source: Variant) -> Dictionary: return source.duplicate(true) if source is Dictionary else {} static func _normalize_policy(source: Variant) -> Dictionary: var normalized: Dictionary = {} if not source is Dictionary: return normalized for raw_key: Variant in source: normalized[StringName(raw_key)] = _copy(source[raw_key]) return normalized static func _vector3i(source: Variant) -> Vector3i: if source is Vector3i: return source if source is Array and source.size() == 3: return Vector3i(int(source[0]), int(source[1]), int(source[2])) return Vector3i.ZERO static func _cell_array(source: Variant) -> Array[Vector3i]: var cells: Array[Vector3i] = [] if not source is Array: return cells for raw_cell: Variant in source: if raw_cell is Vector3i: cells.append(raw_cell) elif raw_cell is Array and raw_cell.size() == 3: cells.append(_vector3i(raw_cell)) return cells static func _parse_intervals(source: Variant) -> Array[Vector2i]: var intervals: Array[Vector2i] = [] if not source is Array: return intervals for raw_interval: Variant in source: if raw_interval is Vector2i: intervals.append(raw_interval) elif raw_interval is Array and raw_interval.size() == 2: intervals.append(Vector2i(int(raw_interval[0]), int(raw_interval[1]))) intervals.sort_custom(func(a: Vector2i, b: Vector2i) -> bool: return a.x < b.x or (a.x == b.x and a.y < b.y) ) return intervals static func _intervals_are_valid(intervals: Array[Vector2i]) -> bool: if intervals.is_empty(): return false var previous_end := -1 for interval: Vector2i in intervals: if interval.x < 0 or interval.y > FULL_DAY_MINUTES or interval.x >= interval.y: return false if interval.x < previous_end: return false previous_end = interval.y return true static func _sorted_unique_names(source: Variant) -> Array[StringName]: var values := _string_name_array(source) var seen: Dictionary = {} var unique: Array[StringName] = [] for value: StringName in values: if not value.is_empty() and not seen.has(value): seen[value] = true unique.append(value) unique.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) return unique static func _string_name_array(source: Variant) -> Array[StringName]: var values: Array[StringName] = [] if source is Array: for value: Variant in source: values.append(StringName(value)) return values static func _sorted_keys(source: Dictionary) -> Array[StringName]: var values: Array[StringName] = [] for value: Variant in source.keys(): values.append(StringName(value)) values.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) return values static func _serialize_cells(source: Array[Vector3i]) -> Array[Array]: var cells: Array[Array] = [] for cell: Vector3i in source: cells.append([cell.x, cell.y, cell.z]) return cells static func _serialize_intervals(source: Array[Vector2i]) -> Array[Array]: var intervals: Array[Array] = [] for interval: Vector2i in source: intervals.append([interval.x, interval.y]) return intervals static func _names_to_strings(source: Array[StringName]) -> Array[String]: var values: Array[String] = [] for value: StringName in source: values.append(String(value)) return values static func _normalize_rotation(value: int) -> int: var normalized := posmod(value, 360) return (normalized / 90) * 90