Files
TheTree_mock/scripts/guest/guest_service.gd
T

1612 lines
57 KiB
GDScript

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 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))
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[&"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)
if ranked.is_empty() or int(ranked[0][&"utility"]) < exit_threshold:
return {}
return ranked[0].duplicate(true)
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:
break
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}
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"]:
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"
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 _candidate_precedes(a: Dictionary, b: Dictionary) -> bool:
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}