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, _origin: Vector3 = Vector3.ZERO) -> 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_WITHDRAW_FOOD, "Selection should independently choose food retrieval" ) _check( not selection.reason.is_empty(), "Selection should expose the real decision branch for presentation" ) npc.hunger = 20.0 npc.energy = 80.0 var work_selection := selector.select_action(npc, village) _check( work_selection.scores.size() == 4, "Ordinary work selection should expose all compared utility scores" ) func _test_target_resolution_and_travel_are_separate() -> void: var adapter := FakeActiveWorld.new() adapter.resource_candidates = [ {"target_id": "boundary_risky_bush", "position": Vector3(1.0, 0.0, 0.0)}, {"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, "safety_risk": 0.0, "comfort_distance": 18.0, "discovery_priority": 0.0 } ) manager.resource_states[resource_state.get_node_id()] = resource_state var risky_resource_state := ResourceStateRecord.from_dictionary( { "schema_version": ResourceStateRecord.SCHEMA_VERSION, "node_id": "boundary_risky_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, "safety_risk": 1.0, "comfort_distance": 18.0, "discovery_priority": 0.0 } ) manager.resource_states[risky_resource_state.get_node_id()] = risky_resource_state var npc: SimNPC = manager.npcs[0] npc.energy = 40.0 npc.set_task(SimulationIds.ACTION_GATHER_FOOD) _check( manager.resolve_npc_target(npc.id, Vector3.ZERO), "Target resolver should resolve a scored resource from adapter facts" ) _check( npc.target_id == &"boundary_bush" and resource_state.get_reserved_by() == npc.id, "Target resolver should prefer the safer comfortable resource over risky proximity" ) _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)