feat: unify authored activity commands
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user