feat: unify authored activity commands

This commit is contained in:
Rijad Zuzo
2026-08-13 00:08:30 +02:00
parent e5aecee0de
commit b3b98c6456
36 changed files with 3013 additions and 96 deletions
@@ -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
+1
View File
@@ -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,
+15
View File
@@ -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()