Files
TheTree_mock/tests/s3/test_guest_service.gd
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
2026-10-11 15:58:00 +09:00

858 lines
40 KiB
GDScript

extends SceneTree
const GuestDefinitionType = preload("res://scripts/guest/guest_definition.gd")
const GuestRuntimeType = preload("res://scripts/guest/guest_runtime.gd")
const GuestServiceType = preload("res://scripts/guest/guest_service.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("S3: deterministic Guest AI")
_test_definition_and_runtime_contract()
_test_need_updates_and_single_clamp()
_test_need_context_and_adult_gate()
_test_shared_need_context_fast_paths()
_test_batched_need_updates_match_individual_api()
_test_candidate_filtering()
_test_integer_utility_and_stable_tie_break()
_test_primary_need_priority()
_test_state_machine_and_loop_guard()
_test_atomic_queue_abandonment()
_test_service_interruption_is_idempotent()
_test_service_need_effect_schema()
_test_path_failure_safe_recovery()
_test_exit_persistence_boundary()
_test_overdue_departure_snapshot()
_test_snapshot_round_trip_and_atomic_rejection()
_test_v1_need_migration_and_schema_rejection()
_test_deterministic_decision_snapshot()
if _failures == 0:
print("PASS: %d checks" % _checks)
else:
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
quit(_failures)
func _test_definition_and_runtime_contract() -> void:
var definition := GuestDefinitionType.new().configure(&"adventurer", {
&"tags": [&"adventurer", &"general", &"general"],
&"wealth_min": 100,
&"wealth_max": 500,
&"base_patience": 45,
&"risk_tolerance": 80,
&"preference_weights": {&"meal": 8, &"quiet": -2},
&"group_size_weights": {1: 80, 2: 20},
&"legal_sensitivity": 60,
&"quality_sensitivity": 70,
&"adult_verified": false,
&"need_rates": {&"hunger": 3, &"sleep": -2, &"leisure": 1},
&"credit_limit_gold": 100,
&"minimum_quality": 40,
})
_expect(definition.is_valid(), "complete guest definition validates")
_expect(definition.tags == [&"adventurer", &"general"], "definition tags are unique and stable-sorted")
_expect(definition.get_need_rate(&"intimacy") == 0, "omitted canonical Need rates normalize to zero")
_expect(definition.need_rates.size() == 6, "definition always materializes the six canonical Need IDs")
var round_trip := GuestDefinitionType.from_snapshot(definition.create_snapshot())
_expect(round_trip != null, "definition reconstructs from its canonical snapshot")
_expect(round_trip.create_snapshot() == definition.create_snapshot(), "definition snapshot round trip is exact")
var legacy_definition := GuestDefinitionType.new().configure(&"legacy_alias", {
&"need_rates": {&"rest": 1},
})
_expect(not legacy_definition.is_valid(), "legacy Definition Need aliases are invalid outside v1 migration")
var legacy_runtime := GuestRuntimeType.new().configure(&"legacy_guest", &"adventurer", {
&"needs": {&"social": 10},
})
_expect(not legacy_runtime.is_valid(), "legacy Runtime Need aliases are invalid outside v1 migration")
var coerced_adult := GuestDefinitionType.new().configure(&"coerced_adult", {
&"adult_verified": "true",
})
_expect(not coerced_adult.is_valid(), "adult verification cannot be supplied through truthy string coercion")
var service := GuestServiceType.new()
_expect(service.register_definition(definition), "valid definition registers")
_expect(not service.register_definition(definition), "duplicate definition ID is rejected")
var guest := service.spawn_guest(&"guest_contract", &"adventurer", 10, {
&"planned_exit_at": 200,
&"current_cell": Vector3i(2, 3, 0),
})
_expect(guest != null and guest.is_valid(), "guest runtime spawns with required authoritative fields")
_expect(guest.wallet_gold == 100 and guest.credit_limit_gold == 100, "definition supplies deterministic wallet floor and credit limit")
_expect(guest.patience_remaining == 45, "definition supplies base queue patience")
_expect(service.validate_invariants()[&"valid"], "fresh GuestService invariants hold")
func _test_need_updates_and_single_clamp() -> void:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"guest_needs", &"adventurer", 0, {
&"state": GuestRuntimeType.State.MOVING,
&"needs": {
&"hunger": 99,
&"sleep": 1,
&"hygiene": 99,
&"medical": 99,
&"leisure": 99,
&"intimacy": 99,
},
&"planned_exit_at": 500,
})
var report: Dictionary = service.update_needs(guest.guest_id, {
&"dirt": 2,
&"injury": 2,
&"illness": 2,
&"need_effects": {
&"hunger": 5,
&"sleep": -5,
&"hygiene": 3,
&"medical": 3,
&"leisure": 3,
&"intimacy": 3,
},
})
_expect(report[&"ok"], "Need update succeeds for a live guest")
_expect(report[&"deltas"]["hunger"] == 10, "base, movement and external hunger effects add before clamp")
_expect(report[&"deltas"]["sleep"] == -5, "positive and negative sleep effects combine before clamp")
_expect(guest.needs[&"hunger"] == 100 and guest.needs[&"sleep"] == 0, "Need upper and lower bounds clamp once")
_expect(guest.needs[&"hygiene"] == 100 and guest.needs[&"medical"] == 100, "dirt, injury and illness update their distinct Needs")
_expect(guest.needs[&"leisure"] == 100 and guest.needs[&"intimacy"] == 100, "leisure and intimacy effects use canonical IDs")
_expect(service.advance_one_minute(1)[&"ok"], "one-minute GuestService cadence advances")
_expect(not service.advance_one_minute(1)[&"ok"], "the same minute cannot apply Need drift twice")
func _test_need_context_and_adult_gate() -> void:
var service := _service_with_definitions()
var verified := service.spawn_guest(&"verified_context", &"verified_noble", 0, {
&"needs": {&"hunger": 0, &"sleep": 0, &"hygiene": 10, &"medical": 20, &"leisure": 0, &"intimacy": 30},
&"planned_exit_at": 500,
})
var unverified := service.spawn_guest(&"unverified_context", &"adventurer", 0, {
&"needs": {&"hunger": 0, &"sleep": 0, &"hygiene": 10, &"medical": 20, &"leisure": 0, &"intimacy": 30},
&"planned_exit_at": 500,
})
var day := service.update_needs(verified.guest_id, {
&"unstimulated": false,
&"dirt": 5,
&"injury": 4,
&"illness": 3,
&"is_night": false,
&"intimacy_day": 3,
&"intimacy_night": 7,
})
_expect(day[&"deltas"]["hygiene"] == 5, "integer dirt context raises Hygiene only")
_expect(day[&"deltas"]["medical"] == 7, "injury and illness combine into Medical")
_expect(day[&"deltas"]["intimacy"] == 3, "day context uses the adult-verified Intimacy day rate")
var night := service.update_needs(verified.guest_id, {
&"unstimulated": false,
&"is_night": true,
&"intimacy_day": 3,
&"intimacy_night": 7,
})
_expect(night[&"deltas"]["intimacy"] == 7, "night context selects the separate Intimacy night rate")
var blocked := service.update_needs(unverified.guest_id, {
&"unstimulated": false,
&"is_night": true,
&"intimacy_night": 7,
})
_expect(blocked[&"deltas"]["intimacy"] == 0, "unverified guests cannot receive contextual adult Intimacy drift")
var before_invalid := service.create_snapshot()
var wrong_type := service.update_needs(verified.guest_id, {&"injury": "4"})
_expect(not wrong_type[&"ok"], "stringified Need context integers are rejected")
_expect(service.create_snapshot() == before_invalid, "invalid context leaves live Need state untouched")
var legacy_alias := service.update_needs(verified.guest_id, {
&"need_effects": {&"rest": -5},
})
_expect(not legacy_alias[&"ok"], "legacy Need aliases are rejected outside snapshot migration")
_expect(service.create_snapshot() == before_invalid, "legacy context rejection is atomic")
func _test_batched_need_updates_match_individual_api() -> void:
var batched := _service_with_definitions()
batched.spawn_guest(&"z_guest", &"adventurer", 0, {
&"state": GuestRuntimeType.State.MOVING,
&"group_id": &"party",
&"needs": {&"hunger": 10, &"sleep": 20, &"hygiene": 30, &"medical": 40, &"leisure": 50, &"intimacy": 60},
&"planned_exit_at": 500,
})
batched.spawn_guest(&"a_guest", &"verified_noble", 0, {
&"needs": {&"hunger": 5, &"sleep": 15, &"hygiene": 25, &"medical": 35, &"leisure": 45, &"intimacy": 55},
&"planned_exit_at": 500,
})
var individual := GuestServiceType.new()
individual.restore_snapshot(batched.create_snapshot())
var default_context := {
&"unstimulated": false,
&"dirt": 1,
&"need_effects": {&"sleep": 3},
}
var z_context := {
&"solo_guest": true,
&"injury": 2,
&"need_effects": {&"hunger": -1, &"leisure": 4},
}
var expected_reports: Array[Dictionary] = [
individual.update_needs(&"a_guest", default_context),
individual.update_needs(&"z_guest", z_context),
]
individual.current_minute = 1
var batch_report := batched.advance_one_minute(1, {
&"default": default_context,
"z_guest": z_context,
})
_expect(
batch_report[&"guest_reports"] == expected_reports,
"batched Need cadence preserves individual report values and stable guest-id order"
)
_expect(
batched.create_snapshot() == individual.create_snapshot(),
"optimized batched Need mutation is snapshot-equivalent to individual updates"
)
func _test_shared_need_context_fast_paths() -> void:
var source := _service_with_definitions()
for guest_id: StringName in [&"z_guest", &"a_guest"]:
source.spawn_guest(guest_id, &"adventurer", 0, {
&"state": GuestRuntimeType.State.MOVING,
&"needs": {
&"hunger": 10,
&"sleep": 20,
&"hygiene": 30,
&"medical": 40,
&"leisure": 50,
&"intimacy": 60,
},
&"planned_exit_at": 500,
})
var start := source.create_snapshot()
var empty_fast := GuestServiceType.new()
var empty_reference := GuestServiceType.new()
empty_fast.restore_snapshot(start)
empty_reference.restore_snapshot(start)
var empty_report := empty_fast.advance_one_minute(1)
var empty_reference_report := empty_reference.advance_one_minute(1, {
&"default": {},
&"a_guest": {},
&"z_guest": {},
})
_expect(
empty_report == empty_reference_report,
"empty shared Need context preserves the preflight-path report exactly"
)
_expect(
empty_fast.create_snapshot() == empty_reference.create_snapshot(),
"empty shared Need context preserves the preflight-path snapshot exactly"
)
var common_context := {
&"unstimulated": false,
&"dirt": 2,
&"injury": 1,
&"need_effects": {&"sleep": 3, &"leisure": -2},
}
var default_fast := GuestServiceType.new()
var default_reference := GuestServiceType.new()
default_fast.restore_snapshot(start)
default_reference.restore_snapshot(start)
var default_report := default_fast.advance_one_minute(1, {
&"default": common_context,
})
var default_reference_report := default_reference.advance_one_minute(1, {
&"default": common_context,
&"a_guest": common_context,
&"z_guest": common_context,
})
_expect(
default_report == default_reference_report,
"default-only shared Need context preserves the override-path report exactly"
)
_expect(
default_fast.create_snapshot() == default_reference.create_snapshot(),
"default-only shared Need context preserves the override-path snapshot exactly"
)
var invalid_default := GuestServiceType.new()
invalid_default.restore_snapshot(start)
var before_invalid := invalid_default.create_snapshot()
var invalid_report := invalid_default.advance_one_minute(1, {
&"default": {&"injury": "1"},
})
_expect(not invalid_report[&"ok"], "invalid default-only Need context is rejected")
_expect(
invalid_default.create_snapshot() == before_invalid,
"invalid default-only Need context is atomic across the population and minute"
)
var malformed_default := GuestServiceType.new()
var malformed_reference := GuestServiceType.new()
malformed_default.restore_snapshot(start)
malformed_reference.restore_snapshot(start)
var malformed_report := malformed_default.advance_one_minute(1, {
&"default": "legacy malformed default",
})
var malformed_reference_report := malformed_reference.advance_one_minute(1)
_expect(
malformed_report == malformed_reference_report,
"non-Dictionary default retains the legacy empty-context meaning"
)
_expect(
malformed_default.create_snapshot() == malformed_reference.create_snapshot(),
"malformed-default compatibility remains snapshot-exact"
)
func _test_candidate_filtering() -> void:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"guest_filter", &"adventurer", 0, {
&"wallet_gold": 500,
&"credit_limit_gold": 100,
&"planned_exit_at": 1_000,
})
var candidates: Array = [
_candidate(&"cannot_pay", {&"price_gold": 501}),
_candidate(&"adult_room", {&"adult_only": true}),
_candidate(&"unreachable", {&"reachable": false}),
_candidate(&"closed", {&"is_open": false}),
_candidate(&"too_slow", {&"wait_minutes": 120}),
_candidate(&"too_risky", {&"risk_level": 81}),
_candidate(&"too_illegal", {&"illegal_level": 41}),
_candidate(&"too_poor", {&"expected_quality": 39}),
_candidate(&"legacy_relief", {&"need_relief": {&"fun": 40}}),
_candidate(&"legacy_effects", {&"need_effects": {&"safety": -20}}),
_candidate(&"canonical_effects", {&"need_effects": {&"medical": -20}}),
_candidate(&"credit_ok", {&"price_gold": 550, &"credit_allowed": true}),
_candidate(&"normal_ok"),
]
var filtered := service.extract_candidates(guest.guest_id, candidates, {
&"absolute_minute": 10,
&"adult_content_enabled": true,
&"wait_hard_limit": 120,
})
_expect(_candidate_ids(filtered) == [&"canonical_effects", &"credit_ok", &"normal_ok"], "candidate extraction applies every hard filter before Utility")
var reasons := _rejection_map(service.get_last_candidate_rejections(guest.guest_id))
_expect(reasons[&"cannot_pay"] == &"CANNOT_PAY", "SYS-GST-001 rejects an unaffordable facility")
_expect(reasons[&"adult_room"] == &"ADULT_NOT_VERIFIED", "SYS-GST-002 rejects restricted content before Utility")
_expect(reasons[&"unreachable"] == &"UNREACHABLE", "unreachable facility is removed")
_expect(reasons[&"too_slow"] == &"WAIT_HARD_LIMIT", "wait equal to the hard limit is removed")
_expect(reasons[&"too_risky"] == &"RISK_REFUSED", "risk above guest tolerance is removed")
_expect(reasons[&"too_illegal"] == &"ILLEGALITY_REFUSED", "illegal level above sensitivity threshold is removed")
_expect(reasons[&"too_poor"] == &"QUALITY_TOO_LOW", "quality below guest minimum is removed")
_expect(reasons[&"legacy_relief"] == &"INVALID_NEED_MAP", "candidate Need maps cannot use legacy aliases")
_expect(reasons[&"legacy_effects"] == &"INVALID_NEED_MAP", "candidate need_effects also reject migration-only aliases")
guest.wallet_gold = 100
var reserve_filtered := service.extract_candidates(guest.guest_id, [
_candidate(&"meal_with_cash", {&"price_gold": 80}),
_candidate(&"leisure_below_reserve", {
&"price_gold": 80,
&"service_category": &"entertainment",
}),
], {&"absolute_minute": 10})
_expect(_candidate_ids(reserve_filtered) == [&"meal_with_cash"], "discretionary spending preserves the personality-based survival reserve")
guest.wallet_gold = 500
var verified := service.spawn_guest(&"guest_verified", &"verified_noble", 0, {
&"wallet_gold": 1_000,
&"planned_exit_at": 1_000,
})
var adult_candidates := service.extract_candidates(verified.guest_id, [
_candidate(&"adult_room", {&"adult_only": true}),
], {&"adult_content_enabled": true, &"absolute_minute": 1})
_expect(adult_candidates.size() == 1, "verified adult guest can retain a permitted restricted candidate")
var content_blocked := service.extract_candidates(verified.guest_id, [
_candidate(&"adult_room", {&"adult_only": true}),
], {&"adult_content_enabled": false, &"absolute_minute": 1})
_expect(content_blocked.is_empty(), "Content Filter still overrides verified adult status")
var callback_order: Array[StringName] = []
service.extract_candidates(guest.guest_id, [
_candidate(&"z_path"),
_candidate(&"a_path"),
], {
&"absolute_minute": 1,
&"reachability_callback": func(_runtime: GuestRuntime, facility: Dictionary, _context: Dictionary) -> bool:
callback_order.append(facility[&"facility_id"])
return true,
})
_expect(callback_order == [&"a_path", &"z_path"], "reachability callbacks run in stable facility-ID order")
func _test_integer_utility_and_stable_tie_break() -> void:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"guest_utility", &"adventurer", 0, {
&"wallet_gold": 1_000,
&"planned_exit_at": 1_000,
&"state": GuestRuntimeType.State.DECIDING,
&"needs": {&"hunger": 90},
})
var shared := {
&"need_relief": 40,
&"preference": 2,
&"expected_quality": 3,
&"novelty": 0,
&"group_fit": 4,
&"price_burden": 5,
&"travel_minutes": 6,
&"wait_minutes": 7,
&"risk": 8,
&"risk_sensitivity": 2,
&"repetition_penalty": 9,
&"urgency": 90,
&"minimum_quality": 0,
}
var candidate_z := shared.duplicate(true)
candidate_z[&"facility_id"] = &"z_room"
var candidate_a := shared.duplicate(true)
candidate_a[&"facility_id"] = &"a_room"
var evaluated := service.evaluate_candidate(guest.guest_id, candidate_z)
_expect(evaluated[&"utility"] is int, "Utility result is an integer")
_expect(evaluated[&"utility"] == 264, "Utility follows the documented integer term equation exactly")
var ranked := service.rank_candidates(guest.guest_id, [candidate_z, candidate_a])
_expect(ranked[0][&"facility_id"] == &"a_room", "SYS-GST-003 resolves a full Utility tie by facility_id")
var decision := service.decide(guest.guest_id, [candidate_z, candidate_a], 1, {
&"exit_threshold": 15,
})
_expect(decision[&"ok"] and decision[&"candidate"][&"facility_id"] == &"a_room", "decision reserves the stable winning candidate")
_expect(guest.state == GuestRuntimeType.State.MOVING and guest.target_facility_id == &"a_room", "successful decision enters MOVING with one target")
func _test_state_machine_and_loop_guard() -> void:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"guest_states", &"adventurer", 0, {
&"planned_exit_at": 500,
})
_expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.ENTERING, 10)[&"ok"], "SPAWNING enters ENTERING")
_expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.DECIDING, 10)[&"ok"], "ENTERING enters DECIDING")
_expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.MOVING, 10)[&"ok"], "DECIDING enters MOVING")
_expect(service.transition_guest(guest.guest_id, GuestRuntimeType.State.REPLANNING, 10)[&"ok"], "MOVING enters REPLANNING")
var guarded := service.transition_guest(guest.guest_id, GuestRuntimeType.State.DECIDING, 10)
_expect(not guarded[&"ok"] and guarded[&"loop_guard"], "fifth transition in one Tick activates AI loop guard")
_expect(guest.state == GuestRuntimeType.State.DECIDING, "AI loop guard stops the guest in DECIDING")
_expect(service.get_diagnostics()[-1][&"code"] == &"AI_LOOP_GUARD", "loop guard leaves a diagnostic record")
var invalid := service.transition_guest(guest.guest_id, GuestRuntimeType.State.DESPAWNING, 11)
_expect(not invalid[&"ok"], "invalid state-machine edge is rejected")
func _test_atomic_queue_abandonment() -> void:
var service := _service_with_definitions()
var guest := _moving_guest(service, &"guest_queue")
var enqueue_calls := [0]
var entered := service.enter_queue(
guest.guest_id,
&"meal_room",
&"request_queue",
4,
1,
func(_guest_id: StringName, _facility_id: StringName, _request_id: StringName) -> bool:
enqueue_calls[0] += 1
return true
)
_expect(entered[&"ok"] and enqueue_calls[0] == 1, "queue entry crosses the Callable boundary once")
_expect(guest.state == GuestRuntimeType.State.QUEUING and guest.patience_remaining == 1, "queue entry records facility, request and initial patience")
var failed_calls := [0]
var failed_leave := service.tick_queue_patience(
guest.guest_id,
5,
0,
func(_guest_id: StringName, _facility_id: StringName, _request_id: StringName) -> bool:
failed_calls[0] += 1
return false
)
_expect(not failed_leave[&"ok"] and failed_calls[0] == 1, "external queue removal failure is surfaced")
_expect(guest.state == GuestRuntimeType.State.QUEUING and guest.patience_remaining == 1, "failed queue removal leaves guest state completely unchanged")
_expect(guest.target_facility_id == &"meal_room" and guest.queue_request_id == &"request_queue", "failed queue removal retains both cross-service references")
var successful_calls := [0]
var abandoned := service.tick_queue_patience(
guest.guest_id,
5,
0,
func(callback_guest_id: StringName, facility_id: StringName, request_id: StringName) -> Dictionary:
successful_calls[0] += 1
return {&"ok": callback_guest_id == guest.guest_id and facility_id == &"meal_room" and request_id == &"request_queue"}
)
_expect(abandoned[&"ok"] and abandoned[&"abandoned"] and successful_calls[0] == 1, "SYS-GST-004 removes an exhausted guest through one atomic callback")
_expect(guest.state == GuestRuntimeType.State.ABANDONING and guest.patience_remaining == 0, "successful patience exhaustion enters ABANDONING")
_expect(guest.target_facility_id.is_empty() and guest.queue_request_id.is_empty(), "atomic abandonment clears facility and queue ownership together")
func _test_service_interruption_is_idempotent() -> void:
var service := _service_with_definitions()
var guest := _moving_guest(service, &"guest_interrupt")
_expect(service.begin_service(guest.guest_id, &"bath", &"service_1", 4)[&"ok"], "moving guest begins a facility service")
var callback_calls := [0]
var first := service.interrupt_service(
guest.guest_id,
&"service_1",
5,
func(_guest_id: StringName, _service_id: StringName) -> bool:
callback_calls[0] += 1
return true
)
var replay := service.interrupt_service(
guest.guest_id,
&"service_1",
5,
func(_guest_id: StringName, _service_id: StringName) -> bool:
callback_calls[0] += 1
return true
)
_expect(first[&"ok"] and not first[&"replayed"], "first facility stop interrupts the active service")
_expect(replay[&"ok"] and replay[&"replayed"], "duplicate facility stop is an idempotent replay")
_expect(callback_calls[0] == 1, "SYS-GST-005 invokes interruption settlement only once")
_expect(guest.state == GuestRuntimeType.State.INTERRUPTED and guest.current_service_id.is_empty(), "interruption frees guest service ownership")
func _test_service_need_effect_schema() -> void:
var service := _service_with_definitions()
var guest := _moving_guest(service, &"guest_effect_schema")
guest.needs[&"sleep"] = 95
guest.needs[&"hygiene"] = 5
_expect(service.begin_service(guest.guest_id, &"bath", &"service_effects", 4)[&"ok"], "canonical Need-effect fixture begins service")
var before_invalid := service.create_snapshot()
var invalid := service.complete_service(
guest.guest_id,
&"service_effects",
5,
80,
{&"rest": -50}
)
_expect(not invalid[&"ok"], "service completion rejects a legacy Need-effect alias")
_expect(service.create_snapshot() == before_invalid, "invalid service effects do not partially complete service")
var completed := service.complete_service(
guest.guest_id,
&"service_effects",
5,
120,
{&"sleep": 20, &"hygiene": -20}
)
_expect(completed[&"ok"] and completed[&"satisfaction_sample"] == 100, "canonical service effects complete with satisfaction clamp")
_expect(guest.needs[&"sleep"] == 100 and guest.needs[&"hygiene"] == 0, "service Need effects clamp each canonical axis once")
func _test_path_failure_safe_recovery() -> void:
var service := _service_with_definitions()
var guest := _moving_guest(service, &"guest_path")
var recovery_targets: Array = [
{&"kind": &"emergency_exit", &"cell": Vector3i(9, 0, 0)},
{&"kind": &"entrance", &"cell": Vector3i(5, 0, 0)},
{&"kind": &"guidance", &"cell": Vector3i(2, 0, 0)},
]
var reachable := func(_from: Vector3i, target: Vector3i, _guest_id: StringName, _data: Dictionary) -> bool:
return target != Vector3i(2, 0, 0)
var first := service.record_path_failure(guest.guest_id, 4, recovery_targets, reachable)
var second := service.record_path_failure(guest.guest_id, 5, recovery_targets, reachable)
_expect(first[&"failure_count"] == 1 and not first[&"recovery_started"], "first path failure enters REPLANNING")
_expect(second[&"failure_count"] == 2 and not second[&"recovery_started"], "second path failure remains recoverable")
var third := service.record_path_failure(guest.guest_id, 6, recovery_targets, reachable)
_expect(third[&"ok"] and third[&"recovery_started"], "SYS-GST-007 starts safe recovery on the third path failure")
_expect(guest.state == GuestRuntimeType.State.LEAVING, "third failure cannot leave the guest stuck in REPLANNING")
_expect(guest.recovery_kind == &"entrance" and guest.recovery_target_cell == Vector3i(5, 0, 0), "recovery tries guidance, entrance, then emergency exit in order")
_expect(service.get_diagnostics()[-1][&"code"] == &"GUEST_PATH_RECOVERY", "safe path recovery emits a diagnostic record")
var fallback_service := _service_with_definitions()
var fallback_guest := _moving_guest(fallback_service, &"guest_fallback")
fallback_service.record_path_failure(fallback_guest.guest_id, 4)
fallback_service.record_path_failure(fallback_guest.guest_id, 5)
var fallback := fallback_service.record_path_failure(fallback_guest.guest_id, 6, [], Callable())
_expect(fallback[&"recovery_kind"] == &"emergency_despawn", "no-route case uses a virtual emergency egress rather than deadlocking")
func _test_exit_persistence_boundary() -> void:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"guest_exit", &"adventurer", 0, {
&"state": GuestRuntimeType.State.LEAVING,
&"wallet_gold": 777,
&"debt_gold": 90,
&"satisfaction_total": 180,
&"satisfaction_samples": 2,
&"unresolved_incident_ids": [&"incident_b", &"incident_a"],
&"outstanding_reservation_ids": [&"reservation_1"],
&"planned_exit_at": 500,
})
var result := service.despawn_guest(guest.guest_id, 1)
_expect(result[&"ok"] and not service.has_guest(guest.guest_id), "leaving guest runtime is released after exit")
var record := service.get_persistent_record(guest.guest_id)
_expect(record[&"debt_gold"] == 90, "SYS-GST-006 preserves unresolved debt")
_expect(record[&"unresolved_incident_ids"] == ["incident_a", "incident_b"], "SYS-GST-006 preserves unresolved incidents")
_expect(record[&"outstanding_reservation_ids"] == ["reservation_1"], "SYS-GST-006 preserves outstanding reservations")
_expect(not record.has(&"wallet_gold") and not record.has(&"needs") and not record.has(&"satisfaction_total"), "ordinary transient guest state does not survive despawn")
func _test_overdue_departure_snapshot() -> void:
for replan: bool in [false, true]:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"overdue_deadline", &"adventurer", 0, {
&"state": GuestRuntimeType.State.DECIDING, &"planned_exit_at": 4,
})
_expect(guest != null, "overdue deadline fixture spawns through public GuestService")
if guest == null: continue
if replan:
_expect(bool(service.enter_queue(guest.guest_id, &"meal_room", &"overdue_request", 4, 1).get(&"ok", false)), "public queue entry before final patience")
_expect(bool(service.tick_queue_patience(guest.guest_id, 5, 0, func(_guest: StringName, _facility: StringName, _request: StringName): return true).get(&"ok", false)), "ordinary patience exhaustion after original deadline")
_expect(bool(service.transition_guest(guest.guest_id, GuestRuntimeType.State.DECIDING, 5, &"REPLAN_AFTER_INTERRUPTION").get(&"ok", false)), "public overdue replan retains deadline")
else:
_expect(bool(service.begin_leaving(guest.guest_id, 5, &"PLANNED_EXIT").get(&"ok", false)), "public leaving can occur after original deadline")
_expect(guest.planned_exit_at == 4 and guest.state_entered_at == 5 and guest.is_valid(), "deadline preserved independently of actual later state timestamp")
var snapshot := service.create_snapshot()
var restored := GuestServiceType.new()
_expect(restored.restore_snapshot(snapshot), "overdue ordinary state accepts complete restore")
_expect(var_to_bytes(restored.create_snapshot()) == var_to_bytes(snapshot), "overdue entire typed state roundtrip preserves original deadline")
for field: StringName in [&"planned_exit_at", &"state_entered_at"]:
var corrupt := snapshot.duplicate(true)
corrupt[&"guests"][0][field] = -1
_expect(not restored.restore_snapshot(corrupt), "negative departure/state timestamp remains rejected " + String(field))
_expect(var_to_bytes(restored.create_snapshot()) == var_to_bytes(snapshot), "negative timestamp rejection remains atomic " + String(field))
func _test_snapshot_round_trip_and_atomic_rejection() -> void:
var original := _service_with_definitions()
var moving := _moving_guest(original, &"guest_saved_moving")
var queued := _moving_guest(original, &"guest_saved_queue")
original.enter_queue(queued.guest_id, &"cafeteria", &"request_saved", 4, 12)
moving.path_failure_count = 1
moving.suspicion = 33
original.advance_one_minute(20)
var snapshot := original.create_snapshot()
var restored := GuestServiceType.new()
_expect(restored.restore_snapshot(snapshot), "GuestService restores a valid snapshot")
_expect(restored.create_snapshot() == snapshot, "GuestService snapshot round trip is byte-structure equivalent")
_expect(restored.validate_invariants()[&"valid"], "restored guest state satisfies every invariant")
var before_corrupt := restored.create_snapshot()
var corrupt_need := snapshot.duplicate(true)
corrupt_need[&"guests"][0][&"needs"]["hunger"] = 101
_expect(not restored.restore_snapshot(corrupt_need), "out-of-range Need rejects snapshot restore")
_expect(restored.create_snapshot() == before_corrupt, "rejected Need snapshot leaves live state untouched")
var duplicate_guest := snapshot.duplicate(true)
duplicate_guest[&"guests"].append(duplicate_guest[&"guests"][0].duplicate(true))
_expect(not restored.restore_snapshot(duplicate_guest), "duplicate guest ID rejects snapshot restore")
_expect(restored.create_snapshot() == before_corrupt, "duplicate rejection is also atomic")
var orphan_type := snapshot.duplicate(true)
orphan_type[&"guests"][0][&"guest_type_id"] = "missing_type"
_expect(not restored.restore_snapshot(orphan_type), "unknown GuestDefinition reference rejects restore")
_expect(restored.create_snapshot() == before_corrupt, "orphan-reference rejection preserves live state")
func _test_v1_need_migration_and_schema_rejection() -> void:
var source := _service_with_definitions()
source.spawn_guest(&"guest_legacy", &"verified_noble", 0, {
&"needs": {&"hunger": 11, &"sleep": 22, &"hygiene": 0, &"medical": 44, &"leisure": 55, &"intimacy": 66},
&"planned_exit_at": 500,
})
var canonical := source.create_snapshot()
var legacy := canonical.duplicate(true)
legacy[&"version"] = 1
for definition: Dictionary in legacy[&"definitions"]:
var rates: Dictionary = definition[&"need_rates"]
definition[&"need_rates"] = {
&"hunger": rates["hunger"],
&"rest": rates["sleep"],
&"fun": rates["leisure"],
&"social": rates["intimacy"],
&"safety": rates["medical"],
}
for guest_snapshot: Dictionary in legacy[&"guests"]:
var needs: Dictionary = guest_snapshot[&"needs"]
guest_snapshot[&"needs"] = {
&"hunger": needs["hunger"],
&"rest": needs["sleep"],
&"fun": needs["leisure"],
&"social": needs["intimacy"],
&"safety": needs["medical"],
}
var migrated := GuestServiceType.new()
_expect(migrated.restore_snapshot(legacy), "GuestService deterministically migrates an exact v1 five-Need snapshot")
var migrated_snapshot := migrated.create_snapshot()
var migrated_needs: Dictionary = migrated_snapshot[&"guests"][0][&"needs"]
_expect(migrated_snapshot[&"version"] == 2, "v1 migration emits only the current GuestService schema")
_expect(migrated_needs == {
"hunger": 11,
"hygiene": 0,
"intimacy": 66,
"leisure": 55,
"medical": 44,
"sleep": 22,
}, "v1 rest/fun/social/safety map to sleep/leisure/intimacy/medical with Hygiene zero")
_expect(migrated.validate_invariants()[&"valid"], "migrated six-Need graph satisfies current invariants")
var stable_before := migrated.create_snapshot()
var missing := canonical.duplicate(true)
missing[&"guests"][0][&"needs"].erase("hygiene")
_expect(not migrated.restore_snapshot(missing), "current snapshots reject a missing canonical Need")
_expect(migrated.create_snapshot() == stable_before, "missing-Need rejection is atomic")
var unknown := canonical.duplicate(true)
unknown[&"guests"][0][&"needs"]["safety"] = 1
_expect(not migrated.restore_snapshot(unknown), "current snapshots reject unknown legacy Need keys")
_expect(migrated.create_snapshot() == stable_before, "unknown-Need rejection is atomic")
var wrong_type := canonical.duplicate(true)
wrong_type[&"definitions"][0][&"need_rates"]["sleep"] = "1"
_expect(not migrated.restore_snapshot(wrong_type), "current definition Need rates reject implicit integer coercion")
_expect(migrated.create_snapshot() == stable_before, "definition type rejection is atomic")
var mixed_v1 := legacy.duplicate(true)
mixed_v1[&"guests"][0][&"needs"]["sleep"] = 22
_expect(not migrated.restore_snapshot(mixed_v1), "v1 migration rejects mixed legacy/current Need maps")
_expect(migrated.create_snapshot() == stable_before, "mixed-schema rejection preserves live state")
func _test_deterministic_decision_snapshot() -> void:
var first := _deterministic_run()
var second := _deterministic_run()
_expect(first == second, "same guest definitions and candidates produce an identical authoritative snapshot")
func _deterministic_run() -> Dictionary:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"guest_determinism", &"adventurer", 0, {
&"wallet_gold": 1_000,
&"planned_exit_at": 1_000,
&"state": GuestRuntimeType.State.DECIDING,
&"needs": {&"hunger": 80, &"leisure": 50},
})
var candidates: Array = [
_candidate(&"z_meal", {&"need_relief": {&"hunger": 60}, &"facility_tags": [&"meal"], &"travel_minutes": 4}),
_candidate(&"a_meal", {&"need_relief": {&"hunger": 60}, &"facility_tags": [&"meal"], &"travel_minutes": 4}),
_candidate(&"theater", {&"need_relief": {&"leisure": 50}, &"facility_tags": [&"leisure"], &"travel_minutes": 3}),
]
service.decide(guest.guest_id, candidates, 1, {&"absolute_minute": 1})
service.update_needs(guest.guest_id, {&"dirt": 2})
return service.create_snapshot()
func _service_with_definitions() -> GuestService:
var service := GuestServiceType.new()
service.register_definition(GuestDefinitionType.new().configure(&"adventurer", {
&"tags": [&"adventurer", &"general"],
&"wealth_min": 100,
&"wealth_max": 500,
&"base_patience": 45,
&"risk_tolerance": 80,
&"preference_weights": {&"meal": 8, &"leisure": 3},
&"group_size_weights": {1: 100},
&"legal_sensitivity": 60,
&"quality_sensitivity": 70,
&"adult_verified": false,
&"need_rates": {&"hunger": 3, &"sleep": -2, &"leisure": 1},
&"credit_limit_gold": 100,
&"minimum_quality": 40,
}))
service.register_definition(GuestDefinitionType.new().configure(&"verified_noble", {
&"tags": [&"wealthy", &"vip"],
&"wealth_min": 1_000,
&"wealth_max": 2_000,
&"base_patience": 20,
&"risk_tolerance": 40,
&"legal_sensitivity": 20,
&"adult_verified": true,
&"minimum_quality": 40,
}))
return service
func _moving_guest(service: GuestService, guest_id: StringName) -> GuestRuntime:
var guest := service.spawn_guest(guest_id, &"adventurer", 0, {
&"wallet_gold": 500,
&"planned_exit_at": 1_000,
})
service.transition_guest(guest_id, GuestRuntimeType.State.ENTERING, 1)
service.transition_guest(guest_id, GuestRuntimeType.State.DECIDING, 2)
service.transition_guest(guest_id, GuestRuntimeType.State.MOVING, 3)
return guest
func _candidate(facility_id: StringName, patch: Dictionary = {}) -> Dictionary:
var candidate := {
&"facility_id": facility_id,
&"is_open": true,
&"reachable": true,
&"price_gold": 100,
&"wait_minutes": 10,
&"risk_level": 10,
&"illegal_level": 10,
&"expected_quality": 60,
&"need_relief": 50,
}
for raw_key: Variant in patch:
candidate[raw_key] = patch[raw_key]
return candidate
func _candidate_ids(candidates: Array[Dictionary]) -> Array[StringName]:
var result: Array[StringName] = []
for candidate: Dictionary in candidates:
result.append(StringName(candidate[&"facility_id"]))
return result
func _rejection_map(rejections: Array[Dictionary]) -> Dictionary:
var result: Dictionary = {}
for rejection: Dictionary in rejections:
result[StringName(rejection[&"facility_id"])] = StringName(rejection[&"reason"])
return result
func _expect(condition: bool, label: String) -> void:
_checks += 1
if condition:
print(" ✓ %s" % label)
return
_failures += 1
push_error(" ✗ %s" % label)
func _test_primary_need_priority() -> void:
var service := _service_with_definitions()
var guest := service.spawn_guest(&"need_priority", &"adventurer", 0, {
&"wallet_gold": 1000, &"planned_exit_at": 1000,
&"state": GuestRuntime.State.DECIDING,
&"needs": {&"hunger": 90, &"sleep": 70},
})
var food := _candidate(&"food", {&"need_relief": {&"hunger": 80}, &"expected_quality": 40})
var room := _candidate(&"room", {&"need_relief": {&"sleep": 90}, &"expected_quality": 100, &"preference": 100})
_expect(service.evaluate_candidate(guest.guest_id, room)[&"utility"] > service.evaluate_candidate(guest.guest_id, food)[&"utility"], "regression scenario has a tempting lower-need facility")
_expect(service.select_best_candidate(guest.guest_id, [room,food])[&"facility_id"] == &"food", "highest need precedes quality and preference bonuses")
var near_food := food.duplicate(true)
near_food[&"facility_id"] = &"near_food"
near_food[&"travel_minutes"] = 1
food[&"travel_minutes"] = 20
_expect(service.select_best_candidate(guest.guest_id, [food,near_food])[&"facility_id"] == &"near_food", "same need still compares distance")
for field: StringName in [&"wait_minutes", &"price_burden"]:
var costly := near_food.duplicate(true)
costly[&"facility_id"] = &"costly"
costly[field] = 100
_expect(service.select_best_candidate(guest.guest_id, [costly,near_food])[&"facility_id"] == &"near_food", "same need still compares queue or price")
var unavailable := food.duplicate(true)
unavailable[&"reachable"] = false
var accepted := service.extract_candidates(guest.guest_id, [unavailable,room])
_expect(service.select_best_candidate(guest.guest_id, accepted)[&"facility_id"] == &"room", "unreachable highest-need facility does not block a usable alternative")
unavailable[&"reachable"] = true
unavailable[&"price_gold"] = 100000
accepted = service.extract_candidates(guest.guest_id, [unavailable,room])
_expect(service.select_best_candidate(guest.guest_id, accepted)[&"facility_id"] == &"room", "highest need does not bypass affordability")
var unattractive := food.duplicate(true)
unattractive[&"travel_minutes"] = 10000
_expect(service.select_best_candidate(guest.guest_id, [unattractive,room])[&"facility_id"] == &"room", "below-threshold high-need candidate does not stop fallback search")
guest.needs[&"sleep"] = 90
_expect(service.select_best_candidate(guest.guest_id, [food,room])[&"facility_id"] == &"room", "equal highest needs use the existing utility comparison")
guest.needs[&"sleep"] = 70
var before := service.create_snapshot()
var observed := service.get_observation(guest.guest_id)
observed[&"needs"][&"hunger"] = 0
_expect(guest.needs[&"hunger"] == 90 and service.create_snapshot() == before, "observation is read-only and owns its dictionaries")
_expect(service.restore_snapshot(before), "pre-decision save restores")
var replay := service.decide(guest.guest_id, [room,food], 1)
_expect(replay[&"candidate"][&"facility_id"] == &"food", "restored runtime uses the same highest-need decision")
var after := service.create_snapshot()
_expect(service.restore_snapshot(before), "decision can be replayed from snapshot")
service.decide(guest.guest_id, [food,room], 1)
_expect(service.create_snapshot() == after, "candidate insertion order cannot change saved decision state")
_expect(service.restore_snapshot(before), "reservation retry starts at the same state")
var reserved := service.decide(guest.guest_id, [food,room], 1, {&"reserve_callback": func(_id: StringName, facility: StringName, _candidate: Dictionary) -> bool: return facility != &"food"})
_expect(reserved[&"candidate"][&"facility_id"] == &"room", "failed reservation tries the next usable candidate")