From 4673f0698da4a9230c4fbda19c38fdaa943e5998 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 5 Jul 2026 14:17:45 +0200 Subject: [PATCH] refactor: separate action system responsibilities --- main.tscn | 19 +- simulation/SimNPC.gd | 173 --------------- simulation/SimulationManager.gd | 78 ++++--- simulation/actions/ActionExecutionSystem.gd | 32 +++ .../actions/ActionExecutionSystem.gd.uid | 1 + simulation/actions/ActionSelectionResult.gd | 12 ++ .../actions/ActionSelectionResult.gd.uid | 1 + simulation/actions/ActionSelectionSystem.gd | 81 +++++++ .../actions/ActionSelectionSystem.gd.uid | 1 + simulation/actions/ActionTargetResolver.gd | 60 ++++++ .../actions/ActionTargetResolver.gd.uid | 1 + tests/action_system_boundaries_test.gd | 124 +++++++++++ tests/action_system_boundaries_test.gd.uid | 1 + tests/flat_map_baseline_test.gd | 7 +- tests/jajce_world_scaffold_test.gd | 18 +- world/active_world_adapter.gd | 33 +++ world/active_world_adapter.gd.uid | 1 + world/world_view_manager.gd | 198 +++--------------- 18 files changed, 458 insertions(+), 383 deletions(-) create mode 100644 simulation/actions/ActionExecutionSystem.gd create mode 100644 simulation/actions/ActionExecutionSystem.gd.uid create mode 100644 simulation/actions/ActionSelectionResult.gd create mode 100644 simulation/actions/ActionSelectionResult.gd.uid create mode 100644 simulation/actions/ActionSelectionSystem.gd create mode 100644 simulation/actions/ActionSelectionSystem.gd.uid create mode 100644 simulation/actions/ActionTargetResolver.gd create mode 100644 simulation/actions/ActionTargetResolver.gd.uid create mode 100644 tests/action_system_boundaries_test.gd create mode 100644 tests/action_system_boundaries_test.gd.uid create mode 100644 world/active_world_adapter.gd create mode 100644 world/active_world_adapter.gd.uid diff --git a/main.tscn b/main.tscn index 321aa71..cc87c74 100644 --- a/main.tscn +++ b/main.tscn @@ -7,6 +7,7 @@ [ext_resource type="PackedScene" uid="uid://dhxxyprqflotq" path="res://player/npc/NpcVisual.tscn" id="5_lquwl"] [ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"] [ext_resource type="PackedScene" uid="uid://dvd3qjpjor4sq" path="res://world/resource_nodes/ResourceNode.tscn" id="8_r3s0u"] +[ext_resource type="Script" uid="uid://bbwol3mrjgn2x" path="res://world/active_world_adapter.gd" id="9_adapter"] [sub_resource type="NavigationMesh" id="NavigationMesh_lquwl"] vertices = PackedVector3Array(-14.5, 0.5, 0, -1, 0.5, 0, -1, 0.5, -0.75, 0, 0.5, -1, 0, 0.5, -14.5, -14.5, 0.5, -14.5, 0.75, 0.5, -1, 0.75, 0.75, -0.5, 14.5, 0.5, -0.5, 14.5, 0.5, -14.5, 0.75, 0.75, 0, 0, 1, 0, 0, 0.75, 0.75, -0.5, 0.75, 0.75, -0.5, 0.5, 14.5, 14.5, 0.5, 14.5, -1, 0.5, 0.75, -14.5, 0.5, 14.5) @@ -103,18 +104,22 @@ deadzone_forward = 2.0 current = true fov = 65.0 -[node name="SimulationManager" type="Node" parent="." unique_id=1099676814] -script = ExtResource("3_lquwl") +[node name="ActiveWorldAdapter" type="Node" parent="." node_paths=PackedStringArray("guard_marker", "study_marker", "rest_marker", "food_marker")] +script = ExtResource("9_adapter") +guard_marker = NodePath("../ActivityMarkers/GuardZone") +study_marker = NodePath("../ActivityMarkers/StudyZone") +rest_marker = NodePath("../ActivityMarkers/RestZone") +food_marker = NodePath("../ActivityMarkers/FoodZone") -[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent", "guard_zone", "study_zone", "rest_zone", "food_zone")] +[node name="SimulationManager" type="Node" parent="." unique_id=1099676814 node_paths=PackedStringArray("active_world_adapter")] +script = ExtResource("3_lquwl") +active_world_adapter = NodePath("../ActiveWorldAdapter") + +[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent")] script = ExtResource("4_1bvp3") npc_visual_scene = ExtResource("5_lquwl") simulation_manager = NodePath("../SimulationManager") active_npcs_parent = NodePath("../ActiveNPCs") -guard_zone = NodePath("../ActivityMarkers/GuardZone") -study_zone = NodePath("../ActivityMarkers/StudyZone") -rest_zone = NodePath("../ActivityMarkers/RestZone") -food_zone = NodePath("../ActivityMarkers/FoodZone") [node name="EventBus" type="Node" parent="." unique_id=1149294963] diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index 095f221..6fff6ae 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -55,56 +55,6 @@ func _init( random_source.randf_range(-8.0, 8.0) ) -func simulate_tick(village: SimVillage) -> void: - if is_dead: - return - - update_needs(village) - - if is_dead: - return - - if task_state == TASK_STATE_IDLE or task_state == TASK_STATE_COMPLETE: - choose_new_task(village) - return - - if task_state == TASK_STATE_TRAVELING: - return - - if task_state == TASK_STATE_WORKING: - task_progress += 1.0 - - if task_progress >= task_duration: - task_complete = true - task_state = TASK_STATE_COMPLETE - -func update_needs(village: SimVillage) -> void: - hunger += 3.0 * village.food_modifier - - match task_state: - TASK_STATE_IDLE: - energy -= 0.5 - TASK_STATE_TRAVELING: - energy -= 2.0 - TASK_STATE_WORKING: - if current_task != SimulationIds.ACTION_REST: - energy -= 3.0 - TASK_STATE_COMPLETE: - energy -= 0.25 - - hunger = clamp(hunger, 0.0, 100.0) - energy = clamp(energy, 0.0, 100.0) - - is_starving = hunger >= 90.0 - - if is_starving: - starvation_ticks += 1 - else: - starvation_ticks = 0 - - if starvation_ticks >= starvation_death_threshold: - die_from_starvation() - func die_from_starvation() -> void: is_dead = true current_task = SimulationIds.ACTION_DEAD @@ -116,30 +66,6 @@ func die_from_starvation() -> void: if debug_logs: print("[SimNPC] ", npc_name, " died from starvation.") -func choose_new_task(village: SimVillage) -> void: - if is_dead: - return - - if is_starving: - if village.food > 0: - set_task(SimulationIds.ACTION_EAT, 1.0) - else: - set_task(SimulationIds.ACTION_GATHER_FOOD, 4.0) - return - - if hunger > 75.0: - if village.food > 0: - set_task(SimulationIds.ACTION_EAT) - else: - set_task(SimulationIds.ACTION_GATHER_FOOD) - return - - if energy < 25.0: - set_task(SimulationIds.ACTION_REST) - return - - set_task(choose_best_work_task(village)) - func should_eat_immediately_after_task() -> bool: return ( current_task == SimulationIds.ACTION_GATHER_FOOD @@ -147,105 +73,6 @@ func should_eat_immediately_after_task() -> bool: and not is_dead ) -func choose_best_work_task(village: SimVillage) -> StringName: - var food_score := calculate_task_score( - SimulationIds.ACTION_GATHER_FOOD, - village.food_priority, - village.food, - 15.0, - 30.0, - 3.0, - 1.5 - ) - - var wood_score := calculate_task_score( - SimulationIds.ACTION_GATHER_WOOD, - village.wood_priority, - village.wood, - 10.0, - 20.0, - 2.5, - 1.0 - ) - - var safety_score := calculate_task_score( - SimulationIds.ACTION_PATROL, - village.safety_priority, - village.safety, - 30.0, - 50.0, - 3.0, - 1.5 - ) - - var knowledge_score := calculate_task_score( - SimulationIds.ACTION_STUDY, - village.knowledge_priority, - village.knowledge, - 10.0, - 25.0, - 1.5, - 0.75 - ) - - var best_task := SimulationIds.ACTION_GATHER_FOOD - var best_score := food_score - - if wood_score > best_score: - best_task = SimulationIds.ACTION_GATHER_WOOD - best_score = wood_score - - if safety_score > best_score: - best_task = SimulationIds.ACTION_PATROL - best_score = safety_score - - if knowledge_score > best_score: - best_task = SimulationIds.ACTION_STUDY - best_score = knowledge_score - - return best_task - -func calculate_task_score( - action_id: StringName, - base_priority: float, - resource_amount: float, - critical_threshold: float, - low_threshold: float, - critical_bonus: float, - low_bonus: float -) -> float: - var score := base_priority - - if resource_amount < critical_threshold: - score += critical_bonus - elif resource_amount < low_threshold: - score += low_bonus - - var definition := SimulationDefinitions.get_action(action_id) - if ( - definition != null - and not definition.preferred_profession_id.is_empty() - and profession == definition.preferred_profession_id - ): - score += 2.0 - - if action_id == last_task: - score -= 1.5 - - score += random_source.randf_range(0.0, 0.5) - - if debug_logs: - print( - "[SimNPC] ", - npc_name, - " score ", - action_id, - " = ", - score - ) - - return score - func set_task(action_id: StringName, duration: float = -1.0) -> void: var definition := SimulationDefinitions.get_action(action_id) if definition == null: diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 8cf53d0..3f5b74d 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -7,6 +7,8 @@ signal npc_task_changed( ) signal village_changed(village: SimVillage) signal npc_died(npc: SimNPC) +signal npc_target_requested(npc: SimNPC) +signal npc_travel_requested(npc: SimNPC, target_position: Vector3) var village := SimVillage.new() @@ -14,11 +16,15 @@ var npcs: Array[SimNPC] = [] @export var tick_interval := 1.2 @export var simulation_seed: int = 1337 @export var debug_logs := true +@export var active_world_adapter: Node var clock: SimulationClock var tick_count := 0 var wander_random_sources := {} var resource_states: Dictionary = {} +var action_selector := ActionSelectionSystem.new() +var action_executor := ActionExecutionSystem.new() +var target_resolver := ActionTargetResolver.new() var names := [ "Amina", "Tarik", "Jasmin" @@ -97,7 +103,24 @@ func simulate_tick() -> void: var was_complete := npc.task_complete var was_dead := npc.is_dead - npc.simulate_tick(village) + action_executor.advance_npc(npc, village) + if ( + not npc.is_dead + and old_state in [ + SimNPC.TASK_STATE_IDLE, + SimNPC.TASK_STATE_COMPLETE + ] + and npc.task_state in [ + SimNPC.TASK_STATE_IDLE, + SimNPC.TASK_STATE_COMPLETE + ] + ): + var selection := action_selector.select_action(npc, village) + if selection != null: + npc.set_task( + selection.action_id, + selection.duration_override + ) if old_task != npc.current_task and old_target != &"": release_npc_reservation(npc.id) @@ -113,6 +136,7 @@ func simulate_tick() -> void: if old_task != npc.current_task and not npc.is_dead: npc_task_changed.emit(npc, old_task, npc.current_task) + npc_target_requested.emit(npc) if not was_complete and npc.task_complete and not npc.is_dead: var completed_task := npc.current_task @@ -162,6 +186,7 @@ func simulate_tick() -> void: if needs_immediate_food_after_gather and village.food > 0: npc.set_task(SimulationIds.ACTION_EAT, 1.0) npc_task_changed.emit(npc, completed_task, npc.current_task) + npc_target_requested.emit(npc) if debug_logs: print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.") @@ -255,6 +280,7 @@ func notify_npc_navigation_failed(npc_id: int) -> void: npc.last_task = failed_task npc.set_task(SimulationIds.ACTION_WANDER, 2.0) npc_task_changed.emit(npc, failed_task, npc.current_task) + npc_target_requested.emit(npc) if debug_logs: print("[SimulationManager] ", npc.npc_name, " could not navigate for ", failed_task, " and is trying a wander target") @@ -263,6 +289,29 @@ func notify_npc_navigation_failed(npc_id: int) -> void: func notify_npc_target_unavailable(npc_id: int) -> void: notify_npc_navigation_failed(npc_id) +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 + for npc in npcs: + if npc.id != npc_id: + continue + if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING: + return false + var result := target_resolver.resolve( + npc, + origin, + self, + active_world_adapter + ) + if result.is_empty(): + notify_npc_target_unavailable(npc.id) + return false + npc.target_id = StringName(result.get("target_id", "")) + npc_travel_requested.emit(npc, result["position"]) + return true + return false + func register_loaded_resource_nodes() -> void: for node in ResourceNode.get_all(): register_resource_node(node) @@ -305,33 +354,6 @@ func release_resource(node_id: StringName, agent_id: int) -> void: if resource_state != null: resource_state.release(agent_id) -func acquire_resource_target( - action_id: StringName, - agent_id: int, - from_position: Vector3 -) -> ResourceNode: - var best: ResourceNode - var best_distance := INF - for node in ResourceNode.get_all(): - var resource_state := get_resource_state(node.node_id) - if resource_state == null: - continue - if resource_state.get_action_id() != action_id: - continue - if not resource_state.can_npc_use(): - continue - if not resource_state.is_available_for(agent_id): - continue - var distance := from_position.distance_squared_to( - node.interaction_point.global_position - ) - if distance < best_distance: - best_distance = distance - best = node - if best == null or not reserve_resource(best.node_id, agent_id): - return null - return best - func find_resource_node_for_player( from_position: Vector3, max_distance: float diff --git a/simulation/actions/ActionExecutionSystem.gd b/simulation/actions/ActionExecutionSystem.gd new file mode 100644 index 0000000..921407f --- /dev/null +++ b/simulation/actions/ActionExecutionSystem.gd @@ -0,0 +1,32 @@ +class_name ActionExecutionSystem +extends RefCounted + +func advance_npc(npc: SimNPC, village: SimVillage) -> void: + if npc.is_dead: + return + _update_needs(npc, village) + if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_WORKING: + return + npc.task_progress += 1.0 + if npc.task_progress >= npc.task_duration: + npc.task_complete = true + npc.task_state = SimNPC.TASK_STATE_COMPLETE + +func _update_needs(npc: SimNPC, village: SimVillage) -> void: + npc.hunger += 3.0 * village.food_modifier + match npc.task_state: + SimNPC.TASK_STATE_IDLE: + npc.energy -= 0.5 + SimNPC.TASK_STATE_TRAVELING: + npc.energy -= 2.0 + SimNPC.TASK_STATE_WORKING: + if npc.current_task != SimulationIds.ACTION_REST: + npc.energy -= 3.0 + SimNPC.TASK_STATE_COMPLETE: + npc.energy -= 0.25 + npc.hunger = clamp(npc.hunger, 0.0, 100.0) + npc.energy = clamp(npc.energy, 0.0, 100.0) + npc.is_starving = npc.hunger >= 90.0 + npc.starvation_ticks = npc.starvation_ticks + 1 if npc.is_starving else 0 + if npc.starvation_ticks >= npc.starvation_death_threshold: + npc.die_from_starvation() diff --git a/simulation/actions/ActionExecutionSystem.gd.uid b/simulation/actions/ActionExecutionSystem.gd.uid new file mode 100644 index 0000000..6cdac1a --- /dev/null +++ b/simulation/actions/ActionExecutionSystem.gd.uid @@ -0,0 +1 @@ +uid://bcoht6j0fvva3 diff --git a/simulation/actions/ActionSelectionResult.gd b/simulation/actions/ActionSelectionResult.gd new file mode 100644 index 0000000..c5c9660 --- /dev/null +++ b/simulation/actions/ActionSelectionResult.gd @@ -0,0 +1,12 @@ +class_name ActionSelectionResult +extends RefCounted + +var action_id: StringName +var duration_override := -1.0 + +func _init( + selected_action_id: StringName, + selected_duration_override: float = -1.0 +) -> void: + action_id = selected_action_id + duration_override = selected_duration_override diff --git a/simulation/actions/ActionSelectionResult.gd.uid b/simulation/actions/ActionSelectionResult.gd.uid new file mode 100644 index 0000000..85fd361 --- /dev/null +++ b/simulation/actions/ActionSelectionResult.gd.uid @@ -0,0 +1 @@ +uid://dy0n3lije1lec diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd new file mode 100644 index 0000000..811d7ec --- /dev/null +++ b/simulation/actions/ActionSelectionSystem.gd @@ -0,0 +1,81 @@ +class_name ActionSelectionSystem +extends RefCounted + +func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult: + if npc.is_dead: + return null + if npc.is_starving: + if village.food > 0: + return ActionSelectionResult.new(SimulationIds.ACTION_EAT, 1.0) + return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD, 4.0) + if npc.hunger > 75.0: + if village.food > 0: + return ActionSelectionResult.new(SimulationIds.ACTION_EAT) + return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD) + if npc.energy < 25.0: + return ActionSelectionResult.new(SimulationIds.ACTION_REST) + return ActionSelectionResult.new(_choose_best_work_action(npc, village)) + +func _choose_best_work_action( + npc: SimNPC, + village: SimVillage +) -> StringName: + var scores := { + SimulationIds.ACTION_GATHER_FOOD: _calculate_score( + npc, SimulationIds.ACTION_GATHER_FOOD, village.food_priority, + village.food, 15.0, 30.0, 3.0, 1.5 + ), + SimulationIds.ACTION_GATHER_WOOD: _calculate_score( + npc, SimulationIds.ACTION_GATHER_WOOD, village.wood_priority, + village.wood, 10.0, 20.0, 2.5, 1.0 + ), + SimulationIds.ACTION_PATROL: _calculate_score( + npc, SimulationIds.ACTION_PATROL, village.safety_priority, + village.safety, 30.0, 50.0, 3.0, 1.5 + ), + SimulationIds.ACTION_STUDY: _calculate_score( + npc, SimulationIds.ACTION_STUDY, village.knowledge_priority, + village.knowledge, 10.0, 25.0, 1.5, 0.75 + ) + } + var best_action := SimulationIds.ACTION_GATHER_FOOD + var best_score: float = scores[best_action] + for action_id in [ + SimulationIds.ACTION_GATHER_WOOD, + SimulationIds.ACTION_PATROL, + SimulationIds.ACTION_STUDY + ]: + var score: float = scores[action_id] + if score > best_score: + best_action = action_id + best_score = score + return best_action + +func _calculate_score( + npc: SimNPC, + action_id: StringName, + base_priority: float, + resource_amount: float, + critical_threshold: float, + low_threshold: float, + critical_bonus: float, + low_bonus: float +) -> float: + var score := base_priority + if resource_amount < critical_threshold: + score += critical_bonus + elif resource_amount < low_threshold: + score += low_bonus + var definition := SimulationDefinitions.get_action(action_id) + if ( + definition != null + and not definition.preferred_profession_id.is_empty() + and npc.profession == definition.preferred_profession_id + ): + score += 2.0 + if action_id == npc.last_task: + score -= 1.5 + score += npc.random_source.randf_range(0.0, 0.5) + if npc.debug_logs: + print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score) + return score diff --git a/simulation/actions/ActionSelectionSystem.gd.uid b/simulation/actions/ActionSelectionSystem.gd.uid new file mode 100644 index 0000000..a199da0 --- /dev/null +++ b/simulation/actions/ActionSelectionSystem.gd.uid @@ -0,0 +1 @@ +uid://c550deaitwskn diff --git a/simulation/actions/ActionTargetResolver.gd b/simulation/actions/ActionTargetResolver.gd new file mode 100644 index 0000000..1c3e22f --- /dev/null +++ b/simulation/actions/ActionTargetResolver.gd @@ -0,0 +1,60 @@ +class_name ActionTargetResolver +extends RefCounted + +func resolve( + npc: SimNPC, + origin: Vector3, + simulation_manager: Node, + active_world_adapter: Node +) -> Dictionary: + var definition := SimulationDefinitions.get_action(npc.current_task) + if definition == null: + return {} + match definition.target_type: + SimulationIds.TARGET_RESOURCE: + return _resolve_resource( + npc, origin, definition, simulation_manager, + active_world_adapter + ) + SimulationIds.TARGET_ACTIVITY: + return active_world_adapter.get_activity_target(npc.current_task) + SimulationIds.TARGET_FREE: + return { + "target_id": "", + "position": origin + simulation_manager.get_wander_offset(npc.id) + } + return {} + +func _resolve_resource( + npc: SimNPC, + origin: Vector3, + definition: ActionDefinition, + simulation_manager: Node, + active_world_adapter: Node +) -> Dictionary: + var best: Dictionary = {} + var best_distance := INF + for candidate in active_world_adapter.get_resource_candidates( + definition.resource_action_id + ): + var node_id := StringName(candidate["target_id"]) + var state: ResourceStateRecord = simulation_manager.get_resource_state( + node_id + ) + if ( + state == null + or not state.can_npc_use() + or not state.is_available_for(npc.id) + ): + continue + var position: Vector3 = candidate["position"] + var distance := origin.distance_squared_to(position) + if distance < best_distance: + best_distance = distance + best = candidate + if best.is_empty(): + return {} + var target_id := StringName(best["target_id"]) + if not simulation_manager.reserve_resource(target_id, npc.id): + return {} + return best diff --git a/simulation/actions/ActionTargetResolver.gd.uid b/simulation/actions/ActionTargetResolver.gd.uid new file mode 100644 index 0000000..e273eda --- /dev/null +++ b/simulation/actions/ActionTargetResolver.gd.uid @@ -0,0 +1 @@ +uid://cq8srsm762mnx diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd new file mode 100644 index 0000000..dd79a67 --- /dev/null +++ b/tests/action_system_boundaries_test.gd @@ -0,0 +1,124 @@ +extends SceneTree + +class FakeActiveWorld: + extends Node + + var resource_candidates: Array[Dictionary] = [] + var activity_position := Vector3(4.0, 0.0, 7.0) + + func get_resource_candidates(_action_id: StringName) -> Array[Dictionary]: + return resource_candidates + + func get_activity_target(_action_id: StringName) -> Dictionary: + return {"target_id": "", "position": activity_position} + +var failures: Array[String] = [] +var travel_requests: Array[Vector3] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _test_selection_and_execution_are_separate() + _test_target_resolution_and_travel_are_separate() + + if failures.is_empty(): + print("[TEST] Action system boundaries passed") + quit(0) + return + + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + +func _test_selection_and_execution_are_separate() -> void: + var village := SimVillage.new() + village.debug_logs = false + var npc := SimNPC.new( + 700, + "BoundaryWorker", + SimulationIds.PROFESSION_FARMER, + 5.0, + 5.0 + ) + npc.hunger = 80.0 + var executor := ActionExecutionSystem.new() + executor.advance_npc(npc, village) + _check( + npc.current_task == SimulationIds.ACTION_IDLE, + "Execution should update state without selecting an action" + ) + var selector := ActionSelectionSystem.new() + var selection := selector.select_action(npc, village) + _check( + selection.action_id == SimulationIds.ACTION_EAT, + "Selection should independently choose the urgent eat action" + ) + +func _test_target_resolution_and_travel_are_separate() -> void: + var adapter := FakeActiveWorld.new() + adapter.resource_candidates = [{ + "target_id": "boundary_bush", + "position": Vector3(3.0, 0.0, 2.0) + }] + root.add_child(adapter) + + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.debug_logs = false + manager.active_world_adapter = adapter + root.add_child(manager) + manager.set_process(false) + manager.npc_travel_requested.connect(_on_travel_requested) + + var resource_state := ResourceStateRecord.from_dictionary({ + "schema_version": ResourceStateRecord.SCHEMA_VERSION, + "node_id": "boundary_bush", + "action_id": String(SimulationIds.ACTION_GATHER_FOOD), + "resource_id": String(SimulationIds.RESOURCE_FOOD), + "amount_remaining": 5.0, + "yield_per_action": 1.0, + "reserved_by": -1, + "enabled": true, + "can_npcs_use": true, + "can_player_use": true + }) + manager.resource_states[resource_state.get_node_id()] = resource_state + + var npc: SimNPC = manager.npcs[0] + npc.set_task(SimulationIds.ACTION_GATHER_FOOD) + _check( + manager.resolve_npc_target(npc.id, Vector3.ZERO), + "Target resolver should resolve a resource from adapter facts" + ) + _check( + npc.target_id == &"boundary_bush" + and resource_state.get_reserved_by() == npc.id, + "Target resolver should authoritatively assign and reserve the target" + ) + _check( + travel_requests == [Vector3(3.0, 0.0, 2.0)], + "Resolution should publish travel without moving a visual itself" + ) + + manager.release_npc_reservation(npc.id) + npc.set_task(SimulationIds.ACTION_STUDY) + _check( + manager.resolve_npc_target(npc.id, Vector3.ZERO), + "Target resolver should resolve an activity through the adapter" + ) + _check( + travel_requests.back() == adapter.activity_position, + "Activity resolution should publish the adapter's position" + ) + manager.free() + adapter.free() + +func _on_travel_requested( + _npc: SimNPC, + target_position: Vector3 +) -> void: + travel_requests.append(target_position) + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/action_system_boundaries_test.gd.uid b/tests/action_system_boundaries_test.gd.uid new file mode 100644 index 0000000..2a5235c --- /dev/null +++ b/tests/action_system_boundaries_test.gd.uid @@ -0,0 +1 @@ +uid://brtbafjq4h1n4 diff --git a/tests/flat_map_baseline_test.gd b/tests/flat_map_baseline_test.gd index b31a243..efdcdd5 100644 --- a/tests/flat_map_baseline_test.gd +++ b/tests/flat_map_baseline_test.gd @@ -61,8 +61,9 @@ func _run() -> void: ) worker.set_task(SimulationIds.ACTION_STUDY, 2.0) worker.start_working() - worker.simulate_tick(village) - worker.simulate_tick(village) + var executor := ActionExecutionSystem.new() + executor.advance_npc(worker, village) + executor.advance_npc(worker, village) _check(worker.task_complete, "A two-tick working task should complete") var starving := SimNPC.new( @@ -74,7 +75,7 @@ func _run() -> void: ) starving.hunger = 100.0 starving.starvation_death_threshold = 1 - starving.simulate_tick(village) + executor.advance_npc(starving, village) _check(starving.is_dead, "Starvation threshold should still kill an NPC") if failures.is_empty(): diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index 6a92daf..0693e03 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -87,26 +87,30 @@ func _run() -> void: "Jajce navigation path should exist from %s to %s" % [origin, destination] ) + var adapter := ActiveWorldAdapter.new() + root.add_child(adapter) var manager: Node = load("res://simulation/SimulationManager.gd").new() manager.debug_logs = false + manager.active_world_adapter = adapter root.add_child(manager) manager.set_process(false) await process_frame - var selected: ResourceNode = manager.acquire_resource_target( - SimulationIds.ACTION_GATHER_FOOD, - 777, - Vector3.ZERO + var npc: SimNPC = manager.npcs[0] + npc.set_task(SimulationIds.ACTION_GATHER_FOOD) + _check( + manager.resolve_npc_target(npc.id, Vector3.ZERO), + "Target resolver should find Jajce resources without a parent path" ) - _check(selected != null, "Target discovery should find Jajce resources without a parent path") + var selected := ResourceNode.get_by_id(npc.target_id) if selected != null: var selected_state: ResourceStateRecord = manager.get_resource_state( selected.node_id ) _check( - selected_state.get_reserved_by() == 777, + selected_state.get_reserved_by() == npc.id, "Selected Jajce resource should be authoritatively reserved" ) - manager.release_resource(selected.node_id, 777) + manager.release_resource(selected.node_id, npc.id) if failures.is_empty(): print("[TEST] Jajce scaffold passed: Terrain3D, stable IDs, 24 navigation routes") diff --git a/world/active_world_adapter.gd b/world/active_world_adapter.gd new file mode 100644 index 0000000..006e55a --- /dev/null +++ b/world/active_world_adapter.gd @@ -0,0 +1,33 @@ +class_name ActiveWorldAdapter +extends Node + +@export var guard_marker: Marker3D +@export var study_marker: Marker3D +@export var rest_marker: Marker3D +@export var food_marker: Marker3D + +func get_resource_candidates(action_id: StringName) -> Array[Dictionary]: + var candidates: Array[Dictionary] = [] + for node in ResourceNode.get_all(): + if node.action_id != action_id or node.interaction_point == null: + continue + candidates.append({ + "target_id": String(node.node_id), + "position": node.interaction_point.global_position + }) + return candidates + +func get_activity_target(action_id: StringName) -> Dictionary: + var marker: Marker3D + match action_id: + SimulationIds.ACTION_PATROL: + marker = guard_marker + SimulationIds.ACTION_STUDY: + marker = study_marker + SimulationIds.ACTION_REST: + marker = rest_marker + SimulationIds.ACTION_EAT: + marker = food_marker + if marker == null: + return {} + return {"target_id": "", "position": marker.global_position} diff --git a/world/active_world_adapter.gd.uid b/world/active_world_adapter.gd.uid new file mode 100644 index 0000000..54d09d2 --- /dev/null +++ b/world/active_world_adapter.gd.uid @@ -0,0 +1 @@ +uid://bbwol3mrjgn2x diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index 9353aed..f3abaa4 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -6,11 +6,6 @@ const DEBUG_LOGS := true @export var simulation_manager: Node @export var active_npcs_parent: Node3D -@export var guard_zone: Marker3D -@export var study_zone: Marker3D -@export var rest_zone: Marker3D -@export var food_zone: Marker3D - var active_npc_visuals := {} func debug_log(message: String) -> void: @@ -23,227 +18,100 @@ func _ready() -> void: func initialize_world_view() -> void: await get_tree().physics_frame spawn_initial_npcs() - - if simulation_manager.has_signal("npc_task_changed"): - simulation_manager.npc_task_changed.connect(_on_npc_task_changed) + if simulation_manager.has_signal("npc_target_requested"): + simulation_manager.npc_target_requested.connect( + _on_npc_target_requested + ) else: - push_error("WorldViewManager: SimulationManager has no npc_task_changed signal") - + push_error("WorldViewManager: missing npc_target_requested signal") + if simulation_manager.has_signal("npc_travel_requested"): + simulation_manager.npc_travel_requested.connect( + _on_npc_travel_requested + ) + else: + push_error("WorldViewManager: missing npc_travel_requested signal") if simulation_manager.has_signal("npc_died"): simulation_manager.npc_died.connect(_on_npc_died) else: push_error("WorldViewManager: SimulationManager has no npc_died signal") - - update_npc_targets() + request_current_targets() func spawn_initial_npcs() -> void: if simulation_manager == null: push_error("WorldViewManager: simulation_manager is missing") return - if npc_visual_scene == null: push_error("WorldViewManager: npc_visual_scene is missing") return - if active_npcs_parent == null: push_error("WorldViewManager: active_npcs_parent is missing") return - debug_log("Spawning NPC visuals. Count: %s" % simulation_manager.npcs.size()) - for npc in simulation_manager.npcs: spawn_npc_visual(npc) func spawn_npc_visual(npc: SimNPC) -> void: if active_npc_visuals.has(npc.id): return - var visual = npc_visual_scene.instantiate() active_npcs_parent.add_child(visual) - visual.global_position = npc.position visual.setup_from_sim(npc) - if visual.has_signal("arrived_at_target"): visual.arrived_at_target.connect(_on_npc_visual_arrived) else: push_error("WorldViewManager: NPCVisual has no arrived_at_target signal") - if visual.has_signal("navigation_failed"): visual.navigation_failed.connect(_on_npc_visual_navigation_failed) else: push_error("WorldViewManager: NPCVisual has no navigation_failed signal") - active_npc_visuals[npc.id] = visual debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position]) -func update_npc_targets() -> void: - if simulation_manager == null: - return - +func request_current_targets() -> void: for npc in simulation_manager.npcs: - if not active_npc_visuals.has(npc.id): + if npc.task_state != SimNPC.TASK_STATE_TRAVELING: continue + _on_npc_target_requested(npc) - var visual = active_npc_visuals[npc.id] - var task = npc.current_task - if task in [ - &"", - SimulationIds.ACTION_IDLE, - SimulationIds.ACTION_DEAD - ]: - continue - - var resource_pos = _try_get_resource_target(npc, npc.current_task) - if resource_pos != null: - visual.set_target_position(resource_pos) - continue - - if task == SimulationIds.ACTION_WANDER: - var offset := _get_wander_offset(npc.id) - visual.set_target_position(visual.global_position + offset) - continue - - var definition := SimulationDefinitions.get_action(task) - if ( - definition != null - and definition.target_type == SimulationIds.TARGET_RESOURCE - ): - if simulation_manager.has_method("notify_npc_target_unavailable"): - simulation_manager.notify_npc_target_unavailable(npc.id) - continue - - var target = get_target_for_task(task) - - if target == null: - debug_log("No target for task: %s" % task) - continue - - visual.set_target_position(target.global_position) - -func get_target_for_task(task: StringName) -> Marker3D: - match task: - SimulationIds.ACTION_PATROL: - return guard_zone - SimulationIds.ACTION_STUDY: - return study_zone - SimulationIds.ACTION_REST: - return rest_zone - SimulationIds.ACTION_EAT: - return food_zone - _: - return rest_zone - -func _try_get_resource_target(npc: SimNPC, task: StringName) -> Variant: - var definition := SimulationDefinitions.get_action(task) - if ( - definition == null - or definition.target_type != SimulationIds.TARGET_RESOURCE - ): - return null - +func _on_npc_target_requested(npc: SimNPC) -> void: var visual = active_npc_visuals.get(npc.id) as Node3D if visual == null: - return null - - if not simulation_manager.has_method("acquire_resource_target"): - push_error("WorldViewManager: SimulationManager cannot acquire resources") - return null - var node: ResourceNode = simulation_manager.acquire_resource_target( - definition.resource_action_id, + return + if not simulation_manager.resolve_npc_target( npc.id, visual.global_position - ) - if node == null: - debug_log("No available resource node for %s" % task) - return null - - npc.target_id = node.node_id - debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task]) - return node.interaction_point.global_position - -func _on_npc_task_changed( - npc: SimNPC, - old_task: StringName, - new_task: StringName -) -> void: - if DEBUG_LOGS: - debug_log("%s changed task: %s -> %s" % [npc.npc_name, old_task, new_task]) - - if not active_npc_visuals.has(npc.id): - return - - if new_task in [ - &"", - SimulationIds.ACTION_IDLE, - SimulationIds.ACTION_DEAD - ]: - return - - var visual = active_npc_visuals[npc.id] - var resource_pos = _try_get_resource_target(npc, new_task) - if resource_pos != null: - visual.set_target_position(resource_pos) - return - - if new_task == SimulationIds.ACTION_WANDER: - var offset := _get_wander_offset(npc.id) - visual.set_target_position(visual.global_position + offset) - return - - var definition := SimulationDefinitions.get_action(new_task) - if ( - definition != null - and definition.target_type == SimulationIds.TARGET_RESOURCE ): - if simulation_manager.has_method("notify_npc_target_unavailable"): - simulation_manager.notify_npc_target_unavailable(npc.id) - else: - push_error("WorldViewManager: SimulationManager cannot handle unavailable targets") - return - - var target := get_target_for_task(new_task) - - if target != null: - visual.set_target_position(target.global_position) + debug_log("Target resolution failed for %s" % npc.npc_name) + +func _on_npc_travel_requested( + npc: SimNPC, + target_position: Vector3 +) -> void: + var visual = active_npc_visuals.get(npc.id) + if visual == null: + return + visual.set_target_position(target_position) -func _get_wander_offset(npc_id: int) -> Vector3: - if simulation_manager != null and simulation_manager.has_method("get_wander_offset"): - return simulation_manager.get_wander_offset(npc_id) - push_error("WorldViewManager: SimulationManager cannot provide deterministic wander offsets") - return Vector3.ZERO - func _on_npc_visual_arrived(sim_id: int) -> void: debug_log("NPC visual arrived. sim_id=%s" % sim_id) - if simulation_manager == null: - push_error("WorldViewManager: simulation_manager missing during arrival report") + push_error("WorldViewManager: simulation_manager missing during arrival") return - - if simulation_manager.has_method("notify_npc_arrived"): - simulation_manager.notify_npc_arrived(sim_id) - else: - push_error("WorldViewManager: SimulationManager has no notify_npc_arrived method") + simulation_manager.notify_npc_arrived(sim_id) func _on_npc_visual_navigation_failed(sim_id: int) -> void: debug_log("NPC visual navigation failed. sim_id=%s" % sim_id) - if simulation_manager == null: - push_error("WorldViewManager: simulation_manager missing during navigation failure") + push_error("WorldViewManager: simulation_manager missing during failure") return - - if simulation_manager.has_method("notify_npc_navigation_failed"): - simulation_manager.notify_npc_navigation_failed(sim_id) - else: - push_error("WorldViewManager: SimulationManager has no notify_npc_navigation_failed method") + simulation_manager.notify_npc_navigation_failed(sim_id) func _on_npc_died(npc: SimNPC) -> void: if not active_npc_visuals.has(npc.id): return - var visual = active_npc_visuals[npc.id] - if visual.has_method("apply_dead_visual_state"): visual.apply_dead_visual_state() else: - push_error("WorldViewManager: NPCVisual has no apply_dead_visual_state method") + push_error("WorldViewManager: NPCVisual has no apply_dead_visual_state")