From d8831481328f05516c2af82675a7f8263e6c31a0 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 5 Jul 2026 23:50:18 +0200 Subject: [PATCH] feat: add npc decision inspector --- main.tscn | 23 +++++ player/npc/NpcVisual.gd | 61 ++++++++++++ player/npc/NpcVisual.tscn | 12 +++ simulation/SimulationManager.gd | 9 ++ simulation/actions/ActionSelectionResult.gd | 11 ++- simulation/actions/ActionSelectionSystem.gd | 40 +++++--- .../definitions/ProfessionDefinition.gd | 4 + .../definitions/professions/farmer.tres | 2 + simulation/definitions/professions/guard.tres | 2 + .../definitions/professions/scholar.tres | 2 + .../definitions/professions/wanderer.tres | 2 + .../definitions/professions/woodcutter.tres | 2 + tests/action_system_boundaries_test.gd | 11 +++ tests/npc_visual_lifecycle_test.gd | 28 ++++++ tests/simulation_definitions_test.gd | 10 +- world/ui/ui.gd | 99 +++++++++++++++++++ 16 files changed, 305 insertions(+), 13 deletions(-) diff --git a/main.tscn b/main.tscn index 5c57273..73af81e 100644 --- a/main.tscn +++ b/main.tscn @@ -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" diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 7b622e5..56ced4b 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -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() diff --git a/player/npc/NpcVisual.tscn b/player/npc/NpcVisual.tscn index 333e3de..619f12a 100644 --- a/player/npc/NpcVisual.tscn +++ b/player/npc/NpcVisual.tscn @@ -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) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index c10ecc5..2a5babe 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -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: diff --git a/simulation/actions/ActionSelectionResult.gd b/simulation/actions/ActionSelectionResult.gd index c600f2b..08a6dfe 100644 --- a/simulation/actions/ActionSelectionResult.gd +++ b/simulation/actions/ActionSelectionResult.gd @@ -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) diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 1e36075..086f372 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -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( diff --git a/simulation/definitions/ProfessionDefinition.gd b/simulation/definitions/ProfessionDefinition.gd index 8756727..8c906c2 100644 --- a/simulation/definitions/ProfessionDefinition.gd +++ b/simulation/definitions/ProfessionDefinition.gd @@ -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 diff --git a/simulation/definitions/professions/farmer.tres b/simulation/definitions/professions/farmer.tres index 65653e5..7cc4b33 100644 --- a/simulation/definitions/professions/farmer.tres +++ b/simulation/definitions/professions/farmer.tres @@ -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) diff --git a/simulation/definitions/professions/guard.tres b/simulation/definitions/professions/guard.tres index 001467a..c9a254c 100644 --- a/simulation/definitions/professions/guard.tres +++ b/simulation/definitions/professions/guard.tres @@ -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) diff --git a/simulation/definitions/professions/scholar.tres b/simulation/definitions/professions/scholar.tres index 36a2a43..8cf3546 100644 --- a/simulation/definitions/professions/scholar.tres +++ b/simulation/definitions/professions/scholar.tres @@ -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) diff --git a/simulation/definitions/professions/wanderer.tres b/simulation/definitions/professions/wanderer.tres index a17094e..6d59761 100644 --- a/simulation/definitions/professions/wanderer.tres +++ b/simulation/definitions/professions/wanderer.tres @@ -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) diff --git a/simulation/definitions/professions/woodcutter.tres b/simulation/definitions/professions/woodcutter.tres index fa91ce1..3fbc064 100644 --- a/simulation/definitions/professions/woodcutter.tres +++ b/simulation/definitions/professions/woodcutter.tres @@ -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) diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index 216cc40..df0bd12 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -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: diff --git a/tests/npc_visual_lifecycle_test.gd b/tests/npc_visual_lifecycle_test.gd index a912955..a0beb8c 100644 --- a/tests/npc_visual_lifecycle_test.gd +++ b/tests/npc_visual_lifecycle_test.gd @@ -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) diff --git a/tests/simulation_definitions_test.gd b/tests/simulation_definitions_test.gd index 04e120c..380f698 100644 --- a/tests/simulation_definitions_test.gd +++ b/tests/simulation_definitions_test.gd @@ -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: diff --git a/world/ui/ui.gd b/world/ui/ui.gd index 0af9eae..df0a62e 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -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()