feat: let opportunity helpers act autonomously
This commit is contained in:
@@ -28,6 +28,7 @@ func _initialize() -> void:
|
||||
|
||||
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():
|
||||
@@ -91,6 +92,57 @@ func _test_selection_and_execution_are_separate() -> void:
|
||||
)
|
||||
|
||||
|
||||
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 = [
|
||||
@@ -185,6 +237,28 @@ 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)
|
||||
|
||||
@@ -317,6 +317,30 @@ func _run() -> void:
|
||||
),
|
||||
"A real missing-wood fact should surface through wood-specific UI without a food cue"
|
||||
)
|
||||
witness.current_task = SimulationIds.ACTION_IDLE
|
||||
witness.task_state = SimNPC.TASK_STATE_IDLE
|
||||
witness.task_complete = true
|
||||
witness.target_id = &""
|
||||
witness.has_travel_target = false
|
||||
witness.hunger = 20.0
|
||||
witness.energy = 80.0
|
||||
witness.mourning_ticks = 0
|
||||
simulation_manager.simulate_tick()
|
||||
var runtime_decision: ActionSelectionResult = simulation_manager.get_latest_decision(witness.id)
|
||||
var helper_resource: ResourceStateRecord = simulation_manager.get_resource_state(
|
||||
witness.target_id
|
||||
)
|
||||
_check(
|
||||
(
|
||||
runtime_decision != null
|
||||
and runtime_decision.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||
and "Responding to village need" in runtime_decision.reason
|
||||
and witness.task_state == SimNPC.TASK_STATE_TRAVELING
|
||||
and helper_resource != null
|
||||
and helper_resource.get_reserved_by() == witness.id
|
||||
),
|
||||
"The runtime helper should autonomously enter normal target resolution and reservation"
|
||||
)
|
||||
contributor.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
|
||||
_check(
|
||||
is_equal_approx(
|
||||
|
||||
@@ -109,13 +109,16 @@ func _run() -> void:
|
||||
)
|
||||
|
||||
manager.simulate_tick()
|
||||
var deposit_decision: ActionSelectionResult = manager.get_latest_decision(helper.id)
|
||||
_check(
|
||||
(
|
||||
helper.current_task == SimulationIds.ACTION_DEPOSIT_FOOD
|
||||
and helper.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
and helper.has_travel_target
|
||||
and deposit_decision != null
|
||||
and "Responding to village need" in deposit_decision.reason
|
||||
),
|
||||
"The helper should autonomously choose the real pantry deposit after gathering"
|
||||
"The re-derived helper should autonomously choose the real pantry deposit"
|
||||
)
|
||||
helper_visual.global_position = helper.travel_target_position
|
||||
manager.synchronize_npc_position(helper.id, helper_visual.global_position)
|
||||
|
||||
@@ -107,6 +107,26 @@ func _run() -> void:
|
||||
manager.get_state_checksum() == checksum_before_helper_query,
|
||||
"Capable-helper discovery should not mutate authoritative simulation state"
|
||||
)
|
||||
witness.current_task = SimulationIds.ACTION_IDLE
|
||||
witness.task_state = SimNPC.TASK_STATE_IDLE
|
||||
witness.task_complete = true
|
||||
witness.target_id = &""
|
||||
witness.has_travel_target = false
|
||||
witness.hunger = 20.0
|
||||
witness.energy = 80.0
|
||||
witness.mourning_ticks = 0
|
||||
manager.simulate_tick()
|
||||
var helper_decision: ActionSelectionResult = manager.get_latest_decision(witness.id)
|
||||
_check(
|
||||
(
|
||||
helper_decision != null
|
||||
and helper_decision.action_id == helper_result.action_id
|
||||
and "Responding to village need" in helper_decision.reason
|
||||
and witness.current_task == SimulationIds.ACTION_GATHER_WOOD
|
||||
and witness.task_state == SimNPC.TASK_STATE_TRAVELING
|
||||
),
|
||||
"An idle derived helper should autonomously select the ordinary wood supply action"
|
||||
)
|
||||
|
||||
var active_json: String = manager.serialize_state()
|
||||
var active_restored := _create_manager(1202)
|
||||
@@ -127,8 +147,12 @@ func _run() -> void:
|
||||
"Active restore should preserve opportunity identity and protected evidence"
|
||||
)
|
||||
_check(
|
||||
restored_helper != null and restored_helper.helper_npc_id == witness.id,
|
||||
"Active restore should re-derive the same capable helper"
|
||||
(
|
||||
restored_helper != null
|
||||
and restored_helper.helper_npc_id == witness.id
|
||||
and active_restored.npcs[witness.id].current_task == SimulationIds.ACTION_GATHER_WOOD
|
||||
),
|
||||
"Active restore should keep the ordinary task and re-derive the helper without assignment state"
|
||||
)
|
||||
active_restored.free()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user