From b3b98c6456b0205aa7d9fc4ed483149b2f916201 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 13 Aug 2026 00:08:30 +0200 Subject: [PATCH] feat: unify authored activity commands --- docs/SIMULATION_DEFINITIONS.md | 27 +- docs/SIMULATION_STATE_SCHEMA.md | 30 +- player/PlayerInteractionResult.gd | 19 +- player/player.gd | 99 ++- simulation/SimulationManager.gd | 671 +++++++++++++++++- simulation/commands/ActionResult.gd | 4 +- .../commands/ActivityActionCommandService.gd | 265 +++++++ .../ActivityActionCommandService.gd.uid | 1 + .../commands/CatalogInteractionService.gd | 7 +- simulation/definitions/ContentCatalog.gd | 79 ++- .../definitions/SimulationDefinitions.gd | 9 + simulation/definitions/SimulationIds.gd | 8 + simulation/definitions/actions/patrol.tres | 14 +- simulation/definitions/actions/study.tres | 14 +- .../event_types/activity_completed.tres | 10 + simulation/definitions/packs/core.tres | 6 +- simulation/economy/VillageEconomy.gd | 81 ++- simulation/events/ActivityEventValidator.gd | 293 ++++++++ .../events/ActivityEventValidator.gd.uid | 1 + simulation/events/SimulationEventLog.gd | 148 +++- simulation/knowledge/EventKnowledgeSystem.gd | 12 +- simulation/state/EconomicEventRecord.gd | 25 +- simulation/state/SimulationStateRecord.gd | 73 +- tests/food_storage_loop_test.gd | 17 +- tests/jajce_runtime_integration_test.gd | 8 +- .../opportunity_communication_helper_test.gd | 1 + tests/resource_node_player_parity_test.gd | 165 ++++- .../test_activity_action_command_service.gd | 464 ++++++++++++ ...est_activity_action_command_service.gd.uid | 1 + .../test_activity_content_catalog_contract.gd | 148 ++++ ...t_activity_content_catalog_contract.gd.uid | 1 + tests/unit/test_emergent_state_validation.gd | 386 ++++++++++ tests/unit/test_event_social_content.gd | 1 + tests/unit/test_simulation_services.gd | 15 + tests/wood_shortage_opportunity_test.gd | 2 + world/active_world_adapter.gd | 4 +- 36 files changed, 3013 insertions(+), 96 deletions(-) create mode 100644 simulation/commands/ActivityActionCommandService.gd create mode 100644 simulation/commands/ActivityActionCommandService.gd.uid create mode 100644 simulation/definitions/event_types/activity_completed.tres create mode 100644 simulation/events/ActivityEventValidator.gd create mode 100644 simulation/events/ActivityEventValidator.gd.uid create mode 100644 tests/unit/test_activity_action_command_service.gd create mode 100644 tests/unit/test_activity_action_command_service.gd.uid create mode 100644 tests/unit/test_activity_content_catalog_contract.gd create mode 100644 tests/unit/test_activity_content_catalog_contract.gd.uid diff --git a/docs/SIMULATION_DEFINITIONS.md b/docs/SIMULATION_DEFINITIONS.md index 48b1a98..473015c 100644 --- a/docs/SIMULATION_DEFINITIONS.md +++ b/docs/SIMULATION_DEFINITIONS.md @@ -9,7 +9,8 @@ state, systems, world adapters, scenes, tests, and save migrations. IDs. `SimulationContentPack` is the no-code authoring manifest and `ContentCatalog` transactionally validates and publishes typed definitions in stable-ID order. The current core pack contains actions, professions, -capability tags, items, resources, storage policies, and enemies. A failed pack +capability tags, items, resources, storage policies, enemies, animals, +situations, dialogue, event types, and social consequences. A failed pack publishes nothing: duplicate IDs, unknown references, unsupported handlers, or missing presentation cues are authoring errors rather than silent overrides. @@ -57,6 +58,30 @@ are copy-only values. `ActionCommandService` is the authority boundary and must revalidate range, capabilities, reservation, permission, cost, and target state at execution time for either a player or NPC caller. +`activity_metric_delta` is the first concrete shared command handler. Patrol +and study targets publish capabilities through the context-owned +`WorldTargetRegistry`; player offers carry the current state revision, target +generation, context ID, and registry-instance identity. The command revalidates +all four, the live provider, actor availability, target kind, capability, and +interaction range, then atomically consumes the authored cost, applies the +bounded real metric delta, and records one `activity_completed` fact. Registry +identity prevents an old offer from matching a replacement target whose local +generation restarted. NPC completion submits the same command and must match +the NPC's authoritative current action, target, and work state. Unloaded NPC +work may use an explicitly registered simulation-owned activity target, but it +never invents a missing target or treats presentation position as travel +authority. A failed cost check records and returns the exact ordinary +`task_blocked` fact; a capped metric consumes nothing and records no false +completion. The completion fact keeps the first event ID while synchronous +situation or commitment consequences may append a deterministic causal suffix; +the command result reports the final revision rather than misclassifying that +valid event chain as failure. + +The catalog validates this handler as a closed typed contract: an activity +target, exactly one positive `metric_delta` effect for safety or knowledge, +typed optional player/NPC modifiers, a known completion event, and a cost item +with an authored storage route. A malformed additive pack publishes nothing. + The current actions are gather food, gather wood, feed animal, deposit food, deposit wood, withdraw food, patrol, study, eat, rest, sleep, wander, and defend. Idle and dead are state sentinels, not executable definitions. The diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 613afbe..f4b629a 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -3,10 +3,10 @@ ## Current contract `SimulationStateRecord` is the versioned JSON boundary for the current -simulation. The current world schema is v15 and captures: +simulation. The current world schema is v16 and captures: -- simulation seed, tick interval, tick count, clock remainder, and elapsed - clock ticks; +- simulation seed, stable regional world/location scope, tick interval, tick + count, clock remainder, and elapsed clock ticks; - every NPC's identity, needs, attributes, task lifecycle, target, position, starvation state, and decision RNG stream; - village resource counters; @@ -45,7 +45,7 @@ The top-level identity is: ```json { "schema": "the_steward.simulation", - "schema_version": 15 + "schema_version": 16 } ``` @@ -213,6 +213,15 @@ nested v1/v2 events receive empty/zero defaults, while a new `task_blocked` event requires its stable action, source storage, resource, and positive requirement contract. +The v3 event envelope also carries validated extension fields for authored +activity facts without adding a parallel quest record. An +`activity_completed` fact stores the target/action IDs, world and location +scope, metric ID, actual bounded delta, and exact item cost. Loading derives +the expected handler, effect, event type, metric, maximum delta, and cost from +the current action definition and rejects forged actor, discriminator, scope, +or amount combinations even when no knowledge record references the event. +Player and NPC performed knowledge points to that same immutable fact. + SimulationStateRecord v10 adds the top-level `animals` array and the first `AnimalStateRecord` schema. Each animal record stores its stable animal and species IDs, display name, authoritative world position, hunger, @@ -294,6 +303,19 @@ templates, balloon state, and prose are transient. Reopening dialogue plans from current facts plus the retained semantic acts, so wording can evolve without changing authoritative history. +SimulationStateRecord v16 adds non-empty `world_id` and `location_id` fields to +the top-level simulation record. Activity completion and blocked-action facts +retain that same scope in their immutable payload, so event-store indexes, +knowledge validation, and save restoration cannot reinterpret a local position +as belonging to another loaded context. World v15 migrates explicitly to the +existing Bosnia/Jajce IDs. An event log that already contains facts refuses a +different configured scope instead of silently reindexing history. Restore +preflights this scope against the active adapter before changing clock, +economy, NPC, or event state, so both empty- and populated-history mismatches +fail atomically. Older unstructured `task_blocked` facts are retained with +explicit legacy provenance for save continuity, but they are not promoted into +knowable evidence or new situation triggers. + Nested `KnownEventStateRecord` v4 adds the player as a valid knower/source and records hop count, confidence, salience, and explicit pinning. Communicated facts remain exactly one hop from a performed or witnessed source. Nested diff --git a/player/PlayerInteractionResult.gd b/player/PlayerInteractionResult.gd index 82f7376..4847d6b 100644 --- a/player/PlayerInteractionResult.gd +++ b/player/PlayerInteractionResult.gd @@ -5,8 +5,7 @@ const KIND_ANIMAL := &"animal" const KIND_RESOURCE := &"resource" const KIND_PANTRY := &"pantry" const KIND_STORAGE_DEPOSIT := &"storage_deposit" -const KIND_GUARD := &"guard" -const KIND_STUDY := &"study" +const KIND_ACTIVITY := &"activity" const KIND_DIALOGUE := &"dialogue" var kind: StringName @@ -17,6 +16,10 @@ var prompt_text: String var detail_text: String var blocked_reason: String var target_node: Node +var expected_state_revision: int +var target_generation: int +var target_context_id: StringName +var target_registry_instance_id: int func _init( @@ -27,7 +30,7 @@ func _init( interaction_prompt_text: String, interaction_detail_text: String, interaction_target_node: Node, - interaction_blocked_reason := "" + metadata: Dictionary = {} ) -> void: kind = interaction_kind action_id = interaction_action_id @@ -36,7 +39,11 @@ func _init( prompt_text = interaction_prompt_text detail_text = interaction_detail_text target_node = interaction_target_node - blocked_reason = interaction_blocked_reason + blocked_reason = String(metadata.get("blocked_reason", "")) + expected_state_revision = int(metadata.get("expected_state_revision", -1)) + target_generation = int(metadata.get("target_generation", -1)) + target_context_id = StringName(metadata.get("target_context_id", &"")) + target_registry_instance_id = int(metadata.get("target_registry_instance_id", 0)) func is_available() -> bool: @@ -54,6 +61,10 @@ func cache_key() -> String: prompt_text, detail_text, blocked_reason, + str(expected_state_revision), + str(target_generation), + String(target_context_id), + str(target_registry_instance_id), ] ) ) diff --git a/player/player.gd b/player/player.gd index fcd26f6..54a1aae 100644 --- a/player/player.gd +++ b/player/player.gd @@ -117,16 +117,8 @@ func try_interact() -> void: _execute_animal_interaction(context) PlayerInteractionResult.KIND_RESOURCE: _execute_resource_interaction(context) - PlayerInteractionResult.KIND_GUARD: - simulation_manager.add_safety(3.0) - interaction_feedback.emit( - "Village guarded", "Safety increased through real guard-post work.", true - ) - PlayerInteractionResult.KIND_STUDY: - simulation_manager.add_knowledge(2.0) - interaction_feedback.emit( - "Knowledge shared", "Village knowledge increased at the study desk.", true - ) + PlayerInteractionResult.KIND_ACTIVITY: + _execute_activity_interaction(context) PlayerInteractionResult.KIND_PANTRY: _execute_pantry_interaction(context) PlayerInteractionResult.KIND_STORAGE_DEPOSIT: @@ -153,26 +145,34 @@ func get_interaction_context() -> PlayerInteractionResult: var resource := _find_resource_node() if resource != null: return _build_resource_context(resource) - if is_near_activity_site(guard_site): - return PlayerInteractionResult.new( - PlayerInteractionResult.KIND_GUARD, - SimulationIds.ACTION_PATROL, - guard_site.site_id, - guard_site.display_name, - "Help guard the village", - "Work here · +3 safety", - guard_site - ) - if is_near_activity_site(study_site): - return PlayerInteractionResult.new( - PlayerInteractionResult.KIND_STUDY, - SimulationIds.ACTION_STUDY, - study_site.site_id, - study_site.display_name, - "Share knowledge", - "Work here · +2 knowledge", - study_site + if simulation_manager.has_method("get_player_activity_offer"): + var activity_offer: Dictionary = simulation_manager.call( + "get_player_activity_offer", global_position, interaction_range ) + if not activity_offer.is_empty(): + var effect: Dictionary = activity_offer.get("effect", {}) + var amount := float(effect.get("amount", 0.0)) + var metric_name := String(effect.get("metric_id", "benefit")).capitalize() + return ( + PlayerInteractionResult + . new( + PlayerInteractionResult.KIND_ACTIVITY, + StringName(activity_offer["action_id"]), + StringName(activity_offer["target_id"]), + String(activity_offer["display_name"]), + String(activity_offer["action_name"]), + "Work here · +%.1f %s" % [amount, metric_name.to_lower()], + null, + { + "blocked_reason": String(activity_offer.get("blocked_reason", "")), + "expected_state_revision": int(activity_offer.get("state_revision", -1)), + "target_generation": int(activity_offer.get("target_generation", -1)), + "target_context_id": String(activity_offer.get("target_context_id", "")), + "target_registry_instance_id": + int(activity_offer.get("target_registry_instance_id", 0)), + } + ) + ) if is_near_storage(pantry_storage): if _get_carried_food() > 0.0: return _build_deposit_context(SimulationIds.RESOURCE_FOOD, pantry_storage) @@ -429,7 +429,7 @@ func _build_animal_context(node: AnimalNode) -> PlayerInteractionResult: "Feed %s" % node.display_name, detail, node, - blocked_reason + {"blocked_reason": blocked_reason} ) @@ -469,7 +469,7 @@ func _build_pantry_context() -> PlayerInteractionResult: "Eat from the pantry", detail, pantry_storage, - blocked_reason + {"blocked_reason": blocked_reason} ) @@ -545,6 +545,43 @@ func _execute_resource_interaction(context: PlayerInteractionResult) -> void: ) +func _execute_activity_interaction(context: PlayerInteractionResult) -> void: + if not context.is_available(): + interaction_feedback.emit( + "%s unavailable" % context.display_name, context.blocked_reason, false + ) + return + if not simulation_manager.has_method("execute_player_activity_action"): + push_error("Player: SimulationManager cannot execute authored activity actions") + return + if simulation_manager.has_method("update_player_combatant"): + simulation_manager.call("update_player_combatant", global_position) + var result := ( + simulation_manager.call( + "execute_player_activity_action", + context.action_id, + context.target_id, + context.expected_state_revision, + context.target_generation, + context.target_context_id, + context.target_registry_instance_id + ) + as ActionResult + ) + if result == null or not result.did_succeed(): + var reason := result.get_message() if result != null else "Activity service unavailable." + interaction_feedback.emit("%s unavailable" % context.display_name, reason, false) + return + var payload := result.get_payload() + var amount := float(payload.get("amount", 0.0)) + var metric_name := String(payload.get("metric_id", "benefit")).capitalize() + interaction_feedback.emit( + String(context.action_id).capitalize(), + "%s increased by %.1f through authoritative work." % [metric_name, amount], + true + ) + + func _execute_pantry_interaction(context: PlayerInteractionResult) -> void: if not context.is_available(): interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index d2cb66d..a5f5dab 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -86,6 +86,11 @@ var latest_decisions: Dictionary = {} var action_selector := ActionSelectionSystem.new() var action_executor := ActionExecutionSystem.new() var target_resolver := ActionTargetResolver.new() +var activity_command_service: ActivityActionCommandService +var interaction_service: CatalogInteractionService +var _abstract_activity_registry: WorldTargetRegistry +var _abstract_activity_command_service: ActivityActionCommandService +var _active_activity_registry_instance_id := 0 var last_player_resource_query_stats: Dictionary = {} var _population_view := SimulationPopulationView.new() var speed_index := 2 @@ -129,6 +134,7 @@ func _ready() -> void: push_error("SimulationManager: " + error) set_process(false) return + _configure_interaction_services() clock = SimulationClock.new(tick_interval) clock.cycle_duration_seconds = cycle_duration_seconds clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval) @@ -395,11 +401,21 @@ func _complete_current_action(npc: SimNPC) -> void: var completed_task := npc.current_task var definition := SimulationDefinitions.get_action(completed_task) var completion_succeeded := false - if completed_task == SimulationIds.ACTION_FEED_ANIMAL: + var completion_already_applied := false + if _uses_activity_command_handler(definition): + var activity_result := execute_activity_action( + WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))), + completed_task, + npc.target_id, + get_action_state_revision() + ) + completion_succeeded = activity_result != null and activity_result.did_succeed() + completion_already_applied = completion_succeeded + elif completed_task == SimulationIds.ACTION_FEED_ANIMAL: completion_succeeded = feed_animal(npc.target_id, npc.id) else: completion_succeeded = economy.consume_completion_cost(npc, definition) - if completion_succeeded: + if completion_succeeded and not completion_already_applied: _apply_action_completion(npc, completed_task, definition) elif not npc.target_id.is_empty(): release_npc_reservation(npc.id) @@ -436,7 +452,7 @@ func _apply_action_completion( record_narrative_event(SimulationIds.EVENT_HOME_DAMAGED, npc.id) SimulationIds.ACTION_FEED_ANIMAL: pass - SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER: + SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER: village.apply_npc_task(npc) SimulationIds.ACTION_DEFEND: village.apply_metric_delta(&"safety", 0.5) @@ -619,12 +635,24 @@ func resolve_npc_target(npc_id: int, origin: Vector3) -> bool: if active_world_adapter == null: push_error("SimulationManager: active_world_adapter is missing") return false + var definition := SimulationDefinitions.get_action(npc.current_task) + if _uses_activity_command_handler(definition) and not _ensure_interaction_services_current(): + return false var result := target_resolver.resolve(npc, origin, self, active_world_adapter) if result.is_empty(): notify_npc_navigation_failed(npc.id) return false npc.target_id = StringName(result.get("target_id", "")) npc.travel_target_position = result["position"] + if ( + definition != null + and _uses_activity_command_handler(definition) + and not _bind_abstract_activity_target( + npc.target_id, npc.current_task, npc.travel_target_position + ) + ): + notify_npc_navigation_failed(npc.id) + return false npc.has_travel_target = true npc_travel_requested.emit(npc, npc.travel_target_position) return true @@ -661,6 +689,612 @@ func get_activity_target_claim_count(target_id: StringName, except_npc_id: int = return count +func get_action_state_revision() -> int: + return event_log.next_event_id + + +func get_player_activity_offer(from_position: Vector3, max_distance: float) -> Dictionary: + if not _ensure_interaction_services_current(): + return {} + if ( + interaction_service == null + or active_world_adapter == null + or not from_position.is_finite() + or not is_finite(max_distance) + or max_distance <= 0.0 + or not active_world_adapter.has_method("get_target_registry") + ): + return {} + var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry + if registry == null: + return {} + var actor := WorldEntityRef.create( + SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID)) + ) + var candidates: Array[Dictionary] = [] + for descriptor in registry.get_descriptors(ActiveWorldAdapter.TARGET_KIND_ACTIVITY): + var distance := from_position.distance_to(descriptor.get_local_position()) + if distance > max_distance: + continue + var handle := registry.get_handle(descriptor.get_target_id()) + for offer in interaction_service.get_offers(actor, handle): + var definition := SimulationDefinitions.get_action(offer.get_action_id()) + if not _uses_activity_command_handler(definition): + continue + var capability := descriptor.get_capability(offer.get_action_id()) + var raw_range: Variant = capability.get_attribute(&"interaction_range", 0.0) + if raw_range is not int and raw_range is not float: + continue + var capability_range := float(raw_range) + if ( + not is_finite(capability_range) + or capability_range <= 0.0 + or distance > capability_range + ): + continue + ( + candidates + . append( + { + "action_id": String(offer.get_action_id()), + "target_id": String(descriptor.get_target_id()), + "display_name": descriptor.get_display_name(), + "action_name": offer.get_display_name(), + "distance": distance, + "priority": offer.get_priority(), + "state_revision": get_action_state_revision(), + "target_generation": handle.get_generation(), + "target_context_id": String(handle.get_context_id()), + "target_registry_instance_id": registry.get_instance_id(), + "target_kind": String(handle.get_target_kind()), + "effect": _player_activity_effect_summary(definition), + "enabled": offer.is_enabled(), + "blocked_reason": offer.get_rejection_reason(), + "offer_parameters": offer.get_parameters(), + } + ) + ) + if candidates.is_empty(): + return {} + candidates.sort_custom(_activity_offer_precedes) + return candidates[0].duplicate(true) + + +func execute_activity_action( + actor: WorldEntityRef, + action_id: StringName, + target_id: StringName, + expected_state_revision: int = -1 +) -> ActionResult: + if ( + actor == null + or not actor.is_valid() + or actor.get_entity_type() != SimulationIds.ENTITY_PERSON + or expected_state_revision < 0 + ): + return ActionResult.rejected(&"activity_invalid_actor", &"invalid_actor") + return _execute_authorized_activity_action(actor, action_id, target_id, expected_state_revision) + + +func _execute_authorized_activity_action( + actor: WorldEntityRef, + action_id: StringName, + target_id: StringName, + expected_state_revision: int +) -> ActionResult: + if not _ensure_interaction_services_current(): + return ActionResult.rejected(&"activity_scope_mismatch", &"service_unavailable") + var selected_service := activity_command_service + var handle: WorldTargetHandle + if actor.get_entity_type() == SimulationIds.ENTITY_PERSON: + var npc_id_text := String(actor.get_entity_id()) + var npc := _find_npc_by_id(npc_id_text.to_int()) if npc_id_text.is_valid_int() else null + if ( + npc == null + or npc.is_dead + or npc.task_state != SimNPC.TASK_STATE_COMPLETE + or not npc.task_complete + or npc.current_task != action_id + or npc.target_id.is_empty() + or npc.target_id != target_id + ): + return ActionResult.rejected( + &"activity_assignment_mismatch", + ActivityActionCommandService.REASON_UNSUPPORTED_ACTOR + ) + selected_service = _abstract_activity_command_service + handle = _ensure_abstract_activity_handle(action_id, target_id) + elif active_world_adapter != null and active_world_adapter.has_method("get_target_registry"): + var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry + handle = registry.get_handle(target_id) if registry != null else null + if selected_service == null or handle == null: + return ActionResult.rejected(&"activity_unavailable", &"service_unavailable") + var actor_key := actor.index_key() if actor != null else "invalid" + var command_revision := get_action_state_revision() + var command_id := StringName( + ( + "activity_%s_%s_%s_%d_%d" + % [ + actor_key.sha256_text().substr(0, 8), + action_id, + target_id, + tick_count, + command_revision, + ] + ) + ) + return selected_service.submit_command( + ActionCommand.new(command_id, actor, action_id, handle, {}, expected_state_revision) + ) + + +func execute_player_activity_action( + action_id: StringName, + target_id: StringName, + expected_state_revision: int = -1, + expected_target_generation: int = -1, + expected_context_id: StringName = &"", + expected_registry_instance_id: int = 0 +) -> ActionResult: + if not _ensure_interaction_services_current(): + return ActionResult.rejected(&"player_activity_stale", &"service_unavailable") + if ( + expected_state_revision < 0 + or expected_target_generation < 1 + or expected_context_id.is_empty() + or expected_registry_instance_id == 0 + or active_world_adapter == null + or not active_world_adapter.has_method("get_target_registry") + ): + return ActionResult.rejected( + &"player_activity_stale", + ActivityActionCommandService.REASON_STALE_TARGET, + "The activity offer is incomplete; refresh it" + ) + var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry + var current_handle := registry.get_handle(target_id) if registry != null else null + if ( + current_handle == null + or registry.get_instance_id() != expected_registry_instance_id + or current_handle.get_generation() != expected_target_generation + or current_handle.get_context_id() != expected_context_id + or current_handle.get_target_kind() != SimulationIds.TARGET_ACTIVITY + ): + return ActionResult.rejected( + &"player_activity_stale", + ActivityActionCommandService.REASON_STALE_TARGET, + "The activity changed; refresh the interaction offer" + ) + return _execute_authorized_activity_action( + WorldEntityRef.create( + SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID)) + ), + action_id, + target_id, + expected_state_revision + ) + + +func _configure_interaction_services() -> bool: + activity_command_service = null + interaction_service = null + _active_activity_registry_instance_id = 0 + var current_scope := event_log.get_scope() + var scope_world_id := StringName( + current_scope.get("world_id", SimulationIds.REGIONAL_WORLD_BOSNIA) + ) + var scope_location_id := StringName( + current_scope.get("location_id", SimulationIds.REGIONAL_LOCATION_JAJCE) + ) + if active_world_adapter != null and active_world_adapter.has_method("get_context_identity"): + var identity: Dictionary = active_world_adapter.call("get_context_identity") + scope_world_id = StringName(identity.get("world_id", scope_world_id)) + scope_location_id = StringName(identity.get("location_id", scope_location_id)) + if not event_log.configure_scope(scope_world_id, scope_location_id): + return false + _abstract_activity_registry = WorldTargetRegistry.new( + &"simulation_jajce", scope_world_id, scope_location_id + ) + _abstract_activity_command_service = ActivityActionCommandService.new( + _abstract_activity_registry, + SimulationDefinitions.get_action, + _get_activity_actor_position, + get_action_state_revision, + _complete_activity_command, + false, + false + ) + register_abstract_activity_target( + SimulationIds.ACTIVITY_GUARD_POST, SimulationIds.ACTION_PATROL, Vector3.ZERO + ) + register_abstract_activity_target( + SimulationIds.ACTIVITY_STUDY_DESK, SimulationIds.ACTION_STUDY, Vector3.ZERO + ) + _rebuild_abstract_activity_targets_from_npcs() + var catalog := ContentCatalog.create_core() + if not catalog.is_valid(): + return false + if active_world_adapter == null or not active_world_adapter.has_method("get_target_registry"): + return true + var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry + if registry == null: + return false + activity_command_service = ActivityActionCommandService.new( + registry, + SimulationDefinitions.get_action, + _get_activity_actor_position, + get_action_state_revision, + _complete_activity_command, + true + ) + interaction_service = CatalogInteractionService.new( + registry, catalog, _evaluate_activity_availability, true + ) + _active_activity_registry_instance_id = registry.get_instance_id() + return true + + +func register_abstract_activity_target( + target_id: StringName, action_id: StringName, position: Vector3, interaction_range: float = 0.5 +) -> bool: + if ( + _abstract_activity_registry == null + or target_id.is_empty() + or not position.is_finite() + or not is_finite(interaction_range) + or interaction_range <= 0.0 + or _abstract_activity_registry.has_target(target_id) + ): + return false + var definition := SimulationDefinitions.get_action(action_id) + if not _uses_activity_command_handler(definition): + return false + var scope := _abstract_activity_registry.get_identity() + var handle := ( + _abstract_activity_registry + . register_target( + ( + WorldTargetDescriptor + . new( + target_id, + ActiveWorldAdapter.TARGET_KIND_ACTIVITY, + [ + WorldTargetCapability.new( + action_id, {"interaction_range": interaction_range} + ) + ], + position, + definition.display_name, + { + "authority": "simulation", + "world_id": String(scope["world_id"]), + "location_id": String(scope["location_id"]), + } + ) + ) + ) + ) + return handle != null + + +func _bind_abstract_activity_target( + target_id: StringName, action_id: StringName, position: Vector3 +) -> bool: + if _abstract_activity_registry == null or target_id.is_empty() or not position.is_finite(): + return false + var existing := _abstract_activity_registry.get_handle(target_id) + if existing == null: + return register_abstract_activity_target(target_id, action_id, position) + var descriptor := _abstract_activity_registry.resolve_handle(existing) + if ( + descriptor == null + or descriptor.get_target_kind() != SimulationIds.TARGET_ACTIVITY + or not descriptor.has_capability(action_id) + ): + return false + return _abstract_activity_registry.update_target_position(existing, position) + + +func _rebuild_abstract_activity_targets_from_npcs() -> void: + for npc in npcs: + if npc.is_dead or npc.target_id.is_empty(): + continue + var definition := SimulationDefinitions.get_action(npc.current_task) + if not _uses_activity_command_handler(definition): + continue + _bind_abstract_activity_target(npc.target_id, npc.current_task, npc.travel_target_position) + + +func _ensure_interaction_services_current() -> bool: + if active_world_adapter == null or not active_world_adapter.has_method("get_target_registry"): + return _abstract_activity_command_service != null + var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry + if registry == null: + return false + var identity := registry.get_identity() + var event_scope := event_log.get_scope() + if ( + StringName(identity["world_id"]) != StringName(event_scope["world_id"]) + or StringName(identity["location_id"]) != StringName(event_scope["location_id"]) + ): + if not economic_events.is_empty(): + activity_command_service = null + interaction_service = null + return false + if registry.get_instance_id() == _active_activity_registry_instance_id: + return activity_command_service != null and _abstract_activity_command_service != null + return _configure_interaction_services() + + +func _ensure_abstract_activity_handle( + action_id: StringName, requested_target_id: StringName +) -> WorldTargetHandle: + if _abstract_activity_registry == null: + return null + var target_id := requested_target_id + if target_id.is_empty(): + var candidates := _abstract_activity_registry.get_target_ids( + ActiveWorldAdapter.TARGET_KIND_ACTIVITY, action_id + ) + if candidates.size() != 1: + return null + target_id = candidates[0] + var existing := _abstract_activity_registry.get_handle(target_id) + if existing == null: + return null + var descriptor := _abstract_activity_registry.resolve_handle(existing) + if descriptor == null or not descriptor.has_capability(action_id): + return null + return _abstract_activity_registry.get_handle(target_id) + + +func _evaluate_activity_availability( + actor: WorldEntityRef, + _descriptor: WorldTargetDescriptor, + definition: ActionDefinition, + _capability: WorldTargetCapability +) -> InteractionAvailabilityDecision: + if not _uses_activity_command_handler(definition): + return InteractionAvailabilityDecision.denied( + &"unsupported_handler", + "This activity has no supported completion handler", + [&"handler_missing"] + ) + var effect := ActivityActionCommandService._metric_effect(definition) + var actor_id := _activity_actor_id(actor) + if effect == null or actor_id < SimulationIds.PLAYER_ACTOR_ID: + return InteractionAvailabilityDecision.denied( + &"invalid_effect", "This activity has no valid effect", [&"effect_rejected"] + ) + var requested_amount := _activity_effect_amount(actor_id, effect.value, effect.parameters) + if _bounded_activity_effect_amount(effect.subject_key, requested_amount) <= 0.0: + return InteractionAvailabilityDecision.denied( + &"no_effect", "This activity cannot improve the village further", [&"effect_capped"] + ) + if definition.has_completion_cost(): + var storage := economy.get_storage_for_resource(definition.completion_cost_resource_id) + var available := ( + storage.get_amount(definition.completion_cost_resource_id) if storage != null else 0.0 + ) + if available < definition.completion_cost_amount: + return InteractionAvailabilityDecision.denied( + &"insufficient_cost", + ( + "Needs %.0f %s" + % [definition.completion_cost_amount, definition.completion_cost_resource_id] + ), + [&"cost_rejected"], + {"available": available, "required": definition.completion_cost_amount} + ) + return InteractionAvailabilityDecision.allowed([&"cost_allowed"]) + + +func _get_activity_actor_position(actor: WorldEntityRef) -> Variant: + var actor_id := _activity_actor_id(actor) + if actor_id == SimulationIds.PLAYER_ACTOR_ID: + var combatant := conflict_system.get_combatant(ConflictSystemScript.PLAYER_COMBATANT_ID) + return combatant.get_position() if combatant != null else null + var npc := _find_npc_by_id(actor_id) + return npc.position if npc != null and not npc.is_dead else null + + +func _complete_activity_command( + actor: WorldEntityRef, + action_id: StringName, + target_id: StringName, + metric_id: StringName, + base_amount: float, + effect_parameters: Dictionary, + world_position: Vector3 +) -> Dictionary: + var actor_id := _activity_actor_id(actor) + var definition := SimulationDefinitions.get_action(action_id) + if actor_id < SimulationIds.PLAYER_ACTOR_ID or not _uses_activity_command_handler(definition): + return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()} + var requested_amount := _activity_effect_amount(actor_id, base_amount, effect_parameters) + var applied_amount := _bounded_activity_effect_amount(metric_id, requested_amount) + if not is_finite(applied_amount) or applied_amount <= 0.0: + return { + "succeeded": false, + "reason_code": "no_effect", + "message": "This activity cannot improve the village further", + "event_id": -1, + "state_revision": get_action_state_revision(), + } + var cost_storage: StorageStateRecord + var cost_item_id: StringName + var cost_amount := 0.0 + if definition.has_completion_cost(): + cost_item_id = definition.completion_cost_resource_id + cost_amount = definition.completion_cost_amount + cost_storage = economy.get_storage_for_resource(cost_item_id) + var available := cost_storage.get_amount(cost_item_id) if cost_storage != null else 0.0 + if available < cost_amount: + var blocked := event_log.record_activity_blocked( + tick_count, + actor_id, + target_id, + action_id, + ( + "%s: needs %.0f %s" + % [definition.display_name, cost_amount, String(cost_item_id).capitalize()] + ), + world_position, + cost_storage.get_storage_id() if cost_storage != null else &"", + cost_item_id, + cost_amount + ) + return { + "succeeded": false, + "reason_code": "insufficient_cost", + "message": "Needs %.0f %s" % [cost_amount, cost_item_id], + "event_id": int(blocked.data["event_id"]) if blocked != null else -1, + "state_revision": get_action_state_revision(), + } + var previous_metric := village.safety if metric_id == &"safety" else village.knowledge + if cost_storage != null and cost_storage.withdraw(cost_item_id, cost_amount) < cost_amount: + return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()} + if cost_storage != null: + economy.sync_resource(cost_item_id) + village.apply_metric_delta(metric_id, applied_amount) + var actual_amount := ( + village.safety - previous_metric + if metric_id == &"safety" + else village.knowledge - previous_metric + ) + if not is_finite(actual_amount) or actual_amount <= 0.0: + if cost_storage != null: + cost_storage.deposit(cost_item_id, cost_amount) + economy.sync_resource(cost_item_id) + _restore_village_metric(metric_id, previous_metric) + return { + "succeeded": false, + "reason_code": "no_effect", + "message": "This activity cannot improve the village further", + "event_id": -1, + "state_revision": get_action_state_revision(), + } + var event := event_log.record_activity_completed( + tick_count, + actor_id, + target_id, + action_id, + definition.event_type_id, + metric_id, + actual_amount, + world_position, + cost_item_id, + cost_amount + ) + if event == null: + if cost_storage != null: + cost_storage.deposit(cost_item_id, cost_amount) + economy.sync_resource(cost_item_id) + _restore_village_metric(metric_id, previous_metric) + return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()} + village_changed.emit(village) + return { + "succeeded": true, + "event_id": int(event.data["event_id"]), + "state_revision": get_action_state_revision(), + "applied_amount": actual_amount, + } + + +func _activity_actor_id(actor: WorldEntityRef) -> int: + if actor == null or not actor.is_valid(): + return SimulationIds.PLAYER_ACTOR_ID - 1 + var id_text := String(actor.get_entity_id()) + if not id_text.is_valid_int(): + return SimulationIds.PLAYER_ACTOR_ID - 1 + var actor_id := id_text.to_int() + if actor.get_entity_type() == SimulationIds.ENTITY_PLAYER: + return ( + actor_id + if ( + actor_id == SimulationIds.PLAYER_ACTOR_ID + and player_system.player_state != null + and not player_system.player_state.is_downed() + ) + else -2 + ) + if actor.get_entity_type() != SimulationIds.ENTITY_PERSON or actor_id < 0: + return -2 + var npc := _find_npc_by_id(actor_id) + return actor_id if npc != null and not npc.is_dead else -2 + + +func _activity_effect_amount(actor_id: int, base_amount: float, parameters: Dictionary) -> float: + var result := base_amount + if actor_id == SimulationIds.PLAYER_ACTOR_ID: + var raw_multiplier: Variant = parameters.get("player_multiplier", 1.0) + if raw_multiplier is int or raw_multiplier is float: + result *= float(raw_multiplier) + return result + if bool(parameters.get("npc_productivity", false)): + var npc := _find_npc_by_id(actor_id) + if npc == null: + return 0.0 + if npc.is_starving: + result *= 0.35 + elif npc.energy < 20.0: + result *= 0.25 + elif npc.energy < 40.0: + result *= 0.5 + return result + + +func _player_activity_effect_summary(definition: ActionDefinition) -> Dictionary: + if definition == null: + return {} + var effect := ActivityActionCommandService._metric_effect(definition) + if effect == null: + return {} + var requested_amount := _activity_effect_amount( + SimulationIds.PLAYER_ACTOR_ID, effect.value, effect.parameters + ) + return { + "metric_id": String(effect.subject_key), + "amount": _bounded_activity_effect_amount(effect.subject_key, requested_amount), + } + + +func _bounded_activity_effect_amount(metric_id: StringName, requested_amount: float) -> float: + if not is_finite(requested_amount) or requested_amount <= 0.0: + return 0.0 + if metric_id == &"safety": + return minf(requested_amount, maxf(100.0 - village.safety, 0.0)) + if metric_id == &"knowledge": + return requested_amount + return 0.0 + + +func _restore_village_metric(metric_id: StringName, value: float) -> void: + if metric_id == &"safety": + village.safety = value + elif metric_id == &"knowledge": + village.knowledge = value + village.update_modifiers() + village.update_priorities() + + +static func _uses_activity_command_handler(definition: ActionDefinition) -> bool: + return ( + definition != null + and definition.completion_handler_id == ActivityActionCommandService.HANDLER_METRIC_DELTA + ) + + +static func _activity_offer_precedes(first: Dictionary, second: Dictionary) -> bool: + if not is_equal_approx(float(first["distance"]), float(second["distance"])): + return float(first["distance"]) < float(second["distance"]) + if float(first["priority"]) != float(second["priority"]): + return float(first["priority"]) > float(second["priority"]) + if String(first["target_id"]) != String(second["target_id"]): + return String(first["target_id"]) < String(second["target_id"]) + return String(first["action_id"]) < String(second["action_id"]) + + func _notify_mourning(dead_npc: SimNPC) -> void: var mourner := relationship_system.get_most_familiar_living(dead_npc.id, npcs, _population_view) if mourner == null: @@ -1653,6 +2287,7 @@ func get_latest_decision(npc_id: int) -> ActionSelectionResult: func create_state_record() -> SimulationStateRecord: var record := SimulationStateRecord.new() + var event_scope := event_log.get_scope() var wander_streams: Array[Dictionary] = [] var sorted_npc_ids: Array = wander_random_sources.keys() sorted_npc_ids.sort() @@ -1674,6 +2309,8 @@ func create_state_record() -> SimulationStateRecord: "next_journal_entry_id": quest_journal_system.get_next_entry_id(), "next_commitment_id": commitment_system.get_next_commitment_id(), "next_conversation_act_id": _next_conversation_act_id, + "world_id": String(event_scope["world_id"]), + "location_id": String(event_scope["location_id"]), "cycle_duration_seconds": clock.cycle_duration_seconds } record.village = VillageStateRecord.capture(village) @@ -1724,7 +2361,7 @@ func restore_state_from_json(json_text: String) -> bool: func restore_state(record: SimulationStateRecord) -> bool: - if record == null: + if record == null or not _saved_scope_matches_active_adapter(record): return false simulation_seed = int(record.simulation["seed"]) tick_interval = float(record.simulation["tick_interval"]) @@ -1740,12 +2377,20 @@ func restore_state(record: SimulationStateRecord) -> bool: # Rebuild them before the next decision so save/load cannot change outcomes. village.update_modifiers() village.update_priorities() - event_log.restore(record.economic_events, int(record.simulation["next_event_id"])) + if not event_log.restore( + record.economic_events, + int(record.simulation["next_event_id"]), + StringName(record.simulation["world_id"]), + StringName(record.simulation["location_id"]) + ): + return false latest_decisions.clear() npcs.clear() for npc_record in record.npcs: npcs.append(npc_record.restore(debug_logs)) refresh_population_index() + if not _configure_interaction_services(): + return false relationship_system.restore(record.relationships) event_knowledge_system.restore(record.event_knowledge) opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"])) @@ -1798,3 +2443,19 @@ func restore_state(record: SimulationStateRecord) -> bool: village_changed.emit(village) state_restored.emit() return true + + +func _saved_scope_matches_active_adapter(record: SimulationStateRecord) -> bool: + if active_world_adapter == null or not active_world_adapter.has_method("get_context_identity"): + return true + var identity: Dictionary = active_world_adapter.call("get_context_identity") + return ( + ( + StringName(identity.get("world_id", &"")) + == StringName(record.simulation.get("world_id", &"")) + ) + and ( + StringName(identity.get("location_id", &"")) + == StringName(record.simulation.get("location_id", &"")) + ) + ) diff --git a/simulation/commands/ActionResult.gd b/simulation/commands/ActionResult.gd index f4e2548..472fe77 100644 --- a/simulation/commands/ActionResult.gd +++ b/simulation/commands/ActionResult.gd @@ -73,9 +73,9 @@ static func accepted(command_id: StringName, payload: Dictionary = {}) -> Action static func rejected( - command_id: StringName, reason_code: StringName, message: String = "" + command_id: StringName, reason_code: StringName, message: String = "", payload: Dictionary = {} ) -> ActionResult: - return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message) + return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message, payload) static func completed(command_id: StringName, payload: Dictionary = {}) -> ActionResult: diff --git a/simulation/commands/ActivityActionCommandService.gd b/simulation/commands/ActivityActionCommandService.gd new file mode 100644 index 0000000..0486796 --- /dev/null +++ b/simulation/commands/ActivityActionCommandService.gd @@ -0,0 +1,265 @@ +class_name ActivityActionCommandService +extends ActionCommandService + +const HANDLER_METRIC_DELTA := SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA +const ACTOR_KIND_PLAYER := SimulationIds.ENTITY_PLAYER +const ACTOR_KIND_PERSON := SimulationIds.ENTITY_PERSON +const REASON_INVALID_COMMAND := &"invalid_command" +const REASON_STALE_TARGET := &"stale_target" +const REASON_STALE_REVISION := &"stale_revision" +const REASON_UNSUPPORTED_ACTION := &"unsupported_action" +const REASON_UNSUPPORTED_ACTOR := &"unsupported_actor" +const REASON_TARGET_CAPABILITY := &"target_capability_missing" +const REASON_OUT_OF_RANGE := &"out_of_range" +const REASON_INVALID_EFFECT := &"invalid_effect" +const REASON_COMPLETION_FAILED := &"completion_failed" + +var _registry: WorldTargetRegistry +var _definition_lookup: Callable +var _actor_position_lookup: Callable +var _state_revision_lookup: Callable +var _completion_handler: Callable +var _effect_planner := ActionEffectPlanner.new() +var _require_target_provider := false +var _enforce_spatial_range := true + + +func _init( + registry: WorldTargetRegistry = null, + definition_lookup: Callable = Callable(), + actor_position_lookup: Callable = Callable(), + state_revision_lookup: Callable = Callable(), + completion_handler: Callable = Callable(), + require_target_provider: bool = false, + enforce_spatial_range: bool = true +) -> void: + _registry = registry + _definition_lookup = definition_lookup + _actor_position_lookup = actor_position_lookup + _state_revision_lookup = state_revision_lookup + _completion_handler = completion_handler + _require_target_provider = require_target_provider + _enforce_spatial_range = enforce_spatial_range + + +func try_execute(command: ActionCommand) -> ActionResult: + var result := _execute(command) + command_resolved.emit(result) + return result + + +func _execute(command: ActionCommand) -> ActionResult: + if command == null or not command.is_valid() or not _services_are_valid(): + return _reject(command, REASON_INVALID_COMMAND, "Command or service wiring is invalid") + var definition := _definition_lookup.call(command.get_action_id()) as ActionDefinition + if ( + definition == null + or definition.target_type != SimulationIds.TARGET_ACTIVITY + or definition.completion_handler_id != HANDLER_METRIC_DELTA + ): + return _reject(command, REASON_UNSUPPORTED_ACTION, "Action has no activity metric handler") + var actor := command.get_actor() + if actor.get_entity_type() not in [ACTOR_KIND_PLAYER, ACTOR_KIND_PERSON]: + return _reject(command, REASON_UNSUPPORTED_ACTOR, "Actor kind cannot perform activity work") + var effect_plan := _effect_planner.plan(definition, actor) + var metric_operation := _metric_operation(effect_plan) + if metric_operation.is_empty(): + return _reject(command, REASON_INVALID_EFFECT, "Action metric effect plan is invalid") + var handle := command.get_target() + if not _registry.is_handle_valid(handle, _require_target_provider): + return _reject(command, REASON_STALE_TARGET, "Activity target is no longer available") + var descriptor := _registry.resolve_handle(handle) + if ( + handle.get_target_kind() != SimulationIds.TARGET_ACTIVITY + or descriptor == null + or descriptor.get_target_kind() != SimulationIds.TARGET_ACTIVITY + or not descriptor.has_capability(command.get_action_id()) + ): + return _reject(command, REASON_TARGET_CAPABILITY, "Activity target lacks the action") + var current_revision := int(_state_revision_lookup.call()) + if ( + command.get_expected_state_revision() >= 0 + and command.get_expected_state_revision() != current_revision + ): + return _reject(command, REASON_STALE_REVISION, "Authoritative state revision changed") + var capability := descriptor.get_capability(command.get_action_id()) + var actor_position: Variant = _actor_position_lookup.call(actor) + if actor_position is not Vector3 or not actor_position.is_finite(): + return _reject(command, REASON_UNSUPPORTED_ACTOR, "Actor has no valid world position") + if _enforce_spatial_range: + var raw_maximum_range: Variant = capability.get_attribute(&"interaction_range", 0.0) + if raw_maximum_range is not int and raw_maximum_range is not float: + return _reject(command, REASON_INVALID_EFFECT, "Activity range metadata is invalid") + var maximum_range := float(raw_maximum_range) + if ( + not is_finite(maximum_range) + or maximum_range <= 0.0 + or actor_position.distance_to(descriptor.get_local_position()) > maximum_range + ): + return _reject(command, REASON_OUT_OF_RANGE, "Actor is outside the activity range") + var effect_payload: Dictionary = metric_operation["payload"] + var metric_id := StringName(effect_payload["subject_key"]) + var amount := float(effect_payload["delta"]) + var completion: Variant = _completion_handler.call( + actor, + command.get_action_id(), + descriptor.get_target_id(), + metric_id, + amount, + (effect_payload["parameters"] as Dictionary).duplicate(true), + actor_position + ) + if not _completion_shape_is_valid(completion): + return ActionResult.failed( + command.get_command_id(), + REASON_COMPLETION_FAILED, + "Authoritative activity completion returned an invalid result" + ) + var completion_result := completion as Dictionary + if not bool(completion_result["succeeded"]): + var failure_reason := StringName(completion_result.get("reason_code", "")) + var failure_message := String(completion_result.get("message", "")) + if failure_reason.is_empty() or failure_message.is_empty(): + return ActionResult.failed( + command.get_command_id(), + REASON_COMPLETION_FAILED, + "Authoritative activity completion was rejected" + ) + var failure_event_id := int(completion_result["event_id"]) + var failure_revision := int(completion_result["state_revision"]) + var failure_payload := {} + if failure_event_id >= 0: + if failure_event_id != current_revision or failure_revision < current_revision + 1: + return ActionResult.failed( + command.get_command_id(), + REASON_COMPLETION_FAILED, + "Authoritative failure fact did not advance state" + ) + failure_payload = { + "event_id": failure_event_id, + "state_revision": failure_revision, + "reason_trace": ["command_rejected", "authoritative_failure_fact_recorded"], + } + elif failure_revision != current_revision: + return ActionResult.failed( + command.get_command_id(), + REASON_COMPLETION_FAILED, + "Rejected completion mutated state without a failure fact" + ) + return ActionResult.rejected( + command.get_command_id(), failure_reason, failure_message, failure_payload + ) + var event_id := int(completion_result["event_id"]) + var resulting_revision := int(completion_result["state_revision"]) + var applied_amount := float(completion_result.get("applied_amount", amount)) + if ( + event_id != current_revision + or resulting_revision < current_revision + 1 + or not is_finite(applied_amount) + ): + return ActionResult.failed( + command.get_command_id(), + REASON_COMPLETION_FAILED, + "Authoritative activity completion did not advance state" + ) + var spatial_trace := ( + "range_revalidated" if _enforce_spatial_range else "abstract_completion_authorized" + ) + return ( + ActionResult + . completed( + command.get_command_id(), + { + "action_id": String(command.get_action_id()), + "target_id": String(descriptor.get_target_id()), + "metric_id": String(metric_id), + "amount": applied_amount, + "event_id": event_id, + "state_revision": resulting_revision, + "reason_trace": + [ + "command_shape_valid", + "actor_authorized", + "target_handle_live", + "target_capability_matched", + "revision_matched", + spatial_trace, + "authoritative_metric_mutated", + "completion_fact_recorded", + "causal_followup_facts_committed", + ], + } + ) + ) + + +func _services_are_valid() -> bool: + return ( + _registry != null + and _definition_lookup.is_valid() + and _actor_position_lookup.is_valid() + and _state_revision_lookup.is_valid() + and _completion_handler.is_valid() + ) + + +static func _metric_effect(definition: ActionDefinition) -> ActionEffect: + if definition == null or definition.effects.size() != 1: + return null + var selected: ActionEffect + for resource in definition.effects: + var effect := resource as ActionEffect + if effect == null or effect.effect_kind != ActionEffect.KIND_METRIC_DELTA: + continue + if selected != null: + return null + selected = effect + if selected == null: + return null + if ( + selected.subject_key not in [&"safety", &"knowledge"] + or not is_finite(selected.value) + or selected.value <= 0.0 + ): + return null + for raw_key in selected.parameters: + if String(raw_key) not in ["player_multiplier", "npc_productivity"]: + return null + var player_multiplier: Variant = selected.parameters.get("player_multiplier", 1.0) + if ( + (player_multiplier is not int and player_multiplier is not float) + or not is_finite(float(player_multiplier)) + or float(player_multiplier) <= 0.0 + ): + return null + if selected.parameters.get("npc_productivity", false) is not bool: + return null + return selected + + +static func _metric_operation(plan: ActionEffectPlan) -> Dictionary: + if plan == null or not plan.is_valid() or plan.get_operation_count() != 1: + return {} + var operation: Dictionary = plan.get_operations()[0] + if StringName(operation.get("operation_kind", &"")) != ActionEffect.KIND_METRIC_DELTA: + return {} + return operation.duplicate(true) + + +static func _completion_shape_is_valid(completion: Variant) -> bool: + if completion is not Dictionary: + return false + var result := completion as Dictionary + if not result.has_all(["succeeded", "event_id", "state_revision"]): + return false + return ( + result["succeeded"] is bool + and result["event_id"] is int + and result["state_revision"] is int + and not WorldTargetCapability._contains_object(result) + ) + + +static func _reject(command: ActionCommand, reason: StringName, message: String) -> ActionResult: + var command_id := command.get_command_id() if command != null else &"invalid_command" + return ActionResult.rejected(command_id, reason, message) diff --git a/simulation/commands/ActivityActionCommandService.gd.uid b/simulation/commands/ActivityActionCommandService.gd.uid new file mode 100644 index 0000000..74759ba --- /dev/null +++ b/simulation/commands/ActivityActionCommandService.gd.uid @@ -0,0 +1 @@ +uid://ce4eqg4gbqcf8 diff --git a/simulation/commands/CatalogInteractionService.gd b/simulation/commands/CatalogInteractionService.gd index 6724540..51bf6a0 100644 --- a/simulation/commands/CatalogInteractionService.gd +++ b/simulation/commands/CatalogInteractionService.gd @@ -17,16 +17,19 @@ var _registry: WorldTargetRegistry var _catalog: ContentCatalog var _availability_evaluator: Callable var _last_query_reason_trace: Array[StringName] = [] +var _require_target_provider := false func _init( registry: WorldTargetRegistry = null, catalog: ContentCatalog = null, - availability_evaluator: Callable = Callable() + availability_evaluator: Callable = Callable(), + require_target_provider: bool = false ) -> void: _registry = registry _catalog = catalog _availability_evaluator = availability_evaluator + _require_target_provider = require_target_provider func is_configured() -> bool: @@ -49,7 +52,7 @@ func get_offers(actor: WorldEntityRef, target: WorldTargetHandle) -> Array[Actio if not _actor_is_supported(actor): _last_query_reason_trace.append(REASON_ACTOR_INVALID) return [] - if not _registry.is_handle_valid(target): + if not _registry.is_handle_valid(target, _require_target_provider): _last_query_reason_trace.append(REASON_TARGET_STALE) return [] var descriptor := _registry.resolve_handle(target) diff --git a/simulation/definitions/ContentCatalog.gd b/simulation/definitions/ContentCatalog.gd index 158ab18..4f1e4f6 100644 --- a/simulation/definitions/ContentCatalog.gd +++ b/simulation/definitions/ContentCatalog.gd @@ -1,5 +1,6 @@ class_name ContentCatalog extends RefCounted +# gdlint: disable=max-file-lines const CORE_PACK_PATH := "res://simulation/definitions/packs/core.tres" const DEFAULT_SITUATION_EVENT_PREDICATE_IDS: Array[StringName] = [ @@ -11,6 +12,7 @@ const DEFAULT_SITUATION_STATE_PREDICATE_IDS: Array[StringName] = [ const DEFAULT_SITUATION_EXPIRY_PREDICATE_IDS: Array[StringName] = [ &"state_number_gt", &"age_reached" ] +const BUILTIN_HANDLER_IDS: Array[StringName] = [SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA] var _valid := false var _errors: Array[String] = [] @@ -81,11 +83,14 @@ func rebuild( var pack_list := content_packs.duplicate() pack_list.sort_custom(_sort_packs) var supported_handlers := {} + for handler_id in BUILTIN_HANDLER_IDS: + supported_handlers[handler_id] = true for handler_id in supported_handler_ids: if handler_id.is_empty(): errors.append("Supported handler ID is empty") elif supported_handlers.has(handler_id): - errors.append("Duplicate supported handler '%s'" % handler_id) + if handler_id not in BUILTIN_HANDLER_IDS: + errors.append("Duplicate supported handler '%s'" % handler_id) else: supported_handlers[handler_id] = true var cue_ids := ( @@ -202,7 +207,9 @@ func rebuild( actions_by_id, professions_by_id, items_by_id, + storages_by_id, capability_tags_by_id, + event_types_by_id, supported_handlers, supported_cues, errors @@ -670,7 +677,9 @@ static func _validate_action_references( actions_by_id: Dictionary, professions_by_id: Dictionary, items_by_id: Dictionary, + storages_by_id: Dictionary, capability_tags_by_id: Dictionary, + event_types_by_id: Dictionary, supported_handlers: Dictionary, supported_cues: Dictionary, errors: Array[String] @@ -696,6 +705,16 @@ static func _validate_action_references( supported_cues, errors ) + if ( + not definition.event_type_id.is_empty() + and not event_types_by_id.has(definition.event_type_id) + ): + errors.append( + ( + "Action '%s' references unknown event type '%s'" + % [definition.action_id, definition.event_type_id] + ) + ) for handler_field in [ ["selection policy", definition.selection_policy_id], ["target policy", definition.target_policy_id], @@ -769,6 +788,64 @@ static func _validate_action_references( % [definition.action_id, scheduled_action_id] ) ) + _validate_builtin_action_handler_contract(definition, items_by_id, storages_by_id, errors) + + +static func _validate_builtin_action_handler_contract( + definition: ActionDefinition, + items_by_id: Dictionary, + storages_by_id: Dictionary, + errors: Array[String] +) -> void: + if definition.completion_handler_id != SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA: + return + var label := "Action '%s' activity_metric_delta" % definition.action_id + if definition.target_type != SimulationIds.TARGET_ACTIVITY: + errors.append("%s handler requires an activity target" % label) + if definition.effects.size() != 1: + errors.append("%s handler requires exactly one effect" % label) + return + var effect := definition.effects[0] as ActionEffect + if effect == null or effect.effect_kind != ActionEffect.KIND_METRIC_DELTA: + errors.append("%s handler requires one metric_delta effect" % label) + return + if effect.subject_key not in [&"safety", &"knowledge"]: + errors.append("%s metric must be safety or knowledge" % label) + if not is_finite(effect.value) or effect.value <= 0.0: + errors.append("%s effect value must be finite and positive" % label) + var allowed_parameters := {"player_multiplier": true, "npc_productivity": true} + for raw_key in effect.parameters: + var key := String(raw_key) + if not allowed_parameters.has(key): + errors.append("%s has unsupported effect parameter '%s'" % [label, key]) + var player_multiplier: Variant = effect.parameters.get("player_multiplier", 1.0) + if player_multiplier is not int and player_multiplier is not float: + errors.append("%s player_multiplier must be numeric" % label) + elif not is_finite(float(player_multiplier)) or float(player_multiplier) <= 0.0: + errors.append("%s player_multiplier must be finite and positive" % label) + var npc_productivity: Variant = effect.parameters.get("npc_productivity", false) + if npc_productivity is not bool: + errors.append("%s npc_productivity must be a bool" % label) + if definition.event_type_id.is_empty(): + errors.append("%s handler requires a nonempty event_type_id" % label) + if not definition.has_completion_cost(): + return + var cost_item := items_by_id.get(definition.completion_cost_resource_id) as ItemDefinition + if cost_item == null: + return + var has_storage_route := false + for storage_value in storages_by_id.values(): + var storage := storage_value as StorageDefinition + if storage != null and storage.accepts_item(cost_item): + has_storage_route = true + break + if not has_storage_route: + errors.append( + ( + "%s completion cost item '%s' has no authored storage route" + % [label, definition.completion_cost_resource_id] + ) + ) static func _validate_item_references( diff --git a/simulation/definitions/SimulationDefinitions.gd b/simulation/definitions/SimulationDefinitions.gd index d32c47d..a090a6b 100644 --- a/simulation/definitions/SimulationDefinitions.gd +++ b/simulation/definitions/SimulationDefinitions.gd @@ -28,6 +28,7 @@ const PROFESSION_PATHS := [ static var _action_list: Array[ActionDefinition] = [] static var _profession_list: Array[ProfessionDefinition] = [] static var _action_map: Dictionary = {} +static var _action_event_type_ids: Dictionary = {} static var _profession_map: Dictionary = {} static var _profession_id_list: Array[StringName] = [] static var _catalog_errors: Array[String] = [] @@ -40,6 +41,7 @@ static func _ensure_cache() -> void: _action_list = [] _profession_list = [] _action_map = {} + _action_event_type_ids = {} _profession_map = {} _profession_id_list = [] _catalog_errors = [] @@ -52,6 +54,8 @@ static func _ensure_cache() -> void: _profession_list = catalog.get_professions() for definition in _action_list: _action_map[definition.action_id] = definition + if not definition.event_type_id.is_empty(): + _action_event_type_ids[definition.event_type_id] = true for definition in _profession_list: _profession_map[definition.profession_id] = definition _profession_id_list.append(definition.profession_id) @@ -78,6 +82,11 @@ static func get_action(action_id: StringName) -> ActionDefinition: return _action_map.get(action_id) +static func has_action_event_type(event_type_id: StringName) -> bool: + _ensure_cache() + return _action_event_type_ids.has(event_type_id) + + static func get_profession(profession_id: StringName) -> ProfessionDefinition: _ensure_cache() return _profession_map.get(profession_id) diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 7006bbe..fe41c20 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -6,6 +6,13 @@ const PLAYER_INVENTORY_ID := &"player_inventory" const WORLD_CORE := &"world_core" const LOCATION_JAJCE := &"settlement_jajce" +const REGIONAL_WORLD_BOSNIA := &"regional_bosnia" +const REGIONAL_LOCATION_JAJCE := &"location_jajce" +const ACTIVITY_GUARD_POST := &"guard_post" +const ACTIVITY_STUDY_DESK := &"study_desk" +const ACTION_HANDLER_ACTIVITY_METRIC_DELTA := &"activity_metric_delta" +const BLOCKED_CONTRACT_ACTION_COST := &"action_cost" +const BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED := &"legacy_unstructured" const ENTITY_PERSON := &"person" const ENTITY_PLAYER := &"player" @@ -72,6 +79,7 @@ const EVENT_COMMITMENT_FULFILLED := &"commitment_fulfilled" const EVENT_COMMITMENT_BROKEN := &"commitment_broken" const EVENT_COMMITMENT_RELEASED := &"commitment_released" const EVENT_COMMITMENT_SUPERSEDED := &"commitment_superseded" +const EVENT_ACTIVITY_COMPLETED := &"activity_completed" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" diff --git a/simulation/definitions/actions/patrol.tres b/simulation/definitions/actions/patrol.tres index 6c94702..520cfa2 100644 --- a/simulation/definitions/actions/patrol.tres +++ b/simulation/definitions/actions/patrol.tres @@ -1,6 +1,15 @@ -[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3] +[gd_resource type="Resource" script_class="ActionDefinition" load_steps=3 format=3] [ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"] +[ext_resource type="Script" path="res://simulation/commands/ActionEffect.gd" id="2"] + +[sub_resource type="Resource" id="Effect_patrol_safety"] +script = ExtResource("2") +effect_id = &"patrol_safety" +effect_kind = &"metric_delta" +subject_key = &"safety" +value = 1.0 +parameters = {"npc_productivity": true, "player_multiplier": 3.0} [resource] script = ExtResource("1") @@ -11,3 +20,6 @@ preferred_profession_id = &"guard" target_type = &"activity" completion_cost_resource_id = &"wood" completion_cost_amount = 1.0 +completion_handler_id = &"activity_metric_delta" +effects = [SubResource("Effect_patrol_safety")] +event_type_id = &"activity_completed" diff --git a/simulation/definitions/actions/study.tres b/simulation/definitions/actions/study.tres index bce600a..c923bf3 100644 --- a/simulation/definitions/actions/study.tres +++ b/simulation/definitions/actions/study.tres @@ -1,6 +1,15 @@ -[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3] +[gd_resource type="Resource" script_class="ActionDefinition" load_steps=3 format=3] [ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"] +[ext_resource type="Script" path="res://simulation/commands/ActionEffect.gd" id="2"] + +[sub_resource type="Resource" id="Effect_study_knowledge"] +script = ExtResource("2") +effect_id = &"study_knowledge" +effect_kind = &"metric_delta" +subject_key = &"knowledge" +value = 1.0 +parameters = {"npc_productivity": true, "player_multiplier": 2.0} [resource] script = ExtResource("1") @@ -11,3 +20,6 @@ preferred_profession_id = &"scholar" target_type = &"activity" completion_cost_resource_id = &"wood" completion_cost_amount = 1.0 +completion_handler_id = &"activity_metric_delta" +effects = [SubResource("Effect_study_knowledge")] +event_type_id = &"activity_completed" diff --git a/simulation/definitions/event_types/activity_completed.tres b/simulation/definitions/event_types/activity_completed.tres new file mode 100644 index 0000000..c96a0c9 --- /dev/null +++ b/simulation/definitions/event_types/activity_completed.tres @@ -0,0 +1,10 @@ +[gd_resource type="Resource" script_class="EventTypeDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/EventTypeDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +event_type_id = &"activity_completed" +display_name = "Activity Completed" +description = "An actor completed a world-backed activity and changed authoritative state." +category = "activity" diff --git a/simulation/definitions/packs/core.tres b/simulation/definitions/packs/core.tres index 1d5d78b..d26d5b8 100644 --- a/simulation/definitions/packs/core.tres +++ b/simulation/definitions/packs/core.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=93 format=3] +[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=94 format=3] [ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"] [ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"] @@ -81,6 +81,7 @@ [ext_resource type="Resource" path="res://simulation/definitions/event_types/war_resolved.tres" id="79_war_resolved"] [ext_resource type="Resource" path="res://simulation/definitions/event_types/war_aborted.tres" id="80_war_aborted"] [ext_resource type="Resource" path="res://simulation/definitions/event_types/wolf_hunt.tres" id="81_wolf_hunt"] +[ext_resource type="Resource" path="res://simulation/definitions/event_types/activity_completed.tres" id="92_activity_completed"] [ext_resource type="Resource" path="res://simulation/definitions/social_consequences/storage_food_aid_trust.tres" id="82_storage_food_aid"] [ext_resource type="Resource" path="res://simulation/definitions/social_consequences/player_food_aid_trust.tres" id="83_player_food_aid"] [ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_accepted_obligation.tres" id="84_accepted_obligation"] @@ -96,6 +97,7 @@ script = ExtResource("1_pack") pack_id = &"core" display_name = "The Steward Core" +required_handler_ids = [&"activity_metric_delta"] actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")] professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")] capability_tags = [ExtResource("28_item_food"), ExtResource("29_item_medicine"), ExtResource("30_item_wood"), ExtResource("31_resource_harvestable"), ExtResource("32_storage_village")] @@ -107,5 +109,5 @@ animals = [ExtResource("39_goat"), ExtResource("40_sheep")] situations = [ExtResource("41_pantry_situation")] dialogue_intents = [ExtResource("42_accept"), ExtResource("43_acknowledge"), ExtResource("44_wellbeing"), ExtResource("45_happened"), ExtResource("46_who"), ExtResource("47_work"), ExtResource("48_decline"), ExtResource("49_need"), ExtResource("50_goodbye"), ExtResource("51_greet"), ExtResource("52_offer"), ExtResource("53_renegotiate"), ExtResource("54_progress"), ExtResource("55_reproach"), ExtResource("56_share"), ExtResource("57_thank")] dialogue_template_catalogs = [ExtResource("58_templates")] -event_types = [ExtResource("59_resource_extracted"), ExtResource("60_storage_deposited"), ExtResource("61_storage_withdrawn"), ExtResource("62_item_consumed"), ExtResource("63_npc_slept"), ExtResource("64_npc_died"), ExtResource("65_task_started"), ExtResource("66_task_blocked"), ExtResource("67_resource_depleted"), ExtResource("68_animal_fed"), ExtResource("69_villager_weak"), ExtResource("70_home_damaged"), ExtResource("71_commitment_accepted"), ExtResource("72_commitment_fulfilled"), ExtResource("73_commitment_broken"), ExtResource("74_commitment_released"), ExtResource("75_commitment_superseded"), ExtResource("76_combatant_hurt"), ExtResource("77_combatant_killed"), ExtResource("78_raid_started"), ExtResource("79_war_resolved"), ExtResource("80_war_aborted"), ExtResource("81_wolf_hunt")] +event_types = [ExtResource("59_resource_extracted"), ExtResource("60_storage_deposited"), ExtResource("61_storage_withdrawn"), ExtResource("62_item_consumed"), ExtResource("63_npc_slept"), ExtResource("64_npc_died"), ExtResource("65_task_started"), ExtResource("66_task_blocked"), ExtResource("67_resource_depleted"), ExtResource("68_animal_fed"), ExtResource("69_villager_weak"), ExtResource("70_home_damaged"), ExtResource("71_commitment_accepted"), ExtResource("72_commitment_fulfilled"), ExtResource("73_commitment_broken"), ExtResource("74_commitment_released"), ExtResource("75_commitment_superseded"), ExtResource("76_combatant_hurt"), ExtResource("77_combatant_killed"), ExtResource("78_raid_started"), ExtResource("79_war_resolved"), ExtResource("80_war_aborted"), ExtResource("81_wolf_hunt"), ExtResource("92_activity_completed")] social_consequences = [ExtResource("82_storage_food_aid"), ExtResource("83_player_food_aid"), ExtResource("84_accepted_obligation"), ExtResource("85_fulfilled_obligation"), ExtResource("86_fulfilled_trust"), ExtResource("87_broken_obligation"), ExtResource("88_broken_trust"), ExtResource("89_broken_hostility"), ExtResource("90_released_obligation"), ExtResource("91_superseded_obligation")] diff --git a/simulation/economy/VillageEconomy.gd b/simulation/economy/VillageEconomy.gd index 3bec035..d73d1fe 100644 --- a/simulation/economy/VillageEconomy.gd +++ b/simulation/economy/VillageEconomy.gd @@ -22,26 +22,43 @@ signal narrative_event_requested( var village: SimVillage var storage_states: Dictionary = {} var debug_logs := false +var content_catalog: ContentCatalog +var storage_routing := StorageRoutingPolicy.new() +var _storage_id_by_item_id: Dictionary = {} -func configure(village_state: SimVillage, should_debug: bool) -> void: +func configure( + village_state: SimVillage, should_debug: bool, configured_catalog: ContentCatalog = null +) -> void: village = village_state debug_logs = should_debug + content_catalog = ( + configured_catalog if configured_catalog != null else ContentCatalog.create_core() + ) + storage_routing = StorageRoutingPolicy.new(content_catalog) + _storage_id_by_item_id.clear() func initialize_storage() -> void: if village == null: return - if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): - storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = StorageStateRecord.create( - SimulationIds.STORAGE_VILLAGE_PANTRY, - {String(SimulationIds.RESOURCE_FOOD): village.food} - ) - if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE): - storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = StorageStateRecord.create( - SimulationIds.STORAGE_VILLAGE_WOODPILE, - {String(SimulationIds.RESOURCE_WOOD): village.wood} - ) + if content_catalog != null and content_catalog.is_valid(): + for definition in content_catalog.get_storages(): + if storage_states.has(definition.storage_id): + continue + var initial_amounts := {} + if definition.storage_id == SimulationIds.STORAGE_VILLAGE_PANTRY: + initial_amounts[String(SimulationIds.RESOURCE_FOOD)] = village.food + elif definition.storage_id == SimulationIds.STORAGE_VILLAGE_WOODPILE: + initial_amounts[String(SimulationIds.RESOURCE_WOOD)] = village.wood + var initial_total := 0.0 + for amount in initial_amounts.values(): + initial_total += float(amount) + storage_states[definition.storage_id] = StorageStateRecord.create( + definition.storage_id, initial_amounts, maxf(definition.capacity, initial_total) + ) + _ensure_legacy_village_storage() + _rebuild_storage_route_cache() sync_all() @@ -67,12 +84,42 @@ func get_woodpile() -> StorageStateRecord: func get_storage_for_resource(resource_id: StringName) -> StorageStateRecord: - match resource_id: - SimulationIds.RESOURCE_FOOD: - return get_pantry() - SimulationIds.RESOURCE_WOOD: - return get_woodpile() - return null + var storage_id := StringName(_storage_id_by_item_id.get(resource_id, &"")) + return get_storage(storage_id) if not storage_id.is_empty() else null + + +func _ensure_legacy_village_storage() -> void: + if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): + storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_PANTRY, + {String(SimulationIds.RESOURCE_FOOD): village.food}, + maxf(StorageStateRecord.DEFAULT_CAPACITY, village.food) + ) + if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE): + storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_WOODPILE, + {String(SimulationIds.RESOURCE_WOOD): village.wood}, + maxf(StorageStateRecord.DEFAULT_CAPACITY, village.wood) + ) + + +func _rebuild_storage_route_cache() -> void: + _storage_id_by_item_id.clear() + if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): + _storage_id_by_item_id[SimulationIds.RESOURCE_FOOD] = (SimulationIds.STORAGE_VILLAGE_PANTRY) + if storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE): + _storage_id_by_item_id[SimulationIds.RESOURCE_WOOD] = ( + SimulationIds.STORAGE_VILLAGE_WOODPILE + ) + if content_catalog == null or not content_catalog.is_valid(): + return + var available_ids: Array[StringName] = [] + for storage_key in storage_states: + available_ids.append(StringName(storage_key)) + for item in content_catalog.get_items(): + var definition := storage_routing.resolve_definition(item.item_id, available_ids) + if definition != null: + _storage_id_by_item_id[item.item_id] = definition.storage_id func sync_all() -> void: diff --git a/simulation/events/ActivityEventValidator.gd b/simulation/events/ActivityEventValidator.gd new file mode 100644 index 0000000..357427f --- /dev/null +++ b/simulation/events/ActivityEventValidator.gd @@ -0,0 +1,293 @@ +class_name ActivityEventValidator +extends RefCounted + +static var _core_catalog: ContentCatalog +static var _core_storage_routing: StorageRoutingPolicy + + +static func is_candidate(event: EconomicEventRecord) -> bool: + if event == null: + return false + if StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED: + return false + if StringName(event.data.get("target_kind", &"")) == SimulationIds.TARGET_ACTIVITY: + return true + var event_type := StringName(event.data.get("event_type", &"")) + return SimulationDefinitions.has_action_event_type(event_type) + + +static func is_valid( + event: EconomicEventRecord, + expected_world_id: StringName = &"", + expected_location_id: StringName = &"" +) -> bool: + if event == null or not _has_activity_fields(event): + return false + var actor_id := int(event.data["actor_id"]) + var action_id := StringName(event.data["action_id"]) + var definition := SimulationDefinitions.get_action(action_id) + var effect := ActivityActionCommandService._metric_effect(definition) + if ( + not KnownEventStateRecord.is_valid_actor_id(actor_id) + or definition == null + or effect == null + or definition.target_type != SimulationIds.TARGET_ACTIVITY + or definition.completion_handler_id != ActivityActionCommandService.HANDLER_METRIC_DELTA + or StringName(event.data["event_type"]) != definition.event_type_id + or StringName(event.data["source_id"]).is_empty() + or not StringName(event.data["destination_id"]).is_empty() + or StringName(event.data["target_kind"]) != SimulationIds.TARGET_ACTIVITY + or StringName(event.data["target_action_id"]) != action_id + or StringName(event.data["world_id"]).is_empty() + or StringName(event.data["location_id"]).is_empty() + or ( + not expected_world_id.is_empty() + and StringName(event.data["world_id"]) != expected_world_id + ) + or ( + not expected_location_id.is_empty() + and StringName(event.data["location_id"]) != expected_location_id + ) + ): + return false + var metric_delta_value: Variant = event.data["metric_delta"] + var cost_amount_value: Variant = event.data["cost_amount"] + if ( + (metric_delta_value is not int and metric_delta_value is not float) + or (cost_amount_value is not int and cost_amount_value is not float) + ): + return false + var metric_delta := float(metric_delta_value) + var cost_amount := float(cost_amount_value) + var maximum_delta := _maximum_delta(effect, actor_id) + return ( + is_finite(metric_delta) + and metric_delta > 0.0 + and is_finite(maximum_delta) + and maximum_delta > 0.0 + and (metric_delta < maximum_delta or is_equal_approx(metric_delta, maximum_delta)) + and is_finite(cost_amount) + and StringName(event.data["metric_id"]) == effect.subject_key + and StringName(event.data["cost_item_id"]) == definition.completion_cost_resource_id + and StringName(event.data["item_id"]) == definition.completion_cost_resource_id + and is_equal_approx(cost_amount, definition.completion_cost_amount) + and is_equal_approx(float(event.data["required_amount"]), definition.completion_cost_amount) + ) + + +static func is_valid_for_state( + event: EconomicEventRecord, + npc_ids: Dictionary, + current_tick: int, + expected_world_id: StringName = &"", + expected_location_id: StringName = &"" +) -> bool: + if ( + not is_valid(event, expected_world_id, expected_location_id) + or int(event.data["tick"]) > current_tick + ): + return false + var actor_id := int(event.data["actor_id"]) + return actor_id == SimulationIds.PLAYER_ACTOR_ID or npc_ids.has(actor_id) + + +static func is_blocked_candidate(event: EconomicEventRecord) -> bool: + return ( + event != null + and StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED + ) + + +static func is_valid_blocked( + event: EconomicEventRecord, + expected_world_id: StringName = &"", + expected_location_id: StringName = &"" +) -> bool: + if event == null or not _has_blocked_fields(event): + return false + if is_legacy_unstructured_blocked(event): + return _is_valid_legacy_unstructured_blocked(event, expected_world_id, expected_location_id) + var actor_id := int(event.data["actor_id"]) + var action_id := StringName(event.data["action_id"]) + var definition := SimulationDefinitions.get_action(action_id) + if ( + StringName(event.data["event_type"]) != SimulationIds.EVENT_TASK_BLOCKED + or not KnownEventStateRecord.is_valid_actor_id(actor_id) + or definition == null + or not definition.has_completion_cost() + or ( + StringName(event.data["blocked_contract_kind"]) + != SimulationIds.BLOCKED_CONTRACT_ACTION_COST + ) + or StringName(event.data["item_id"]) != definition.completion_cost_resource_id + or not is_equal_approx( + float(event.data["required_amount"]), definition.completion_cost_amount + ) + or StringName(event.data["source_id"]).is_empty() + or not StringName(event.data["destination_id"]).is_empty() + or not is_zero_approx(float(event.data["amount"])) + or StringName(event.data["world_id"]).is_empty() + or StringName(event.data["location_id"]).is_empty() + or ( + not expected_world_id.is_empty() + and StringName(event.data["world_id"]) != expected_world_id + ) + or ( + not expected_location_id.is_empty() + and StringName(event.data["location_id"]) != expected_location_id + ) + ): + return false + var route := _get_core_storage_routing().resolve_definition( + definition.completion_cost_resource_id + ) + if route == null or not _blocked_source_matches(event, action_id, actor_id, route.storage_id): + return false + if event.data.has("activity_target_id"): + return ( + definition.target_type == SimulationIds.TARGET_ACTIVITY + and ( + definition.completion_handler_id + == SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA + ) + and not StringName(event.data["activity_target_id"]).is_empty() + and StringName(event.data.get("target_kind", &"")) == SimulationIds.TARGET_ACTIVITY + and StringName(event.data.get("target_action_id", &"")) == action_id + ) + return true + + +static func _blocked_source_matches( + event: EconomicEventRecord, action_id: StringName, actor_id: int, routed_storage_id: StringName +) -> bool: + var source_id := StringName(event.data["source_id"]) + if source_id == routed_storage_id: + return true + return ( + action_id == SimulationIds.ACTION_FEED_ANIMAL + and actor_id >= 0 + and source_id == SimulationIds.npc_inventory_id(actor_id) + ) + + +static func is_valid_blocked_for_state( + event: EconomicEventRecord, + npc_ids: Dictionary, + storage_records_by_id: Dictionary, + current_tick: int, + expected_world_id: StringName = &"", + expected_location_id: StringName = &"" +) -> bool: + if ( + not is_valid_blocked(event, expected_world_id, expected_location_id) + or int(event.data["tick"]) > current_tick + ): + return false + var actor_id := int(event.data["actor_id"]) + if actor_id != SimulationIds.PLAYER_ACTOR_ID and not npc_ids.has(actor_id): + return false + if is_legacy_unstructured_blocked(event): + return true + var source_id := StringName(event.data["source_id"]) + return ( + storage_records_by_id.has(source_id) + or ( + StringName(event.data["action_id"]) == SimulationIds.ACTION_FEED_ANIMAL + and actor_id >= 0 + and source_id == SimulationIds.npc_inventory_id(actor_id) + ) + ) + + +static func is_legacy_unstructured_blocked(event: EconomicEventRecord) -> bool: + return ( + event != null + and StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED + and ( + StringName(event.data.get("blocked_contract_kind", &"")) + == SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED + ) + ) + + +static func _is_valid_legacy_unstructured_blocked( + event: EconomicEventRecord, expected_world_id: StringName, expected_location_id: StringName +) -> bool: + var legacy_version := int(event.data.get("legacy_world_schema_version", -1)) + return ( + ( + legacy_version + in range( + EconomicEventRecord.LEGACY_BLOCKED_WORLD_SCHEMA_MIN, + EconomicEventRecord.LEGACY_BLOCKED_WORLD_SCHEMA_MAX + 1 + ) + ) + and KnownEventStateRecord.is_valid_actor_id(int(event.data["actor_id"])) + and StringName(event.data["action_id"]).is_empty() + and StringName(event.data["item_id"]).is_empty() + and is_zero_approx(float(event.data["required_amount"])) + and StringName(event.data["destination_id"]).is_empty() + and is_zero_approx(float(event.data["amount"])) + and not StringName(event.data["world_id"]).is_empty() + and not StringName(event.data["location_id"]).is_empty() + and ( + expected_world_id.is_empty() or StringName(event.data["world_id"]) == expected_world_id + ) + and ( + expected_location_id.is_empty() + or StringName(event.data["location_id"]) == expected_location_id + ) + ) + + +static func _has_activity_fields(event: EconomicEventRecord) -> bool: + return ( + event + . data + . has_all( + [ + "metric_id", + "metric_delta", + "cost_item_id", + "cost_amount", + "target_kind", + "target_action_id", + "world_id", + "location_id", + ] + ) + ) + + +static func _has_blocked_fields(event: EconomicEventRecord) -> bool: + return ( + event + . data + . has_all( + [ + "blocked_contract_kind", + "world_id", + "location_id", + "action_id", + "item_id", + "required_amount", + ] + ) + ) + + +static func _maximum_delta(effect: ActionEffect, actor_id: int) -> float: + var maximum_delta := effect.value + if actor_id != SimulationIds.PLAYER_ACTOR_ID: + return maximum_delta + var raw_multiplier: Variant = effect.parameters.get("player_multiplier", 1.0) + if raw_multiplier is not int and raw_multiplier is not float: + return -1.0 + return maximum_delta * float(raw_multiplier) + + +static func _get_core_storage_routing() -> StorageRoutingPolicy: + if _core_storage_routing == null: + _core_catalog = ContentCatalog.create_core() + _core_storage_routing = StorageRoutingPolicy.new(_core_catalog) + return _core_storage_routing diff --git a/simulation/events/ActivityEventValidator.gd.uid b/simulation/events/ActivityEventValidator.gd.uid new file mode 100644 index 0000000..4b08767 --- /dev/null +++ b/simulation/events/ActivityEventValidator.gd.uid @@ -0,0 +1 @@ +uid://spr5p8ebsyc1 diff --git a/simulation/events/SimulationEventLog.gd b/simulation/events/SimulationEventLog.gd index ec50a92..c4ae6d1 100644 --- a/simulation/events/SimulationEventLog.gd +++ b/simulation/events/SimulationEventLog.gd @@ -28,6 +28,23 @@ var _events_by_id: Dictionary = {} var _event_ids_by_actor: Dictionary = {} var _event_ids_by_type: Dictionary = {} var _event_ids_by_tick: Dictionary = {} +var _world_id := SimulationIds.REGIONAL_WORLD_BOSNIA +var _location_id := SimulationIds.REGIONAL_LOCATION_JAJCE + + +func configure_scope(world_id: StringName, location_id: StringName) -> bool: + if world_id.is_empty() or location_id.is_empty(): + return false + if not events.is_empty(): + return world_id == _world_id and location_id == _location_id + _world_id = world_id + _location_id = location_id + world_events = WorldEventStore.new() + return true + + +func get_scope() -> Dictionary: + return {"world_id": _world_id, "location_id": _location_id} func record_economic( @@ -80,6 +97,14 @@ func record_narrative( item_id, required_amount ) + if event_type == SimulationIds.EVENT_TASK_BLOCKED: + event.data["blocked_contract_kind"] = String(SimulationIds.BLOCKED_CONTRACT_ACTION_COST) + event.data["world_id"] = String(_world_id) + event.data["location_id"] = String(_location_id) + var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary()) + if normalized == null: + return null + event = normalized _append(event) return event @@ -115,11 +140,105 @@ func record_fact( var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary()) if normalized == null: return null + if WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null: + return null + _append(normalized) + return normalized + + +func record_activity_completed( + tick: int, + actor_id: int, + target_id: StringName, + action_id: StringName, + event_type_id: StringName, + metric_id: StringName, + metric_delta: float, + world_position: Vector3 = Vector3.ZERO, + cost_item_id: StringName = &"", + cost_amount: float = 0.0 +) -> EconomicEventRecord: if ( - WorldEventRecord.from_economic_event( - normalized, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE - ) - == null + tick < 0 + or actor_id < SimulationIds.PLAYER_ACTOR_ID + or target_id.is_empty() + or action_id.is_empty() + or event_type_id.is_empty() + or metric_id not in [&"safety", &"knowledge"] + or not is_finite(metric_delta) + or metric_delta <= 0.0 + or not world_position.is_finite() + or not is_finite(cost_amount) + or cost_amount < 0.0 + or (cost_item_id.is_empty() and cost_amount > 0.0) + or (not cost_item_id.is_empty() and cost_amount <= 0.0) + ): + return null + var event := EconomicEventRecord.create_narrative( + next_event_id, + event_type_id, + tick, + actor_id, + target_id, + "", + world_position, + action_id, + cost_item_id, + cost_amount + ) + event.data["metric_id"] = String(metric_id) + event.data["metric_delta"] = metric_delta + event.data["cost_item_id"] = String(cost_item_id) + event.data["cost_amount"] = cost_amount + event.data["target_kind"] = String(SimulationIds.TARGET_ACTIVITY) + event.data["target_action_id"] = String(action_id) + event.data["world_id"] = String(_world_id) + event.data["location_id"] = String(_location_id) + var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary()) + if ( + normalized == null + or (WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null) + ): + return null + _append(normalized) + return normalized + + +func record_activity_blocked( + tick: int, + actor_id: int, + target_id: StringName, + action_id: StringName, + action_display: String, + world_position: Vector3, + cost_storage_id: StringName, + cost_item_id: StringName, + cost_amount: float +) -> EconomicEventRecord: + var event := EconomicEventRecord.create_narrative( + next_event_id, + SimulationIds.EVENT_TASK_BLOCKED, + tick, + actor_id, + cost_storage_id, + action_display, + world_position, + action_id, + cost_item_id, + cost_amount + ) + if target_id.is_empty(): + return null + event.data["blocked_contract_kind"] = String(SimulationIds.BLOCKED_CONTRACT_ACTION_COST) + event.data["world_id"] = String(_world_id) + event.data["location_id"] = String(_location_id) + event.data["activity_target_id"] = String(target_id) + event.data["target_kind"] = String(SimulationIds.TARGET_ACTIVITY) + event.data["target_action_id"] = String(action_id) + var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary()) + if ( + normalized == null + or WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null ): return null _append(normalized) @@ -207,16 +326,27 @@ func get_consumption_rates( } -func restore(restored_events: Array[EconomicEventRecord], restored_next_id: int) -> void: +func restore( + restored_events: Array[EconomicEventRecord], + restored_next_id: int, + restored_world_id: StringName = &"", + restored_location_id: StringName = &"" +) -> bool: + if restored_world_id.is_empty() != restored_location_id.is_empty(): + return false events.clear() _events_by_id.clear() _event_ids_by_actor.clear() _event_ids_by_type.clear() _event_ids_by_tick.clear() + if not restored_world_id.is_empty(): + _world_id = restored_world_id + _location_id = restored_location_id world_events = WorldEventStore.new() for event in restored_events: _index(event) next_event_id = restored_next_id + return true func _append(event: EconomicEventRecord) -> void: @@ -236,7 +366,13 @@ func _index(event: EconomicEventRecord) -> void: _append_index(_event_ids_by_actor, int(event.data["actor_id"]), event_id) _append_index(_event_ids_by_type, StringName(event.data["event_type"]), event_id) _append_index(_event_ids_by_tick, int(event.data["tick"]), event_id) - world_events.append_economic(event, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE) + var event_world_id := StringName(event.data.get("world_id", _world_id)) + var event_location_id := StringName(event.data.get("location_id", _location_id)) + if event_world_id.is_empty(): + event_world_id = _world_id + if event_location_id.is_empty(): + event_location_id = _location_id + world_events.append_economic(event, event_world_id, event_location_id) static func _append_index(index: Dictionary, key: Variant, event_id: int) -> void: diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index 668f7eb..0a870d9 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -336,15 +336,11 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool: ) if event_type == SimulationIds.EVENT_TASK_BLOCKED: return ( - int(event.data["actor_id"]) >= 0 - and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE - and item_id == SimulationIds.RESOURCE_WOOD - and ( - StringName(event.data.get("action_id", &"")) - in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY] - ) - and float(event.data.get("required_amount", 0.0)) > 0.0 + not ActivityEventValidator.is_legacy_unstructured_blocked(event) + and ActivityEventValidator.is_valid_blocked(event) ) + if ActivityEventValidator.is_candidate(event): + return ActivityEventValidator.is_valid(event) if ( event_type in [ diff --git a/simulation/state/EconomicEventRecord.gd b/simulation/state/EconomicEventRecord.gd index 556927c..9f48d1b 100644 --- a/simulation/state/EconomicEventRecord.gd +++ b/simulation/state/EconomicEventRecord.gd @@ -4,6 +4,8 @@ extends RefCounted const SCHEMA_VERSION := 3 const POSITION_LEGACY_SCHEMA_VERSION := 2 const LEGACY_SCHEMA_VERSION := 1 +const LEGACY_BLOCKED_WORLD_SCHEMA_MIN := 3 +const LEGACY_BLOCKED_WORLD_SCHEMA_MAX := 15 var data: Dictionary @@ -117,7 +119,17 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord: and not normalized["item_id"].is_empty() and normalized["required_amount"] > 0.0 ) - if version == SCHEMA_VERSION and not has_stable_contract: + var has_legacy_marker := ( + ( + StringName(normalized.get("blocked_contract_kind", &"")) + == SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED + ) + and ( + int(normalized.get("legacy_world_schema_version", -1)) + in range(LEGACY_BLOCKED_WORLD_SCHEMA_MIN, LEGACY_BLOCKED_WORLD_SCHEMA_MAX + 1) + ) + ) + if version == SCHEMA_VERSION and not has_stable_contract and not has_legacy_marker: return null return EconomicEventRecord.new(normalized) @@ -160,5 +172,16 @@ func description(npc_names: Dictionary = {}) -> String: return "%s was depleted" % source "animal_fed": return "%s fed %s %.0f %s" % [actor_name, destination, amount, item] + "activity_completed": + return ( + "%s completed %s at %s (+%.1f %s)" + % [ + actor_name, + String(data.get("action_id", "activity")).capitalize(), + source, + float(data.get("metric_delta", 0.0)), + String(data.get("metric_id", "benefit")), + ] + ) _: return "tick %d: %s" % [int(data["tick"]), event_type] diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 7f9e6a1..e2039a7 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -1,8 +1,9 @@ class_name SimulationStateRecord extends RefCounted +# gdlint: disable=max-file-lines const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 15 +const SCHEMA_VERSION := 16 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 @@ -16,6 +17,7 @@ const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11 const PLAYER_NEEDS_SCHEMA_VERSION := 12 const CONFLICT_SCHEMA_VERSION := 13 const PRE_EMERGENT_SCHEMA_VERSION := 14 +const EMERGENT_SCHEMA_VERSION := 15 const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary @@ -137,6 +139,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: PLAYER_NEEDS_SCHEMA_VERSION, CONFLICT_SCHEMA_VERSION, PRE_EMERGENT_SCHEMA_VERSION, + EMERGENT_SCHEMA_VERSION, ] ): record_data = _migrate_legacy(record_data, version) @@ -187,6 +190,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "next_journal_entry_id", "next_commitment_id", "next_conversation_act_id", + "world_id", + "location_id", ] ) ): @@ -200,6 +205,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var saved_next_journal_entry_id := int(simulation_data["next_journal_entry_id"]) var saved_next_commitment_id := int(simulation_data["next_commitment_id"]) var saved_next_conversation_act_id := int(simulation_data["next_conversation_act_id"]) + var saved_world_id := StringName(simulation_data["world_id"]) + var saved_location_id := StringName(simulation_data["location_id"]) if ( not is_finite(saved_tick_interval) or saved_tick_interval <= 0.0 @@ -212,6 +219,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: or saved_next_journal_entry_id < 0 or saved_next_commitment_id < 0 or saved_next_conversation_act_id < 0 + or saved_world_id.is_empty() + or saved_location_id.is_empty() ): return null if simulation_data.has("cycle_duration_seconds"): @@ -447,6 +456,25 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: int(latest_feed_ticks.get(fed_animal_id, AnimalStateRecord.NEVER_FED_TICK)), int(event_record.data["tick"]) ) + elif ActivityEventValidator.is_candidate(event_record): + if not ActivityEventValidator.is_valid_for_state( + event_record, + npc_ids, + int(record.simulation["tick_count"]), + saved_world_id, + saved_location_id + ): + return null + elif ActivityEventValidator.is_blocked_candidate(event_record): + if not ActivityEventValidator.is_valid_blocked_for_state( + event_record, + npc_ids, + storage_records_by_id, + int(record.simulation["tick_count"]), + saved_world_id, + saved_location_id + ): + return null record.economic_events.append(event_record) if int(record.simulation["next_event_id"]) <= highest_event_id: return null @@ -932,6 +960,13 @@ static func _is_valid_supply_resolution( static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary: var migrated := legacy_data.duplicate(true) migrated["schema_version"] = SCHEMA_VERSION + if version == EMERGENT_SCHEMA_VERSION: + var scoped_simulation_data: Dictionary = migrated.get("simulation", {}) + scoped_simulation_data["world_id"] = String(SimulationIds.REGIONAL_WORLD_BOSNIA) + scoped_simulation_data["location_id"] = String(SimulationIds.REGIONAL_LOCATION_JAJCE) + migrated["simulation"] = scoped_simulation_data + _migrate_blocked_event_scope(migrated, scoped_simulation_data, version) + return migrated migrated["situations"] = [] migrated["quest_journal"] = [] migrated["commitments"] = [] @@ -941,7 +976,10 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary emergent_simulation_data["next_journal_entry_id"] = 0 emergent_simulation_data["next_commitment_id"] = 0 emergent_simulation_data["next_conversation_act_id"] = 0 + emergent_simulation_data["world_id"] = String(SimulationIds.REGIONAL_WORLD_BOSNIA) + emergent_simulation_data["location_id"] = String(SimulationIds.REGIONAL_LOCATION_JAJCE) migrated["simulation"] = emergent_simulation_data + _migrate_blocked_event_scope(migrated, emergent_simulation_data, version) if not migrated.has("player"): migrated["player"] = PlayerStateRecord.create_default().to_dictionary() if version < CONFLICT_SCHEMA_VERSION: @@ -1031,6 +1069,39 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary return migrated +static func _migrate_blocked_event_scope( + migrated: Dictionary, simulation_data: Dictionary, source_world_schema_version: int +) -> void: + var migrated_events: Variant = migrated.get("economic_events", []) + if migrated_events is not Array: + return + for event_value in migrated_events: + if ( + event_value is Dictionary + and StringName(event_value.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED + ): + var event := event_value as Dictionary + var raw_required_amount: Variant = event.get("required_amount", 0.0) + var has_stable_contract := ( + not String(event.get("action_id", "")).is_empty() + and not String(event.get("item_id", "")).is_empty() + and (raw_required_amount is int or raw_required_amount is float) + and is_finite(float(raw_required_amount)) + and float(raw_required_amount) > 0.0 + ) + event["blocked_contract_kind"] = String( + ( + SimulationIds.BLOCKED_CONTRACT_ACTION_COST + if has_stable_contract + else SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED + ) + ) + if not has_stable_contract: + event["legacy_world_schema_version"] = source_world_schema_version + event["world_id"] = String(simulation_data["world_id"]) + event["location_id"] = String(simulation_data["location_id"]) + + static func _migrate_npc_familiarity(npc_data: Variant) -> Array[Dictionary]: var migrated_relationships: Array[Dictionary] = [] if not npc_data is Array: diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index 1471c0e..fb6ec1d 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -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() diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index cf016a9..55306b5 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -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() diff --git a/tests/opportunity_communication_helper_test.gd b/tests/opportunity_communication_helper_test.gd index 35bd0e7..d6882a2 100644 --- a/tests/opportunity_communication_helper_test.gd +++ b/tests/opportunity_communication_helper_test.gd @@ -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() diff --git a/tests/resource_node_player_parity_test.gd b/tests/resource_node_player_parity_test.gd index de1c32a..5a92337 100644 --- a/tests/resource_node_player_parity_test.gd +++ b/tests/resource_node_player_parity_test.gd @@ -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(): diff --git a/tests/unit/test_activity_action_command_service.gd b/tests/unit/test_activity_action_command_service.gd new file mode 100644 index 0000000..905df48 --- /dev/null +++ b/tests/unit/test_activity_action_command_service.gd @@ -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), + } diff --git a/tests/unit/test_activity_action_command_service.gd.uid b/tests/unit/test_activity_action_command_service.gd.uid new file mode 100644 index 0000000..59ee63f --- /dev/null +++ b/tests/unit/test_activity_action_command_service.gd.uid @@ -0,0 +1 @@ +uid://b3o3qhh73no24 diff --git a/tests/unit/test_activity_content_catalog_contract.gd b/tests/unit/test_activity_content_catalog_contract.gd new file mode 100644 index 0000000..852fa42 --- /dev/null +++ b/tests/unit/test_activity_content_catalog_contract.gd @@ -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 diff --git a/tests/unit/test_activity_content_catalog_contract.gd.uid b/tests/unit/test_activity_content_catalog_contract.gd.uid new file mode 100644 index 0000000..13ef26e --- /dev/null +++ b/tests/unit/test_activity_content_catalog_contract.gd.uid @@ -0,0 +1 @@ +uid://m58cgatc3ub diff --git a/tests/unit/test_emergent_state_validation.gd b/tests/unit/test_emergent_state_validation.gd index ae822f7..7c25d33 100644 --- a/tests/unit/test_emergent_state_validation.gd +++ b/tests/unit/test_emergent_state_validation.gd @@ -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 diff --git a/tests/unit/test_event_social_content.gd b/tests/unit/test_event_social_content.gd index c5fbeed..22a8b8f 100644 --- a/tests/unit/test_event_social_content.gd +++ b/tests/unit/test_event_social_content.gd @@ -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, diff --git a/tests/unit/test_simulation_services.gd b/tests/unit/test_simulation_services.gd index c2cfec2..700b5f5 100644 --- a/tests/unit/test_simulation_services.gd +++ b/tests/unit/test_simulation_services.gd @@ -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() diff --git a/tests/wood_shortage_opportunity_test.gd b/tests/wood_shortage_opportunity_test.gd index cdb67b8..3434a8c 100644 --- a/tests/wood_shortage_opportunity_test.gd +++ b/tests/wood_shortage_opportunity_test.gd @@ -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") diff --git a/world/active_world_adapter.gd b/world/active_world_adapter.gd index b0871c3..83ddd89 100644 --- a/world/active_world_adapter.gd +++ b/world/active_world_adapter.gd @@ -6,8 +6,8 @@ const LoadedResourceSpatialIndexScript := preload( ) const DEFAULT_CONTEXT_ID := &"jajce_interior" -const DEFAULT_WORLD_ID := &"regional_bosnia" -const DEFAULT_LOCATION_ID := &"location_jajce" +const DEFAULT_WORLD_ID := SimulationIds.REGIONAL_WORLD_BOSNIA +const DEFAULT_LOCATION_ID := SimulationIds.REGIONAL_LOCATION_JAJCE const TARGET_KIND_RESOURCE := &"resource" const TARGET_KIND_STORAGE := &"storage" const TARGET_KIND_ACTIVITY := &"activity"