Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
1689 lines
60 KiB
GDScript
1689 lines
60 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 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))
|
|
|
|
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"]:
|
|
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}
|