extends GutTest const TARGET_ID := &"shared_activity" var registry: WorldTargetRegistry var catalog: ContentCatalog var evaluated_actor_types: Array[StringName] = [] var availability_call_count := 0 func before_each() -> void: registry = WorldTargetRegistry.new(&"interaction_context", &"test_world", &"test_location") catalog = ContentCatalog.create_core() evaluated_actor_types.clear() availability_call_count = 0 assert_true(catalog.is_valid(), "%s" % [catalog.get_errors()]) func test_player_and_person_receive_identical_copied_stably_ordered_offers() -> void: var handle := _register_target( [ _capability(SimulationIds.ACTION_REST, 20.0), _capability(&"missing_authored_action", 100.0), _capability(SimulationIds.ACTION_STUDY, 20.0), _capability(SimulationIds.ACTION_PATROL, 40.0), ] ) var service := CatalogInteractionService.new(registry, catalog) var player_offers := service.get_offers(_player_ref(), handle) var person_offers := service.get_offers(_person_ref(), handle) assert_eq( _action_ids(player_offers), [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_REST, SimulationIds.ACTION_STUDY], ) assert_eq(_offer_snapshots(player_offers), _offer_snapshots(person_offers)) assert_eq( _offer_snapshots(person_offers), _offer_snapshots(service.get_action_offers(&"7", handle)) ) assert_eq(player_offers[0].get_priority(), 40.0) assert_eq(player_offers[1].get_priority(), 20.0) assert_eq(player_offers[2].get_priority(), 20.0) assert_ne(player_offers[0], person_offers[0], "Each query should return fresh offer values") assert_ne(player_offers[0].get_target(), handle, "Offers should carry copied handles") assert_not_null(registry.resolve_handle(player_offers[0].get_target())) assert_eq( service.get_last_query_reason_trace(), [&"offers_projected", &"stable_priority_action_order"], ) var caller_parameters := player_offers[0].get_parameters() caller_parameters["capability_attributes"]["offer_priority"] = -500.0 assert_eq( float(player_offers[0].get_parameters()["capability_attributes"]["offer_priority"]), 40.0, "ActionOffer parameters should be isolated from caller mutation", ) func test_availability_callback_exposes_disabled_offer_and_cannot_mutate_sources() -> void: var handle := _register_target( [ _capability(SimulationIds.ACTION_REST, 10.0), _capability(SimulationIds.ACTION_STUDY, 10.0), ] ) var descriptor_before := registry.resolve_handle(handle).to_dictionary() var authored_study_name := catalog.get_action(SimulationIds.ACTION_STUDY).display_name var service := CatalogInteractionService.new(registry, catalog, _availability_policy) var player_offers := service.get_offers(_player_ref(), handle) var person_offers := service.get_offers(_person_ref(), handle) assert_eq(_offer_snapshots(player_offers), _offer_snapshots(person_offers)) assert_eq( evaluated_actor_types, [ SimulationIds.ENTITY_PLAYER, SimulationIds.ENTITY_PLAYER, SimulationIds.ENTITY_PERSON, SimulationIds.ENTITY_PERSON ] ) var study_offer := _offer_for(player_offers, SimulationIds.ACTION_STUDY) assert_not_null(study_offer) assert_false(study_offer.is_enabled()) assert_eq(study_offer.get_rejection_reason(), "Actor lacks study permission") var parameters := study_offer.get_parameters() assert_eq(parameters["reason_code"], "permission_denied") assert_has(parameters["reason_trace"], &"permission_checked") assert_has(parameters["reason_trace"], &"permission_denied") assert_eq(parameters["decision_parameters"], {"required_role": "scholar"}) assert_eq( registry.resolve_handle(handle).to_dictionary(), descriptor_before, "The evaluator should receive a descriptor copy", ) assert_eq( catalog.get_action(SimulationIds.ACTION_STUDY).display_name, authored_study_name, "The evaluator should receive an authored-definition copy", ) assert_eq(study_offer.get_display_name(), authored_study_name) func test_stale_foreign_and_unsupported_actor_queries_are_rejected_before_policy() -> void: var handle := _register_target([_capability(SimulationIds.ACTION_REST, 0.0)]) var service := CatalogInteractionService.new(registry, catalog, _counting_allow_policy) assert_eq(service.get_offers(_player_ref(), handle).size(), 1) assert_eq(availability_call_count, 1) assert_true(registry.unregister_target(handle)) assert_true(service.get_offers(_player_ref(), handle).is_empty()) assert_eq(service.get_last_query_reason_trace(), [&"interaction_target_stale"]) var foreign_registry := WorldTargetRegistry.new(&"foreign_context") var foreign_provider := Node.new() add_child_autofree(foreign_provider) var foreign_handle := ( foreign_registry . register_target( ( WorldTargetDescriptor . new( TARGET_ID, &"activity", [_capability(SimulationIds.ACTION_REST, 0.0)], Vector3.ZERO, ) ), foreign_provider, ) ) assert_true(service.get_offers(_player_ref(), foreign_handle).is_empty()) assert_eq(service.get_last_query_reason_trace(), [&"interaction_target_stale"]) assert_true( service.get_offers(WorldEntityRef.create(&"world", &"world_1"), foreign_handle).is_empty() ) assert_eq(service.get_last_query_reason_trace(), [&"interaction_actor_invalid"]) assert_eq( availability_call_count, 1, "Invalid actor and target queries must not invoke the availability policy", ) func test_invalid_availability_decision_is_a_disabled_explainable_offer() -> void: var handle := _register_target([_capability(SimulationIds.ACTION_REST, NAN)]) var service := CatalogInteractionService.new(registry, catalog, _invalid_policy) var offers := service.get_offers(_person_ref(), handle) assert_eq(offers.size(), 1) assert_false(offers[0].is_enabled()) assert_eq(offers[0].get_priority(), 0.0) assert_eq(offers[0].get_rejection_reason(), "Availability policy returned an invalid decision") var parameters := offers[0].get_parameters() assert_eq(parameters["reason_code"], "availability_decision_invalid") assert_has(parameters["reason_trace"], &"offer_priority_defaulted") assert_has(parameters["reason_trace"], &"availability_callback_invalid") func test_abstract_target_offers_do_not_depend_on_a_loaded_provider() -> void: var handle := registry.register_target( WorldTargetDescriptor.new( &"abstract_activity", &"activity", [_capability(SimulationIds.ACTION_PATROL, 5.0)], Vector3.ZERO, "Abstract guard post" ) ) assert_true(registry.is_handle_valid(handle)) assert_false(registry.is_handle_valid(handle, true)) var offers := CatalogInteractionService.new(registry, catalog).get_offers(_person_ref(), handle) assert_eq(offers.size(), 1) assert_eq(offers[0].get_action_id(), SimulationIds.ACTION_PATROL) assert_true(offers[0].is_enabled()) func _availability_policy( actor: WorldEntityRef, descriptor: WorldTargetDescriptor, definition: ActionDefinition, capability: WorldTargetCapability ) -> InteractionAvailabilityDecision: evaluated_actor_types.append(actor.get_entity_type()) actor._entity_id = &"mutated_copy" descriptor._display_name = "Mutated copy" definition.display_name = "Mutated copy" capability._attributes["offer_priority"] = -1000.0 if definition.action_id == SimulationIds.ACTION_STUDY: return ( InteractionAvailabilityDecision . denied( &"permission_denied", "Actor lacks study permission", [&"permission_checked", &"permission_denied"], {"required_role": "scholar"}, ) ) return InteractionAvailabilityDecision.allowed([&"permission_checked", &"permission_allowed"]) func _counting_allow_policy( _actor: WorldEntityRef, _descriptor: WorldTargetDescriptor, _definition: ActionDefinition, _capability: WorldTargetCapability ) -> InteractionAvailabilityDecision: availability_call_count += 1 return InteractionAvailabilityDecision.allowed() func _invalid_policy( _actor: WorldEntityRef, _descriptor: WorldTargetDescriptor, _definition: ActionDefinition, _capability: WorldTargetCapability ) -> InteractionAvailabilityDecision: return InteractionAvailabilityDecision.new(false) func _register_target(capabilities: Array) -> WorldTargetHandle: var provider := Node.new() add_child_autofree(provider) return ( registry . register_target( WorldTargetDescriptor.new( TARGET_ID, &"activity", capabilities, Vector3(3.0, 0.0, 2.0), "Town Hall" ), provider, ) ) func _capability(action_id: StringName, priority: float) -> WorldTargetCapability: return WorldTargetCapability.new(action_id, {"offer_priority": priority}) func _player_ref() -> WorldEntityRef: return WorldEntityRef.create(SimulationIds.ENTITY_PLAYER, &"player") func _person_ref() -> WorldEntityRef: return WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"7") func _action_ids(offers: Array[ActionOffer]) -> Array[StringName]: var action_ids: Array[StringName] = [] for offer in offers: action_ids.append(offer.get_action_id()) return action_ids func _offer_for(offers: Array[ActionOffer], action_id: StringName) -> ActionOffer: for offer in offers: if offer.get_action_id() == action_id: return offer return null func _offer_snapshots(offers: Array[ActionOffer]) -> Array[Dictionary]: var snapshots: Array[Dictionary] = [] for offer in offers: ( snapshots . append( { "offer_id": String(offer.get_offer_id()), "action_id": String(offer.get_action_id()), "display_name": offer.get_display_name(), "enabled": offer.is_enabled(), "rejection_reason": offer.get_rejection_reason(), "parameters": offer.get_parameters(), "priority": offer.get_priority(), } ) ) return snapshots