class_name GuestRuntime extends RefCounted ## Authoritative per-guest state. All scalar simulation values are integers. enum State { SPAWNING, ENTERING, DECIDING, MOVING, QUEUING, RECEIVING_SERVICE, LEAVING, DESPAWNING, REPLANNING, ABANDONING, INTERRUPTED, FLEEING, INCAPACITATED, } const NEED_IDS: Array[StringName] = [ &"hunger", &"sleep", &"hygiene", &"medical", &"leisure", &"intimacy", ] var guest_id: StringName = &"" var guest_type_id: StringName = &"" var group_id: StringName = &"" var wallet_gold := 0 var credit_limit_gold := 0 var current_cell := Vector3i.ZERO var state: State = State.SPAWNING var state_entered_at := 0 var target_facility_id: StringName = &"" var current_service_id: StringName = &"" var queue_request_id: StringName = &"" var needs: Dictionary = {} var satisfaction_total := 0 var satisfaction_samples := 0 var patience_remaining := 0 var debt_gold := 0 var suspicion := 0 var addiction_by_tag: Dictionary = {} var visited_facility_ids: Array[StringName] = [] var path_handle: Variant = null var planned_exit_at := 0 ## Recovery and lifecycle data used by GuestService. var path_failure_count := 0 var recovery_kind: StringName = &"" var recovery_target_cell := Vector3i.ZERO var last_progress_cell := Vector3i.ZERO var last_progress_at := 0 var unresolved_incident_ids: Array[StringName] = [] var outstanding_reservation_ids: Array[StringName] = [] var interrupted_service_ids: Array[StringName] = [] var exit_reason: StringName = &"" var transition_tick := -1 var transitions_this_tick := 0 func configure( p_guest_id: StringName, p_guest_type_id: StringName, options: Dictionary = {} ) -> GuestRuntime: guest_id = p_guest_id guest_type_id = p_guest_type_id group_id = StringName(_value(options, &"group_id", &"")) wallet_gold = maxi(int(_value(options, &"wallet_gold", 0)), 0) credit_limit_gold = maxi(int(_value(options, &"credit_limit_gold", 0)), 0) current_cell = _vector3i(_value(options, &"current_cell", Vector3i.ZERO)) state = _state_from_variant(_value(options, &"state", State.SPAWNING)) state_entered_at = maxi(int(_value(options, &"state_entered_at", 0)), 0) target_facility_id = StringName(_value(options, &"target_facility_id", &"")) current_service_id = StringName(_value(options, &"current_service_id", &"")) queue_request_id = StringName(_value(options, &"queue_request_id", &"")) var normalized_needs: Variant = _normalized_needs( _value(options, &"needs", {}), true, false ) needs = normalized_needs if normalized_needs is Dictionary else {} satisfaction_total = maxi(int(_value(options, &"satisfaction_total", 0)), 0) satisfaction_samples = maxi(int(_value(options, &"satisfaction_samples", 0)), 0) patience_remaining = maxi(int(_value(options, &"patience_remaining", 0)), 0) debt_gold = maxi(int(_value(options, &"debt_gold", 0)), 0) suspicion = clampi(int(_value(options, &"suspicion", 0)), 0, 100) addiction_by_tag = _normalized_clamped_dictionary( _value(options, &"addiction_by_tag", {}) ) visited_facility_ids = _sorted_unique_names(_value(options, &"visited_facility_ids", [])) path_handle = _copy(_value(options, &"path_handle", null)) planned_exit_at = maxi(int(_value(options, &"planned_exit_at", state_entered_at)), state_entered_at) path_failure_count = maxi(int(_value(options, &"path_failure_count", 0)), 0) recovery_kind = StringName(_value(options, &"recovery_kind", &"")) recovery_target_cell = _vector3i(_value(options, &"recovery_target_cell", current_cell)) last_progress_cell = _vector3i(_value(options, &"last_progress_cell", current_cell)) last_progress_at = maxi(int(_value(options, &"last_progress_at", state_entered_at)), 0) unresolved_incident_ids = _sorted_unique_names( _value(options, &"unresolved_incident_ids", []) ) outstanding_reservation_ids = _sorted_unique_names( _value(options, &"outstanding_reservation_ids", []) ) interrupted_service_ids = _sorted_unique_names( _value(options, &"interrupted_service_ids", []) ) exit_reason = StringName(_value(options, &"exit_reason", &"")) transition_tick = int(_value(options, &"transition_tick", -1)) transitions_this_tick = maxi(int(_value(options, &"transitions_this_tick", 0)), 0) return self func is_valid() -> bool: if guest_id.is_empty() or guest_type_id.is_empty(): return false if wallet_gold < 0 or credit_limit_gold < 0 or debt_gold < 0: return false if state < State.SPAWNING or state > State.INCAPACITATED: return false # The planned departure is a deadline, independent of later state transitions. # Queue/service completion may legitimately move state_entered_at past it. if state_entered_at < 0 or planned_exit_at < 0: return false if satisfaction_total < 0 or satisfaction_samples < 0 or patience_remaining < 0: return false if suspicion < 0 or suspicion > 100 or path_failure_count < 0: return false if transitions_this_tick < 0: return false if needs.size() != NEED_IDS.size(): return false for need_id: StringName in NEED_IDS: if not needs.has(need_id) or not needs[need_id] is int: return false var value := int(needs[need_id]) if value < 0 or value > 100: return false for raw_tag: Variant in addiction_by_tag: var addiction := int(addiction_by_tag[raw_tag]) if StringName(raw_tag).is_empty() or addiction < 0 or addiction > 100: return false return _state_references_are_valid() func average_satisfaction() -> int: if satisfaction_samples <= 0: return 50 return clampi(satisfaction_total / satisfaction_samples, 0, 100) func max_addiction() -> int: var result := 0 for value: Variant in addiction_by_tag.values(): result = maxi(result, int(value)) return result func create_snapshot() -> Dictionary: return { &"guest_id": String(guest_id), &"guest_type_id": String(guest_type_id), &"group_id": String(group_id), &"wallet_gold": wallet_gold, &"credit_limit_gold": credit_limit_gold, &"current_cell": _cell_to_array(current_cell), &"state": State.keys()[state], &"state_entered_at": state_entered_at, &"target_facility_id": String(target_facility_id), &"current_service_id": String(current_service_id), &"queue_request_id": String(queue_request_id), &"needs": _string_key_dictionary(needs), &"satisfaction_total": satisfaction_total, &"satisfaction_samples": satisfaction_samples, &"patience_remaining": patience_remaining, &"debt_gold": debt_gold, &"suspicion": suspicion, &"addiction_by_tag": _string_key_dictionary(addiction_by_tag), &"visited_facility_ids": _names_to_strings(visited_facility_ids), &"path_handle": _copy(path_handle), &"planned_exit_at": planned_exit_at, &"path_failure_count": path_failure_count, &"recovery_kind": String(recovery_kind), &"recovery_target_cell": _cell_to_array(recovery_target_cell), &"last_progress_cell": _cell_to_array(last_progress_cell), &"last_progress_at": last_progress_at, &"unresolved_incident_ids": _names_to_strings(unresolved_incident_ids), &"outstanding_reservation_ids": _names_to_strings(outstanding_reservation_ids), &"interrupted_service_ids": _names_to_strings(interrupted_service_ids), &"exit_reason": String(exit_reason), &"transition_tick": transition_tick, &"transitions_this_tick": transitions_this_tick, } static func from_snapshot(snapshot: Dictionary) -> GuestRuntime: for array_key: StringName in [ &"current_cell", &"recovery_target_cell", &"last_progress_cell", &"visited_facility_ids", &"unresolved_incident_ids", &"outstanding_reservation_ids", &"interrupted_service_ids", ]: if not snapshot.get(array_key, null) is Array: return null if not snapshot.get(&"needs", null) is Dictionary: return null if not snapshot.get(&"addiction_by_tag", null) is Dictionary: return null var state_names := State.keys() var state_index := state_names.find(String(snapshot.get(&"state", ""))) if state_index < 0: return null var runtime := GuestRuntime.new() runtime.guest_id = StringName(snapshot.get(&"guest_id", "")) runtime.guest_type_id = StringName(snapshot.get(&"guest_type_id", "")) runtime.group_id = StringName(snapshot.get(&"group_id", "")) runtime.wallet_gold = int(snapshot.get(&"wallet_gold", -1)) runtime.credit_limit_gold = int(snapshot.get(&"credit_limit_gold", -1)) runtime.current_cell = _vector3i(snapshot.get(&"current_cell", [])) runtime.state = state_index as State runtime.state_entered_at = int(snapshot.get(&"state_entered_at", -1)) runtime.target_facility_id = StringName(snapshot.get(&"target_facility_id", "")) runtime.current_service_id = StringName(snapshot.get(&"current_service_id", "")) runtime.queue_request_id = StringName(snapshot.get(&"queue_request_id", "")) var normalized_needs: Variant = _normalized_needs( snapshot.get(&"needs", {}), false, true ) if not normalized_needs is Dictionary: return null runtime.needs = normalized_needs runtime.satisfaction_total = int(snapshot.get(&"satisfaction_total", -1)) runtime.satisfaction_samples = int(snapshot.get(&"satisfaction_samples", -1)) runtime.patience_remaining = int(snapshot.get(&"patience_remaining", -1)) runtime.debt_gold = int(snapshot.get(&"debt_gold", -1)) runtime.suspicion = int(snapshot.get(&"suspicion", -1)) runtime.addiction_by_tag = _normalized_clamped_dictionary_strict( snapshot.get(&"addiction_by_tag", {}) ) runtime.visited_facility_ids = _sorted_unique_names(snapshot.get(&"visited_facility_ids", [])) runtime.path_handle = _copy(snapshot.get(&"path_handle", null)) runtime.planned_exit_at = int(snapshot.get(&"planned_exit_at", -1)) runtime.path_failure_count = int(snapshot.get(&"path_failure_count", -1)) runtime.recovery_kind = StringName(snapshot.get(&"recovery_kind", "")) runtime.recovery_target_cell = _vector3i(snapshot.get(&"recovery_target_cell", [])) runtime.last_progress_cell = _vector3i(snapshot.get(&"last_progress_cell", [])) runtime.last_progress_at = int(snapshot.get(&"last_progress_at", -1)) runtime.unresolved_incident_ids = _sorted_unique_names( snapshot.get(&"unresolved_incident_ids", []) ) runtime.outstanding_reservation_ids = _sorted_unique_names( snapshot.get(&"outstanding_reservation_ids", []) ) runtime.interrupted_service_ids = _sorted_unique_names( snapshot.get(&"interrupted_service_ids", []) ) runtime.exit_reason = StringName(snapshot.get(&"exit_reason", "")) runtime.transition_tick = int(snapshot.get(&"transition_tick", -1)) runtime.transitions_this_tick = int(snapshot.get(&"transitions_this_tick", -1)) return runtime if runtime.is_valid() else null func _state_references_are_valid() -> bool: if state == State.QUEUING: return not target_facility_id.is_empty() and not queue_request_id.is_empty() if state == State.RECEIVING_SERVICE: return not target_facility_id.is_empty() and not current_service_id.is_empty() return true static func _state_from_variant(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.SPAWNING var parsed := int(value) return parsed as State if parsed >= State.SPAWNING and parsed <= State.INCAPACITATED else State.SPAWNING static func _normalized_needs( source: Variant, clamp_values: bool, require_all: bool ) -> Variant: var result: Dictionary = {} if not source is Dictionary: return null var seen: Dictionary = {} for raw_key: Variant in source: if not raw_key is String and not raw_key is StringName: return null var need_id := StringName(raw_key) if not NEED_IDS.has(need_id) or seen.has(need_id): return null var raw_value: Variant = source[raw_key] if not raw_value is int: return null seen[need_id] = true result[need_id] = clampi(raw_value, 0, 100) if clamp_values else raw_value if require_all and seen.size() != NEED_IDS.size(): return null for need_id: StringName in NEED_IDS: if not result.has(need_id): result[need_id] = 0 return result static func _normalized_clamped_dictionary(source: Variant) -> Dictionary: var result: Dictionary = {} if not source is Dictionary: return result var keys: Array = source.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) if not key.is_empty(): result[key] = clampi(int(source[raw_key]), 0, 100) return result static func _normalized_clamped_dictionary_strict(source: Variant) -> Dictionary: var result: Dictionary = {} if not source is Dictionary: return result var keys: Array = source.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) if not key.is_empty(): result[key] = int(source[raw_key]) return result static func _sorted_unique_names(source: Variant) -> Array[StringName]: var result: Array[StringName] = [] if not source is Array: return result for raw_value: Variant in source: 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 _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: return source.get(key, source.get(String(key), fallback)) 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])) return Vector3i.ZERO static func _cell_to_array(cell: Vector3i) -> Array[int]: return [cell.x, cell.y, cell.z] static func _names_to_strings(source: Array[StringName]) -> Array[String]: var result: Array[String] = [] for value: StringName in source: result.append(String(value)) return result static func _string_key_dictionary(source: Dictionary) -> Dictionary: var result: Dictionary = {} var keys: Array = source.keys() keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) for raw_key: Variant in keys: result[String(raw_key)] = source[raw_key] return result static func _copy(value: Variant) -> Variant: if value is Array or value is Dictionary: return value.duplicate(true) return value