feat: unify authored activity commands
This commit is contained in:
@@ -197,6 +197,7 @@ func _test_wood_work_requires_material() -> void:
|
||||
var safety_before: float = manager.village.safety
|
||||
|
||||
npc.set_task(SimulationIds.ACTION_PATROL, 1.0)
|
||||
npc.target_id = SimulationIds.ACTIVITY_GUARD_POST
|
||||
npc.start_working()
|
||||
manager.simulate_tick()
|
||||
_check(
|
||||
@@ -227,13 +228,27 @@ func _test_wood_work_requires_material() -> void:
|
||||
)
|
||||
var knowledge_before: float = manager.village.knowledge
|
||||
npc.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
npc.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
npc.start_working()
|
||||
manager.simulate_tick()
|
||||
var completed_events: Array[EconomicEventRecord] = manager.get_npc_events(npc.id, 2)
|
||||
var completed_event: EconomicEventRecord = (
|
||||
completed_events.back() if not completed_events.is_empty() else null
|
||||
)
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 1.0)
|
||||
and manager.village.knowledge > knowledge_before
|
||||
and completed_event != null
|
||||
and (
|
||||
StringName(completed_event.data["event_type"])
|
||||
== SimulationIds.EVENT_ACTIVITY_COMPLETED
|
||||
)
|
||||
and StringName(completed_event.data["action_id"]) == SimulationIds.ACTION_STUDY
|
||||
and StringName(completed_event.data["metric_id"]) == &"knowledge"
|
||||
and StringName(completed_event.data["cost_item_id"]) == SimulationIds.RESOURCE_WOOD
|
||||
and is_equal_approx(float(completed_event.data["cost_amount"]), 1.0)
|
||||
),
|
||||
"Funded study should consume one wood and produce knowledge"
|
||||
"Funded study should consume one wood and produce one exact authored activity fact"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
@@ -352,16 +352,16 @@ func _run() -> void:
|
||||
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
)
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var woodpile_node := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||
var study_site := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/StudyDesk") as ActivitySite
|
||||
)
|
||||
contributor.position = woodpile_node.get_interaction_position()
|
||||
contributor.position = study_site.get_interaction_position()
|
||||
for npc in simulation_manager.npcs:
|
||||
if npc.id != contributor.id:
|
||||
npc.position = contributor.position + Vector3(20.0 + npc.id * 2.0, 0.0, 0.0)
|
||||
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
contributor.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
contributor.target_id = study_site.site_id
|
||||
contributor.travel_target_position = contributor.position
|
||||
contributor.start_working()
|
||||
simulation_manager.simulate_tick()
|
||||
|
||||
@@ -23,6 +23,7 @@ func _run() -> void:
|
||||
_set_wood_amount(manager, 0.5)
|
||||
|
||||
interested.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
interested.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
interested.start_working()
|
||||
manager.simulate_tick()
|
||||
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
|
||||
|
||||
@@ -126,11 +126,172 @@ func _run() -> void:
|
||||
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
|
||||
)
|
||||
player.global_position = guard_site.get_interaction_position()
|
||||
var adapter := main_scene.get_node("ActiveWorldAdapter") as ActiveWorldAdapter
|
||||
var adapter_identity := adapter.get_context_identity()
|
||||
_check(
|
||||
adapter.configure_context_identity(
|
||||
StringName(adapter_identity["context_id"]),
|
||||
StringName(adapter_identity["world_id"]),
|
||||
StringName(adapter_identity["location_id"])
|
||||
),
|
||||
"Activity registry ABA setup should create a clean first registry"
|
||||
)
|
||||
var guard_context := player.get_interaction_context() as PlayerInteractionResult
|
||||
var stale_safety: float = simulation_manager.village.safety
|
||||
var stale_woodpile := simulation_manager.get_woodpile() as StorageStateRecord
|
||||
var stale_wood: float = stale_woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var stale_event_count: int = simulation_manager.economic_events.size()
|
||||
_check(
|
||||
(
|
||||
guard_context != null
|
||||
and adapter.configure_context_identity(
|
||||
StringName(adapter_identity["context_id"]),
|
||||
StringName(adapter_identity["world_id"]),
|
||||
StringName(adapter_identity["location_id"])
|
||||
)
|
||||
and adapter.get_target_handle(guard_site.site_id) != null
|
||||
and (
|
||||
adapter.get_target_handle(guard_site.site_id).get_generation()
|
||||
== guard_context.target_generation
|
||||
)
|
||||
),
|
||||
"Activity registry replacement should recreate the same visible target generation"
|
||||
)
|
||||
simulation_manager.update_player_combatant(player.global_position)
|
||||
var stale_result: ActionResult = simulation_manager.execute_player_activity_action(
|
||||
guard_context.action_id,
|
||||
guard_context.target_id,
|
||||
guard_context.expected_state_revision,
|
||||
guard_context.target_generation,
|
||||
guard_context.target_context_id,
|
||||
guard_context.target_registry_instance_id
|
||||
)
|
||||
var incomplete_result: ActionResult = simulation_manager.execute_player_activity_action(
|
||||
guard_context.action_id, guard_context.target_id
|
||||
)
|
||||
_check(
|
||||
(
|
||||
stale_result != null
|
||||
and not stale_result.did_succeed()
|
||||
and stale_result.get_reason_code() == ActivityActionCommandService.REASON_STALE_TARGET
|
||||
and is_equal_approx(simulation_manager.village.safety, stale_safety)
|
||||
and is_equal_approx(stale_woodpile.get_amount(SimulationIds.RESOURCE_WOOD), stale_wood)
|
||||
and simulation_manager.economic_events.size() == stale_event_count
|
||||
and incomplete_result != null
|
||||
and not incomplete_result.did_succeed()
|
||||
and (
|
||||
incomplete_result.get_reason_code()
|
||||
== ActivityActionCommandService.REASON_STALE_TARGET
|
||||
)
|
||||
),
|
||||
(
|
||||
"A replaced registry and an incomplete direct call must reject without mutating "
|
||||
+ "authority"
|
||||
)
|
||||
)
|
||||
guard_context = player.get_interaction_context() as PlayerInteractionResult
|
||||
var safety_before: float = simulation_manager.village.safety
|
||||
var woodpile := simulation_manager.get_woodpile() as StorageStateRecord
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var event_count_before: int = simulation_manager.economic_events.size()
|
||||
player.try_interact()
|
||||
var activity_event := simulation_manager.economic_events.back() as EconomicEventRecord
|
||||
_check(
|
||||
(
|
||||
guard_context != null
|
||||
and guard_context.kind == PlayerInteractionResult.KIND_ACTIVITY
|
||||
and guard_context.action_id == SimulationIds.ACTION_PATROL
|
||||
and guard_context.target_id == guard_site.site_id
|
||||
and guard_context.expected_state_revision == event_count_before
|
||||
and simulation_manager.village.safety > safety_before
|
||||
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before - 1.0)
|
||||
and simulation_manager.economic_events.size() == event_count_before + 1
|
||||
and (
|
||||
StringName(activity_event.data["event_type"])
|
||||
== SimulationIds.EVENT_ACTIVITY_COMPLETED
|
||||
)
|
||||
and int(activity_event.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID
|
||||
and StringName(activity_event.data["source_id"]) == guard_site.site_id
|
||||
and StringName(activity_event.data["action_id"]) == SimulationIds.ACTION_PATROL
|
||||
and StringName(activity_event.data["metric_id"]) == &"safety"
|
||||
and is_equal_approx(float(activity_event.data["metric_delta"]), 3.0)
|
||||
and StringName(activity_event.data["cost_item_id"]) == SimulationIds.RESOURCE_WOOD
|
||||
and is_equal_approx(float(activity_event.data["cost_amount"]), 1.0)
|
||||
and StringName(activity_event.data["world_id"]) == SimulationIds.REGIONAL_WORLD_BOSNIA
|
||||
and (
|
||||
StringName(activity_event.data["location_id"])
|
||||
== SimulationIds.REGIONAL_LOCATION_JAJCE
|
||||
)
|
||||
and simulation_manager.event_knowledge_system.knows_event(
|
||||
SimulationIds.PLAYER_ACTOR_ID, int(activity_event.data["event_id"])
|
||||
)
|
||||
),
|
||||
(
|
||||
"Player activity should use the same revisioned command, cost, metric, fact, and "
|
||||
+ "knowledge path as NPC work"
|
||||
)
|
||||
)
|
||||
var activity_checksum: String = simulation_manager.get_state_checksum()
|
||||
var activity_snapshot: String = simulation_manager.serialize_state()
|
||||
_check(
|
||||
(
|
||||
simulation_manager.restore_state_from_json(activity_snapshot)
|
||||
and simulation_manager.get_state_checksum() == activity_checksum
|
||||
and simulation_manager.event_knowledge_system.knows_event(
|
||||
SimulationIds.PLAYER_ACTOR_ID, int(activity_event.data["event_id"])
|
||||
)
|
||||
),
|
||||
"Player activity facts and performed knowledge should survive exact save/restore"
|
||||
)
|
||||
woodpile = simulation_manager.get_woodpile()
|
||||
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD))
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var blocked_guard_context := player.get_interaction_context() as PlayerInteractionResult
|
||||
var blocked_guard_safety: float = simulation_manager.village.safety
|
||||
var blocked_guard_events: int = simulation_manager.economic_events.size()
|
||||
player.try_interact()
|
||||
_check(
|
||||
simulation_manager.village.safety > safety_before,
|
||||
"Player should help guard from the typed GuardPost ActivitySite"
|
||||
(
|
||||
blocked_guard_context != null
|
||||
and blocked_guard_context.kind == PlayerInteractionResult.KIND_ACTIVITY
|
||||
and blocked_guard_context.target_id == guard_site.site_id
|
||||
and not blocked_guard_context.is_available()
|
||||
and "wood" in blocked_guard_context.blocked_reason.to_lower()
|
||||
and is_equal_approx(simulation_manager.village.safety, blocked_guard_safety)
|
||||
and simulation_manager.economic_events.size() == blocked_guard_events
|
||||
),
|
||||
"An unfunded authored activity offer should explain the cost without mutating authority"
|
||||
)
|
||||
woodpile.deposit(SimulationIds.RESOURCE_WOOD, 1.0)
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var downed_context := player.get_interaction_context() as PlayerInteractionResult
|
||||
var downed_wood := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var downed_safety: float = simulation_manager.village.safety
|
||||
var downed_event_count: int = simulation_manager.economic_events.size()
|
||||
simulation_manager.get_player_state().take_damage(
|
||||
simulation_manager.get_player_state().get_max_health()
|
||||
)
|
||||
var downed_result: ActionResult
|
||||
if downed_context != null:
|
||||
downed_result = simulation_manager.execute_player_activity_action(
|
||||
downed_context.action_id,
|
||||
downed_context.target_id,
|
||||
downed_context.expected_state_revision,
|
||||
downed_context.target_generation,
|
||||
downed_context.target_context_id,
|
||||
downed_context.target_registry_instance_id
|
||||
)
|
||||
_check(
|
||||
(
|
||||
downed_context != null
|
||||
and downed_context.kind == PlayerInteractionResult.KIND_ACTIVITY
|
||||
and downed_result != null
|
||||
and not downed_result.did_succeed()
|
||||
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), downed_wood)
|
||||
and is_equal_approx(simulation_manager.village.safety, downed_safety)
|
||||
and simulation_manager.economic_events.size() == downed_event_count
|
||||
),
|
||||
"A downed player must not work, consume cost, mutate metrics, or record a fact"
|
||||
)
|
||||
|
||||
if failures.is_empty():
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3o3qhh73no24
|
||||
@@ -0,0 +1,148 @@
|
||||
extends GutTest
|
||||
|
||||
const ACTION_ID := &"test_activity_metric_action"
|
||||
|
||||
|
||||
func test_valid_activity_metric_action_uses_the_builtin_contract() -> void:
|
||||
var action := _valid_action()
|
||||
action.completion_cost_resource_id = SimulationIds.RESOURCE_WOOD
|
||||
action.completion_cost_amount = 1.0
|
||||
|
||||
var catalog := _catalog_with_action(action)
|
||||
assert_true(catalog.is_valid(), "%s" % [catalog.get_errors()])
|
||||
assert_not_null(catalog.get_action(ACTION_ID))
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_non_activity_targets() -> void:
|
||||
var action := _valid_action()
|
||||
action.target_type = SimulationIds.TARGET_FREE
|
||||
|
||||
_assert_contract_error(action, "activity")
|
||||
|
||||
|
||||
func test_activity_metric_handler_requires_exactly_one_effect() -> void:
|
||||
var without_effects := _valid_action()
|
||||
without_effects.effects = []
|
||||
_assert_contract_error(without_effects, "exactly one")
|
||||
|
||||
var with_multiple_effects := _valid_action()
|
||||
var second_effect := _valid_effect()
|
||||
second_effect.effect_id = &"second_metric_effect"
|
||||
with_multiple_effects.effects = [_valid_effect(), second_effect]
|
||||
_assert_contract_error(with_multiple_effects, "exactly one")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_wrong_effect_kind() -> void:
|
||||
var action := _valid_action()
|
||||
var damage := ActionEffect.new()
|
||||
damage.effect_id = &"damage_instead_of_metric"
|
||||
damage.effect_kind = ActionEffect.KIND_DAMAGE
|
||||
damage.amount = 1.0
|
||||
action.effects = [damage]
|
||||
|
||||
_assert_contract_error(action, "metric_delta")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_unsupported_metric() -> void:
|
||||
var action := _valid_action()
|
||||
var effect := _valid_effect()
|
||||
effect.subject_key = &"morale"
|
||||
action.effects = [effect]
|
||||
|
||||
_assert_contract_error(action, "safety")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_malformed_player_multiplier() -> void:
|
||||
var action := _valid_action()
|
||||
var effect := _valid_effect()
|
||||
effect.parameters = {"player_multiplier": "three", "npc_productivity": true}
|
||||
action.effects = [effect]
|
||||
|
||||
_assert_contract_error(action, "player_multiplier")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_malformed_npc_productivity() -> void:
|
||||
var action := _valid_action()
|
||||
var effect := _valid_effect()
|
||||
effect.parameters = {"player_multiplier": 2.0, "npc_productivity": "yes"}
|
||||
action.effects = [effect]
|
||||
|
||||
_assert_contract_error(action, "npc_productivity")
|
||||
|
||||
|
||||
func test_activity_metric_handler_requires_a_completion_event_type() -> void:
|
||||
var action := _valid_action()
|
||||
action.event_type_id = &""
|
||||
|
||||
_assert_contract_error(action, "event_type_id")
|
||||
|
||||
|
||||
func test_activity_metric_completion_cost_must_have_a_storage_route() -> void:
|
||||
var action := _valid_action()
|
||||
action.completion_cost_resource_id = &"unrouted_activity_cost"
|
||||
action.completion_cost_amount = 1.0
|
||||
var unrouted_item := ItemDefinition.new()
|
||||
unrouted_item.item_id = action.completion_cost_resource_id
|
||||
unrouted_item.display_name = "Unrouted Activity Cost"
|
||||
unrouted_item.category = ItemDefinition.CATEGORY_RESOURCE
|
||||
|
||||
var catalog := _catalog_with_action(action, [unrouted_item])
|
||||
assert_false(catalog.is_valid(), "An executable cost needs an authoritative storage route")
|
||||
assert_true(
|
||||
_has_error(catalog.get_errors(), "storage"),
|
||||
"Expected a storage routing error, got %s" % [catalog.get_errors()]
|
||||
)
|
||||
|
||||
|
||||
func _assert_contract_error(action: ActionDefinition, expected_fragment: String) -> void:
|
||||
var catalog := _catalog_with_action(action)
|
||||
assert_false(catalog.is_valid(), "Invalid '%s' contract was published" % action.action_id)
|
||||
assert_true(
|
||||
_has_error(catalog.get_errors(), expected_fragment),
|
||||
"Expected '%s' in %s" % [expected_fragment, catalog.get_errors()]
|
||||
)
|
||||
|
||||
|
||||
func _catalog_with_action(
|
||||
action: ActionDefinition, items: Array[ItemDefinition] = []
|
||||
) -> ContentCatalog:
|
||||
var core := load(ContentCatalog.CORE_PACK_PATH) as SimulationContentPack
|
||||
var addon := SimulationContentPack.new()
|
||||
addon.pack_id = &"activity_contract_test"
|
||||
addon.display_name = "Activity Contract Test"
|
||||
addon.required_pack_ids = [&"core"]
|
||||
addon.required_handler_ids = [SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA]
|
||||
addon.actions = [action]
|
||||
addon.items = items
|
||||
var packs: Array[SimulationContentPack] = [addon, core]
|
||||
var catalog := ContentCatalog.new()
|
||||
catalog.rebuild(packs)
|
||||
return catalog
|
||||
|
||||
|
||||
func _valid_action() -> ActionDefinition:
|
||||
var action := ActionDefinition.new()
|
||||
action.action_id = ACTION_ID
|
||||
action.display_name = "Test Activity Metric Action"
|
||||
action.target_type = SimulationIds.TARGET_ACTIVITY
|
||||
action.completion_handler_id = SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA
|
||||
action.effects = [_valid_effect()]
|
||||
action.event_type_id = SimulationIds.EVENT_ACTIVITY_COMPLETED
|
||||
return action
|
||||
|
||||
|
||||
func _valid_effect() -> ActionEffect:
|
||||
var effect := ActionEffect.new()
|
||||
effect.effect_id = &"test_metric_effect"
|
||||
effect.effect_kind = ActionEffect.KIND_METRIC_DELTA
|
||||
effect.subject_key = &"safety"
|
||||
effect.value = 1.0
|
||||
effect.parameters = {"player_multiplier": 2.0, "npc_productivity": true}
|
||||
return effect
|
||||
|
||||
|
||||
func _has_error(errors: Array[String], fragment: String) -> bool:
|
||||
for error in errors:
|
||||
if fragment in error:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://m58cgatc3ub
|
||||
@@ -32,6 +32,70 @@ func test_world_v14_migrates_to_empty_emergent_collections() -> void:
|
||||
assert_eq(int(migrated.simulation["next_conversation_act_id"]), 0)
|
||||
|
||||
|
||||
func test_world_v15_migrates_to_the_existing_explicit_scope() -> void:
|
||||
var previous: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous["schema_version"] = SimulationStateRecord.EMERGENT_SCHEMA_VERSION
|
||||
previous["simulation"].erase("world_id")
|
||||
previous["simulation"].erase("location_id")
|
||||
|
||||
var migrated := SimulationStateRecord.from_dictionary(previous)
|
||||
assert_not_null(migrated)
|
||||
assert_eq(StringName(migrated.simulation["world_id"]), SimulationIds.REGIONAL_WORLD_BOSNIA)
|
||||
assert_eq(StringName(migrated.simulation["location_id"]), SimulationIds.REGIONAL_LOCATION_JAJCE)
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
manager.record_narrative_event(
|
||||
SimulationIds.EVENT_TASK_BLOCKED,
|
||||
npc.id,
|
||||
SimulationIds.npc_inventory_id(npc.id),
|
||||
"Feed animal: needs 1 food",
|
||||
SimulationIds.ACTION_FEED_ANIMAL,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
var previous_with_blocked_fact: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous_with_blocked_fact["schema_version"] = SimulationStateRecord.EMERGENT_SCHEMA_VERSION
|
||||
previous_with_blocked_fact["simulation"].erase("world_id")
|
||||
previous_with_blocked_fact["simulation"].erase("location_id")
|
||||
for field in ["blocked_contract_kind", "world_id", "location_id"]:
|
||||
previous_with_blocked_fact["economic_events"][0].erase(field)
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(previous_with_blocked_fact),
|
||||
"A legacy compound-action inventory failure must remain loadable under v16"
|
||||
)
|
||||
|
||||
|
||||
func test_legacy_unstructured_blocked_fact_is_preserved_but_not_made_knowable() -> void:
|
||||
var legacy_state: Dictionary = manager.create_state_record().to_dictionary()
|
||||
legacy_state["schema_version"] = SimulationStateRecord.PRE_EMERGENT_SCHEMA_VERSION
|
||||
var legacy_event: Dictionary = (
|
||||
EconomicEventRecord
|
||||
. create_narrative(
|
||||
0,
|
||||
SimulationIds.EVENT_TASK_BLOCKED,
|
||||
0,
|
||||
manager.npcs[0].id,
|
||||
&"legacy_unknown_source",
|
||||
"Legacy work was blocked"
|
||||
)
|
||||
. to_dictionary()
|
||||
)
|
||||
legacy_event["schema_version"] = EconomicEventRecord.POSITION_LEGACY_SCHEMA_VERSION
|
||||
legacy_event.erase("action_id")
|
||||
legacy_event.erase("required_amount")
|
||||
legacy_state["economic_events"] = [legacy_event]
|
||||
legacy_state["simulation"]["next_event_id"] = 1
|
||||
|
||||
var migrated := SimulationStateRecord.from_dictionary(legacy_state)
|
||||
assert_not_null(migrated)
|
||||
var preserved: EconomicEventRecord = migrated.economic_events[0]
|
||||
assert_true(ActivityEventValidator.is_legacy_unstructured_blocked(preserved))
|
||||
assert_false(EventKnowledgeSystem.is_knowable_event(preserved))
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(migrated.to_dictionary()),
|
||||
"A grandfathered fact must survive its next current-schema save"
|
||||
)
|
||||
|
||||
|
||||
func test_orphan_journal_and_situation_evidence_are_rejected() -> void:
|
||||
var orphan_journal: Dictionary = manager.create_state_record().to_dictionary()
|
||||
orphan_journal["quest_journal"] = [
|
||||
@@ -84,3 +148,325 @@ func test_conversation_history_requires_valid_actors_and_monotonic_id() -> void:
|
||||
assert_not_null(SimulationStateRecord.from_dictionary(state))
|
||||
state["conversation_history"][0]["speaker"]["entity_id"] = "missing_npc"
|
||||
assert_null(SimulationStateRecord.from_dictionary(state))
|
||||
|
||||
|
||||
func test_activity_facts_validate_actor_target_effect_and_exact_cost_without_knowledge() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_STUDY, SimulationIds.ACTIVITY_STUDY_DESK)
|
||||
var result: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(result.did_succeed())
|
||||
var state: Dictionary = manager.create_state_record().to_dictionary()
|
||||
state["event_knowledge"] = []
|
||||
assert_not_null(SimulationStateRecord.from_dictionary(state))
|
||||
|
||||
var invalid_actor := state.duplicate(true)
|
||||
invalid_actor["economic_events"][0]["actor_id"] = 999
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_actor))
|
||||
var invalid_target := state.duplicate(true)
|
||||
invalid_target["economic_events"][0]["target_kind"] = "storage"
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_target))
|
||||
var invalid_metric := state.duplicate(true)
|
||||
invalid_metric["economic_events"][0]["metric_id"] = "safety"
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_metric))
|
||||
var invalid_cost := state.duplicate(true)
|
||||
invalid_cost["economic_events"][0]["cost_amount"] = 2.0
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_cost))
|
||||
var invalid_scope := state.duplicate(true)
|
||||
invalid_scope["economic_events"][0]["location_id"] = "location_elsewhere"
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_scope))
|
||||
var impossible_delta := state.duplicate(true)
|
||||
impossible_delta["economic_events"][0]["metric_delta"] = 99.0
|
||||
assert_null(SimulationStateRecord.from_dictionary(impossible_delta))
|
||||
|
||||
|
||||
func test_capped_activity_never_consumes_cost_or_claims_a_false_delta() -> void:
|
||||
manager.village.safety = 100.0
|
||||
manager.village.update_modifiers()
|
||||
manager.village.update_priorities()
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var events_before: int = manager.economic_events.size()
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_PATROL, SimulationIds.ACTIVITY_GUARD_POST)
|
||||
var capped: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(capped.did_succeed())
|
||||
assert_eq(capped.get_reason_code(), &"no_effect")
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_eq(manager.economic_events.size(), events_before)
|
||||
|
||||
manager.village.safety = 99.0
|
||||
var partial: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(partial.did_succeed())
|
||||
assert_eq(manager.village.safety, 100.0)
|
||||
assert_eq(float(partial.get_payload()["amount"]), 1.0)
|
||||
assert_eq(float(manager.economic_events.back().data["metric_delta"]), 1.0)
|
||||
|
||||
|
||||
func test_rejected_activity_exposes_its_exact_failure_fact_and_revision() -> void:
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var available := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
assert_eq(woodpile.withdraw(SimulationIds.RESOURCE_WOOD, available), available)
|
||||
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var revision_before: int = manager.get_action_state_revision()
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_PATROL, SimulationIds.ACTIVITY_GUARD_POST)
|
||||
var result: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
revision_before
|
||||
)
|
||||
assert_false(result.did_succeed())
|
||||
assert_eq(result.get_reason_code(), &"insufficient_cost")
|
||||
var payload := result.get_payload()
|
||||
assert_eq(int(payload["event_id"]), revision_before)
|
||||
assert_eq(int(payload["state_revision"]), revision_before + 1)
|
||||
assert_has(payload["reason_trace"], "authoritative_failure_fact_recorded")
|
||||
var failure_fact: EconomicEventRecord = manager.event_log.get_by_id(int(payload["event_id"]))
|
||||
assert_not_null(failure_fact)
|
||||
assert_eq(StringName(failure_fact.data["event_type"]), SimulationIds.EVENT_TASK_BLOCKED)
|
||||
assert_eq(StringName(failure_fact.data["action_id"]), SimulationIds.ACTION_PATROL)
|
||||
assert_eq(int(failure_fact.data["actor_id"]), npc.id)
|
||||
var state: Dictionary = manager.create_state_record().to_dictionary()
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(state), "Original blocked state must parse"
|
||||
)
|
||||
state["event_knowledge"] = []
|
||||
state["opportunities"] = []
|
||||
state["situations"] = []
|
||||
state["quest_journal"] = []
|
||||
state["commitments"] = []
|
||||
var npc_ids := {}
|
||||
for saved_npc in state["npcs"]:
|
||||
npc_ids[int(saved_npc["id"])] = true
|
||||
var storage_records := {}
|
||||
for saved_storage in state["storages"]:
|
||||
var storage := StorageStateRecord.from_dictionary(saved_storage)
|
||||
storage_records[storage.get_storage_id()] = storage
|
||||
assert_true(ActivityEventValidator.is_valid_blocked(failure_fact))
|
||||
assert_true(
|
||||
ActivityEventValidator.is_valid_blocked_for_state(
|
||||
failure_fact,
|
||||
npc_ids,
|
||||
storage_records,
|
||||
int(state["simulation"]["tick_count"]),
|
||||
StringName(state["simulation"]["world_id"]),
|
||||
StringName(state["simulation"]["location_id"])
|
||||
)
|
||||
)
|
||||
assert_not_null(SimulationStateRecord.from_dictionary(state))
|
||||
for mutation in [
|
||||
["actor_id", 999],
|
||||
["action_id", String(SimulationIds.ACTION_STUDY)],
|
||||
["source_id", String(SimulationIds.STORAGE_VILLAGE_PANTRY)],
|
||||
["item_id", String(SimulationIds.RESOURCE_FOOD)],
|
||||
["required_amount", 2.0],
|
||||
["world_id", "wrong_world"],
|
||||
["location_id", "wrong_location"],
|
||||
]:
|
||||
var tampered: Dictionary = state.duplicate(true)
|
||||
tampered["economic_events"][0][mutation[0]] = mutation[1]
|
||||
assert_null(
|
||||
SimulationStateRecord.from_dictionary(tampered),
|
||||
"Tampered task_blocked field '%s' must be rejected" % mutation[0]
|
||||
)
|
||||
|
||||
|
||||
func test_unloaded_activity_requires_a_registered_capability_target() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
var actor := WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id)))
|
||||
_assign_activity(npc, SimulationIds.ACTION_STUDY, &"invented_library")
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var rejected: ActionResult = manager.execute_activity_action(
|
||||
actor, SimulationIds.ACTION_STUDY, &"invented_library", manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(rejected.did_succeed())
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_true(manager.economic_events.is_empty())
|
||||
|
||||
assert_true(
|
||||
manager.register_abstract_activity_target(
|
||||
&"library_annex", SimulationIds.ACTION_STUDY, npc.position
|
||||
)
|
||||
)
|
||||
npc.target_id = &"library_annex"
|
||||
var completed: ActionResult = manager.execute_activity_action(
|
||||
actor, SimulationIds.ACTION_STUDY, &"library_annex", manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(completed.did_succeed())
|
||||
assert_eq(StringName(manager.economic_events.back().data["source_id"]), &"library_annex")
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(manager.create_state_record().to_dictionary())
|
||||
)
|
||||
|
||||
|
||||
func test_dead_npc_cannot_complete_an_abstract_activity() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
npc.die_from_combat()
|
||||
var actor := WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id)))
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var safety_before: float = manager.village.safety
|
||||
var events_before: int = manager.economic_events.size()
|
||||
var result: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(result.did_succeed())
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_UNSUPPORTED_ACTOR)
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_eq(manager.village.safety, safety_before)
|
||||
assert_eq(manager.economic_events.size(), events_before)
|
||||
|
||||
|
||||
func test_npc_activity_requires_the_exact_authoritative_work_assignment() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
var actor := WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id)))
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var safety_before: float = manager.village.safety
|
||||
var events_before: int = manager.economic_events.size()
|
||||
var idle_result: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(idle_result.did_succeed())
|
||||
var omitted_revision: ActionResult = manager.execute_activity_action(
|
||||
actor, SimulationIds.ACTION_PATROL, SimulationIds.ACTIVITY_GUARD_POST
|
||||
)
|
||||
assert_false(omitted_revision.did_succeed())
|
||||
var player_bypass: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(
|
||||
SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
|
||||
),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(player_bypass.did_succeed())
|
||||
npc.set_task(SimulationIds.ACTION_STUDY)
|
||||
npc.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
npc.travel_target_position = npc.position
|
||||
npc.has_travel_target = false
|
||||
npc.start_working()
|
||||
var early_result: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(early_result.did_succeed())
|
||||
npc.task_progress = npc.task_duration
|
||||
npc.task_complete = true
|
||||
npc.task_state = SimNPC.TASK_STATE_COMPLETE
|
||||
var wrong_task: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(wrong_task.did_succeed())
|
||||
npc.target_id = SimulationIds.ACTIVITY_GUARD_POST
|
||||
var wrong_target: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(wrong_target.did_succeed())
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_eq(manager.village.safety, safety_before)
|
||||
assert_eq(manager.economic_events.size(), events_before)
|
||||
|
||||
|
||||
func test_custom_activity_scope_round_trips_and_rebuilds_the_world_event_index() -> void:
|
||||
var adapter := ActiveWorldAdapter.new()
|
||||
assert_true(
|
||||
adapter.configure_context_identity(&"test_context", &"test_world", &"test_location")
|
||||
)
|
||||
add_child_autofree(adapter)
|
||||
var scoped_manager := MANAGER_SCRIPT.new()
|
||||
scoped_manager.active_world_adapter = adapter
|
||||
add_child_autofree(scoped_manager)
|
||||
var npc: SimNPC = scoped_manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_STUDY, SimulationIds.ACTIVITY_STUDY_DESK)
|
||||
var result: ActionResult = scoped_manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
scoped_manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(result.did_succeed())
|
||||
var snapshot := scoped_manager.serialize_state()
|
||||
var checksum := scoped_manager.get_state_checksum()
|
||||
var parsed := SimulationStateRecord.from_json(snapshot)
|
||||
assert_not_null(parsed)
|
||||
assert_eq(StringName(parsed.simulation["world_id"]), &"test_world")
|
||||
assert_eq(StringName(parsed.simulation["location_id"]), &"test_location")
|
||||
assert_true(scoped_manager.restore_state_from_json(snapshot))
|
||||
assert_eq(scoped_manager.get_state_checksum(), checksum)
|
||||
var location := SpatialAddress.create(&"test_world", &"test_location", Vector3.ZERO)
|
||||
assert_eq(scoped_manager.event_log.world_events.get_for_location(location).size(), 1)
|
||||
|
||||
|
||||
func test_restore_rejects_adapter_scope_mismatch_before_mutating_empty_history() -> void:
|
||||
var source_adapter := ActiveWorldAdapter.new()
|
||||
assert_true(source_adapter.configure_context_identity(&"source", &"world_a", &"location_a"))
|
||||
add_child_autofree(source_adapter)
|
||||
var source_manager := MANAGER_SCRIPT.new()
|
||||
source_manager.active_world_adapter = source_adapter
|
||||
add_child_autofree(source_manager)
|
||||
var source_record: SimulationStateRecord = source_manager.create_state_record()
|
||||
assert_true(source_record.economic_events.is_empty())
|
||||
|
||||
var target_adapter := ActiveWorldAdapter.new()
|
||||
assert_true(target_adapter.configure_context_identity(&"target", &"world_b", &"location_b"))
|
||||
add_child_autofree(target_adapter)
|
||||
var target_manager := MANAGER_SCRIPT.new()
|
||||
target_manager.active_world_adapter = target_adapter
|
||||
add_child_autofree(target_manager)
|
||||
var checksum_before: String = target_manager.get_state_checksum()
|
||||
var scope_before: Dictionary = target_manager.event_log.get_scope()
|
||||
|
||||
assert_false(target_manager.restore_state(source_record))
|
||||
assert_eq(target_manager.get_state_checksum(), checksum_before)
|
||||
assert_eq(target_manager.event_log.get_scope(), scope_before)
|
||||
source_manager.record_narrative_event(
|
||||
SimulationIds.EVENT_TASK_STARTED, source_manager.npcs[0].id, &"", "Started scoped work"
|
||||
)
|
||||
var populated_source_record: SimulationStateRecord = source_manager.create_state_record()
|
||||
assert_false(populated_source_record.economic_events.is_empty())
|
||||
assert_false(target_manager.restore_state(populated_source_record))
|
||||
assert_eq(target_manager.get_state_checksum(), checksum_before)
|
||||
assert_eq(target_manager.event_log.get_scope(), scope_before)
|
||||
|
||||
|
||||
func _assign_activity(npc: SimNPC, action_id: StringName, target_id: StringName) -> void:
|
||||
npc.set_task(action_id)
|
||||
npc.target_id = target_id
|
||||
npc.travel_target_position = npc.position
|
||||
npc.has_travel_target = false
|
||||
npc.start_working()
|
||||
npc.task_progress = npc.task_duration
|
||||
npc.task_complete = true
|
||||
npc.task_state = SimNPC.TASK_STATE_COMPLETE
|
||||
|
||||
@@ -3,6 +3,7 @@ extends GutTest
|
||||
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||
|
||||
const CORE_EVENT_TYPE_IDS: Array[StringName] = [
|
||||
SimulationIds.EVENT_ACTIVITY_COMPLETED,
|
||||
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
SimulationIds.EVENT_STORAGE_WITHDRAWN,
|
||||
|
||||
@@ -114,6 +114,21 @@ func test_economy_moves_inventory_into_authoritative_storage() -> void:
|
||||
assert_eq(village.food, 5.0)
|
||||
|
||||
|
||||
func test_economy_routes_authored_items_without_resource_switches() -> void:
|
||||
var village := SimVillage.new()
|
||||
var economy := VillageEconomyScript.new()
|
||||
economy.configure(village, false)
|
||||
economy.initialize_storage()
|
||||
|
||||
var apothecary := economy.get_storage_for_resource(&"herb")
|
||||
assert_not_null(apothecary)
|
||||
assert_eq(apothecary.get_storage_id(), &"village_apothecary")
|
||||
assert_same(economy.get_storage_for_resource(SimulationIds.RESOURCE_FOOD), economy.get_pantry())
|
||||
assert_same(
|
||||
economy.get_storage_for_resource(SimulationIds.RESOURCE_WOOD), economy.get_woodpile()
|
||||
)
|
||||
|
||||
|
||||
func test_failed_food_consumption_preserves_fractional_inventory() -> void:
|
||||
var village := SimVillage.new()
|
||||
var economy := VillageEconomyScript.new()
|
||||
|
||||
@@ -22,6 +22,7 @@ func _run() -> void:
|
||||
var event_count_before: int = manager.economic_events.size()
|
||||
|
||||
actor.set_task(SimulationIds.ACTION_PATROL, 1.0)
|
||||
actor.target_id = SimulationIds.ACTIVITY_GUARD_POST
|
||||
actor.start_working()
|
||||
manager.simulate_tick()
|
||||
var trigger := _latest_event_of_type(manager, SimulationIds.EVENT_TASK_BLOCKED, actor.id)
|
||||
@@ -365,6 +366,7 @@ func _open_missing_wood_need(manager: Node) -> SimNPC:
|
||||
actor.position = Vector3.ZERO
|
||||
_set_wood_amount(manager, 0.0)
|
||||
actor.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
actor.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
actor.start_working()
|
||||
manager.simulate_tick()
|
||||
_check(manager.get_active_opportunity() != null, "Setup should open a missing-wood need")
|
||||
|
||||
Reference in New Issue
Block a user