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