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