feat: add npc decision inspector

This commit is contained in:
2026-07-05 23:50:18 +02:00
parent 7edf9acd57
commit d883148132
16 changed files with 305 additions and 13 deletions
+23
View File
@@ -101,3 +101,26 @@ theme_override_constants/margin_bottom = 4
[node name="VillageStatsLabel" type="Label" parent="UI/VillagePanel/MarginContainer" unique_id=1554319259]
layout_mode = 2
theme_override_font_sizes/font_size = 10
[node name="NpcInspectorPanel" type="PanelContainer" parent="UI"]
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -370.0
offset_top = 20.0
offset_right = -20.0
offset_bottom = 330.0
grow_horizontal = 0
[node name="MarginContainer" type="MarginContainer" parent="UI/NpcInspectorPanel"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12
[node name="NpcInspectorLabel" type="Label" parent="UI/NpcInspectorPanel/MarginContainer"]
layout_mode = 2
theme_override_colors/font_color = Color(0.96, 0.91, 0.78, 1)
theme_override_font_sizes/font_size = 15
text = "Villager inspector"
+61
View File
@@ -12,6 +12,9 @@ const DEBUG_LOGS := true
@export var stuck_timeout := 1.5
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
@onready var body_mesh: MeshInstance3D = $MeshInstance3D
@onready var profession_prop: MeshInstance3D = $ProfessionProp
@onready var profession_label: Label3D = $ProfessionLabel
@onready var carried_food_visual: MeshInstance3D = $CarriedFood
var has_reported_arrival := false
@@ -45,9 +48,66 @@ func setup_from_sim(npc: SimNPC) -> void:
npc_name = npc.npc_name
profession = npc.profession
name = "NPC_%s_%s" % [sim_id, npc_name]
_apply_profession_presentation()
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
func _apply_profession_presentation() -> void:
var definition := SimulationDefinitions.get_profession(profession)
if definition == null:
profession_label.text = npc_name
profession_prop.visible = false
return
var body_material := StandardMaterial3D.new()
body_material.albedo_color = definition.visual_color
body_material.roughness = 0.82
body_mesh.material_override = body_material
var prop_material := StandardMaterial3D.new()
prop_material.albedo_color = definition.prop_color
prop_material.roughness = 0.78
profession_prop.material_override = prop_material
profession_prop.mesh = _create_profession_prop_mesh()
profession_prop.visible = profession_prop.mesh != null
profession_label.text = "%s\n%s" % [npc_name, definition.display_name]
func _create_profession_prop_mesh() -> PrimitiveMesh:
profession_prop.position = Vector3(0.48, 0.85, 0.0)
profession_prop.rotation_degrees = Vector3.ZERO
match profession:
SimulationIds.PROFESSION_FARMER:
var basket := SphereMesh.new()
basket.radius = 0.24
basket.height = 0.34
return basket
SimulationIds.PROFESSION_WOODCUTTER:
var tool := BoxMesh.new()
tool.size = Vector3(0.12, 0.72, 0.16)
profession_prop.position = Vector3(0.48, 0.92, 0.0)
profession_prop.rotation_degrees.z = -18.0
return tool
SimulationIds.PROFESSION_GUARD:
var shield := CylinderMesh.new()
shield.top_radius = 0.3
shield.bottom_radius = 0.3
shield.height = 0.12
profession_prop.rotation_degrees.x = 90.0
return shield
SimulationIds.PROFESSION_SCHOLAR:
var book := BoxMesh.new()
book.size = Vector3(0.42, 0.1, 0.3)
return book
SimulationIds.PROFESSION_WANDERER:
var pack := SphereMesh.new()
pack.radius = 0.28
pack.height = 0.5
profession_prop.position = Vector3(-0.42, 0.86, 0.0)
return pack
return null
func set_carried_food_visible(is_visible: bool) -> void:
carried_food_visual.visible = is_visible and not is_dead_visual
@@ -179,6 +239,7 @@ func _report_navigation_failure_once() -> void:
func apply_dead_visual_state() -> void:
is_dead_visual = true
carried_food_visual.visible = false
profession_prop.visible = false
path_request_id += 1
velocity = Vector3.ZERO
current_path = PackedVector3Array()
+12
View File
@@ -38,6 +38,18 @@ mesh = SubResource("CapsuleMesh_d844k")
transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.35, 0, 1.2, 0.59052056)
mesh = SubResource("BoxMesh_d844k")
[node name="ProfessionProp" type="MeshInstance3D" parent="."]
[node name="ProfessionLabel" type="Label3D" parent="."]
position = Vector3(0, 2.05, 0)
billboard = 1
no_depth_test = true
text = "Villager"
font_size = 24
outline_size = 6
modulate = Color(1, 0.96, 0.82, 1)
pixel_size = 0.007
[node name="CarriedFood" type="MeshInstance3D" parent="."]
visible = false
transform = Transform3D(0.75, 0, 0, 0, 0.75, 0, 0, 0, 0.75, 0.48, 0.7, 0)
+9
View File
@@ -8,6 +8,7 @@ signal npc_travel_requested(npc: SimNPC, target_position: Vector3)
signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
signal economic_event_recorded(event: EconomicEventRecord)
signal state_restored
signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult)
var village := SimVillage.new()
@@ -24,6 +25,7 @@ var resource_states: Dictionary = {}
var storage_states: Dictionary = {}
var economic_events: Array[EconomicEventRecord] = []
var next_event_id := 0
var latest_decisions: Dictionary = {}
var action_selector := ActionSelectionSystem.new()
var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.new()
@@ -111,6 +113,8 @@ func simulate_tick() -> void:
):
var selection := action_selector.select_action(npc, village)
if selection != null:
latest_decisions[npc.id] = selection
npc_decision_recorded.emit(npc, selection)
npc.set_task(selection.action_id, selection.duration_override)
if old_task != npc.current_task and old_target != &"":
@@ -679,6 +683,10 @@ func get_state_checksum() -> String:
return create_state_record().to_json().sha256_text()
func get_latest_decision(npc_id: int) -> ActionSelectionResult:
return latest_decisions.get(npc_id)
func create_state_record() -> SimulationStateRecord:
var record := SimulationStateRecord.new()
var wander_streams: Array[Dictionary] = []
@@ -747,6 +755,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
storage_states[storage_record.get_storage_id()] = storage_record
_sync_village_food()
economic_events = record.economic_events.duplicate()
latest_decisions.clear()
npcs.clear()
for npc_record in record.npcs:
+10 -1
View File
@@ -3,8 +3,17 @@ extends RefCounted
var action_id: StringName
var duration_override := -1.0
var reason: String
var scores: Dictionary
func _init(selected_action_id: StringName, selected_duration_override: float = -1.0) -> void:
func _init(
selected_action_id: StringName,
selected_duration_override: float = -1.0,
decision_reason: String = "",
decision_scores: Dictionary = {}
) -> void:
action_id = selected_action_id
duration_override = selected_duration_override
reason = decision_reason
scores = decision_scores.duplicate(true)
+29 -11
View File
@@ -7,24 +7,40 @@ func select_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
return null
if npc.is_starving:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(SimulationIds.ACTION_EAT, 1.0)
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food"
)
if village.food > 0:
return ActionSelectionResult.new(SimulationIds.ACTION_WITHDRAW_FOOD, 1.0)
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD, 4.0)
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, 4.0, "Starving; pantry is empty"
)
if npc.hunger > 75.0:
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
return ActionSelectionResult.new(SimulationIds.ACTION_EAT)
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Hungry and carrying food"
)
if village.food > 0:
return ActionSelectionResult.new(SimulationIds.ACTION_WITHDRAW_FOOD)
return ActionSelectionResult.new(SimulationIds.ACTION_GATHER_FOOD)
return ActionSelectionResult.new(
SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Hungry; pantry has food"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Hungry; pantry is empty"
)
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0:
return ActionSelectionResult.new(SimulationIds.ACTION_DEPOSIT_FOOD)
return ActionSelectionResult.new(
SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage"
)
if npc.energy < 25.0:
return ActionSelectionResult.new(SimulationIds.ACTION_REST)
return ActionSelectionResult.new(_choose_best_work_action(npc, village))
return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
)
return _choose_best_work_action(npc, village)
func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> StringName:
func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
var scores := {
SimulationIds.ACTION_GATHER_FOOD:
_calculate_score(
@@ -80,7 +96,9 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> StringName:
if score > best_score:
best_action = action_id
best_score = score
return best_action
return ActionSelectionResult.new(
best_action, -1.0, "Highest current work utility", scores
)
func _calculate_score(
@@ -3,6 +3,8 @@ extends Resource
@export var profession_id: StringName
@export var display_name: String
@export var visual_color := Color.WHITE
@export var prop_color := Color(0.35, 0.25, 0.15, 1.0)
func validate() -> Array[String]:
@@ -11,4 +13,6 @@ func validate() -> Array[String]:
errors.append("profession_id is empty")
if display_name.is_empty():
errors.append("display_name is empty for '%s'" % profession_id)
if visual_color.a <= 0.0:
errors.append("visual_color is transparent for '%s'" % profession_id)
return errors
@@ -6,3 +6,5 @@
script = ExtResource("1")
profession_id = &"farmer"
display_name = "Farmer"
visual_color = Color(0.36, 0.62, 0.28, 1)
prop_color = Color(0.65, 0.45, 0.2, 1)
@@ -6,3 +6,5 @@
script = ExtResource("1")
profession_id = &"guard"
display_name = "Guard"
visual_color = Color(0.25, 0.42, 0.68, 1)
prop_color = Color(0.72, 0.75, 0.78, 1)
@@ -6,3 +6,5 @@
script = ExtResource("1")
profession_id = &"scholar"
display_name = "Scholar"
visual_color = Color(0.52, 0.34, 0.68, 1)
prop_color = Color(0.78, 0.66, 0.28, 1)
@@ -6,3 +6,5 @@
script = ExtResource("1")
profession_id = &"wanderer"
display_name = "Wanderer"
visual_color = Color(0.72, 0.57, 0.3, 1)
prop_color = Color(0.38, 0.28, 0.19, 1)
@@ -6,3 +6,5 @@
script = ExtResource("1")
profession_id = &"woodcutter"
display_name = "Woodcutter"
visual_color = Color(0.67, 0.34, 0.2, 1)
prop_color = Color(0.34, 0.22, 0.14, 1)
+11
View File
@@ -53,6 +53,17 @@ func _test_selection_and_execution_are_separate() -> void:
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:
+28
View File
@@ -16,10 +16,38 @@ func _run() -> void:
var manager: Node = main_scene.get_node("SimulationManager")
var view: Node = main_scene.get_node("WorldViewManager")
var ui: CanvasLayer = main_scene.get_node("UI")
manager.set_process(false)
var npc: SimNPC = manager.npcs[0]
var visual: Node3D = view.active_npc_visuals[npc.id]
visual.set_physics_process(false)
var profession_definition := SimulationDefinitions.get_profession(npc.profession)
var body: MeshInstance3D = visual.get_node("MeshInstance3D")
_check(
body.material_override.albedo_color == profession_definition.visual_color,
"NPC body color should come from its profession definition"
)
_check(
visual.get_node("ProfessionProp").mesh != null
and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
"NPC visual should expose a profession prop and readable label"
)
var test_decision := ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD,
-1.0,
"Test shortage reason",
{SimulationIds.ACTION_GATHER_FOOD: 4.5}
)
manager.latest_decisions[npc.id] = test_decision
manager.emit_signal("npc_decision_recorded", npc, test_decision)
var inspector_label: Label = ui.get_node(
"NpcInspectorPanel/MarginContainer/NpcInspectorLabel"
)
_check(
"Test shortage reason" in inspector_label.text
and npc.npc_name in inspector_label.text,
"NPC inspector should show the selected villager's real decision reason"
)
var carried_food: MeshInstance3D = visual.get_node("CarriedFood")
_check(not carried_food.visible, "NPC should begin without a carried-food prop")
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
+9 -1
View File
@@ -38,11 +38,19 @@ func _test_registry_integrity() -> void:
var profession_ids := SimulationDefinitions.get_profession_ids()
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
var profession_colors := {}
for profession_id in profession_ids:
var profession := SimulationDefinitions.get_profession(profession_id)
_check(
SimulationDefinitions.get_profession(profession_id) != null,
profession != null,
"Profession lookup should return '%s'" % profession_id
)
if profession != null:
profession_colors[profession.visual_color.to_html()] = true
_check(
profession_colors.size() == profession_ids.size(),
"Each profession should have a distinct definition-backed visual color"
)
func _test_definition_backed_behavior() -> void:
+99
View File
@@ -5,6 +5,10 @@ const DEBUG_LOGS := false
@export var simulation_manager: Node
@onready var village_stats_label: Label = $VillagePanel/MarginContainer/VillageStatsLabel
@onready var npc_inspector_label: Label = $NpcInspectorPanel/MarginContainer/NpcInspectorLabel
var selected_npc_index := 0
var inspector_refresh_accumulator := 0.0
func _ready() -> void:
@@ -16,9 +20,34 @@ func _ready() -> void:
simulation_manager.village_changed.connect(_on_village_changed)
else:
push_error("VillageUI: SimulationManager has no village_changed signal")
if simulation_manager.has_signal("npc_decision_recorded"):
simulation_manager.npc_decision_recorded.connect(_on_npc_decision_recorded)
else:
push_error("VillageUI: SimulationManager has no npc_decision_recorded signal")
if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_state_restored)
if "village" in simulation_manager:
_on_village_changed(simulation_manager.village)
_refresh_npc_inspector()
func _process(delta: float) -> void:
inspector_refresh_accumulator += delta
if inspector_refresh_accumulator < 0.25:
return
inspector_refresh_accumulator = 0.0
_refresh_npc_inspector()
func _unhandled_key_input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.echo:
return
if event.keycode != KEY_TAB or simulation_manager.npcs.is_empty():
return
selected_npc_index = (selected_npc_index + 1) % simulation_manager.npcs.size()
_refresh_npc_inspector()
get_viewport().set_input_as_handled()
func _on_village_changed(village: SimVillage) -> void:
@@ -47,3 +76,73 @@ func _on_village_changed(village: SimVillage) -> void:
village.knowledge_modifier
]
)
func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -> void:
_refresh_npc_inspector()
func _on_state_restored() -> void:
selected_npc_index = 0
_refresh_npc_inspector()
func _refresh_npc_inspector() -> void:
if simulation_manager == null or simulation_manager.npcs.is_empty():
npc_inspector_label.text = "No villagers available"
return
selected_npc_index = clampi(selected_npc_index, 0, simulation_manager.npcs.size() - 1)
var npc: SimNPC = simulation_manager.npcs[selected_npc_index]
var profession_definition := SimulationDefinitions.get_profession(npc.profession)
var profession_name := String(npc.profession)
if profession_definition != null:
profession_name = profession_definition.display_name
var action_name := _get_action_name(npc.current_task)
var destination_name := "Resolving"
if not npc.target_id.is_empty():
destination_name = String(npc.target_id)
var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id)
var reason_text := "Awaiting the next decision"
var score_text := ""
if decision != null:
reason_text = decision.reason
var score_keys: Array = decision.scores.keys()
score_keys.sort()
var score_rows: Array[String] = []
for action_id in score_keys:
score_rows.append(
"%s %.2f" % [_get_action_name(StringName(action_id)), float(decision.scores[action_id])]
)
if not score_rows.is_empty():
score_text = "\n\nUtility\n" + "\n".join(score_rows)
npc_inspector_label.text = (
"%s · %s\n"
+ "Task: %s (%s)\n"
+ "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\n"
+ "Carrying food: %.0f\n\n"
+ "Why\n%s%s\n\n"
+ "[Tab] Next villager"
) % [
npc.npc_name,
profession_name,
action_name,
npc.task_state,
destination_name,
npc.hunger,
npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
reason_text,
score_text
]
func _get_action_name(action_id: StringName) -> String:
if action_id == SimulationIds.ACTION_IDLE:
return "Idle"
if action_id == SimulationIds.ACTION_DEAD:
return "Dead"
var definition := SimulationDefinitions.get_action(action_id)
if definition != null:
return definition.display_name
return String(action_id).capitalize()