Files
gamedev-the-steward/tests/unit/test_catalog_interaction_service.gd
2026-08-12 22:09:42 +02:00

278 lines
9.4 KiB
GDScript

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