From 9ed985d6a3a8055a0d88a5446168c86ee1144e80 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Mon, 10 Aug 2026 23:36:47 +0200 Subject: [PATCH] feat: surface inspected villager's open village need in field notes --- main.tscn | 13 +++- player/VillagerInspectionResult.gd | 16 +++- player/player.gd | 98 ++++++++++++++++++++++++- tests/jajce_runtime_integration_test.gd | 24 ++++++ world/ui/villager_field_note_hud.gd | 6 ++ 5 files changed, 154 insertions(+), 3 deletions(-) diff --git a/main.tscn b/main.tscn index 1c9fc90..1815e61 100644 --- a/main.tscn +++ b/main.tscn @@ -204,7 +204,7 @@ anchors_preset = 2 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 34.0 -offset_top = -194.0 +offset_top = -236.0 offset_right = 430.0 offset_bottom = -46.0 grow_vertical = 0 @@ -279,6 +279,17 @@ theme_override_constants/outline_size = 4 theme_override_font_sizes/font_size = 13 text = "Carrying · nothing" +[node name="Need" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] +custom_minimum_size = Vector2(380, 0) +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.98, 0.78, 0.36, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9) +theme_override_constants/outline_size = 4 +theme_override_font_sizes/font_size = 13 +text = "Need" +autowrap_mode = 2 + [node name="Reason" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] custom_minimum_size = Vector2(380, 0) layout_mode = 2 diff --git a/player/VillagerInspectionResult.gd b/player/VillagerInspectionResult.gd index 21df7d2..daf0648 100644 --- a/player/VillagerInspectionResult.gd +++ b/player/VillagerInspectionResult.gd @@ -14,6 +14,11 @@ var carried_amounts: Dictionary var decision_reason: String var _has_decision_reason: bool +var has_open_need := false +var need_type: StringName +var need_progress: String +var need_response: String + func _init( inspection_npc_id: int, @@ -24,7 +29,8 @@ func _init( inspection_target_id: StringName, inspection_target_name: String, inspection_carried_amounts: Dictionary, - inspection_decision_reason := "" + inspection_decision_reason := "", + inspection_need: Dictionary = {} ) -> void: npc_id = inspection_npc_id npc_name = inspection_npc_name @@ -36,6 +42,10 @@ func _init( carried_amounts = inspection_carried_amounts.duplicate(true) _has_decision_reason = not inspection_decision_reason.strip_edges().is_empty() decision_reason = (inspection_decision_reason if _has_decision_reason else AWAITING_REASON) + has_open_need = bool(inspection_need.get("has_open_need", false)) + need_type = StringName(inspection_need.get("need_type", &"")) + need_progress = String(inspection_need.get("progress", "")) + need_response = String(inspection_need.get("response", "")) func has_decision_reason() -> bool: @@ -80,6 +90,10 @@ func cache_key() -> String: target_name, ",".join(carried_parts), decision_reason, + str(has_open_need), + String(need_type), + need_progress, + need_response, ] ) ) diff --git a/player/player.gd b/player/player.gd index c02415d..5a361b2 100644 --- a/player/player.gd +++ b/player/player.gd @@ -142,6 +142,7 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult: var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id) if decision != null and decision.action_id == npc.current_task: reason = decision.reason + var need := _get_open_need_for_npc(npc) return VillagerInspectionResult.new( npc.id, npc.npc_name, @@ -151,10 +152,105 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult: npc.target_id, target_name, carried_amounts, - reason + reason, + need ) +func _get_open_need_for_npc(npc: SimNPC) -> Dictionary: + if not simulation_manager.has_method("get_latest_opportunity_for_npc"): + return { + "has_open_need": false, + "need_type": &"", + "progress": "", + "response": "", + } + var opportunity: OpportunityStateRecord = simulation_manager.get_latest_opportunity_for_npc( + npc.id + ) + if ( + opportunity == null + or opportunity.get_status() != OpportunityStateRecord.STATUS_OPEN + or opportunity.get_interested_npc_id() != npc.id + ): + return { + "has_open_need": false, + "need_type": &"", + "progress": "", + "response": "", + } + var resource_name := String(opportunity.get_resource_id()).capitalize() + var current_amount := _get_need_storage_amount(opportunity) + var progress := ( + "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()] + ) + var response := "No one is able to supply this right now." + if simulation_manager.has_method("get_active_opportunity_helper"): + var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() + if helper != null and helper.opportunity_id == opportunity.get_opportunity_id(): + var helper_name := _get_npc_name(helper.helper_npc_id) + var action_phrase := _get_action_phrase(helper.action_id) + response = "%s can %s." % [helper_name, action_phrase] + if response == "No one is able to supply this right now.": + if simulation_manager.has_method("get_active_opportunity_player_response"): + var player_route: OpportunityPlayerResponseResult = ( + simulation_manager.get_active_opportunity_player_response() + ) + if player_route != null: + var route_phrase := ( + "forage food" + if opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD + else "harvest a tree" + ) + response = ( + "You can help — %s to the %s." + % [route_phrase, _get_need_target_name(opportunity)] + ) + return { + "has_open_need": true, + "need_type": opportunity.get_opportunity_type(), + "progress": progress, + "response": response, + } + + +func _get_need_storage_amount(opportunity: OpportunityStateRecord) -> float: + if not simulation_manager.has_method("get_pantry"): + return 0.0 + match opportunity.get_target_id(): + SimulationIds.STORAGE_VILLAGE_PANTRY: + var pantry: StorageStateRecord = simulation_manager.get_pantry() + return pantry.get_amount(opportunity.get_resource_id()) if pantry != null else 0.0 + SimulationIds.STORAGE_VILLAGE_WOODPILE: + if simulation_manager.has_method("get_woodpile"): + var woodpile: StorageStateRecord = simulation_manager.get_woodpile() + return ( + woodpile.get_amount(opportunity.get_resource_id()) if woodpile != null else 0.0 + ) + return 0.0 + + +func _get_npc_name(npc_id: int) -> String: + for npc in simulation_manager.npcs: + if npc.id == npc_id: + return npc.npc_name + return "Someone" + + +func _get_action_phrase(action_id: StringName) -> String: + var definition := SimulationDefinitions.get_action(action_id) + return definition.display_name.to_lower() if definition != null else String(action_id) + + +func _get_need_target_name(opportunity: OpportunityStateRecord) -> String: + match opportunity.get_target_id(): + SimulationIds.STORAGE_VILLAGE_PANTRY: + return "village pantry" + SimulationIds.STORAGE_VILLAGE_WOODPILE: + return "village woodpile" + return String(opportunity.get_target_id()).replace("_", " ") + + func _find_feed_animal() -> AnimalNode: if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null: push_error("Player: SimulationManager has no animal-care service") diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 8c8f6a3..9bf7285 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -214,6 +214,30 @@ func _run() -> void: ), "The interested villager should expose the open pantry need in their inspector" ) + var field_note := ( + main_scene.get_node("VillagerInspectionLayer/VillagerFieldNote") as VillagerFieldNoteHud + ) + contributor_visual.global_position = Vector3(-30.0, 0.0, -30.0) + var note_player := main_scene.get_node("Player") as Node3D + note_player.global_position = contributor_visual.global_position + Vector3(1.0, 0.0, 0.0) + var note_context: VillagerInspectionResult = note_player.get_nearby_villager_inspection() + _check( + ( + note_context != null + and note_context.npc_id == contributor.id + and note_context.has_open_need + and note_context.need_type == SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY + and note_context.need_progress == "Food 0 / 1" + and not note_context.need_response.strip_edges().is_empty() + ), + "Field note should derive the inspected villager's exact open need and response" + ) + field_note.refresh_note(true) + var need_label := field_note.get_node("Copy/Need") as Label + _check( + field_note.visible and need_label.visible and "Food 0 / 1" in need_label.text, + "Field note HUD should render the surfaced need line for its interested villager" + ) village_ui.selected_npc_index = listener.id village_ui.call("_refresh_npc_inspector") _check( diff --git a/world/ui/villager_field_note_hud.gd b/world/ui/villager_field_note_hud.gd index 55f7d34..94528a1 100644 --- a/world/ui/villager_field_note_hud.gd +++ b/world/ui/villager_field_note_hud.gd @@ -13,6 +13,7 @@ const EXIT_OFFSET := 4.0 @onready var activity_label: Label = $Copy/Activity @onready var target_label: Label = $Copy/Target @onready var carrying_label: Label = $Copy/Carrying +@onready var need_label: Label = $Copy/Need @onready var reason_label: Label = $Copy/Reason var copy_resting_position: Vector2 @@ -63,6 +64,11 @@ func _present(context: VillagerInspectionResult, animate: bool) -> void: activity_label.text = context.activity_text() target_label.text = "Target · %s" % context.target_name carrying_label.text = "Carrying · %s" % context.carrying_text() + if context.has_open_need: + need_label.text = "Need · %s\n%s" % [context.need_progress, context.need_response] + need_label.visible = true + else: + need_label.visible = false reason_label.text = "Why · %s" % context.decision_reason visible = true accent.pivot_offset = accent.size * 0.5