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())