170 lines
5.2 KiB
GDScript
170 lines
5.2 KiB
GDScript
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_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)
|
|
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 _check(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append(message)
|