class_name GuestService extends RefCounted ## Deterministic guest needs, decisions and lifecycle authority. ## ## Facility, path and economy integrations intentionally cross Dictionary and ## Callable boundaries. GuestService therefore remains independently testable ## and cannot accidentally mutate another service's private state. signal guest_spawned(guest: GuestRuntime) signal guest_state_changed(guest_id: StringName, old_state: int, new_state: int, reason: StringName) signal guest_queue_abandoned(guest_id: StringName, facility_id: StringName, request_id: StringName) signal guest_recovery_started(guest_id: StringName, recovery_kind: StringName, target_cell: Vector3i) signal guest_despawned(guest_id: StringName, persistent_record: Dictionary) const GuestDefinitionType = preload("res://scripts/guest/guest_definition.gd") const GuestRuntimeType = preload("res://scripts/guest/guest_runtime.gd") const SNAPSHOT_VERSION := 2 const MAX_TRANSITIONS_PER_TICK := 4 const PATH_FAILURE_LIMIT := 3 const STUCK_REPLAN_MINUTES := 60 const DEFAULT_WAIT_HARD_LIMIT := 120 const DEFAULT_EXIT_THRESHOLD := 15 const PRIMARY_NEEDS: Array[StringName] = [&"hunger", &"sleep", &"leisure", &"intimacy"] const NEED_IDS: Array[StringName] = [ &"hunger", &"sleep", &"hygiene", &"medical", &"leisure", &"intimacy", ] const LEGACY_NEED_IDS: Array[StringName] = [ &"hunger", &"rest", &"fun", &"social", &"safety", ] var current_minute := -1 var last_error := "" var _definitions: Dictionary = {} var _guests: Dictionary = {} var _persistent_records: Dictionary = {} var _decision_rejections: Dictionary = {} var _diagnostics: Array[Dictionary] = [] var _sorted_guest_ids_cache: Array[StringName] = [] var _guest_order_dirty := true var _pending_spawn_notifications: Dictionary = {} var _external_mutation_blocked := false func register_definition(definition: Variant) -> bool: if definition == null or not definition is GuestDefinition: return _fail("Guest definition has an invalid type.") var typed_definition: GuestDefinition = definition if not typed_definition.is_valid(): return _fail("Guest definition is invalid.") if _definitions.has(typed_definition.guest_type_id): return _fail("Duplicate guest definition: %s" % typed_definition.guest_type_id) _definitions[typed_definition.guest_type_id] = typed_definition last_error = "" return true func create_definition(guest_type_id: StringName, options: Dictionary = {}) -> GuestDefinition: var definition := GuestDefinitionType.new().configure(guest_type_id, options) return definition if register_definition(definition) else null func get_definition(guest_type_id: StringName) -> GuestDefinition: return _definitions.get(guest_type_id) as GuestDefinition func get_definitions() -> Array[GuestDefinition]: var result: Array[GuestDefinition] = [] for guest_type_id: StringName in _sorted_ids(_definitions.keys()): result.append(_definitions[guest_type_id]) return result func register_guest(guest: Variant, emit_notification := true) -> bool: if _external_mutation_blocked: return _fail("Guest mutation is blocked while committed notifications are delivered.") if guest == null or not guest is GuestRuntime: return _fail("Guest runtime has an invalid type.") var runtime: GuestRuntime = guest if not runtime.is_valid(): return _fail("Guest runtime is invalid.") if not _definitions.has(runtime.guest_type_id): return _fail("Guest runtime references an unknown definition.") if _guests.has(runtime.guest_id) or _persistent_records.has(runtime.guest_id): return _fail("Duplicate guest id: %s" % runtime.guest_id) _guests[runtime.guest_id] = runtime _guest_order_dirty = true if emit_notification: guest_spawned.emit(runtime) else: _pending_spawn_notifications[runtime.guest_id] = true last_error = "" return true func spawn_guest( guest_id: StringName, guest_type_id: StringName, absolute_minute: int, options: Dictionary = {}, emit_notification := true ) -> GuestRuntime: var definition := get_definition(guest_type_id) if definition == null or absolute_minute < 0: _fail("A known definition and non-negative spawn minute are required.") return null var runtime_options := options.duplicate(true) if not _has_value(runtime_options, &"wallet_gold"): runtime_options[&"wallet_gold"] = definition.wealth_min if not _has_value(runtime_options, &"credit_limit_gold"): runtime_options[&"credit_limit_gold"] = definition.credit_limit_gold if not _has_value(runtime_options, &"patience_remaining"): runtime_options[&"patience_remaining"] = definition.base_patience if not _has_value(runtime_options, &"state"): runtime_options[&"state"] = GuestRuntimeType.State.SPAWNING if not _has_value(runtime_options, &"state_entered_at"): runtime_options[&"state_entered_at"] = absolute_minute if not _has_value(runtime_options, &"planned_exit_at"): runtime_options[&"planned_exit_at"] = absolute_minute + 720 var runtime := GuestRuntimeType.new().configure(guest_id, guest_type_id, runtime_options) if not register_guest(runtime, emit_notification): return null return runtime func commit_guest_spawned_notification(guest_id: StringName) -> bool: if not _pending_spawn_notifications.has(guest_id) or not _guests.has(guest_id): return _fail("Guest has no pending spawn notification: %s" % guest_id) _pending_spawn_notifications.erase(guest_id) guest_spawned.emit(_guests[guest_id] as GuestRuntime) last_error = "" return true func discard_guest_spawned_notification(guest_id: StringName) -> bool: if not _pending_spawn_notifications.has(guest_id): return false _pending_spawn_notifications.erase(guest_id) return true func has_pending_guest_spawned_notification(guest_id: StringName) -> bool: return _pending_spawn_notifications.has(guest_id) func set_external_mutation_blocked(blocked: bool) -> void: _external_mutation_blocked = blocked func has_guest(guest_id: StringName) -> bool: return _guests.has(guest_id) func get_guest(guest_id: StringName) -> GuestRuntime: return _guests.get(guest_id) as GuestRuntime func get_guests() -> Array[GuestRuntime]: var result: Array[GuestRuntime] = [] for guest_id: StringName in _ordered_guest_ids(): result.append(_guests[guest_id]) return result func guest_count() -> int: return _guests.size() func get_persistent_record(guest_id: StringName) -> Dictionary: if not _persistent_records.has(guest_id): return {} return (_persistent_records[guest_id] as Dictionary).duplicate(true) func get_diagnostics() -> Array[Dictionary]: return _diagnostics.duplicate(true) func advance_one_minute( absolute_minute: int, context_by_guest: Dictionary = {} ) -> Dictionary: if absolute_minute < 0 or absolute_minute <= current_minute: return _error_result("Guest minute must advance monotonically.") var ordered_guest_ids := _ordered_guest_ids() # The common no-context tick has no external values to validate or isolate. # Avoid allocating one duplicated context plus a resolved-context entry per # Guest while retaining the same deterministic order and report contract. if context_by_guest.is_empty(): return _advance_with_shared_need_context( absolute_minute, ordered_guest_ids, {} ) var resolved_contexts: Dictionary = {} var default_context: Dictionary = {} var raw_default: Variant = _value(context_by_guest, &"default", {}) if raw_default is Dictionary: default_context = raw_default # Production supplies one common default context. Validate it once, then # share the same read-only Dictionary across the population. Any per-Guest # override keeps using the all-context preflight below, preserving atomic # rejection and the malformed-override contract. if context_by_guest.size() == 1 and _has_value(context_by_guest, &"default"): # Historically an unused default was not validated when the population # was empty. Preserve that edge case and the first-Guest error wording. if not ordered_guest_ids.is_empty(): var default_error := _need_context_error(default_context) if not default_error.is_empty(): return _error_result( "Invalid Need context for %s: %s" % [ ordered_guest_ids[0], default_error, ] ) return _advance_with_shared_need_context( absolute_minute, ordered_guest_ids, default_context ) for guest_id: StringName in ordered_guest_ids: var context := default_context var raw_context: Variant = null var has_guest_context := false if context_by_guest.has(guest_id): has_guest_context = true raw_context = context_by_guest[guest_id] else: var string_guest_id := String(guest_id) if context_by_guest.has(string_guest_id): has_guest_context = true raw_context = context_by_guest[string_guest_id] if raw_context is Dictionary: context = raw_context elif has_guest_context: # Preserve the original contract: a malformed per-guest override # suppresses the default context rather than inheriting it. context = {} var context_error := _need_context_error(context) if not context_error.is_empty(): return _error_result("Invalid Need context for %s: %s" % [guest_id, context_error]) resolved_contexts[guest_id] = context.duplicate(true) ## All contexts are preflighted before mutating any guest, so a malformed ## batched update cannot partially advance the population or the minute. var reports: Array[Dictionary] = [] for guest_id: StringName in ordered_guest_ids: var context: Dictionary = resolved_contexts[guest_id] var guest: GuestRuntime = _guests[guest_id] var definition: GuestDefinition = _definitions.get(guest.guest_type_id) as GuestDefinition if definition == null: reports.append(_error_result("Guest definition is missing.")) else: reports.append(_update_needs_runtime(guest, definition, context)) current_minute = absolute_minute last_error = "" return { &"ok": true, &"minute": absolute_minute, &"guest_reports": reports, } func _advance_with_shared_need_context( absolute_minute: int, ordered_guest_ids: Array[StringName], context: Dictionary ) -> Dictionary: var reports: Array[Dictionary] = [] for guest_id: StringName in ordered_guest_ids: var guest: GuestRuntime = _guests[guest_id] var definition: GuestDefinition = _definitions.get( guest.guest_type_id ) as GuestDefinition if definition == null: reports.append(_error_result("Guest definition is missing.")) else: reports.append(_update_needs_runtime(guest, definition, context)) current_minute = absolute_minute last_error = "" return { &"ok": true, &"minute": absolute_minute, &"guest_reports": reports, } func update_needs(guest_id: StringName, context: Dictionary = {}) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) var definition := get_definition(guest.guest_type_id) if definition == null: return _error_result("Guest definition is missing.") var context_error := _need_context_error(context) if not context_error.is_empty(): return _error_result("Invalid Need context: %s" % context_error) last_error = "" return _update_needs_runtime(guest, definition, context) func _update_needs_runtime( guest: GuestRuntime, definition: GuestDefinition, context: Dictionary ) -> Dictionary: var before_hunger := int(guest.needs[&"hunger"]) var before_sleep := int(guest.needs[&"sleep"]) var before_hygiene := int(guest.needs[&"hygiene"]) var before_medical := int(guest.needs[&"medical"]) var before_leisure := int(guest.needs[&"leisure"]) var before_intimacy := int(guest.needs[&"intimacy"]) var delta_hunger := int(definition.need_rates[&"hunger"]) var delta_sleep := int(definition.need_rates[&"sleep"]) var delta_hygiene := int(definition.need_rates[&"hygiene"]) var delta_medical := int(definition.need_rates[&"medical"]) var delta_leisure := int(definition.need_rates[&"leisure"]) var delta_intimacy := int(definition.need_rates[&"intimacy"]) ## State effects are accumulated first; every Need is clamped exactly once. if guest.state == GuestRuntimeType.State.MOVING \ or guest.state == GuestRuntimeType.State.REPLANNING: delta_hunger += 2 delta_sleep += 2 elif guest.state == GuestRuntimeType.State.QUEUING: delta_hunger += 1 if context.is_empty(): if guest.state != GuestRuntimeType.State.RECEIVING_SERVICE: delta_leisure += 1 else: if bool(_value(context, &"work_like_activity", false)): delta_sleep += 1 if bool(_value( context, &"unstimulated", guest.state != GuestRuntimeType.State.RECEIVING_SERVICE )): delta_leisure += 1 delta_hygiene += int(_value(context, &"dirt", 0)) delta_medical += int(_value(context, &"injury", 0)) delta_medical += int(_value(context, &"illness", 0)) if definition.adult_verified: var intimacy_key := &"intimacy_night" \ if bool(_value(context, &"is_night", false)) \ else &"intimacy_day" delta_intimacy += int(_value(context, intimacy_key, 0)) var raw_effects: Variant = _value(context, &"need_effects", {}) var normalized_effects: Dictionary = _normalized_partial_need_map(raw_effects) delta_hunger += int(normalized_effects.get(&"hunger", 0)) delta_sleep += int(normalized_effects.get(&"sleep", 0)) delta_hygiene += int(normalized_effects.get(&"hygiene", 0)) delta_medical += int(normalized_effects.get(&"medical", 0)) delta_leisure += int(normalized_effects.get(&"leisure", 0)) delta_intimacy += int(normalized_effects.get(&"intimacy", 0)) ## A13: mana fog raises an adult-verified guest's positive intimacy increase ## to floor(x1.5) = n + floor(n / 2); decreases and other guests are unchanged. if definition.adult_verified and delta_intimacy > 0 and bool(_value(context, &"mana_fog", false)): delta_intimacy += delta_intimacy >> 1 var after_hunger := clampi(before_hunger + delta_hunger, 0, 100) var after_sleep := clampi(before_sleep + delta_sleep, 0, 100) var after_hygiene := clampi(before_hygiene + delta_hygiene, 0, 100) var after_medical := clampi(before_medical + delta_medical, 0, 100) var after_leisure := clampi(before_leisure + delta_leisure, 0, 100) var after_intimacy := clampi(before_intimacy + delta_intimacy, 0, 100) guest.needs[&"hunger"] = after_hunger guest.needs[&"sleep"] = after_sleep guest.needs[&"hygiene"] = after_hygiene guest.needs[&"medical"] = after_medical guest.needs[&"leisure"] = after_leisure guest.needs[&"intimacy"] = after_intimacy return { &"ok": true, &"guest_id": String(guest.guest_id), &"before": { "hunger": before_hunger, "hygiene": before_hygiene, "intimacy": before_intimacy, "leisure": before_leisure, "medical": before_medical, "sleep": before_sleep, }, &"deltas": { "hunger": delta_hunger, "hygiene": delta_hygiene, "intimacy": delta_intimacy, "leisure": delta_leisure, "medical": delta_medical, "sleep": delta_sleep, }, &"after": { "hunger": after_hunger, "hygiene": after_hygiene, "intimacy": after_intimacy, "leisure": after_leisure, "medical": after_medical, "sleep": after_sleep, }, } ## Returns canonical candidate copies in facility_id order. Supported canonical ## fields are documented by the filtering helpers below; aliases allow existing ## FacilityService projections to be adapted without a hard dependency. func extract_candidates( guest_id: StringName, facilities: Array, context: Dictionary = {} ) -> Array[Dictionary]: var guest := get_guest(guest_id) if guest == null: _fail("Unknown guest: %s" % guest_id) return [] var definition := get_definition(guest.guest_type_id) if definition == null: _fail("Guest definition is missing.") return [] var ordered: Array[Dictionary] = [] for raw_candidate: Variant in facilities: if not raw_candidate is Dictionary: continue var candidate: Dictionary = raw_candidate.duplicate(true) var facility_id := _facility_id(candidate) if facility_id.is_empty(): continue candidate[&"facility_id"] = facility_id ordered.append(candidate) ordered.sort_custom( func(a: Dictionary, b: Dictionary) -> bool: return String(a[&"facility_id"]) < String(b[&"facility_id"]) ) var accepted: Array[Dictionary] = [] var rejected: Array[Dictionary] = [] for candidate: Dictionary in ordered: var reason := _candidate_rejection_reason(guest, definition, candidate, context) if reason.is_empty(): accepted.append(candidate) else: rejected.append({ &"facility_id": String(candidate[&"facility_id"]), &"reason": String(reason), }) _decision_rejections[guest_id] = rejected last_error = "" return accepted func get_last_candidate_rejections(guest_id: StringName) -> Array[Dictionary]: return (_decision_rejections.get(guest_id, []) as Array).duplicate(true) func evaluate_candidate(guest_id: StringName, candidate: Dictionary) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) var definition := get_definition(guest.guest_type_id) if definition == null: return _error_result("Guest definition is missing.") if not _candidate_need_maps_are_valid(candidate): return _error_result("Candidate Need map is invalid.") var need_relief := _need_relief(guest, candidate) var preference := int(_value( candidate, &"preference", definition.preference_for(_value(candidate, &"facility_tags", _value(candidate, &"tags", []))) )) var expected_quality := int(_value( candidate, &"expected_quality", _value(candidate, &"quality", 0) )) var novelty := int(_value( candidate, &"novelty", 0 if guest.visited_facility_ids.has(_facility_id(candidate)) else 10 )) var group_fit := int(_value(candidate, &"group_fit", 0)) var price_burden := int(_value(candidate, &"price_burden", _price_burden(guest, candidate))) var travel_minutes := maxi(int(_value(candidate, &"travel_minutes", 0)), 0) var wait_minutes := maxi(int(_value( candidate, &"wait_minutes", _value(candidate, &"expected_wait", 0) )), 0) var risk := maxi(int(_value(candidate, &"risk", _value(candidate, &"risk_level", 0))), 0) var default_risk_sensitivity := (100 - definition.risk_tolerance + 24) / 25 var risk_sensitivity := maxi(int(_value( candidate, &"risk_sensitivity", default_risk_sensitivity )), 0) var repetition_penalty := int(_value( candidate, &"repetition_penalty", 10 if guest.visited_facility_ids.has(_facility_id(candidate)) else 0 )) var urgency := int(_value(candidate, &"urgency", _candidate_urgency(guest, candidate))) var utility := ( need_relief * 8 + preference * 5 + expected_quality * 3 + novelty * 2 + group_fit * 2 - price_burden * 5 - travel_minutes * 2 - wait_minutes * 3 - risk * risk_sensitivity - repetition_penalty ) var result := candidate.duplicate(true) result[&"facility_id"] = _facility_id(candidate) result[&"need_priority"] = _primary_need_priority(guest, candidate) result[&"utility"] = utility result[&"urgency"] = urgency result[&"travel_minutes"] = travel_minutes result[&"utility_terms"] = { &"need_relief": need_relief, &"preference": preference, &"expected_quality": expected_quality, &"novelty": novelty, &"group_fit": group_fit, &"price_burden": price_burden, &"travel_minutes": travel_minutes, &"wait_minutes": wait_minutes, &"risk": risk, &"risk_sensitivity": risk_sensitivity, &"repetition_penalty": repetition_penalty, } return result func rank_candidates(guest_id: StringName, candidates: Array) -> Array[Dictionary]: var ranked: Array[Dictionary] = [] for raw_candidate: Variant in candidates: if raw_candidate is Dictionary: var evaluated := evaluate_candidate(guest_id, raw_candidate) if bool(evaluated.get(&"ok", true)): ranked.append(evaluated) ranked.sort_custom(_candidate_precedes) return ranked func select_best_candidate( guest_id: StringName, candidates: Array, exit_threshold: int = DEFAULT_EXIT_THRESHOLD ) -> Dictionary: var ranked := rank_candidates(guest_id, candidates) for candidate: Dictionary in ranked: if int(candidate[&"utility"]) >= exit_threshold: return candidate.duplicate(true) return {} func decide( guest_id: StringName, facilities: Array, absolute_minute: int, context: Dictionary = {} ) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) var filtered := extract_candidates(guest_id, facilities, context) var ranked := rank_candidates(guest_id, filtered) var threshold := int(_value(context, &"exit_threshold", DEFAULT_EXIT_THRESHOLD)) var reserve_callback: Callable = _value(context, &"reserve_callback", Callable()) for candidate: Dictionary in ranked: if int(candidate[&"utility"]) < threshold: continue if reserve_callback.is_valid(): var reservation_result: Variant = reserve_callback.call( guest_id, candidate[&"facility_id"], candidate.duplicate(true) ) if not _callback_succeeded(reservation_result): continue if not _transition_is_available(guest, GuestRuntimeType.State.MOVING, absolute_minute): return _error_result("Guest cannot enter MOVING in this tick.") guest.target_facility_id = candidate[&"facility_id"] guest.path_handle = _copy(_value(candidate, &"path_handle", null)) _apply_transition(guest, GuestRuntimeType.State.MOVING, absolute_minute, &"FACILITY_SELECTED") last_error = "" return { &"ok": true, &"action": &"MOVE_TO_FACILITY", &"candidate": candidate.duplicate(true), } if not _transition_is_available(guest, GuestRuntimeType.State.LEAVING, absolute_minute): return _error_result("Guest cannot enter LEAVING in this tick.") guest.target_facility_id = &"" guest.path_handle = null guest.exit_reason = StringName(_value(context, &"exit_reason", &"NO_SUITABLE_FACILITY")) _apply_transition(guest, GuestRuntimeType.State.LEAVING, absolute_minute, guest.exit_reason) last_error = "" return {&"ok": true, &"action": &"LEAVE", &"candidate": {}} func transition_guest( guest_id: StringName, new_state: int, absolute_minute: int, reason: StringName = &"" ) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) if new_state < GuestRuntimeType.State.SPAWNING or new_state > GuestRuntimeType.State.INCAPACITATED: return _error_result("Unknown guest state.") if guest.state == new_state: return {&"ok": true, &"changed": false, &"state": new_state} if not _is_allowed_transition(guest.state, new_state): return _error_result( "Invalid guest transition: %s -> %s" % [ GuestRuntimeType.State.keys()[guest.state], GuestRuntimeType.State.keys()[new_state], ] ) if not _transition_is_available(guest, new_state, absolute_minute): _force_loop_guard(guest, absolute_minute) return { &"ok": false, &"changed": true, &"loop_guard": true, &"state": guest.state, } _apply_transition(guest, new_state, absolute_minute, reason) last_error = "" return {&"ok": true, &"changed": true, &"state": new_state} func enter_queue( guest_id: StringName, facility_id: StringName, request_id: StringName, absolute_minute: int, initial_patience: int = -1, enqueue_callback: Callable = Callable() ) -> Dictionary: var guest := get_guest(guest_id) if guest == null or facility_id.is_empty() or request_id.is_empty(): return _error_result("Guest, facility and request are required for queue entry.") if not _transition_is_available(guest, GuestRuntimeType.State.QUEUING, absolute_minute): return _error_result("Guest cannot enter QUEUING in this tick.") if enqueue_callback.is_valid(): var external_result: Variant = enqueue_callback.call(guest_id, facility_id, request_id) if not _callback_succeeded(external_result): return _error_result("Facility queue rejected the guest.") var definition := get_definition(guest.guest_type_id) var resolved_patience := initial_patience if resolved_patience < 0: resolved_patience = definition.base_patience if definition != null else 0 guest.target_facility_id = facility_id guest.queue_request_id = request_id guest.patience_remaining = maxi(resolved_patience, 0) _apply_transition(guest, GuestRuntimeType.State.QUEUING, absolute_minute, &"QUEUE_ENTERED") last_error = "" return {&"ok": true, &"patience_remaining": guest.patience_remaining} ## Closure asks the guest to choose again, without charging a patience penalty. ## The callback releases the owning facility request and inventory reservations. func release_queue_for_replanning( guest_id: StringName, absolute_minute: int, release_callback: Callable = Callable() ) -> Dictionary: if _external_mutation_blocked: return _error_result("Guest mutation is blocked while committed notifications are delivered.") var guest := get_guest(guest_id) if guest == null or guest.state != GuestRuntimeType.State.QUEUING: return _error_result("Guest is not in a queue.") if not _transition_is_available(guest, GuestRuntimeType.State.DECIDING, absolute_minute): return _error_result("Guest cannot atomically replan the queue in this tick.") var facility_id := guest.target_facility_id var request_id := guest.queue_request_id if facility_id.is_empty() or request_id.is_empty(): return _error_result("Queued guest is missing a facility or request.") if release_callback.is_valid(): var external_result: Variant = release_callback.call(guest_id, facility_id, request_id) if not _callback_succeeded(external_result): return _error_result("Facility queue removal failed; guest state was not changed.") guest.target_facility_id = &"" guest.queue_request_id = &"" guest.path_handle = null _apply_transition(guest, GuestRuntimeType.State.DECIDING, absolute_minute, &"FACILITY_CLOSED") last_error = "" return { &"ok": true, &"facility_id": facility_id, &"request_id": request_id, &"patience_remaining": guest.patience_remaining, } func tick_queue_patience( guest_id: StringName, absolute_minute: int, recovery: int = 0, leave_queue_callback: Callable = Callable() ) -> Dictionary: var guest := get_guest(guest_id) if guest == null or guest.state != GuestRuntimeType.State.QUEUING: return _error_result("Guest is not in a queue.") var next_patience := maxi(guest.patience_remaining - 1 + recovery, 0) if next_patience > 0: guest.patience_remaining = next_patience last_error = "" return {&"ok": true, &"abandoned": false, &"patience_remaining": next_patience} ## Preflight every internal mutation before invoking the external queue owner. if not _transition_is_available(guest, GuestRuntimeType.State.ABANDONING, absolute_minute): return _error_result("Guest cannot atomically abandon the queue in this tick.") var facility_id := guest.target_facility_id var request_id := guest.queue_request_id if leave_queue_callback.is_valid(): var external_result: Variant = leave_queue_callback.call( guest_id, facility_id, request_id ) if not _callback_succeeded(external_result): return _error_result("Facility queue removal failed; guest state was not changed.") guest.patience_remaining = 0 guest.target_facility_id = &"" guest.queue_request_id = &"" guest.path_handle = null _apply_transition(guest, GuestRuntimeType.State.ABANDONING, absolute_minute, &"PATIENCE_EXHAUSTED") guest_queue_abandoned.emit(guest_id, facility_id, request_id) last_error = "" return {&"ok": true, &"abandoned": true, &"patience_remaining": 0} func begin_service( guest_id: StringName, facility_id: StringName, service_id: StringName, absolute_minute: int ) -> Dictionary: var guest := get_guest(guest_id) if guest == null or facility_id.is_empty() or service_id.is_empty(): return _error_result("Guest, facility and service are required.") if not _transition_is_available(guest, GuestRuntimeType.State.RECEIVING_SERVICE, absolute_minute): return _error_result("Guest cannot begin service in this tick.") guest.target_facility_id = facility_id guest.current_service_id = service_id guest.queue_request_id = &"" _apply_transition(guest, GuestRuntimeType.State.RECEIVING_SERVICE, absolute_minute, &"SERVICE_STARTED") last_error = "" return {&"ok": true, &"service_id": service_id} func interrupt_service( guest_id: StringName, service_id: StringName, absolute_minute: int, interrupt_callback: Callable = Callable() ) -> Dictionary: var guest := get_guest(guest_id) if guest == null or service_id.is_empty(): return _error_result("Guest and service are required.") if guest.interrupted_service_ids.has(service_id): return {&"ok": true, &"replayed": true, &"service_id": service_id} if guest.current_service_id != service_id: return _error_result("Guest is not receiving the specified service.") if not _transition_is_available(guest, GuestRuntimeType.State.INTERRUPTED, absolute_minute): return _error_result("Guest cannot enter INTERRUPTED in this tick.") if interrupt_callback.is_valid(): var external_result: Variant = interrupt_callback.call(guest_id, service_id) if not _callback_succeeded(external_result): return _error_result("Service interruption callback failed.") guest.interrupted_service_ids.append(service_id) guest.interrupted_service_ids.sort_custom( func(a: StringName, b: StringName) -> bool: return String(a) < String(b) ) guest.current_service_id = &"" _apply_transition(guest, GuestRuntimeType.State.INTERRUPTED, absolute_minute, &"FACILITY_STOPPED") last_error = "" return {&"ok": true, &"replayed": false, &"service_id": service_id} func complete_service( guest_id: StringName, service_id: StringName, absolute_minute: int, satisfaction_sample: int, need_effects: Dictionary = {} ) -> Dictionary: var guest := get_guest(guest_id) if guest == null or guest.current_service_id != service_id: return _error_result("Guest is not receiving the specified service.") var normalized_effects: Variant = _normalized_partial_need_map(need_effects) if not normalized_effects is Dictionary: return _error_result("Service Need effects must use canonical integer Need IDs.") if not _transition_is_available(guest, GuestRuntimeType.State.DECIDING, absolute_minute): return _error_result("Guest cannot complete service in this tick.") var sample := clampi(satisfaction_sample, 0, 100) guest.satisfaction_total += sample guest.satisfaction_samples += 1 for need_id: StringName in NEED_IDS: var effect := int(normalized_effects.get(need_id, 0)) guest.needs[need_id] = clampi(int(guest.needs[need_id]) + effect, 0, 100) if not guest.target_facility_id.is_empty() \ and not guest.visited_facility_ids.has(guest.target_facility_id): guest.visited_facility_ids.append(guest.target_facility_id) guest.visited_facility_ids.sort_custom( func(a: StringName, b: StringName) -> bool: return String(a) < String(b) ) guest.current_service_id = &"" guest.queue_request_id = &"" guest.target_facility_id = &"" _apply_transition(guest, GuestRuntimeType.State.DECIDING, absolute_minute, &"SERVICE_COMPLETED") last_error = "" return {&"ok": true, &"satisfaction_sample": sample} func record_path_failure( guest_id: StringName, absolute_minute: int, recovery_targets: Array = [], reachability_callback: Callable = Callable() ) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) guest.path_failure_count += 1 if guest.path_failure_count < PATH_FAILURE_LIMIT: if guest.state != GuestRuntimeType.State.REPLANNING: var transition := transition_guest( guest_id, GuestRuntimeType.State.REPLANNING, absolute_minute, &"PATH_INVALID" ) if not bool(transition.get(&"ok", false)): return transition last_error = "" return { &"ok": true, &"recovery_started": false, &"failure_count": guest.path_failure_count, } var selected := _select_recovery_target(guest, recovery_targets, reachability_callback) if selected.is_empty(): ## A virtual emergency egress is preferable to leaving an entity stranded. selected = { &"kind": &"emergency_despawn", &"cell": guest.current_cell, &"path_handle": {&"virtual_evacuation": true}, } if not _transition_is_available(guest, GuestRuntimeType.State.LEAVING, absolute_minute): return _error_result("Guest cannot enter safe recovery in this tick.") guest.recovery_kind = StringName(_value(selected, &"kind", &"emergency_exit")) guest.recovery_target_cell = _vector3i(_value(selected, &"cell", guest.current_cell)) guest.path_handle = _copy(_value(selected, &"path_handle", { &"recovery": true, &"target_cell": guest.recovery_target_cell, })) guest.target_facility_id = &"" guest.queue_request_id = &"" guest.current_service_id = &"" guest.exit_reason = &"PATH_RECOVERY" _apply_transition(guest, GuestRuntimeType.State.LEAVING, absolute_minute, &"PATH_FAILURE_LIMIT") _diagnostics.append({ &"code": &"GUEST_PATH_RECOVERY", &"guest_id": String(guest_id), &"minute": absolute_minute, &"failure_count": guest.path_failure_count, &"recovery_kind": String(guest.recovery_kind), &"target_cell": _cell_to_array(guest.recovery_target_cell), }) guest_recovery_started.emit(guest_id, guest.recovery_kind, guest.recovery_target_cell) last_error = "" return { &"ok": true, &"recovery_started": true, &"failure_count": guest.path_failure_count, &"recovery_kind": guest.recovery_kind, &"target_cell": guest.recovery_target_cell, } func record_path_success(guest_id: StringName, cell: Vector3i, absolute_minute: int) -> bool: var guest := get_guest(guest_id) if guest == null or absolute_minute < 0: return _fail("A valid guest and minute are required.") guest.current_cell = cell guest.last_progress_cell = cell guest.last_progress_at = absolute_minute guest.path_failure_count = 0 guest.recovery_kind = &"" last_error = "" return true func note_movement( guest_id: StringName, cell: Vector3i, absolute_minute: int, recovery_targets: Array = [], reachability_callback: Callable = Callable() ) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) if cell != guest.last_progress_cell: record_path_success(guest_id, cell, absolute_minute) return {&"ok": true, &"progressed": true, &"replanned": false} guest.current_cell = cell if absolute_minute - guest.last_progress_at < STUCK_REPLAN_MINUTES: return {&"ok": true, &"progressed": false, &"replanned": false} guest.last_progress_at = absolute_minute var result := record_path_failure( guest_id, absolute_minute, recovery_targets, reachability_callback ) result[&"progressed"] = false result[&"replanned"] = true return result func begin_leaving( guest_id: StringName, absolute_minute: int, reason: StringName ) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) if not _transition_is_available(guest, GuestRuntimeType.State.LEAVING, absolute_minute): return _error_result("Guest cannot begin leaving in this tick.") guest.target_facility_id = &"" guest.queue_request_id = &"" guest.current_service_id = &"" guest.exit_reason = reason _apply_transition(guest, GuestRuntimeType.State.LEAVING, absolute_minute, reason) last_error = "" return {&"ok": true} func despawn_guest(guest_id: StringName, absolute_minute: int) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return _error_result("Unknown guest: %s" % guest_id) if guest.state != GuestRuntimeType.State.LEAVING \ and guest.state != GuestRuntimeType.State.DESPAWNING: return _error_result("Only a leaving guest can despawn.") if guest.state != GuestRuntimeType.State.DESPAWNING: if not _transition_is_available(guest, GuestRuntimeType.State.DESPAWNING, absolute_minute): return _error_result("Guest cannot enter DESPAWNING in this tick.") _apply_transition(guest, GuestRuntimeType.State.DESPAWNING, absolute_minute, &"EXIT_REACHED") var record := { &"guest_id": String(guest.guest_id), &"guest_type_id": String(guest.guest_type_id), &"debt_gold": guest.debt_gold, &"unresolved_incident_ids": _names_to_strings(guest.unresolved_incident_ids), &"outstanding_reservation_ids": _names_to_strings(guest.outstanding_reservation_ids), } _persistent_records[guest_id] = record _guests.erase(guest_id) _pending_spawn_notifications.erase(guest_id) _guest_order_dirty = true _decision_rejections.erase(guest_id) guest_despawned.emit(guest_id, record.duplicate(true)) last_error = "" return {&"ok": true, &"persistent_record": record.duplicate(true)} func create_snapshot() -> Dictionary: var saved_definitions: Array[Dictionary] = [] for guest_type_id: StringName in _sorted_ids(_definitions.keys()): saved_definitions.append((_definitions[guest_type_id] as GuestDefinition).create_snapshot()) var saved_guests: Array[Dictionary] = [] for guest_id: StringName in _ordered_guest_ids(): saved_guests.append((_guests[guest_id] as GuestRuntime).create_snapshot()) var saved_records: Array[Dictionary] = [] for guest_id: StringName in _sorted_ids(_persistent_records.keys()): saved_records.append((_persistent_records[guest_id] as Dictionary).duplicate(true)) return { &"version": SNAPSHOT_VERSION, &"current_minute": current_minute, &"definitions": saved_definitions, &"guests": saved_guests, &"persistent_records": saved_records, &"diagnostics": _diagnostics.duplicate(true), } func restore_snapshot(snapshot: Dictionary) -> bool: var raw_version: Variant = snapshot.get(&"version", snapshot.get("version", null)) if not raw_version is int or int(raw_version) not in [1, SNAPSHOT_VERSION]: return _fail("Unsupported GuestService snapshot version.") var prepared: Variant = snapshot.duplicate(true) if int(raw_version) == 1: prepared = _migrate_v1_snapshot(snapshot) if not prepared is Dictionary: return _fail("GuestService v1 Need schema is invalid or mixed.") var candidate: Dictionary = prepared if not candidate.get(&"definitions", null) is Array \ or not candidate.get(&"guests", null) is Array \ or not candidate.get(&"persistent_records", null) is Array \ or not candidate.get(&"diagnostics", null) is Array: return _fail("GuestService snapshot arrays are missing.") var next_minute := int(candidate.get(&"current_minute", -2)) if next_minute < -1: return _fail("GuestService snapshot minute is invalid.") ## Parse into temporary containers. Live state is replaced only after every ## cross-reference and invariant has been validated. var next_definitions: Dictionary = {} for raw_definition: Variant in candidate[&"definitions"]: if not raw_definition is Dictionary: return _fail("Guest definition snapshot has an invalid type.") var definition := GuestDefinitionType.from_snapshot(raw_definition) if definition == null or next_definitions.has(definition.guest_type_id): return _fail("Guest definition snapshot is invalid or duplicated.") next_definitions[definition.guest_type_id] = definition var next_guests: Dictionary = {} for raw_guest: Variant in candidate[&"guests"]: if not raw_guest is Dictionary: return _fail("Guest runtime snapshot has an invalid type.") var guest := GuestRuntimeType.from_snapshot(raw_guest) if guest == null or next_guests.has(guest.guest_id): return _fail("Guest runtime snapshot is invalid or duplicated.") if not next_definitions.has(guest.guest_type_id): return _fail("Guest runtime snapshot references an unknown definition.") next_guests[guest.guest_id] = guest var next_records: Dictionary = {} for raw_record: Variant in candidate[&"persistent_records"]: if not raw_record is Dictionary or not _persistent_record_is_valid(raw_record): return _fail("Persistent guest record is invalid.") var record_id := StringName(raw_record.get(&"guest_id", "")) if next_records.has(record_id) or next_guests.has(record_id): return _fail("Persistent guest id is duplicated or still active.") next_records[record_id] = raw_record.duplicate(true) var next_diagnostics: Array[Dictionary] = [] for raw_diagnostic: Variant in candidate[&"diagnostics"]: if not raw_diagnostic is Dictionary: return _fail("Guest diagnostic snapshot has an invalid type.") next_diagnostics.append(raw_diagnostic.duplicate(true)) _definitions = next_definitions _guests = next_guests _guest_order_dirty = true _persistent_records = next_records _decision_rejections = {} _diagnostics = next_diagnostics _pending_spawn_notifications.clear() _external_mutation_blocked = false current_minute = next_minute last_error = "" return true func validate_invariants() -> Dictionary: var errors: Array[String] = [] for guest_type_id: StringName in _sorted_ids(_definitions.keys()): var definition: GuestDefinition = _definitions[guest_type_id] if not definition.is_valid() or definition.guest_type_id != guest_type_id: errors.append("Invalid definition: %s" % guest_type_id) for guest_id: StringName in _sorted_ids(_guests.keys()): var guest: GuestRuntime = _guests[guest_id] if not guest.is_valid() or guest.guest_id != guest_id: errors.append("Invalid runtime: %s" % guest_id) elif not _definitions.has(guest.guest_type_id): errors.append("Unknown guest type for: %s" % guest_id) if _persistent_records.has(guest_id): errors.append("Guest is both active and persisted: %s" % guest_id) for guest_id: StringName in _sorted_ids(_persistent_records.keys()): if not _persistent_record_is_valid(_persistent_records[guest_id]): errors.append("Invalid persistent record: %s" % guest_id) return {&"valid": errors.is_empty(), &"errors": errors} func _migrate_v1_snapshot(snapshot: Dictionary) -> Variant: var definitions: Variant = snapshot.get(&"definitions", snapshot.get("definitions", null)) var guests: Variant = snapshot.get(&"guests", snapshot.get("guests", null)) if not definitions is Array or not guests is Array: return null var migrated := snapshot.duplicate(true) for index: int in range(definitions.size()): var raw_definition: Variant = definitions[index] if not raw_definition is Dictionary: return null var translated_rates: Variant = _translate_v1_need_map( _value(raw_definition, &"need_rates", null) ) if not translated_rates is Dictionary: return null migrated[&"definitions"][index][&"need_rates"] = translated_rates for index: int in range(guests.size()): var raw_guest: Variant = guests[index] if not raw_guest is Dictionary: return null var translated_needs: Variant = _translate_v1_need_map( _value(raw_guest, &"needs", null) ) if not translated_needs is Dictionary: return null migrated[&"guests"][index][&"needs"] = translated_needs migrated[&"version"] = SNAPSHOT_VERSION return migrated static func _translate_v1_need_map(source: Variant) -> Variant: if not source is Dictionary or source.size() != LEGACY_NEED_IDS.size(): return null var legacy: Dictionary = {} for raw_key: Variant in source: if not raw_key is String and not raw_key is StringName: return null var legacy_id := StringName(raw_key) if not LEGACY_NEED_IDS.has(legacy_id) or legacy.has(legacy_id): return null var raw_value: Variant = source[raw_key] if not raw_value is int: return null legacy[legacy_id] = raw_value if legacy.size() != LEGACY_NEED_IDS.size(): return null return { &"hunger": legacy[&"hunger"], &"sleep": legacy[&"rest"], &"hygiene": 0, &"medical": legacy[&"safety"], &"leisure": legacy[&"fun"], &"intimacy": legacy[&"social"], } func _need_context_error(context: Dictionary) -> String: for field_id: StringName in [ &"dirt", &"injury", &"illness", &"intimacy_day", &"intimacy_night", ]: if not _has_value(context, field_id): continue var raw_value: Variant = _value(context, field_id, null) if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100: return "%s must be an integer from 0 through 100." % field_id for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated", &"mana_fog"]: if _has_value(context, field_id) and not _value(context, field_id, null) is bool: return "%s must be boolean." % field_id if _has_value(context, &"need_effects"): var effects: Variant = _normalized_partial_need_map( _value(context, &"need_effects", null) ) if not effects is Dictionary: return "need_effects must contain only canonical integer Need IDs." return "" static func _normalized_partial_need_map(source: Variant) -> Variant: if not source is Dictionary or source.size() > NEED_IDS.size(): return null var result: 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 result.has(need_id): return null var raw_value: Variant = source[raw_key] if not raw_value is int: return null result[need_id] = raw_value return result static func _candidate_need_maps_are_valid(candidate: Dictionary) -> bool: for field_id: StringName in [&"need_relief", &"need_effects"]: if not _has_value(candidate, field_id): continue var raw_value: Variant = _value(candidate, field_id, null) if field_id == &"need_relief" and raw_value is int: continue if not _normalized_partial_need_map(raw_value) is Dictionary: return false return true func _candidate_rejection_reason( guest: GuestRuntime, definition: GuestDefinition, candidate: Dictionary, context: Dictionary ) -> StringName: if not _candidate_need_maps_are_valid(candidate): return &"INVALID_NEED_MAP" if not bool(_value(candidate, &"inputs_available", true)): return &"MISSING_INPUTS" var adult_only := bool(_value( candidate, &"adult_only", _value(candidate, &"requires_adult_verified", false) )) if adult_only: if not definition.adult_verified: return &"ADULT_NOT_VERIFIED" if not bool(_value(context, &"adult_content_enabled", true)): return &"CONTENT_FILTERED" var required_tags := _name_array(_value(candidate, &"required_guest_tags", [])) for tag: StringName in required_tags: if not definition.tags.has(tag): return &"MISSING_PERMISSION" var forbidden_tags := _name_array(_value(candidate, &"forbidden_guest_tags", [])) for tag: StringName in forbidden_tags: if definition.tags.has(tag): return &"FORBIDDEN_GUEST_TAG" var allowed_types := _name_array(_value(candidate, &"allowed_guest_types", [])) if not allowed_types.is_empty() and not allowed_types.has(guest.guest_type_id): return &"GUEST_TYPE_NOT_ALLOWED" if not bool(_value(candidate, &"is_open", _value(candidate, &"open", true))) \ and not bool(_value(candidate, &"open_at_arrival", false)): return &"FACILITY_CLOSED" if not _candidate_is_reachable(guest, candidate, context): return &"UNREACHABLE" var travel_minutes := maxi(int(_value(candidate, &"travel_minutes", 0)), 0) var decision_minute := int(_value(context, &"absolute_minute", current_minute)) if decision_minute >= 0 and decision_minute + travel_minutes > guest.planned_exit_at: return &"ETA_AFTER_EXIT" if not _candidate_is_affordable(guest, definition, candidate): return &"CANNOT_PAY" var wait_minutes := maxi(int(_value( candidate, &"wait_minutes", _value(candidate, &"expected_wait", 0) )), 0) var hard_limit := maxi(int(_value( candidate, &"wait_hard_limit", _value(context, &"wait_hard_limit", DEFAULT_WAIT_HARD_LIMIT) )), 0) if wait_minutes >= hard_limit: return &"WAIT_HARD_LIMIT" var risk_level := clampi(int(_value(candidate, &"risk_level", _value(candidate, &"risk", 0))), 0, 100) if risk_level > definition.risk_tolerance: return &"RISK_REFUSED" var illegal_level := clampi(int(_value(candidate, &"illegal_level", 0)), 0, 100) if illegal_level > 100 - definition.legal_sensitivity \ and not bool(_value(candidate, &"legal_permission_override", false)): return &"ILLEGALITY_REFUSED" var quality := clampi(int(_value(candidate, &"expected_quality", _value(candidate, &"quality", 0))), 0, 100) var minimum_quality := clampi(int(_value(candidate, &"minimum_quality", definition.minimum_quality)), 0, 100) if quality < minimum_quality: return &"QUALITY_TOO_LOW" return &"" func _candidate_is_reachable( guest: GuestRuntime, candidate: Dictionary, context: Dictionary ) -> bool: var callback: Callable = _value(context, &"reachability_callback", Callable()) if callback.is_valid(): return _callback_succeeded(callback.call(guest, candidate.duplicate(true), context)) return bool(_value(candidate, &"reachable", true)) func _candidate_is_affordable( guest: GuestRuntime, definition: GuestDefinition, candidate: Dictionary ) -> bool: var price := _candidate_price(candidate) var cash_available := guest.wallet_gold var respect_reserve := bool(_value( candidate, &"respect_survival_reserve", _candidate_is_discretionary(candidate) )) if respect_reserve: cash_available = maxi(cash_available - _survival_reserve(guest, definition), 0) if price <= cash_available: return true if not bool(_value(candidate, &"credit_allowed", _value(candidate, &"allow_credit", false))): return false var remaining_credit := maxi(guest.credit_limit_gold - guest.debt_gold, 0) return price - cash_available <= remaining_credit func _candidate_is_discretionary(candidate: Dictionary) -> bool: if bool(_value(candidate, &"discretionary", false)): return true var category := StringName(_value(candidate, &"service_category", &"")) if category in [&"leisure", &"entertainment", &"gambling", &"intimacy", &"tourism"]: return true var facility_tags := _name_array(_value( candidate, &"facility_tags", _value(candidate, &"tags", []) )) for tag: StringName in [&"leisure", &"entertainment", &"gambling", &"adult", &"intimacy"]: if facility_tags.has(tag): return true return false func _survival_reserve(guest: GuestRuntime, definition: GuestDefinition) -> int: if guest.max_addiction() >= 75: return 0 if definition.tags.has(&"spendthrift"): return guest.wallet_gold / 10 if definition.tags.has(&"frugal"): return guest.wallet_gold / 2 return guest.wallet_gold * 3 / 10 func _candidate_price(candidate: Dictionary) -> int: return maxi(int(_value( candidate, &"estimated_total_gold", _value(candidate, &"price_gold", _value(candidate, &"quoted_price", 0)) )), 0) func _price_burden(guest: GuestRuntime, candidate: Dictionary) -> int: var price := _candidate_price(candidate) var purchasing_power := maxi(guest.wallet_gold + maxi(guest.credit_limit_gold - guest.debt_gold, 0), 1) return clampi((price * 100 + purchasing_power - 1) / purchasing_power, 0, 100) func _need_relief(guest: GuestRuntime, candidate: Dictionary) -> int: var raw_relief: Variant = _value(candidate, &"need_relief", 0) if raw_relief is int: return maxi(int(raw_relief), 0) if not raw_relief is Dictionary: return 0 var normalized: Dictionary = _normalized_partial_need_map(raw_relief) var best := 0 for need_id: StringName in NEED_IDS: var relief := maxi(int(normalized.get(need_id, 0)), 0) best = maxi(best, relief * int(guest.needs[need_id]) / 100) return best func _candidate_urgency(guest: GuestRuntime, candidate: Dictionary) -> int: var raw_relief: Variant = _value(candidate, &"need_relief", {}) if not raw_relief is Dictionary: return 0 var normalized: Dictionary = _normalized_partial_need_map(raw_relief) var result := 0 for need_id: StringName in NEED_IDS: if int(normalized.get(need_id, 0)) > 0: result = maxi(result, int(guest.needs[need_id])) return result func _primary_need_priority(guest: GuestRuntime, candidate: Dictionary) -> int: var relief: Variant = _value(candidate, &"need_relief", {}) if not relief is Dictionary: return 0 var normalized: Dictionary = _normalized_partial_need_map(relief) var priority := 0 for need: StringName in PRIMARY_NEEDS: if int(normalized.get(need, 0)) > 0: priority = maxi(priority, int(guest.needs[need])) return priority func get_observation(guest_id: StringName) -> Dictionary: var guest := get_guest(guest_id) if guest == null: return {} var highest := 0 var primary: Array[StringName] = [] for need: StringName in PRIMARY_NEEDS: var value := int(guest.needs[need]) if value > highest: highest = value primary.clear() if value == highest and value > 0: primary.append(need) var observed_needs: Dictionary = {} for need: StringName in NEED_IDS: observed_needs[need] = guest.needs[need] return {&"guest_id": guest.guest_id, &"state": guest.state, &"needs": observed_needs, &"primary_needs": primary, &"target_facility_id": guest.target_facility_id, &"wallet_gold": guest.wallet_gold, &"patience": guest.patience_remaining} func _candidate_precedes(a: Dictionary, b: Dictionary) -> bool: var a_priority := int(a.get(&"need_priority", 0)) var b_priority := int(b.get(&"need_priority", 0)) if a_priority != b_priority: return a_priority > b_priority var a_utility := int(a[&"utility"]) var b_utility := int(b[&"utility"]) if a_utility != b_utility: return a_utility > b_utility var a_urgency := int(a[&"urgency"]) var b_urgency := int(b[&"urgency"]) if a_urgency != b_urgency: return a_urgency > b_urgency var a_travel := int(a[&"travel_minutes"]) var b_travel := int(b[&"travel_minutes"]) if a_travel != b_travel: return a_travel < b_travel return String(a[&"facility_id"]) < String(b[&"facility_id"]) func _select_recovery_target( guest: GuestRuntime, targets: Array, reachability_callback: Callable ) -> Dictionary: var ordered: Array[Dictionary] = [] for raw_target: Variant in targets: if raw_target is Dictionary: var target: Dictionary = raw_target.duplicate(true) target[&"kind"] = StringName(_value(target, &"kind", &"emergency_exit")) target[&"cell"] = _vector3i(_value(target, &"cell", guest.current_cell)) ordered.append(target) ordered.sort_custom( func(a: Dictionary, b: Dictionary) -> bool: var a_priority := _recovery_priority(StringName(a[&"kind"])) var b_priority := _recovery_priority(StringName(b[&"kind"])) if a_priority != b_priority: return a_priority < b_priority return _cell_precedes(a[&"cell"], b[&"cell"]) ) for target: Dictionary in ordered: var reachable := bool(_value(target, &"reachable", true)) if reachability_callback.is_valid(): reachable = _callback_succeeded(reachability_callback.call( guest.current_cell, target[&"cell"], guest.guest_id, target.duplicate(true) )) if reachable: return target return {} func _recovery_priority(kind: StringName) -> int: match kind: &"guidance", &"guidance_point": return 0 &"entrance", &"main_entrance": return 1 &"emergency_exit", &"emergency_egress": return 2 _: return 3 func _transition_is_available(guest: GuestRuntime, new_state: int, minute: int) -> bool: if minute < 0 or not _is_allowed_transition(guest.state, new_state): return false var count := guest.transitions_this_tick if guest.transition_tick == minute else 0 return count < MAX_TRANSITIONS_PER_TICK func _apply_transition( guest: GuestRuntime, new_state: int, absolute_minute: int, reason: StringName ) -> void: var old_state := guest.state if guest.transition_tick != absolute_minute: guest.transition_tick = absolute_minute guest.transitions_this_tick = 0 guest.transitions_this_tick += 1 guest.state = new_state as GuestRuntime.State guest.state_entered_at = absolute_minute guest_state_changed.emit(guest.guest_id, old_state, new_state, reason) func _force_loop_guard(guest: GuestRuntime, absolute_minute: int) -> void: var old_state := guest.state guest.state = GuestRuntimeType.State.DECIDING guest.state_entered_at = absolute_minute guest.transition_tick = absolute_minute guest.transitions_this_tick = MAX_TRANSITIONS_PER_TICK guest.target_facility_id = &"" guest.queue_request_id = &"" guest.current_service_id = &"" guest.path_handle = null _diagnostics.append({ &"code": &"AI_LOOP_GUARD", &"guest_id": String(guest.guest_id), &"minute": absolute_minute, }) guest_state_changed.emit( guest.guest_id, old_state, GuestRuntimeType.State.DECIDING, &"AI_LOOP_GUARD" ) func _is_allowed_transition(from_state: int, to_state: int) -> bool: if from_state == to_state: return true if to_state in [ GuestRuntimeType.State.LEAVING, GuestRuntimeType.State.FLEEING, GuestRuntimeType.State.INCAPACITATED, ]: return from_state != GuestRuntimeType.State.DESPAWNING match from_state: GuestRuntimeType.State.SPAWNING: return to_state in [GuestRuntimeType.State.ENTERING, GuestRuntimeType.State.DECIDING] GuestRuntimeType.State.ENTERING: return to_state in [GuestRuntimeType.State.DECIDING, GuestRuntimeType.State.MOVING] GuestRuntimeType.State.DECIDING: return to_state in [GuestRuntimeType.State.MOVING, GuestRuntimeType.State.QUEUING] GuestRuntimeType.State.MOVING: return to_state in [ GuestRuntimeType.State.QUEUING, GuestRuntimeType.State.RECEIVING_SERVICE, GuestRuntimeType.State.DECIDING, GuestRuntimeType.State.REPLANNING, ] GuestRuntimeType.State.QUEUING: return to_state in [ GuestRuntimeType.State.RECEIVING_SERVICE, GuestRuntimeType.State.ABANDONING, GuestRuntimeType.State.DECIDING, ] GuestRuntimeType.State.RECEIVING_SERVICE: return to_state in [GuestRuntimeType.State.DECIDING, GuestRuntimeType.State.INTERRUPTED] GuestRuntimeType.State.REPLANNING: return to_state in [GuestRuntimeType.State.MOVING, GuestRuntimeType.State.DECIDING] GuestRuntimeType.State.ABANDONING, GuestRuntimeType.State.INTERRUPTED: return to_state == GuestRuntimeType.State.DECIDING GuestRuntimeType.State.FLEEING: return to_state in [GuestRuntimeType.State.LEAVING, GuestRuntimeType.State.DESPAWNING] GuestRuntimeType.State.LEAVING: return to_state == GuestRuntimeType.State.DESPAWNING _: return false static func _callback_succeeded(result: Variant) -> bool: if result == null: return true if result is bool: return result if result is Dictionary: return bool(result.get(&"ok", result.get("ok", result.get(&"success", result.get("success", false))))) if result is Object and result.has_method("is_success"): return bool(result.call("is_success")) return false static func _persistent_record_is_valid(record: Dictionary) -> bool: if StringName(record.get(&"guest_id", "")).is_empty(): return false if StringName(record.get(&"guest_type_id", "")).is_empty(): return false if int(record.get(&"debt_gold", -1)) < 0: return false return record.get(&"unresolved_incident_ids", null) is Array \ and record.get(&"outstanding_reservation_ids", null) is Array static func _facility_id(candidate: Dictionary) -> StringName: return StringName(_value( candidate, &"facility_id", _value(candidate, &"instance_id", &"") )) static func _name_array(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 _sorted_ids(source: Array) -> Array[StringName]: var result: Array[StringName] = [] for raw_value: Variant in source: result.append(StringName(raw_value)) result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) return result func _ordered_guest_ids() -> Array[StringName]: if _guest_order_dirty: _sorted_guest_ids_cache = _sorted_ids(_guests.keys()) _guest_order_dirty = false return _sorted_guest_ids_cache static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: if source.has(key): return source[key] var string_key := String(key) return source[string_key] if source.has(string_key) else fallback static func _has_value(source: Dictionary, key: StringName) -> bool: return source.has(key) or source.has(String(key)) static func _need_report(values: Dictionary) -> Dictionary: # Matches _string_key_dictionary's canonical lexical key order without # allocating and sorting a six-key array for every guest and minute. return { "hunger": int(values[&"hunger"]), "hygiene": int(values[&"hygiene"]), "intimacy": int(values[&"intimacy"]), "leisure": int(values[&"leisure"]), "medical": int(values[&"medical"]), "sleep": int(values[&"sleep"]), } static func _copy(value: Variant) -> Variant: if value is Array or value is Dictionary: return value.duplicate(true) return value 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 _cell_precedes(a: Vector3i, b: Vector3i) -> bool: if a.z != b.z: return a.z < b.z if a.y != b.y: return a.y < b.y return a.x < b.x 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 func _fail(message: String) -> bool: last_error = message return false func _error_result(message: String) -> Dictionary: last_error = message return {&"ok": false, &"error": message}