From fa0e588d1cc4e6dad80aab96abc382c9b7b3064c Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 22:09:42 +0200 Subject: [PATCH] feat: project catalog interaction offers --- .../commands/CatalogInteractionService.gd | 167 +++++++++++ .../commands/CatalogInteractionService.gd.uid | 1 + .../InteractionAvailabilityDecision.gd | 75 +++++ .../InteractionAvailabilityDecision.gd.uid | 1 + .../unit/test_catalog_interaction_service.gd | 277 ++++++++++++++++++ .../test_catalog_interaction_service.gd.uid | 1 + 6 files changed, 522 insertions(+) create mode 100644 simulation/commands/CatalogInteractionService.gd create mode 100644 simulation/commands/CatalogInteractionService.gd.uid create mode 100644 simulation/commands/InteractionAvailabilityDecision.gd create mode 100644 simulation/commands/InteractionAvailabilityDecision.gd.uid create mode 100644 tests/unit/test_catalog_interaction_service.gd create mode 100644 tests/unit/test_catalog_interaction_service.gd.uid diff --git a/simulation/commands/CatalogInteractionService.gd b/simulation/commands/CatalogInteractionService.gd new file mode 100644 index 0000000..6724540 --- /dev/null +++ b/simulation/commands/CatalogInteractionService.gd @@ -0,0 +1,167 @@ +class_name CatalogInteractionService +extends InteractionService + +const CAPABILITY_PRIORITY_ATTRIBUTE := &"offer_priority" +const PARAM_CAPABILITY_ATTRIBUTES := &"capability_attributes" +const PARAM_DECISION_PARAMETERS := &"decision_parameters" +const PARAM_REASON_CODE := &"reason_code" +const PARAM_REASON_TRACE := &"reason_trace" + +const REASON_SERVICE_INVALID := &"interaction_service_invalid" +const REASON_ACTOR_INVALID := &"interaction_actor_invalid" +const REASON_TARGET_STALE := &"interaction_target_stale" +const REASON_TARGET_INVALID := &"interaction_target_invalid" +const REASON_DECISION_INVALID := &"availability_decision_invalid" + +var _registry: WorldTargetRegistry +var _catalog: ContentCatalog +var _availability_evaluator: Callable +var _last_query_reason_trace: Array[StringName] = [] + + +func _init( + registry: WorldTargetRegistry = null, + catalog: ContentCatalog = null, + availability_evaluator: Callable = Callable() +) -> void: + _registry = registry + _catalog = catalog + _availability_evaluator = availability_evaluator + + +func is_configured() -> bool: + return _registry != null and _catalog != null and _catalog.is_valid() + + +func get_last_query_reason_trace() -> Array[StringName]: + return _last_query_reason_trace.duplicate() + + +# The optional evaluator is advisory and pure. It receives isolated copies of +# actor, target descriptor, action definition, and target capability, and must +# return an InteractionAvailabilityDecision. This service never receives an +# authority mutation callback. +func get_offers(actor: WorldEntityRef, target: WorldTargetHandle) -> Array[ActionOffer]: + _last_query_reason_trace.clear() + if not is_configured(): + _last_query_reason_trace.append(REASON_SERVICE_INVALID) + return [] + if not _actor_is_supported(actor): + _last_query_reason_trace.append(REASON_ACTOR_INVALID) + return [] + if not _registry.is_handle_valid(target): + _last_query_reason_trace.append(REASON_TARGET_STALE) + return [] + var descriptor := _registry.resolve_handle(target) + if descriptor == null or not descriptor.is_valid(): + _last_query_reason_trace.append(REASON_TARGET_INVALID) + return [] + + var offers: Array[ActionOffer] = [] + for capability_id in descriptor.get_capability_ids(): + var definition := _catalog.get_action(capability_id) + if definition == null or not definition.validate().is_empty(): + continue + var capability := descriptor.get_capability(capability_id) + if capability == null or not capability.is_valid(): + continue + var priority_result := _offer_priority(capability) + var decision := _evaluate_availability(actor, descriptor, definition, capability) + var reason_trace: Array[StringName] = [ + &"target_handle_live", &"target_capability_matched", &"action_definition_authored" + ] + reason_trace.append_array(priority_result["reason_trace"]) + reason_trace.append_array(decision.get_reason_trace()) + var parameters := { + String(PARAM_CAPABILITY_ATTRIBUTES): capability.get_attributes(), + String(PARAM_DECISION_PARAMETERS): decision.get_parameters(), + String(PARAM_REASON_CODE): String(decision.get_reason_code()), + String(PARAM_REASON_TRACE): reason_trace, + } + var offer := ActionOffer.new( + _offer_id(target, capability_id), + capability_id, + _copy_handle(target), + definition.display_name, + decision.is_enabled(), + decision.get_reason_message() if not decision.is_enabled() else "", + parameters, + float(priority_result["priority"]) + ) + if offer.is_valid(): + offers.append(offer) + offers.sort_custom(_offer_less) + _last_query_reason_trace = [&"offers_projected", &"stable_priority_action_order"] + return offers + + +func _evaluate_availability( + actor: WorldEntityRef, + descriptor: WorldTargetDescriptor, + definition: ActionDefinition, + capability: WorldTargetCapability +) -> InteractionAvailabilityDecision: + if not _availability_evaluator.is_valid(): + return InteractionAvailabilityDecision.allowed([&"availability_default_allowed"]) + var definition_copy := definition.duplicate(true) as ActionDefinition + var result: Variant = _availability_evaluator.call( + actor.duplicate_ref(), descriptor.copy(), definition_copy, capability.copy() + ) + var decision := result as InteractionAvailabilityDecision + if decision != null and decision.is_valid(): + return decision + return ( + InteractionAvailabilityDecision + . denied( + REASON_DECISION_INVALID, + "Availability policy returned an invalid decision", + [&"availability_callback_invalid"], + ) + ) + + +func _offer_priority(capability: WorldTargetCapability) -> Dictionary: + var raw_priority: Variant = capability.get_attribute(CAPABILITY_PRIORITY_ATTRIBUTE, 0.0) + if raw_priority is int or raw_priority is float: + var priority := float(raw_priority) + if is_finite(priority): + return {"priority": priority, "reason_trace": [&"offer_priority_authored"]} + return {"priority": 0.0, "reason_trace": [&"offer_priority_defaulted"]} + + +func _offer_id(target: WorldTargetHandle, action_id: StringName) -> StringName: + var stable_source := ( + "%s|%s|%s|%d|%s" + % [ + target.get_context_id(), + target.get_target_kind(), + target.get_target_id(), + target.get_generation(), + action_id, + ] + ) + return StringName("offer_" + stable_source.sha256_text().substr(0, 24)) + + +func _copy_handle(source: WorldTargetHandle) -> WorldTargetHandle: + return WorldTargetHandle.new( + source.get_registry_instance_id(), + source.get_context_id(), + source.get_target_id(), + source.get_target_kind(), + source.get_generation() + ) + + +static func _actor_is_supported(actor: WorldEntityRef) -> bool: + return ( + actor != null + and actor.is_valid() + and actor.get_entity_type() in [SimulationIds.ENTITY_PLAYER, SimulationIds.ENTITY_PERSON] + ) + + +static func _offer_less(first: ActionOffer, second: ActionOffer) -> bool: + if first.get_priority() != second.get_priority(): + return first.get_priority() > second.get_priority() + return String(first.get_action_id()) < String(second.get_action_id()) diff --git a/simulation/commands/CatalogInteractionService.gd.uid b/simulation/commands/CatalogInteractionService.gd.uid new file mode 100644 index 0000000..87de7d8 --- /dev/null +++ b/simulation/commands/CatalogInteractionService.gd.uid @@ -0,0 +1 @@ +uid://bii1h3n8sv10q diff --git a/simulation/commands/InteractionAvailabilityDecision.gd b/simulation/commands/InteractionAvailabilityDecision.gd new file mode 100644 index 0000000..3cca898 --- /dev/null +++ b/simulation/commands/InteractionAvailabilityDecision.gd @@ -0,0 +1,75 @@ +class_name InteractionAvailabilityDecision +extends RefCounted + +var _enabled := true +var _reason_code: StringName +var _reason_message := "" +var _reason_trace: Array[StringName] = [] +var _parameters: Dictionary = {} +var _valid := false + + +func _init( + enabled: bool = true, + reason_code: StringName = &"", + reason_message: String = "", + reason_trace: Array = [], + parameters: Dictionary = {} +) -> void: + _enabled = enabled + _reason_code = reason_code + _reason_message = reason_message + _parameters = parameters.duplicate(true) + _valid = not WorldTargetCapability._contains_object(_parameters) + for trace_value in reason_trace: + if trace_value is not String and trace_value is not StringName: + _valid = false + continue + var trace_id := StringName(trace_value) + if trace_id.is_empty(): + _valid = false + continue + _reason_trace.append(trace_id) + if not _enabled and (_reason_code.is_empty() or _reason_message.is_empty()): + _valid = false + + +static func allowed( + reason_trace: Array = [&"availability_allowed"], parameters: Dictionary = {} +) -> InteractionAvailabilityDecision: + return InteractionAvailabilityDecision.new(true, &"", "", reason_trace, parameters) + + +static func denied( + reason_code: StringName, + reason_message: String, + reason_trace: Array, + parameters: Dictionary = {} +) -> InteractionAvailabilityDecision: + return InteractionAvailabilityDecision.new( + false, reason_code, reason_message, reason_trace, parameters + ) + + +func is_valid() -> bool: + return _valid + + +func is_enabled() -> bool: + return _enabled + + +func get_reason_code() -> StringName: + return _reason_code + + +func get_reason_message() -> String: + return _reason_message + + +func get_reason_trace() -> Array[StringName]: + return _reason_trace.duplicate() + + +func get_parameters() -> Dictionary: + return _parameters.duplicate(true) diff --git a/simulation/commands/InteractionAvailabilityDecision.gd.uid b/simulation/commands/InteractionAvailabilityDecision.gd.uid new file mode 100644 index 0000000..a317bf6 --- /dev/null +++ b/simulation/commands/InteractionAvailabilityDecision.gd.uid @@ -0,0 +1 @@ +uid://d2cn8m03q4yvy diff --git a/tests/unit/test_catalog_interaction_service.gd b/tests/unit/test_catalog_interaction_service.gd new file mode 100644 index 0000000..2424a27 --- /dev/null +++ b/tests/unit/test_catalog_interaction_service.gd @@ -0,0 +1,277 @@ +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 diff --git a/tests/unit/test_catalog_interaction_service.gd.uid b/tests/unit/test_catalog_interaction_service.gd.uid new file mode 100644 index 0000000..4fd5c41 --- /dev/null +++ b/tests/unit/test_catalog_interaction_service.gd.uid @@ -0,0 +1 @@ +uid://d4i4g5gkmnb87