refactor: separate action system responsibilities

This commit is contained in:
2026-07-05 14:17:45 +02:00
parent 81df7b5939
commit 4673f0698d
18 changed files with 458 additions and 383 deletions
+124
View File
@@ -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)
@@ -0,0 +1 @@
uid://brtbafjq4h1n4
+4 -3
View File
@@ -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():
+11 -7
View File
@@ -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")