extends SceneTree class FakeActiveWorld: extends Node var resource_candidates: Array[Dictionary] = [] var activity_position := Vector3(4.0, 0.0, 7.0) var activity_candidates: Array[Dictionary] = [] 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} func get_activity_candidates(_action_id: StringName) -> Array[Dictionary]: return activity_candidates 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_opportunity_helper_selection_preserves_precedence() _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" ) var guard := SimNPC.new(701, "BoundaryGuard", SimulationIds.PROFESSION_GUARD, 5.0, 5.0) guard.hunger = 20.0 guard.energy = 80.0 village.food = 100.0 village.wood = 0.0 village.safety = 0.0 village.knowledge = 100.0 village.update_modifiers() village.update_priorities() var unfunded_selection := selector.select_action(guard, village, 0.5) _check( unfunded_selection.action_id == SimulationIds.ACTION_GATHER_WOOD, "A guard should gather wood instead of selecting patrol when its material cost is unavailable" ) _check( ( unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL) and "Needs 1 Wood" in String(unfunded_selection.rejections[SimulationIds.ACTION_PATROL]) ), "Selection should expose a readable definition-backed rejection reason" ) func _test_opportunity_helper_selection_preserves_precedence() -> void: var village := SimVillage.new() village.food = 100.0 village.wood = 100.0 village.safety = 100.0 village.knowledge = 100.0 village.update_modifiers() village.update_priorities() var npc := SimNPC.new(702, "OpportunityHelper", SimulationIds.PROFESSION_WANDERER, 5.0, 5.0) npc.hunger = 20.0 npc.energy = 80.0 var helper := _helper_result(npc.id, SimulationIds.ACTION_GATHER_WOOD) var selector := ActionSelectionSystem.new() var selection := selector.select_action(npc, village, 0.5, [npc], helper) _check( ( selection.action_id == SimulationIds.ACTION_GATHER_WOOD and "Responding to village need" in selection.reason and "Knows the need" in selection.reason ), "The matching derived helper should choose its reported ordinary supply action" ) npc.energy = 20.0 selection = selector.select_action(npc, village, 0.5, [npc], helper) _check( selection.action_id == SimulationIds.ACTION_REST, "Urgent low energy should take precedence over an opportunity response" ) npc.energy = 50.0 selection = selector.select_action(npc, village, 0.0, [npc], helper) _check( selection.action_id == SimulationIds.ACTION_SLEEP, "The sleep schedule should take precedence over an opportunity response" ) npc.energy = 80.0 npc.hunger = 90.0 selection = selector.select_action(npc, village, 0.5, [npc], helper) _check( selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD, "Critical hunger should take precedence over an opportunity response" ) npc.hunger = 20.0 helper = _helper_result(npc.id + 1, SimulationIds.ACTION_GATHER_WOOD) selection = selector.select_action(npc, village, 0.5, [npc], helper) _check( "Responding to village need" not in selection.reason, "A helper result must not redirect a different NPC" ) 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, "max_amount": 5.0, "regrow_rate": 0.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, "max_amount": 5.0, "regrow_rate": 0.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) adapter.activity_candidates = [ {"target_id": "study_desk_full", "position": Vector3(1.0, 0.0, 0.0), "capacity": 1}, {"target_id": "study_desk_open", "position": Vector3(4.0, 0.0, 7.0), "capacity": 1} ] var occupying_npc: SimNPC = manager.npcs[1] occupying_npc.set_task(SimulationIds.ACTION_STUDY) occupying_npc.target_id = &"study_desk_full" occupying_npc.task_state = SimNPC.TASK_STATE_WORKING 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( npc.target_id == &"study_desk_open" and travel_requests.back() == adapter.activity_position, "Activity resolution should skip a full-capacity site and publish the open site's position" ) manager.free() adapter.free() func _on_travel_requested(_npc: SimNPC, target_position: Vector3) -> void: travel_requests.append(target_position) func _helper_result(helper_id: int, action_id: StringName) -> OpportunityHelperResult: return ( OpportunityHelperResult . new( { "opportunity_id": 9, "helper_npc_id": helper_id, "action_id": action_id, "source_id": "", "resource_id": SimulationIds.RESOURCE_WOOD, "trigger_event_id": 14, "trust": 0.7, "familiarity": 0.8, "uses_inventory": false, "available_source_count": 1, "profession_match": false, "reason": "Knows the need; trust 0.70 toward Neighbour; has 1 finite source", } ) ) func _check(condition: bool, message: String) -> void: if not condition: failures.append(message)