Files
gamedev-the-steward/tests/unit/test_activity_action_command_service.gd
T
2026-08-13 00:08:30 +02:00

465 lines
14 KiB
GDScript

extends GutTest
var registry: WorldTargetRegistry
var village: SimVillage
var revision := 0
var next_event_id := 0
var events: Array[Dictionary] = []
var player_position := Vector3.ZERO
func before_each() -> void:
registry = WorldTargetRegistry.new(&"jajce")
village = SimVillage.new()
revision = 0
next_event_id = 0
events.clear()
player_position = Vector3.ZERO
func test_player_and_npc_use_one_revalidated_activity_command_path() -> void:
var guard := _register_activity(
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(2.0, 0.0, 0.0)
)
var study := _register_activity(
&"study_desk", SimulationIds.ACTION_STUDY, Vector3(2.0, 0.0, 1.0)
)
var service := _service()
var safety_before := village.safety
var player_result := service.submit_command(
_command(&"player_guard", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_true(player_result.did_succeed())
assert_eq(village.safety, safety_before + 3.0)
assert_eq(player_result.get_payload()["event_id"], 0)
assert_eq(player_result.get_payload()["state_revision"], 1)
assert_eq(events[0]["actor"], _player_ref().index_key())
assert_eq(events[0]["target_id"], "guard_post")
assert_eq(events[0]["metric_id"], "safety")
var npc_result := service.submit_command(
_command(
&"npc_guard",
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"7"),
SimulationIds.ACTION_PATROL,
guard,
1
)
)
assert_true(npc_result.did_succeed())
assert_eq(village.safety, safety_before + 6.0)
assert_eq(
events[1]["actor"], WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"7").index_key()
)
var study_result := service.submit_command(
_command(&"player_study", _player_ref(), SimulationIds.ACTION_STUDY, study, 2)
)
assert_true(study_result.did_succeed())
assert_eq(village.knowledge, 2.0)
assert_eq(events[2]["metric_id"], "knowledge")
func test_stale_revision_handle_capability_and_range_reject_without_mutation() -> void:
var guard := _register_activity(
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(2.0, 0.0, 0.0)
)
var study := _register_activity(
&"study_desk", SimulationIds.ACTION_STUDY, Vector3(2.0, 0.0, 1.0)
)
var service := _service()
var before := _snapshot()
var stale_revision := service.submit_command(
_command(&"stale_revision", _player_ref(), SimulationIds.ACTION_PATROL, guard, 4)
)
assert_eq(stale_revision.get_reason_code(), ActivityActionCommandService.REASON_STALE_REVISION)
assert_eq(_snapshot(), before)
var wrong_capability := service.submit_command(
_command(&"wrong_capability", _player_ref(), SimulationIds.ACTION_PATROL, study, 0)
)
assert_eq(
wrong_capability.get_reason_code(), ActivityActionCommandService.REASON_TARGET_CAPABILITY
)
assert_eq(_snapshot(), before)
player_position = Vector3(20.0, 0.0, 0.0)
var out_of_range := service.submit_command(
_command(&"out_of_range", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_eq(out_of_range.get_reason_code(), ActivityActionCommandService.REASON_OUT_OF_RANGE)
assert_eq(_snapshot(), before)
player_position = Vector3.ZERO
assert_true(registry.unregister_target(guard))
var stale_handle := service.submit_command(
_command(&"stale_handle", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_eq(stale_handle.get_reason_code(), ActivityActionCommandService.REASON_STALE_TARGET)
assert_eq(_snapshot(), before)
func test_unsupported_action_definition_cannot_smuggle_a_metric_effect() -> void:
var guard := _register_activity(&"guard_post", SimulationIds.ACTION_PATROL, Vector3.ZERO)
var before := _snapshot()
var result := _service().submit_command(
_command(&"wrong_action", _player_ref(), SimulationIds.ACTION_REST, guard, 0)
)
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_UNSUPPORTED_ACTION)
assert_eq(_snapshot(), before)
func test_compound_effects_fail_closed_until_one_transactional_handler_owns_them() -> void:
var guard := _register_activity(&"guard_post", SimulationIds.ACTION_PATROL, Vector3.ZERO)
var service := ActivityActionCommandService.new(
registry,
_compound_definition,
_actor_position,
func() -> int: return revision,
_complete_activity
)
var before := _snapshot()
var result := service.submit_command(
_command(&"compound_action", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_INVALID_EFFECT)
assert_eq(_snapshot(), before)
func test_abstract_npc_target_does_not_require_a_loaded_presentation_provider() -> void:
var abstract_guard := _register_activity(
&"abstract_guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0), false
)
player_position = Vector3(100.0, 0.0, 0.0)
var result := _service(false, false).submit_command(
_command(
&"abstract_npc_guard",
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"12"),
SimulationIds.ACTION_PATROL,
abstract_guard,
0
)
)
assert_true(result.did_succeed())
assert_eq(village.safety, 53.0)
assert_true(registry.is_handle_valid(abstract_guard))
assert_false(registry.is_handle_valid(abstract_guard, true))
assert_has(result.get_payload()["reason_trace"], "abstract_completion_authorized")
func test_loaded_service_rejects_a_dead_provider_even_before_registry_pruning() -> void:
var provider := Node.new()
var handle := registry.register_target(
WorldTargetDescriptor.new(
&"guard_post",
&"activity",
[WorldTargetCapability.new(SimulationIds.ACTION_PATROL, {"interaction_range": 3.0})],
Vector3.ZERO
),
provider
)
provider.free()
var result := _service(true).submit_command(
_command(&"dead_provider", _player_ref(), SimulationIds.ACTION_PATROL, handle, 0)
)
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_STALE_TARGET)
assert_true(events.is_empty())
func test_invalid_actor_position_rejects_in_loaded_and_abstract_modes() -> void:
var guard := _register_activity(
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(2.0, 0.0, 0.0)
)
var dead_position_lookup := func(_actor: WorldEntityRef) -> Variant: return null
for enforce_spatial_range in [true, false]:
var service := ActivityActionCommandService.new(
registry,
_definition,
dead_position_lookup,
func() -> int: return revision,
_complete_activity,
false,
enforce_spatial_range
)
var result := service.submit_command(
_command(
StringName("dead_%s" % enforce_spatial_range),
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"12"),
SimulationIds.ACTION_PATROL,
guard,
0
)
)
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_UNSUPPORTED_ACTOR)
assert_true(events.is_empty())
assert_eq(revision, 0)
func test_non_activity_target_cannot_smuggle_an_activity_capability() -> void:
var provider := Node.new()
add_child_autofree(provider)
var storage_handle := registry.register_target(
WorldTargetDescriptor.new(
&"false_guard",
&"storage",
[WorldTargetCapability.new(SimulationIds.ACTION_PATROL)],
Vector3.ZERO
),
provider
)
var result := _service().submit_command(
_command(
&"wrong_kind", _player_ref(), SimulationIds.ACTION_PATROL, storage_handle, revision
)
)
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_TARGET_CAPABILITY)
assert_true(events.is_empty())
func test_event_position_comes_from_authoritative_actor_not_presentation_target() -> void:
player_position = Vector3(7.0, 0.0, -3.0)
var guard := _register_activity(
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(200.0, 0.0, 200.0)
)
var result := _service(false, false).submit_command(
_command(&"abstract_position", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_true(result.did_succeed())
assert_eq(events[0]["world_position"], player_position)
func test_rejected_atomic_completion_leaves_authority_unchanged() -> void:
var guard := _register_activity(
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0)
)
var service := ActivityActionCommandService.new(
registry,
_definition,
_actor_position,
func() -> int: return revision,
func(
_actor: WorldEntityRef,
_action_id: StringName,
_target_id: StringName,
_metric_id: StringName,
_amount: float,
_parameters: Dictionary,
_world_position: Vector3
) -> Dictionary:
return {"succeeded": false, "event_id": -1, "state_revision": revision}
)
var before := _snapshot()
var result := service.submit_command(
_command(&"rejected_completion", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_eq(result.get_status(), ActionResult.STATUS_FAILED)
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_COMPLETION_FAILED)
assert_eq(_snapshot(), before)
func test_completion_results_cannot_hide_or_forge_revision_changes() -> void:
var guard := _register_activity(
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0)
)
var hidden_mutation_service := ActivityActionCommandService.new(
registry,
_definition,
_actor_position,
func() -> int: return revision,
func(
_actor: WorldEntityRef,
_action_id: StringName,
_target_id: StringName,
_metric_id: StringName,
_amount: float,
_parameters: Dictionary,
_world_position: Vector3
) -> Dictionary:
return {
"succeeded": false,
"reason_code": "rejected",
"message": "Rejected",
"event_id": -1,
"state_revision": revision + 1,
}
)
var hidden_mutation := hidden_mutation_service.submit_command(
_command(&"hidden_mutation", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_eq(hidden_mutation.get_status(), ActionResult.STATUS_FAILED)
assert_eq(
hidden_mutation.get_reason_code(), ActivityActionCommandService.REASON_COMPLETION_FAILED
)
var forged_fact_service := ActivityActionCommandService.new(
registry,
_definition,
_actor_position,
func() -> int: return revision,
func(
_actor: WorldEntityRef,
_action_id: StringName,
_target_id: StringName,
_metric_id: StringName,
_amount: float,
_parameters: Dictionary,
_world_position: Vector3
) -> Dictionary:
return {
"succeeded": true,
"event_id": revision + 4,
"state_revision": revision + 1,
}
)
var forged_fact := forged_fact_service.submit_command(
_command(&"forged_fact", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_eq(forged_fact.get_status(), ActionResult.STATUS_FAILED)
assert_eq(forged_fact.get_reason_code(), ActivityActionCommandService.REASON_COMPLETION_FAILED)
func test_completion_accepts_ordered_causal_followup_facts() -> void:
var guard := _register_activity(
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0)
)
var service := ActivityActionCommandService.new(
registry,
_definition,
_actor_position,
func() -> int: return revision,
func(
_actor: WorldEntityRef,
_action_id: StringName,
_target_id: StringName,
_metric_id: StringName,
_amount: float,
_parameters: Dictionary,
_world_position: Vector3
) -> Dictionary:
var completion_event_id := revision
revision += 2
return {
"succeeded": true,
"event_id": completion_event_id,
"state_revision": revision,
"applied_amount": 3.0,
}
)
var result := service.submit_command(
_command(&"causal_chain", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
)
assert_true(result.did_succeed())
assert_eq(int(result.get_payload()["event_id"]), 0)
assert_eq(int(result.get_payload()["state_revision"]), 2)
assert_has(result.get_payload()["reason_trace"], "causal_followup_facts_committed")
func _service(
require_target_provider: bool = false, enforce_spatial_range: bool = true
) -> ActivityActionCommandService:
return ActivityActionCommandService.new(
registry,
_definition,
_actor_position,
func() -> int: return revision,
_complete_activity,
require_target_provider,
enforce_spatial_range
)
func _definition(action_id: StringName) -> ActionDefinition:
if action_id not in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]:
return null
var definition := ActionDefinition.new()
definition.action_id = action_id
definition.display_name = String(action_id)
definition.completion_handler_id = ActivityActionCommandService.HANDLER_METRIC_DELTA
var effect := ActionEffect.new()
var is_patrol := action_id == SimulationIds.ACTION_PATROL
effect.effect_id = &"patrol_safety" if is_patrol else &"study_knowledge"
effect.effect_kind = ActionEffect.KIND_METRIC_DELTA
effect.subject_key = &"safety" if is_patrol else &"knowledge"
effect.value = 3.0 if is_patrol else 2.0
definition.effects = [effect]
return definition
func _compound_definition(action_id: StringName) -> ActionDefinition:
var definition := _definition(action_id)
var healing := ActionEffect.new()
healing.effect_id = &"smuggled_healing"
healing.effect_kind = ActionEffect.KIND_HEALING
healing.amount = 2.0
definition.effects.append(healing)
return definition
func _actor_position(_actor: WorldEntityRef) -> Vector3:
return player_position
func _complete_activity(
actor: WorldEntityRef,
_action_id: StringName,
target_id: StringName,
metric_id: StringName,
amount: float,
_parameters: Dictionary,
_world_position: Vector3
) -> Dictionary:
village.apply_metric_delta(metric_id, amount)
revision += 1
var event_id := next_event_id
next_event_id += 1
(
events
. append(
{
"event_id": event_id,
"actor": actor.index_key(),
"target_id": String(target_id),
"metric_id": String(metric_id),
"amount": amount,
"world_position": _world_position,
}
)
)
return {"succeeded": true, "event_id": event_id, "state_revision": revision}
func _register_activity(
target_id: StringName, action_id: StringName, position: Vector3, with_provider: bool = true
) -> WorldTargetHandle:
var provider: Node
if with_provider:
provider = Node.new()
add_child_autofree(provider)
var capability := WorldTargetCapability.new(action_id, {"interaction_range": 3.0})
return registry.register_target(
WorldTargetDescriptor.new(target_id, &"activity", [capability], position), provider
)
func _command(
command_id: StringName,
actor: WorldEntityRef,
action_id: StringName,
target: WorldTargetHandle,
expected_revision: int
) -> ActionCommand:
return ActionCommand.new(command_id, actor, action_id, target, {}, expected_revision)
func _player_ref() -> WorldEntityRef:
return WorldEntityRef.create(SimulationIds.ENTITY_PLAYER, &"-1")
func _snapshot() -> Dictionary:
return {
"safety": village.safety,
"knowledge": village.knowledge,
"revision": revision,
"events": events.duplicate(true),
}