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.
858 lines
40 KiB
GDScript
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")
|