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
+12 -7
View File
@@ -7,6 +7,7 @@
[ext_resource type="PackedScene" uid="uid://dhxxyprqflotq" path="res://player/npc/NpcVisual.tscn" id="5_lquwl"] [ext_resource type="PackedScene" uid="uid://dhxxyprqflotq" path="res://player/npc/NpcVisual.tscn" id="5_lquwl"]
[ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"] [ext_resource type="Script" uid="uid://cjvbgqx8rao0t" path="res://world/ui/ui.gd" id="6_7mycd"]
[ext_resource type="PackedScene" uid="uid://dvd3qjpjor4sq" path="res://world/resource_nodes/ResourceNode.tscn" id="8_r3s0u"] [ext_resource type="PackedScene" uid="uid://dvd3qjpjor4sq" path="res://world/resource_nodes/ResourceNode.tscn" id="8_r3s0u"]
[ext_resource type="Script" uid="uid://bbwol3mrjgn2x" path="res://world/active_world_adapter.gd" id="9_adapter"]
[sub_resource type="NavigationMesh" id="NavigationMesh_lquwl"] [sub_resource type="NavigationMesh" id="NavigationMesh_lquwl"]
vertices = PackedVector3Array(-14.5, 0.5, 0, -1, 0.5, 0, -1, 0.5, -0.75, 0, 0.5, -1, 0, 0.5, -14.5, -14.5, 0.5, -14.5, 0.75, 0.5, -1, 0.75, 0.75, -0.5, 14.5, 0.5, -0.5, 14.5, 0.5, -14.5, 0.75, 0.75, 0, 0, 1, 0, 0, 0.75, 0.75, -0.5, 0.75, 0.75, -0.5, 0.5, 14.5, 14.5, 0.5, 14.5, -1, 0.5, 0.75, -14.5, 0.5, 14.5) vertices = PackedVector3Array(-14.5, 0.5, 0, -1, 0.5, 0, -1, 0.5, -0.75, 0, 0.5, -1, 0, 0.5, -14.5, -14.5, 0.5, -14.5, 0.75, 0.5, -1, 0.75, 0.75, -0.5, 14.5, 0.5, -0.5, 14.5, 0.5, -14.5, 0.75, 0.75, 0, 0, 1, 0, 0, 0.75, 0.75, -0.5, 0.75, 0.75, -0.5, 0.5, 14.5, 14.5, 0.5, 14.5, -1, 0.5, 0.75, -14.5, 0.5, 14.5)
@@ -103,18 +104,22 @@ deadzone_forward = 2.0
current = true current = true
fov = 65.0 fov = 65.0
[node name="SimulationManager" type="Node" parent="." unique_id=1099676814] [node name="ActiveWorldAdapter" type="Node" parent="." node_paths=PackedStringArray("guard_marker", "study_marker", "rest_marker", "food_marker")]
script = ExtResource("3_lquwl") script = ExtResource("9_adapter")
guard_marker = NodePath("../ActivityMarkers/GuardZone")
study_marker = NodePath("../ActivityMarkers/StudyZone")
rest_marker = NodePath("../ActivityMarkers/RestZone")
food_marker = NodePath("../ActivityMarkers/FoodZone")
[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent", "guard_zone", "study_zone", "rest_zone", "food_zone")] [node name="SimulationManager" type="Node" parent="." unique_id=1099676814 node_paths=PackedStringArray("active_world_adapter")]
script = ExtResource("3_lquwl")
active_world_adapter = NodePath("../ActiveWorldAdapter")
[node name="WorldViewManager" type="Node" parent="." unique_id=763982891 node_paths=PackedStringArray("simulation_manager", "active_npcs_parent")]
script = ExtResource("4_1bvp3") script = ExtResource("4_1bvp3")
npc_visual_scene = ExtResource("5_lquwl") npc_visual_scene = ExtResource("5_lquwl")
simulation_manager = NodePath("../SimulationManager") simulation_manager = NodePath("../SimulationManager")
active_npcs_parent = NodePath("../ActiveNPCs") active_npcs_parent = NodePath("../ActiveNPCs")
guard_zone = NodePath("../ActivityMarkers/GuardZone")
study_zone = NodePath("../ActivityMarkers/StudyZone")
rest_zone = NodePath("../ActivityMarkers/RestZone")
food_zone = NodePath("../ActivityMarkers/FoodZone")
[node name="EventBus" type="Node" parent="." unique_id=1149294963] [node name="EventBus" type="Node" parent="." unique_id=1149294963]
-173
View File
@@ -55,56 +55,6 @@ func _init(
random_source.randf_range(-8.0, 8.0) random_source.randf_range(-8.0, 8.0)
) )
func simulate_tick(village: SimVillage) -> void:
if is_dead:
return
update_needs(village)
if is_dead:
return
if task_state == TASK_STATE_IDLE or task_state == TASK_STATE_COMPLETE:
choose_new_task(village)
return
if task_state == TASK_STATE_TRAVELING:
return
if task_state == TASK_STATE_WORKING:
task_progress += 1.0
if task_progress >= task_duration:
task_complete = true
task_state = TASK_STATE_COMPLETE
func update_needs(village: SimVillage) -> void:
hunger += 3.0 * village.food_modifier
match task_state:
TASK_STATE_IDLE:
energy -= 0.5
TASK_STATE_TRAVELING:
energy -= 2.0
TASK_STATE_WORKING:
if current_task != SimulationIds.ACTION_REST:
energy -= 3.0
TASK_STATE_COMPLETE:
energy -= 0.25
hunger = clamp(hunger, 0.0, 100.0)
energy = clamp(energy, 0.0, 100.0)
is_starving = hunger >= 90.0
if is_starving:
starvation_ticks += 1
else:
starvation_ticks = 0
if starvation_ticks >= starvation_death_threshold:
die_from_starvation()
func die_from_starvation() -> void: func die_from_starvation() -> void:
is_dead = true is_dead = true
current_task = SimulationIds.ACTION_DEAD current_task = SimulationIds.ACTION_DEAD
@@ -116,30 +66,6 @@ func die_from_starvation() -> void:
if debug_logs: if debug_logs:
print("[SimNPC] ", npc_name, " died from starvation.") print("[SimNPC] ", npc_name, " died from starvation.")
func choose_new_task(village: SimVillage) -> void:
if is_dead:
return
if is_starving:
if village.food > 0:
set_task(SimulationIds.ACTION_EAT, 1.0)
else:
set_task(SimulationIds.ACTION_GATHER_FOOD, 4.0)
return
if hunger > 75.0:
if village.food > 0:
set_task(SimulationIds.ACTION_EAT)
else:
set_task(SimulationIds.ACTION_GATHER_FOOD)
return
if energy < 25.0:
set_task(SimulationIds.ACTION_REST)
return
set_task(choose_best_work_task(village))
func should_eat_immediately_after_task() -> bool: func should_eat_immediately_after_task() -> bool:
return ( return (
current_task == SimulationIds.ACTION_GATHER_FOOD current_task == SimulationIds.ACTION_GATHER_FOOD
@@ -147,105 +73,6 @@ func should_eat_immediately_after_task() -> bool:
and not is_dead and not is_dead
) )
func choose_best_work_task(village: SimVillage) -> StringName:
var food_score := calculate_task_score(
SimulationIds.ACTION_GATHER_FOOD,
village.food_priority,
village.food,
15.0,
30.0,
3.0,
1.5
)
var wood_score := calculate_task_score(
SimulationIds.ACTION_GATHER_WOOD,
village.wood_priority,
village.wood,
10.0,
20.0,
2.5,
1.0
)
var safety_score := calculate_task_score(
SimulationIds.ACTION_PATROL,
village.safety_priority,
village.safety,
30.0,
50.0,
3.0,
1.5
)
var knowledge_score := calculate_task_score(
SimulationIds.ACTION_STUDY,
village.knowledge_priority,
village.knowledge,
10.0,
25.0,
1.5,
0.75
)
var best_task := SimulationIds.ACTION_GATHER_FOOD
var best_score := food_score
if wood_score > best_score:
best_task = SimulationIds.ACTION_GATHER_WOOD
best_score = wood_score
if safety_score > best_score:
best_task = SimulationIds.ACTION_PATROL
best_score = safety_score
if knowledge_score > best_score:
best_task = SimulationIds.ACTION_STUDY
best_score = knowledge_score
return best_task
func calculate_task_score(
action_id: StringName,
base_priority: float,
resource_amount: float,
critical_threshold: float,
low_threshold: float,
critical_bonus: float,
low_bonus: float
) -> float:
var score := base_priority
if resource_amount < critical_threshold:
score += critical_bonus
elif resource_amount < low_threshold:
score += low_bonus
var definition := SimulationDefinitions.get_action(action_id)
if (
definition != null
and not definition.preferred_profession_id.is_empty()
and profession == definition.preferred_profession_id
):
score += 2.0
if action_id == last_task:
score -= 1.5
score += random_source.randf_range(0.0, 0.5)
if debug_logs:
print(
"[SimNPC] ",
npc_name,
" score ",
action_id,
" = ",
score
)
return score
func set_task(action_id: StringName, duration: float = -1.0) -> void: func set_task(action_id: StringName, duration: float = -1.0) -> void:
var definition := SimulationDefinitions.get_action(action_id) var definition := SimulationDefinitions.get_action(action_id)
if definition == null: if definition == null:
+50 -28
View File
@@ -7,6 +7,8 @@ signal npc_task_changed(
) )
signal village_changed(village: SimVillage) signal village_changed(village: SimVillage)
signal npc_died(npc: SimNPC) signal npc_died(npc: SimNPC)
signal npc_target_requested(npc: SimNPC)
signal npc_travel_requested(npc: SimNPC, target_position: Vector3)
var village := SimVillage.new() var village := SimVillage.new()
@@ -14,11 +16,15 @@ var npcs: Array[SimNPC] = []
@export var tick_interval := 1.2 @export var tick_interval := 1.2
@export var simulation_seed: int = 1337 @export var simulation_seed: int = 1337
@export var debug_logs := true @export var debug_logs := true
@export var active_world_adapter: Node
var clock: SimulationClock var clock: SimulationClock
var tick_count := 0 var tick_count := 0
var wander_random_sources := {} var wander_random_sources := {}
var resource_states: Dictionary = {} var resource_states: Dictionary = {}
var action_selector := ActionSelectionSystem.new()
var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.new()
var names := [ var names := [
"Amina", "Tarik", "Jasmin" "Amina", "Tarik", "Jasmin"
@@ -97,7 +103,24 @@ func simulate_tick() -> void:
var was_complete := npc.task_complete var was_complete := npc.task_complete
var was_dead := npc.is_dead var was_dead := npc.is_dead
npc.simulate_tick(village) action_executor.advance_npc(npc, village)
if (
not npc.is_dead
and old_state in [
SimNPC.TASK_STATE_IDLE,
SimNPC.TASK_STATE_COMPLETE
]
and npc.task_state in [
SimNPC.TASK_STATE_IDLE,
SimNPC.TASK_STATE_COMPLETE
]
):
var selection := action_selector.select_action(npc, village)
if selection != null:
npc.set_task(
selection.action_id,
selection.duration_override
)
if old_task != npc.current_task and old_target != &"": if old_task != npc.current_task and old_target != &"":
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
@@ -113,6 +136,7 @@ func simulate_tick() -> void:
if old_task != npc.current_task and not npc.is_dead: if old_task != npc.current_task and not npc.is_dead:
npc_task_changed.emit(npc, old_task, npc.current_task) npc_task_changed.emit(npc, old_task, npc.current_task)
npc_target_requested.emit(npc)
if not was_complete and npc.task_complete and not npc.is_dead: if not was_complete and npc.task_complete and not npc.is_dead:
var completed_task := npc.current_task var completed_task := npc.current_task
@@ -162,6 +186,7 @@ func simulate_tick() -> void:
if needs_immediate_food_after_gather and village.food > 0: if needs_immediate_food_after_gather and village.food > 0:
npc.set_task(SimulationIds.ACTION_EAT, 1.0) npc.set_task(SimulationIds.ACTION_EAT, 1.0)
npc_task_changed.emit(npc, completed_task, npc.current_task) npc_task_changed.emit(npc, completed_task, npc.current_task)
npc_target_requested.emit(npc)
if debug_logs: if debug_logs:
print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.") print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.")
@@ -255,6 +280,7 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
npc.last_task = failed_task npc.last_task = failed_task
npc.set_task(SimulationIds.ACTION_WANDER, 2.0) npc.set_task(SimulationIds.ACTION_WANDER, 2.0)
npc_task_changed.emit(npc, failed_task, npc.current_task) npc_task_changed.emit(npc, failed_task, npc.current_task)
npc_target_requested.emit(npc)
if debug_logs: if debug_logs:
print("[SimulationManager] ", npc.npc_name, " could not navigate for ", failed_task, " and is trying a wander target") print("[SimulationManager] ", npc.npc_name, " could not navigate for ", failed_task, " and is trying a wander target")
@@ -263,6 +289,29 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
func notify_npc_target_unavailable(npc_id: int) -> void: func notify_npc_target_unavailable(npc_id: int) -> void:
notify_npc_navigation_failed(npc_id) notify_npc_navigation_failed(npc_id)
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
if active_world_adapter == null:
push_error("SimulationManager: active_world_adapter is missing")
return false
for npc in npcs:
if npc.id != npc_id:
continue
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
return false
var result := target_resolver.resolve(
npc,
origin,
self,
active_world_adapter
)
if result.is_empty():
notify_npc_target_unavailable(npc.id)
return false
npc.target_id = StringName(result.get("target_id", ""))
npc_travel_requested.emit(npc, result["position"])
return true
return false
func register_loaded_resource_nodes() -> void: func register_loaded_resource_nodes() -> void:
for node in ResourceNode.get_all(): for node in ResourceNode.get_all():
register_resource_node(node) register_resource_node(node)
@@ -305,33 +354,6 @@ func release_resource(node_id: StringName, agent_id: int) -> void:
if resource_state != null: if resource_state != null:
resource_state.release(agent_id) resource_state.release(agent_id)
func acquire_resource_target(
action_id: StringName,
agent_id: int,
from_position: Vector3
) -> ResourceNode:
var best: ResourceNode
var best_distance := INF
for node in ResourceNode.get_all():
var resource_state := get_resource_state(node.node_id)
if resource_state == null:
continue
if resource_state.get_action_id() != action_id:
continue
if not resource_state.can_npc_use():
continue
if not resource_state.is_available_for(agent_id):
continue
var distance := from_position.distance_squared_to(
node.interaction_point.global_position
)
if distance < best_distance:
best_distance = distance
best = node
if best == null or not reserve_resource(best.node_id, agent_id):
return null
return best
func find_resource_node_for_player( func find_resource_node_for_player(
from_position: Vector3, from_position: Vector3,
max_distance: float max_distance: float
@@ -0,0 +1,32 @@
class_name ActionExecutionSystem
extends RefCounted
func advance_npc(npc: SimNPC, village: SimVillage) -> void:
if npc.is_dead:
return
_update_needs(npc, village)
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_WORKING:
return
npc.task_progress += 1.0
if npc.task_progress >= npc.task_duration:
npc.task_complete = true
npc.task_state = SimNPC.TASK_STATE_COMPLETE
func _update_needs(npc: SimNPC, village: SimVillage) -> void:
npc.hunger += 3.0 * village.food_modifier
match npc.task_state:
SimNPC.TASK_STATE_IDLE:
npc.energy -= 0.5
SimNPC.TASK_STATE_TRAVELING:
npc.energy -= 2.0
SimNPC.TASK_STATE_WORKING:
if npc.current_task != SimulationIds.ACTION_REST:
npc.energy -= 3.0
SimNPC.TASK_STATE_COMPLETE:
npc.energy -= 0.25
npc.hunger = clamp(npc.hunger, 0.0, 100.0)
npc.energy = clamp(npc.energy, 0.0, 100.0)
npc.is_starving = npc.hunger >= 90.0
npc.starvation_ticks = npc.starvation_ticks + 1 if npc.is_starving else 0
if npc.starvation_ticks >= npc.starvation_death_threshold:
npc.die_from_starvation()
@@ -0,0 +1 @@
uid://bcoht6j0fvva3
@@ -0,0 +1,12 @@
class_name ActionSelectionResult
extends RefCounted
var action_id: StringName
var duration_override := -1.0
func _init(
selected_action_id: StringName,
selected_duration_override: float = -1.0
) -> void:
action_id = selected_action_id
duration_override = selected_duration_override
@@ -0,0 +1 @@
uid://dy0n3lije1lec
@@ -0,0 +1,81 @@
class_name ActionSelectionSystem
extends RefCounted
func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
if npc.is_dead:
return null
if npc.is_starving:
if village.food > 0:
return ActionSelectionResult.new(SimulationIds.ACTION_EAT, 1.0)
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD, 4.0)
if npc.hunger > 75.0:
if village.food > 0:
return ActionSelectionResult.new(SimulationIds.ACTION_EAT)
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD)
if npc.energy < 25.0:
return ActionSelectionResult.new(SimulationIds.ACTION_REST)
return ActionSelectionResult.new(_choose_best_work_action(npc, village))
func _choose_best_work_action(
npc: SimNPC,
village: SimVillage
) -> StringName:
var scores := {
SimulationIds.ACTION_GATHER_FOOD: _calculate_score(
npc, SimulationIds.ACTION_GATHER_FOOD, village.food_priority,
village.food, 15.0, 30.0, 3.0, 1.5
),
SimulationIds.ACTION_GATHER_WOOD: _calculate_score(
npc, SimulationIds.ACTION_GATHER_WOOD, village.wood_priority,
village.wood, 10.0, 20.0, 2.5, 1.0
),
SimulationIds.ACTION_PATROL: _calculate_score(
npc, SimulationIds.ACTION_PATROL, village.safety_priority,
village.safety, 30.0, 50.0, 3.0, 1.5
),
SimulationIds.ACTION_STUDY: _calculate_score(
npc, SimulationIds.ACTION_STUDY, village.knowledge_priority,
village.knowledge, 10.0, 25.0, 1.5, 0.75
)
}
var best_action := SimulationIds.ACTION_GATHER_FOOD
var best_score: float = scores[best_action]
for action_id in [
SimulationIds.ACTION_GATHER_WOOD,
SimulationIds.ACTION_PATROL,
SimulationIds.ACTION_STUDY
]:
var score: float = scores[action_id]
if score > best_score:
best_action = action_id
best_score = score
return best_action
func _calculate_score(
npc: SimNPC,
action_id: StringName,
base_priority: float,
resource_amount: float,
critical_threshold: float,
low_threshold: float,
critical_bonus: float,
low_bonus: float
) -> float:
var score := base_priority
if resource_amount < critical_threshold:
score += critical_bonus
elif resource_amount < low_threshold:
score += low_bonus
var definition := SimulationDefinitions.get_action(action_id)
if (
definition != null
and not definition.preferred_profession_id.is_empty()
and npc.profession == definition.preferred_profession_id
):
score += 2.0
if action_id == npc.last_task:
score -= 1.5
score += npc.random_source.randf_range(0.0, 0.5)
if npc.debug_logs:
print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score)
return score
@@ -0,0 +1 @@
uid://c550deaitwskn
@@ -0,0 +1,60 @@
class_name ActionTargetResolver
extends RefCounted
func resolve(
npc: SimNPC,
origin: Vector3,
simulation_manager: Node,
active_world_adapter: Node
) -> Dictionary:
var definition := SimulationDefinitions.get_action(npc.current_task)
if definition == null:
return {}
match definition.target_type:
SimulationIds.TARGET_RESOURCE:
return _resolve_resource(
npc, origin, definition, simulation_manager,
active_world_adapter
)
SimulationIds.TARGET_ACTIVITY:
return active_world_adapter.get_activity_target(npc.current_task)
SimulationIds.TARGET_FREE:
return {
"target_id": "",
"position": origin + simulation_manager.get_wander_offset(npc.id)
}
return {}
func _resolve_resource(
npc: SimNPC,
origin: Vector3,
definition: ActionDefinition,
simulation_manager: Node,
active_world_adapter: Node
) -> Dictionary:
var best: Dictionary = {}
var best_distance := INF
for candidate in active_world_adapter.get_resource_candidates(
definition.resource_action_id
):
var node_id := StringName(candidate["target_id"])
var state: ResourceStateRecord = simulation_manager.get_resource_state(
node_id
)
if (
state == null
or not state.can_npc_use()
or not state.is_available_for(npc.id)
):
continue
var position: Vector3 = candidate["position"]
var distance := origin.distance_squared_to(position)
if distance < best_distance:
best_distance = distance
best = candidate
if best.is_empty():
return {}
var target_id := StringName(best["target_id"])
if not simulation_manager.reserve_resource(target_id, npc.id):
return {}
return best
@@ -0,0 +1 @@
uid://cq8srsm762mnx
+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.set_task(SimulationIds.ACTION_STUDY, 2.0)
worker.start_working() worker.start_working()
worker.simulate_tick(village) var executor := ActionExecutionSystem.new()
worker.simulate_tick(village) executor.advance_npc(worker, village)
executor.advance_npc(worker, village)
_check(worker.task_complete, "A two-tick working task should complete") _check(worker.task_complete, "A two-tick working task should complete")
var starving := SimNPC.new( var starving := SimNPC.new(
@@ -74,7 +75,7 @@ func _run() -> void:
) )
starving.hunger = 100.0 starving.hunger = 100.0
starving.starvation_death_threshold = 1 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") _check(starving.is_dead, "Starvation threshold should still kill an NPC")
if failures.is_empty(): 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] "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() var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.debug_logs = false manager.debug_logs = false
manager.active_world_adapter = adapter
root.add_child(manager) root.add_child(manager)
manager.set_process(false) manager.set_process(false)
await process_frame await process_frame
var selected: ResourceNode = manager.acquire_resource_target( var npc: SimNPC = manager.npcs[0]
SimulationIds.ACTION_GATHER_FOOD, npc.set_task(SimulationIds.ACTION_GATHER_FOOD)
777, _check(
Vector3.ZERO 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: if selected != null:
var selected_state: ResourceStateRecord = manager.get_resource_state( var selected_state: ResourceStateRecord = manager.get_resource_state(
selected.node_id selected.node_id
) )
_check( _check(
selected_state.get_reserved_by() == 777, selected_state.get_reserved_by() == npc.id,
"Selected Jajce resource should be authoritatively reserved" "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(): if failures.is_empty():
print("[TEST] Jajce scaffold passed: Terrain3D, stable IDs, 24 navigation routes") print("[TEST] Jajce scaffold passed: Terrain3D, stable IDs, 24 navigation routes")
+33
View File
@@ -0,0 +1,33 @@
class_name ActiveWorldAdapter
extends Node
@export var guard_marker: Marker3D
@export var study_marker: Marker3D
@export var rest_marker: Marker3D
@export var food_marker: Marker3D
func get_resource_candidates(action_id: StringName) -> Array[Dictionary]:
var candidates: Array[Dictionary] = []
for node in ResourceNode.get_all():
if node.action_id != action_id or node.interaction_point == null:
continue
candidates.append({
"target_id": String(node.node_id),
"position": node.interaction_point.global_position
})
return candidates
func get_activity_target(action_id: StringName) -> Dictionary:
var marker: Marker3D
match action_id:
SimulationIds.ACTION_PATROL:
marker = guard_marker
SimulationIds.ACTION_STUDY:
marker = study_marker
SimulationIds.ACTION_REST:
marker = rest_marker
SimulationIds.ACTION_EAT:
marker = food_marker
if marker == null:
return {}
return {"target_id": "", "position": marker.global_position}
+1
View File
@@ -0,0 +1 @@
uid://bbwol3mrjgn2x
+30 -162
View File
@@ -6,11 +6,6 @@ const DEBUG_LOGS := true
@export var simulation_manager: Node @export var simulation_manager: Node
@export var active_npcs_parent: Node3D @export var active_npcs_parent: Node3D
@export var guard_zone: Marker3D
@export var study_zone: Marker3D
@export var rest_zone: Marker3D
@export var food_zone: Marker3D
var active_npc_visuals := {} var active_npc_visuals := {}
func debug_log(message: String) -> void: func debug_log(message: String) -> void:
@@ -23,227 +18,100 @@ func _ready() -> void:
func initialize_world_view() -> void: func initialize_world_view() -> void:
await get_tree().physics_frame await get_tree().physics_frame
spawn_initial_npcs() spawn_initial_npcs()
if simulation_manager.has_signal("npc_target_requested"):
if simulation_manager.has_signal("npc_task_changed"): simulation_manager.npc_target_requested.connect(
simulation_manager.npc_task_changed.connect(_on_npc_task_changed) _on_npc_target_requested
)
else: else:
push_error("WorldViewManager: SimulationManager has no npc_task_changed signal") push_error("WorldViewManager: missing npc_target_requested signal")
if simulation_manager.has_signal("npc_travel_requested"):
simulation_manager.npc_travel_requested.connect(
_on_npc_travel_requested
)
else:
push_error("WorldViewManager: missing npc_travel_requested signal")
if simulation_manager.has_signal("npc_died"): if simulation_manager.has_signal("npc_died"):
simulation_manager.npc_died.connect(_on_npc_died) simulation_manager.npc_died.connect(_on_npc_died)
else: else:
push_error("WorldViewManager: SimulationManager has no npc_died signal") push_error("WorldViewManager: SimulationManager has no npc_died signal")
request_current_targets()
update_npc_targets()
func spawn_initial_npcs() -> void: func spawn_initial_npcs() -> void:
if simulation_manager == null: if simulation_manager == null:
push_error("WorldViewManager: simulation_manager is missing") push_error("WorldViewManager: simulation_manager is missing")
return return
if npc_visual_scene == null: if npc_visual_scene == null:
push_error("WorldViewManager: npc_visual_scene is missing") push_error("WorldViewManager: npc_visual_scene is missing")
return return
if active_npcs_parent == null: if active_npcs_parent == null:
push_error("WorldViewManager: active_npcs_parent is missing") push_error("WorldViewManager: active_npcs_parent is missing")
return return
debug_log("Spawning NPC visuals. Count: %s" % simulation_manager.npcs.size()) debug_log("Spawning NPC visuals. Count: %s" % simulation_manager.npcs.size())
for npc in simulation_manager.npcs: for npc in simulation_manager.npcs:
spawn_npc_visual(npc) spawn_npc_visual(npc)
func spawn_npc_visual(npc: SimNPC) -> void: func spawn_npc_visual(npc: SimNPC) -> void:
if active_npc_visuals.has(npc.id): if active_npc_visuals.has(npc.id):
return return
var visual = npc_visual_scene.instantiate() var visual = npc_visual_scene.instantiate()
active_npcs_parent.add_child(visual) active_npcs_parent.add_child(visual)
visual.global_position = npc.position visual.global_position = npc.position
visual.setup_from_sim(npc) visual.setup_from_sim(npc)
if visual.has_signal("arrived_at_target"): if visual.has_signal("arrived_at_target"):
visual.arrived_at_target.connect(_on_npc_visual_arrived) visual.arrived_at_target.connect(_on_npc_visual_arrived)
else: else:
push_error("WorldViewManager: NPCVisual has no arrived_at_target signal") push_error("WorldViewManager: NPCVisual has no arrived_at_target signal")
if visual.has_signal("navigation_failed"): if visual.has_signal("navigation_failed"):
visual.navigation_failed.connect(_on_npc_visual_navigation_failed) visual.navigation_failed.connect(_on_npc_visual_navigation_failed)
else: else:
push_error("WorldViewManager: NPCVisual has no navigation_failed signal") push_error("WorldViewManager: NPCVisual has no navigation_failed signal")
active_npc_visuals[npc.id] = visual active_npc_visuals[npc.id] = visual
debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position]) debug_log("Spawned visual for %s at %s" % [npc.npc_name, npc.position])
func update_npc_targets() -> void: func request_current_targets() -> void:
if simulation_manager == null:
return
for npc in simulation_manager.npcs: for npc in simulation_manager.npcs:
if not active_npc_visuals.has(npc.id): if npc.task_state != SimNPC.TASK_STATE_TRAVELING:
continue continue
_on_npc_target_requested(npc)
var visual = active_npc_visuals[npc.id] func _on_npc_target_requested(npc: SimNPC) -> void:
var task = npc.current_task
if task in [
&"",
SimulationIds.ACTION_IDLE,
SimulationIds.ACTION_DEAD
]:
continue
var resource_pos = _try_get_resource_target(npc, npc.current_task)
if resource_pos != null:
visual.set_target_position(resource_pos)
continue
if task == SimulationIds.ACTION_WANDER:
var offset := _get_wander_offset(npc.id)
visual.set_target_position(visual.global_position + offset)
continue
var definition := SimulationDefinitions.get_action(task)
if (
definition != null
and definition.target_type == SimulationIds.TARGET_RESOURCE
):
if simulation_manager.has_method("notify_npc_target_unavailable"):
simulation_manager.notify_npc_target_unavailable(npc.id)
continue
var target = get_target_for_task(task)
if target == null:
debug_log("No target for task: %s" % task)
continue
visual.set_target_position(target.global_position)
func get_target_for_task(task: StringName) -> Marker3D:
match task:
SimulationIds.ACTION_PATROL:
return guard_zone
SimulationIds.ACTION_STUDY:
return study_zone
SimulationIds.ACTION_REST:
return rest_zone
SimulationIds.ACTION_EAT:
return food_zone
_:
return rest_zone
func _try_get_resource_target(npc: SimNPC, task: StringName) -> Variant:
var definition := SimulationDefinitions.get_action(task)
if (
definition == null
or definition.target_type != SimulationIds.TARGET_RESOURCE
):
return null
var visual = active_npc_visuals.get(npc.id) as Node3D var visual = active_npc_visuals.get(npc.id) as Node3D
if visual == null: if visual == null:
return null return
if not simulation_manager.resolve_npc_target(
if not simulation_manager.has_method("acquire_resource_target"):
push_error("WorldViewManager: SimulationManager cannot acquire resources")
return null
var node: ResourceNode = simulation_manager.acquire_resource_target(
definition.resource_action_id,
npc.id, npc.id,
visual.global_position visual.global_position
)
if node == null:
debug_log("No available resource node for %s" % task)
return null
npc.target_id = node.node_id
debug_log("%s reserved %s for %s" % [npc.npc_name, node.node_id, task])
return node.interaction_point.global_position
func _on_npc_task_changed(
npc: SimNPC,
old_task: StringName,
new_task: StringName
) -> void:
if DEBUG_LOGS:
debug_log("%s changed task: %s -> %s" % [npc.npc_name, old_task, new_task])
if not active_npc_visuals.has(npc.id):
return
if new_task in [
&"",
SimulationIds.ACTION_IDLE,
SimulationIds.ACTION_DEAD
]:
return
var visual = active_npc_visuals[npc.id]
var resource_pos = _try_get_resource_target(npc, new_task)
if resource_pos != null:
visual.set_target_position(resource_pos)
return
if new_task == SimulationIds.ACTION_WANDER:
var offset := _get_wander_offset(npc.id)
visual.set_target_position(visual.global_position + offset)
return
var definition := SimulationDefinitions.get_action(new_task)
if (
definition != null
and definition.target_type == SimulationIds.TARGET_RESOURCE
): ):
if simulation_manager.has_method("notify_npc_target_unavailable"): debug_log("Target resolution failed for %s" % npc.npc_name)
simulation_manager.notify_npc_target_unavailable(npc.id)
else: func _on_npc_travel_requested(
push_error("WorldViewManager: SimulationManager cannot handle unavailable targets") npc: SimNPC,
target_position: Vector3
) -> void:
var visual = active_npc_visuals.get(npc.id)
if visual == null:
return return
visual.set_target_position(target_position)
var target := get_target_for_task(new_task)
if target != null:
visual.set_target_position(target.global_position)
func _get_wander_offset(npc_id: int) -> Vector3:
if simulation_manager != null and simulation_manager.has_method("get_wander_offset"):
return simulation_manager.get_wander_offset(npc_id)
push_error("WorldViewManager: SimulationManager cannot provide deterministic wander offsets")
return Vector3.ZERO
func _on_npc_visual_arrived(sim_id: int) -> void: func _on_npc_visual_arrived(sim_id: int) -> void:
debug_log("NPC visual arrived. sim_id=%s" % sim_id) debug_log("NPC visual arrived. sim_id=%s" % sim_id)
if simulation_manager == null: if simulation_manager == null:
push_error("WorldViewManager: simulation_manager missing during arrival report") push_error("WorldViewManager: simulation_manager missing during arrival")
return return
if simulation_manager.has_method("notify_npc_arrived"):
simulation_manager.notify_npc_arrived(sim_id) simulation_manager.notify_npc_arrived(sim_id)
else:
push_error("WorldViewManager: SimulationManager has no notify_npc_arrived method")
func _on_npc_visual_navigation_failed(sim_id: int) -> void: func _on_npc_visual_navigation_failed(sim_id: int) -> void:
debug_log("NPC visual navigation failed. sim_id=%s" % sim_id) debug_log("NPC visual navigation failed. sim_id=%s" % sim_id)
if simulation_manager == null: if simulation_manager == null:
push_error("WorldViewManager: simulation_manager missing during navigation failure") push_error("WorldViewManager: simulation_manager missing during failure")
return return
if simulation_manager.has_method("notify_npc_navigation_failed"):
simulation_manager.notify_npc_navigation_failed(sim_id) simulation_manager.notify_npc_navigation_failed(sim_id)
else:
push_error("WorldViewManager: SimulationManager has no notify_npc_navigation_failed method")
func _on_npc_died(npc: SimNPC) -> void: func _on_npc_died(npc: SimNPC) -> void:
if not active_npc_visuals.has(npc.id): if not active_npc_visuals.has(npc.id):
return return
var visual = active_npc_visuals[npc.id] var visual = active_npc_visuals[npc.id]
if visual.has_method("apply_dead_visual_state"): if visual.has_method("apply_dead_visual_state"):
visual.apply_dead_visual_state() visual.apply_dead_visual_state()
else: else:
push_error("WorldViewManager: NPCVisual has no apply_dead_visual_state method") push_error("WorldViewManager: NPCVisual has no apply_dead_visual_state")