Files
gamedev-the-steward/tests/action_system_boundaries_test.gd
T

269 lines
8.5 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_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)