diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index c78a1f6..17273a2 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -171,6 +171,24 @@ State restore clears active feedback and re-derives the prompt from current simulation and loaded-world facts. The HUD is separate from development overlays so the embodied action remains legible in cinematic mode. +## Player villager-inspection contract + +`WorldViewManager.find_nearest_active_npc_id()` resolves only loaded, living +NPC presentations within the player's bounded range. It compares presentation +positions and uses stable NPC ID order for equal distances; it does not inspect +tasks or choose actions. `Player.get_nearby_villager_inspection()` reacquires +that ID from the manager's current `SimNPC` records and constructs one +ephemeral `VillagerInspectionResult` from the authoritative name, action, task +state, exact target ID, and carried inventory. + +The optional decision reason is honest only when the current transient +`ActionSelectionResult` still names the NPC's live action. A missing or stale +trace renders `Awaiting the next decision`. `VillagerFieldNoteHud` formats that +result in a separate player-facing layer, so observation never changes `E` +priority or execution. Visual unload removes the context, while restore +reacquires the newly reconstructed NPC record and derives saved facts again; +neither the selection, result, nor HUD enters simulation state or checksums. + ## Compound animal-care delivery contract NPC `feed_animal` remains one task and one exact animal claim across two travel diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 853d214..6126b66 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -829,15 +829,22 @@ Completed: food sack; delivery consumes that unit and records the inventory-to-animal fact. One-unit contention, both route legs, and post-pickup continuation prove conservation without adding a saved phase field. +45. `Jajce Villager Field Note 12`: a quiet bottom-left field note now selects + the nearest loaded living villager with stable-ID tie order and presents + their authoritative name, current action and task state, exact target, and + carried inventory. A decision reason appears only while it matches the live + action; unload clears the note and restore re-derives saved facts while + awaiting the next transient reason. The note coexists with the `E` prompt, + remains in cinematic mode, and adds no saved state or simulation mutation. Next: -1. Give the player one restrained nearby-villager inspection context backed by - the authoritative task, exact target, carried item, and recorded decision - reason when it currently exists. -2. Prove that the field note follows live task changes and, after restore, - re-derives the persistent facts while honestly awaiting the next decision - reason. It must not become dialogue, quest, or saved presentation state. +1. Let an inspected villager who owns an open opportunity surface that exact + village need plus the authoritative direct-help route, capable helper, or + honest unavailable state that currently exists. +2. Prove that the ordinary finite-resource-to-storage harvest resolves an + available player route and updates the context without dialogue branching, + acceptance state, rewards, a quest log, or saved presentation state. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 3f46695..55df851 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -965,16 +965,33 @@ the losing claim. A post-pickup restore preserves the inventory, exact goat, destination, event count, and final checksum without a new saved phase field or RNG draw. -The next bounded slice should advance Milestone 9 with player-facing villager -inspection. One nearby-person context should show the authoritative name, -current action, exact target, carried item, and current recorded decision reason -when available. After restore it should honestly say that it is awaiting the -next decision while the other facts re-derive. Do not add dialogue, trade, -reputation, quest state, breeding, products, herd scheduling, animal indexing, -or active/abstract animal LOD in that slice. +Player-facing villager inspection now advances Milestone 9 without stealing the +existing `E` action. A quiet cardless field note chooses the nearest loaded, +living villager with stable-ID tie order, then re-reads that NPC's authoritative +name, action, exact target, task state, and carried inventory. It includes the +latest decision reason only while that trace still matches the live action. +Presentation unload clears the note; restore reacquires the replacement NPC +record, re-derives every saved fact, and honestly awaits the next ephemeral +decision reason. The query and HUD add no mutation, RNG draw, or saved state. + +The next bounded slice should move from observation to one honest social +affordance. When the nearby inspected villager is the interested person for an +open village opportunity, surface that exact need. Show the already-proven +finite-resource-to-storage route only when the authoritative player-response +query can currently derive it; otherwise name the capable helper or honestly +say why direct help is unavailable. Let the ordinary finite-resource harvest +resolve the need without adding a dialogue tree, quest acceptance, rewards, a +quest log, reputation, a generic conversation framework, or saved presentation +state in that slice. Recently completed: +- `Jajce Villager Field Note 12`: a separate player-facing note selects the + nearest loaded living villager and shows live name, action, task state, exact + target, carried inventory, and a currently matching decision reason. It + coexists with the `E` prompt, survives cinematic mode, clears on visual + unload, and re-derives saved facts after restore without reviving transient + reasoning or changing the schema. - `Jajce Care Delivery 11`: NPC `feed_animal` is now one compound, two-leg action from the real pantry through caretaker inventory to the exact reserved goat. Carried presentation, ordered transfer facts, late contention, diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 2b7ecbf..046f105 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -491,10 +491,11 @@ that receives the command. The UI subscribes to village, task, event, knowledge, relationship, and opportunity changes. It formats aggregate state and resolves known facts, relationship causes, and opportunity lifecycle details through manager queries -without owning or recomputing simulation facts. `village_whisper_hud.gd` and -`player_interaction_hud.gd` are separate transient player layers that format -authoritative signals or the current exact context and remain independent of -the development overlays. +without owning or recomputing simulation facts. `village_whisper_hud.gd`, +`player_interaction_hud.gd`, and `villager_field_note_hud.gd` are separate +transient player layers. They format authoritative signals, the current exact +action context, or one nearby villager's current facts while remaining +independent of the development overlays. ## Current runtime flow @@ -1023,11 +1024,17 @@ Milestone 9 context-action result shared by prompt and execution, with exact pantry feedback and no persistent UI state. NPC care now completes that physical proof with one compound pantry-to-inventory-to-goat action, exact claim continuity, visible carrying, late-contention safety, and post-pickup -deterministic continuation. The next bounded proof should let the player -inspect one nearby villager's real action, target, carried item, and recorded -decision reason when available. After restore it should re-derive persistent -facts and honestly await the next decision reason without introducing dialogue -or saved UI state. +deterministic continuation. Nearby villager inspection now selects one loaded +living presentation, reacquires its authoritative simulation record, and shows +the live action, task state, exact target, carried inventory, and only a +currently matching decision reason. It remains separate from `E`, clears on +presentation unload, and after restore re-derives saved facts while awaiting a +new transient decision trace. The next bounded proof should let an inspected +interested villager surface their exact open opportunity and whichever +authoritative response currently exists: a finite-resource-to-storage player +route, a capable helper, or an honest unavailable state. An available player +route should resolve through the ordinary harvest path without dialogue +branching, quest acceptance, rewards, or saved UI state. The remaining simulation-garden target still aims for: diff --git a/main.tscn b/main.tscn index 876ceb5..1c9fc90 100644 --- a/main.tscn +++ b/main.tscn @@ -14,6 +14,7 @@ [ext_resource type="PackedScene" path="res://player/PlayerVisual.tscn" id="14_player_visual"] [ext_resource type="Script" uid="uid://dwwxux24jc6yq" path="res://world/ui/village_whisper_hud.gd" id="15_whisper"] [ext_resource type="Script" path="res://world/ui/player_interaction_hud.gd" id="16_interaction"] +[ext_resource type="Script" path="res://world/ui/villager_field_note_hud.gd" id="17_villager_note"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] radius = 0.4 @@ -23,11 +24,12 @@ height = 1.7 [node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")] -[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "pantry_storage", "guard_site", "study_site")] +[node name="Player" type="CharacterBody3D" parent="." unique_id=2022843760 node_paths=PackedStringArray("camera_rig", "simulation_manager", "world_view_manager", "pantry_storage", "guard_site", "study_site")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0) script = ExtResource("1_h2yge") camera_rig = NodePath("../CameraRig") simulation_manager = NodePath("../SimulationManager") +world_view_manager = NodePath("../WorldViewManager") pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry") guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost") study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk") @@ -193,6 +195,101 @@ theme_override_font_sizes/font_size = 13 text = "Context" horizontal_alignment = 1 +[node name="VillagerInspectionLayer" type="CanvasLayer" parent="."] +layer = 2 + +[node name="VillagerFieldNote" type="Control" parent="VillagerInspectionLayer" node_paths=PackedStringArray("player", "simulation_manager")] +visible = false +anchors_preset = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 34.0 +offset_top = -194.0 +offset_right = 430.0 +offset_bottom = -46.0 +grow_vertical = 0 +mouse_filter = 2 +script = ExtResource("17_villager_note") +player = NodePath("../../Player") +simulation_manager = NodePath("../../SimulationManager") + +[node name="Accent" type="ColorRect" parent="VillagerInspectionLayer/VillagerFieldNote"] +layout_mode = 1 +anchors_preset = 9 +anchor_bottom = 1.0 +offset_right = 2.0 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.9, 0.63, 0.28, 0.92) + +[node name="Copy" type="VBoxContainer" parent="VillagerInspectionLayer/VillagerFieldNote"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 14.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_constants/separation = 1 +alignment = 1 + +[node name="Kicker" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.9, 0.63, 0.28, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.92) +theme_override_constants/outline_size = 4 +theme_override_font_sizes/font_size = 10 +text = "NEARBY VILLAGER" + +[node name="Name" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.98, 0.92, 0.79, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94) +theme_override_constants/outline_size = 6 +theme_override_font_sizes/font_size = 24 +text = "Villager" + +[node name="Activity" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.9, 0.84, 0.7, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.9) +theme_override_constants/outline_size = 5 +theme_override_font_sizes/font_size = 15 +text = "Current activity" + +[node name="Target" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.82, 0.75, 0.63, 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 = "Target · None recorded" + +[node name="Carrying" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.82, 0.75, 0.63, 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 = "Carrying · nothing" + +[node name="Reason" 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.88, 0.78, 0.6, 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 = "Why · Awaiting the next decision" +autowrap_mode = 2 + [node name="VillageWhisperLayer" type="CanvasLayer" parent="."] layer = 3 diff --git a/player/VillagerInspectionResult.gd b/player/VillagerInspectionResult.gd new file mode 100644 index 0000000..21df7d2 --- /dev/null +++ b/player/VillagerInspectionResult.gd @@ -0,0 +1,89 @@ +class_name VillagerInspectionResult +extends RefCounted + +const AWAITING_REASON := "Awaiting the next decision" + +var npc_id: int +var npc_name: String +var action_id: StringName +var action_name: String +var task_state: StringName +var target_id: StringName +var target_name: String +var carried_amounts: Dictionary +var decision_reason: String +var _has_decision_reason: bool + + +func _init( + inspection_npc_id: int, + inspection_npc_name: String, + inspection_action_id: StringName, + inspection_action_name: String, + inspection_task_state: StringName, + inspection_target_id: StringName, + inspection_target_name: String, + inspection_carried_amounts: Dictionary, + inspection_decision_reason := "" +) -> void: + npc_id = inspection_npc_id + npc_name = inspection_npc_name + action_id = inspection_action_id + action_name = inspection_action_name + task_state = inspection_task_state + target_id = inspection_target_id + target_name = inspection_target_name + 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) + + +func has_decision_reason() -> bool: + return _has_decision_reason + + +func activity_text() -> String: + return "%s · %s" % [action_name, _display_id(task_state)] + + +func carrying_text() -> String: + var item_ids: Array = carried_amounts.keys() + item_ids.sort() + var entries: Array[String] = [] + for item_id in item_ids: + var amount := float(carried_amounts[item_id]) + if amount <= 0.0: + continue + var amount_text := ( + "%.0f" % amount if is_equal_approx(amount, roundf(amount)) else "%.1f" % amount + ) + entries.append("%s %s" % [amount_text, _display_id(StringName(item_id)).to_lower()]) + return "nothing" if entries.is_empty() else " · ".join(entries) + + +func cache_key() -> String: + var carried_keys: Array = carried_amounts.keys() + carried_keys.sort() + var carried_parts: Array[String] = [] + for item_id in carried_keys: + carried_parts.append("%s=%.3f" % [String(item_id), float(carried_amounts[item_id])]) + return ( + "|" + . join( + [ + str(npc_id), + npc_name, + String(action_id), + action_name, + String(task_state), + String(target_id), + target_name, + ",".join(carried_parts), + decision_reason, + ] + ) + ) + + +static func _display_id(value: StringName) -> String: + return String(value).replace("_", " ").capitalize() diff --git a/player/VillagerInspectionResult.gd.uid b/player/VillagerInspectionResult.gd.uid new file mode 100644 index 0000000..093f361 --- /dev/null +++ b/player/VillagerInspectionResult.gd.uid @@ -0,0 +1 @@ +uid://dec7j7j08cipj diff --git a/player/player.gd b/player/player.gd index 3408bf8..c02415d 100644 --- a/player/player.gd +++ b/player/player.gd @@ -8,6 +8,7 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool) @export var camera_rig: Node3D @export var simulation_manager: Node +@export var world_view_manager: Node @export var pantry_storage: StorageNode @export var guard_site: ActivitySite @@ -15,6 +16,7 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool) @export var stomach_capacity_for_food := 5 @export var interaction_range := 3.0 +@export var villager_inspection_range := 3.0 func _ready() -> void: @@ -115,6 +117,44 @@ func get_interaction_context() -> PlayerInteractionResult: return null +func get_nearby_villager_inspection() -> VillagerInspectionResult: + if ( + simulation_manager == null + or world_view_manager == null + or not world_view_manager.has_method("find_nearest_active_npc_id") + ): + return null + var npc_id: int = world_view_manager.find_nearest_active_npc_id( + global_position, villager_inspection_range + ) + var npc := _find_npc(npc_id) + if npc == null: + return null + var action_name := _get_action_display_name(npc.current_task) + var target_name := _get_target_display_name(npc.target_id) + var carried_amounts := {} + for raw_item_id in npc.inventory: + var amount := npc.get_inventory_amount(StringName(raw_item_id)) + if amount > 0.0: + carried_amounts[String(raw_item_id)] = amount + var reason := "" + if simulation_manager.has_method("get_latest_decision"): + var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id) + if decision != null and decision.action_id == npc.current_task: + reason = decision.reason + return VillagerInspectionResult.new( + npc.id, + npc.npc_name, + npc.current_task, + action_name, + npc.task_state, + npc.target_id, + target_name, + carried_amounts, + reason + ) + + 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") @@ -129,6 +169,44 @@ func _find_resource_node() -> ResourceNode: return simulation_manager.find_resource_node_for_player(global_position, interaction_range) +func _find_npc(npc_id: int) -> SimNPC: + if npc_id < 0: + return null + for npc in simulation_manager.npcs: + if npc.id == npc_id and not npc.is_dead: + return npc + return null + + +func _get_action_display_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) + return definition.display_name if definition != null else _display_id(action_id) + + +func _get_target_display_name(target_id: StringName) -> String: + if target_id.is_empty(): + return "None recorded" + if "animal_care" in simulation_manager and simulation_manager.animal_care != null: + var animal_state: AnimalStateRecord = simulation_manager.animal_care.get_state(target_id) + if animal_state != null: + return animal_state.get_display_name() + var storage := StorageNode.get_by_id(target_id) as StorageNode + if storage != null: + return storage.display_name + var activity := ActivitySite.get_by_id(target_id) as ActivitySite + if activity != null: + return activity.display_name + return _display_id(target_id) + + +static func _display_id(value: StringName) -> String: + return String(value).replace("_", " ").capitalize() + + func _build_animal_context(node: AnimalNode) -> PlayerInteractionResult: var cost := _get_animal_feed_cost() var pantry_food := _get_pantry_food() diff --git a/tests/player_villager_inspection_test.gd b/tests/player_villager_inspection_test.gd new file mode 100644 index 0000000..779ad02 --- /dev/null +++ b/tests/player_villager_inspection_test.gd @@ -0,0 +1,507 @@ +extends SceneTree + +const INSPECTION_REASON := "Inspection proof: pantry stores need a careful hand." +const UPDATED_REASON := "Inspection proof: quiet study will help the village." +const STUDY_DESK_ID := &"study_desk" + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main_scene: Node = load("res://main.tscn").instantiate() + root.add_child(main_scene) + await process_frame + for _frame in 3: + await physics_frame + + var manager: Node = main_scene.get_node("SimulationManager") + var view: Node = main_scene.get_node("WorldViewManager") + var player: CharacterBody3D = main_scene.get_node("Player") + var interaction_hud := main_scene.get_node_or_null( + "PlayerInteractionLayer/PlayerInteractionHud" + ) + var field_note := main_scene.get_node_or_null("VillagerInspectionLayer/VillagerFieldNote") + manager.set_process(false) + player.set_physics_process(false) + _freeze_visuals(view) + + _check( + ( + manager.npcs.size() >= 2 + and view.has_method("find_nearest_active_npc_id") + and player.has_method("get_nearby_villager_inspection") + and interaction_hud != null + and field_note != null + and field_note.has_method("refresh_note") + ), + "The real main scene should expose loaded villager inspection beside the E prompt", + ) + if ( + manager.npcs.size() < 2 + or not view.has_method("find_nearest_active_npc_id") + or not player.has_method("get_nearby_villager_inspection") + or interaction_hud == null + or field_note == null + or not field_note.has_method("refresh_note") + ): + _finish() + return + + var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode + _check(pantry_node != null, "The inspection proof needs the authored village pantry") + if pantry_node == null: + _finish() + return + + var first: SimNPC = manager.npcs[0] + var second: SimNPC = manager.npcs[1] + var lower_id_npc := first if first.id < second.id else second + var higher_id_npc := second if lower_id_npc == first else first + var player_position: Vector3 = pantry_node.get_interaction_position() + player.global_position = player_position + player.set("interaction_range", 3.0) + _move_population_away(manager, view, player_position) + + _set_loaded_position(manager, view, lower_id_npc, player_position + Vector3(1.5, 0.0, 0.0)) + _set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(-0.75, 0.0, 0.0)) + _check( + view.find_nearest_active_npc_id(player_position, 3.0) == higher_id_npc.id, + "Loaded villager discovery should choose the nearest active presentation", + ) + + _set_loaded_position(manager, view, lower_id_npc, player_position + Vector3(1.0, 0.0, 0.0)) + _set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(-1.0, 0.0, 0.0)) + _check( + view.find_nearest_active_npc_id(player_position, 3.0) == lower_id_npc.id, + "Equal-distance loaded villagers should resolve by stable NPC ID", + ) + + _set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(30.0, 0.0, 0.0)) + var selected := lower_id_npc + _configure_deposit_inspection(manager, selected, pantry_node.get_interaction_position()) + _set_loaded_position(manager, view, selected, player_position + Vector3(1.0, 0.0, 0.0)) + + var pantry_context := player.call("get_interaction_context") as PlayerInteractionResult + var inspection := player.call("get_nearby_villager_inspection") as VillagerInspectionResult + field_note.call("refresh_note", true) + interaction_hud.call("refresh_prompt", true) + var labels := _get_note_labels(field_note) + var interaction_action := interaction_hud.get_node("Copy/Action") as Label + _check( + ( + pantry_context != null + and pantry_context.kind == PlayerInteractionResult.KIND_PANTRY + and pantry_context.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY + and pantry_context.prompt_text in interaction_action.text + and inspection != null + and field_note.visible + ), + "The automatic villager field note should coexist with the existing pantry E action", + ) + if inspection == null or labels.is_empty(): + _finish() + return + + _check_deposit_facts(inspection, selected) + _check_rendered_deposit_facts(labels, inspection, selected) + + var woodpile_node := ( + StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageNode + ) + _check(woodpile_node != null, "The inspection proof needs the authored village woodpile") + if woodpile_node == null: + _finish() + return + selected.set_task(SimulationIds.ACTION_DEPOSIT_WOOD, 1000.0) + selected.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE + selected.travel_target_position = woodpile_node.get_interaction_position() + selected.has_travel_target = true + selected.inventory.clear() + selected.add_inventory(SimulationIds.RESOURCE_WOOD, 2.0) + manager.latest_decisions[selected.id] = ( + ActionSelectionResult + . new( + SimulationIds.ACTION_DEPOSIT_WOOD, + -1.0, + "Inspection proof: carry timber to the authored woodpile.", + {SimulationIds.ACTION_DEPOSIT_WOOD: 5.0}, + ) + ) + field_note.call("refresh_note", true) + var wood_inspection := player.call("get_nearby_villager_inspection") as VillagerInspectionResult + _check( + ( + wood_inspection != null + and wood_inspection.target_id == SimulationIds.STORAGE_VILLAGE_WOODPILE + and wood_inspection.target_name == "Village Woodpile" + and woodpile_node.display_name == "Village Woodpile" + and String(labels["target"].text) == "Target · Village Woodpile" + ), + "Wood delivery inspection should preserve the exact ID and authored woodpile name", + ) + + selected.set_task(SimulationIds.ACTION_STUDY, 1000.0) + selected.target_id = STUDY_DESK_ID + selected.travel_target_position = ( + ActivitySite.get_by_id(STUDY_DESK_ID).get_interaction_position() + ) + selected.has_travel_target = true + selected.inventory.clear() + selected.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0) + ( + manager + . emit_signal( + "npc_inventory_changed", + selected, + SimulationIds.RESOURCE_WOOD, + selected.get_inventory_amount(SimulationIds.RESOURCE_WOOD), + ) + ) + field_note.call("refresh_note", true) + var stale_reason_inspection := ( + player.call("get_nearby_villager_inspection") as VillagerInspectionResult + ) + _check( + ( + stale_reason_inspection != null + and stale_reason_inspection.action_id == SimulationIds.ACTION_STUDY + and stale_reason_inspection.action_name == "Study" + and stale_reason_inspection.task_state == SimNPC.TASK_STATE_TRAVELING + and stale_reason_inspection.target_id == STUDY_DESK_ID + and stale_reason_inspection.target_name == "Study Desk" + and is_equal_approx( + _carried_amount(stale_reason_inspection, SimulationIds.RESOURCE_FOOD), 0.0 + ) + and is_equal_approx( + _carried_amount(stale_reason_inspection, SimulationIds.RESOURCE_WOOD), 1.0 + ) + and not stale_reason_inspection.has_decision_reason() + and "await" in String(labels["reason"].text).to_lower() + ), + "Live task, target, and inventory should update while a stale decision reason is withheld", + ) + + manager.latest_decisions[selected.id] = ( + ActionSelectionResult + . new( + SimulationIds.ACTION_STUDY, + -1.0, + UPDATED_REASON, + {SimulationIds.ACTION_STUDY: 4.0}, + ) + ) + field_note.call("refresh_note", true) + var updated_inspection := ( + player.call("get_nearby_villager_inspection") as VillagerInspectionResult + ) + _check( + ( + updated_inspection != null + and updated_inspection.has_decision_reason() + and updated_inspection.decision_reason == UPDATED_REASON + and UPDATED_REASON in labels["reason"].text + and "wood" in updated_inspection.carrying_text().to_lower() + and "1" in updated_inspection.carrying_text() + and "Study" in labels["activity"].text + and "Study Desk" in labels["target"].text + and "wood" in String(labels["carrying"].text).to_lower() + ), + "A matching live decision should join the updated authoritative field-note facts", + ) + + var note_position_before_resize: Vector2 = field_note.position + root.size += Vector2i(120, 80) + await process_frame + field_note.call("refresh_note", true) + var resize_tween := field_note.get("motion_tween") as Tween + if resize_tween != null: + await resize_tween.finished + _check( + ( + is_equal_approx(field_note.position.x, note_position_before_resize.x) + and is_equal_approx(field_note.position.y, note_position_before_resize.y + 80.0) + ), + "Field-note motion should preserve its bottom-anchored position after a window resize", + ) + + field_note.set("player", null) + field_note.call("refresh_note", true) + var cancelled_exit := field_note.get("motion_tween") as Tween + field_note.set("player", player) + field_note.call("refresh_note", true) + var reentry_tween := field_note.get("motion_tween") as Tween + if reentry_tween != null: + await reentry_tween.finished + _check( + ( + cancelled_exit != null + and not cancelled_exit.is_valid() + and field_note.visible + and not bool(field_note.get("is_hiding")) + and player.call("get_nearby_villager_inspection") != null + ), + "Immediate re-entry should cancel the exit without leaving a suspended hide state", + ) + + var checksum_before_queries: String = manager.get_state_checksum() + var state_before_queries: String = manager.serialize_state() + var events_before_queries: int = manager.economic_events.size() + for _query in 3: + view.find_nearest_active_npc_id(player_position, 3.0) + player.call("get_nearby_villager_inspection") + field_note.call("refresh_note", true) + _check( + ( + manager.get_state_checksum() == checksum_before_queries + and manager.serialize_state() == state_before_queries + and manager.economic_events.size() == events_before_queries + and UPDATED_REASON not in state_before_queries + ), + "Inspection queries and presentation refreshes must not mutate or serialize transient state", + ) + + var selected_id := selected.id + var selected_before_restore := selected + var saved_json := state_before_queries + _check(view.despawn_npc_visual(selected_id), "The selected villager visual should unload") + await process_frame + field_note.call("refresh_note", true) + var hiding_tween := field_note.get("motion_tween") as Tween + if hiding_tween != null: + await hiding_tween.finished + _check( + ( + view.find_nearest_active_npc_id(player_position, 3.0) == -1 + and player.call("get_nearby_villager_inspection") == null + and not field_note.visible + and manager.get_state_checksum() == checksum_before_queries + ), + "Unloading the selected presentation should clear only the ephemeral nearby field note", + ) + + selected.current_task = SimulationIds.ACTION_IDLE + selected.task_state = SimNPC.TASK_STATE_IDLE + selected.target_id = &"" + selected.has_travel_target = false + selected.inventory.clear() + _check( + manager.get_latest_decision(selected_id) != null, + "The pre-restore proof should keep a transient decision available to discard", + ) + _check( + manager.restore_state_from_json(saved_json), + "The authoritative inspected villager facts should restore through the existing schema", + ) + await process_frame + await physics_frame + _freeze_visuals(view) + var restored_npc := _find_npc(manager.npcs, selected_id) + field_note.call("refresh_note", true) + var restored_inspection := ( + player.call("get_nearby_villager_inspection") as VillagerInspectionResult + ) + _check( + ( + restored_npc != null + and restored_npc != selected_before_restore + and view.active_npc_visuals.has(selected_id) + and restored_inspection != null + and restored_inspection.npc_id == selected_id + and restored_inspection.npc_name == restored_npc.npc_name + and restored_inspection.action_id == SimulationIds.ACTION_STUDY + and restored_inspection.action_name == "Study" + and restored_inspection.task_state == SimNPC.TASK_STATE_TRAVELING + and restored_inspection.target_id == STUDY_DESK_ID + and restored_inspection.target_name == "Study Desk" + and is_equal_approx( + _carried_amount(restored_inspection, SimulationIds.RESOURCE_WOOD), 1.0 + ) + and manager.get_latest_decision(selected_id) == null + and not restored_inspection.has_decision_reason() + and restored_inspection.decision_reason == VillagerInspectionResult.AWAITING_REASON + and ( + String(labels["reason"].text) + == "Why · %s" % VillagerInspectionResult.AWAITING_REASON + ) + ), + "Restore should reacquire the loaded villager and derive saved facts without reviving a reason", + ) + if restored_npc == null or restored_inspection == null: + _finish() + return + + var demo_controller := main_scene.get_node("DemoController") + var debug_ui := main_scene.get_node("UI") as CanvasLayer + demo_controller.call("set_debug_overlay_visible", false) + field_note.call("refresh_note", true) + interaction_hud.call("refresh_prompt", true) + _check( + ( + not debug_ui.visible + and field_note.visible + and labels["kicker"].visible + and labels["name"].visible + and restored_npc.npc_name in labels["name"].text + and interaction_hud.visible + and pantry_context.prompt_text in interaction_action.text + ), + "Cinematic mode should preserve both player-facing contexts while hiding debug UI", + ) + + field_note.set("player", null) + field_note.call("refresh_note", true) + var teardown_tween := field_note.get("motion_tween") as Tween + if teardown_tween != null: + await teardown_tween.finished + _check( + not field_note.visible, + "A missing player should complete one field-note exit instead of restarting it every frame", + ) + + _finish() + + +func _configure_deposit_inspection(manager: Node, npc: SimNPC, pantry_position: Vector3) -> void: + manager.release_npc_reservation(npc.id) + npc.set_task(SimulationIds.ACTION_DEPOSIT_FOOD, 1000.0) + npc.target_id = SimulationIds.STORAGE_VILLAGE_PANTRY + npc.travel_target_position = pantry_position + npc.has_travel_target = true + npc.inventory.clear() + npc.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0) + manager.latest_decisions[npc.id] = ( + ActionSelectionResult + . new( + SimulationIds.ACTION_DEPOSIT_FOOD, + -1.0, + INSPECTION_REASON, + {SimulationIds.ACTION_DEPOSIT_FOOD: 5.0}, + ) + ) + ( + manager + . emit_signal( + "npc_inventory_changed", + npc, + SimulationIds.RESOURCE_FOOD, + npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), + ) + ) + + +func _check_deposit_facts(inspection: VillagerInspectionResult, npc: SimNPC) -> void: + _check( + ( + inspection.npc_id == npc.id + and inspection.npc_name == npc.npc_name + and inspection.action_id == SimulationIds.ACTION_DEPOSIT_FOOD + and inspection.action_name == "Deposit Food" + and inspection.task_state == SimNPC.TASK_STATE_TRAVELING + and inspection.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY + and inspection.target_name == "Village Pantry" + and is_equal_approx(_carried_amount(inspection, SimulationIds.RESOURCE_FOOD), 2.0) + and inspection.has_decision_reason() + and inspection.decision_reason == INSPECTION_REASON + and "food" in inspection.carrying_text().to_lower() + and "2" in inspection.carrying_text() + ), + "The typed inspection should expose the selected villager's exact authoritative facts", + ) + + +func _check_rendered_deposit_facts( + labels: Dictionary, inspection: VillagerInspectionResult, npc: SimNPC +) -> void: + _check( + ( + "nearby" in String(labels["kicker"].text).to_lower() + and npc.npc_name in labels["name"].text + and inspection.action_name in labels["activity"].text + and "Travel" in labels["activity"].text + and inspection.target_name in labels["target"].text + and "food" in String(labels["carrying"].text).to_lower() + and "2" in labels["carrying"].text + and inspection.decision_reason in labels["reason"].text + ), + "The restrained field note should render name, activity, target, carrying, and reason", + ) + + +func _move_population_away(manager: Node, view: Node, origin: Vector3) -> void: + manager.latest_decisions.clear() + for index in manager.npcs.size(): + var npc: SimNPC = manager.npcs[index] + _set_loaded_position( + manager, + view, + npc, + origin + Vector3(40.0 + float(index) * 2.0, 0.0, 20.0), + ) + + +func _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void: + manager.synchronize_npc_position(npc.id, position) + var visual := view.active_npc_visuals.get(npc.id) as Node3D + if visual != null: + visual.set_physics_process(false) + if visual.has_method("stop_travel"): + visual.stop_travel() + visual.global_position = position + + +func _freeze_visuals(view: Node) -> void: + for visual in view.active_npc_visuals.values(): + visual.set_physics_process(false) + if visual.has_method("stop_travel"): + visual.stop_travel() + + +func _get_note_labels(field_note: Node) -> Dictionary: + var paths := { + "kicker": "Copy/Kicker", + "name": "Copy/Name", + "activity": "Copy/Activity", + "target": "Copy/Target", + "carrying": "Copy/Carrying", + "reason": "Copy/Reason", + } + var labels := {} + for key in paths: + var label := field_note.get_node_or_null(paths[key]) as Label + _check(label != null, "Villager field note should expose %s" % paths[key]) + if label == null: + return {} + labels[key] = label + return labels + + +func _carried_amount(inspection: VillagerInspectionResult, item_id: StringName) -> float: + if inspection.carried_amounts.has(item_id): + return float(inspection.carried_amounts[item_id]) + return float(inspection.carried_amounts.get(String(item_id), 0.0)) + + +func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC: + for npc in npcs: + if npc.id == npc_id: + return npc + return null + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Player villager inspection passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/player_villager_inspection_test.gd.uid b/tests/player_villager_inspection_test.gd.uid new file mode 100644 index 0000000..2071803 --- /dev/null +++ b/tests/player_villager_inspection_test.gd.uid @@ -0,0 +1 @@ +uid://uiedaakmwr2r diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 7efc063..fa36e8c 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -638,6 +638,7 @@ visible = false [node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")] position = Vector3(8, 0, -12) storage_id = &"village_woodpile" +display_name = "Village Woodpile" [node name="PantrySignPole" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"] position = Vector3(-1.05, 1.1, 1.25) diff --git a/world/ui/villager_field_note_hud.gd b/world/ui/villager_field_note_hud.gd new file mode 100644 index 0000000..55f7d34 --- /dev/null +++ b/world/ui/villager_field_note_hud.gd @@ -0,0 +1,111 @@ +class_name VillagerFieldNoteHud +extends Control + +const ENTER_OFFSET := 9.0 +const EXIT_OFFSET := 4.0 + +@export var player: Node +@export var simulation_manager: Node + +@onready var accent: ColorRect = $Accent +@onready var copy: VBoxContainer = $Copy +@onready var name_label: Label = $Copy/Name +@onready var activity_label: Label = $Copy/Activity +@onready var target_label: Label = $Copy/Target +@onready var carrying_label: Label = $Copy/Carrying +@onready var reason_label: Label = $Copy/Reason + +var copy_resting_position: Vector2 +var current_context_key := "" +var motion_tween: Tween +var motion_version := 0 +var is_hiding := false + + +func _ready() -> void: + copy_resting_position = copy.position + visible = false + modulate.a = 0.0 + if simulation_manager != null and simulation_manager.has_signal("state_restored"): + simulation_manager.state_restored.connect(_on_state_restored) + call_deferred("refresh_note", true) + + +func _process(_delta: float) -> void: + refresh_note() + + +func refresh_note(force: bool = false) -> void: + if player == null or not player.has_method("get_nearby_villager_inspection"): + current_context_key = "" + _hide_note() + return + var context: VillagerInspectionResult = player.get_nearby_villager_inspection() + if context == null: + if force or not current_context_key.is_empty(): + current_context_key = "" + _hide_note() + return + var next_key := context.cache_key() + if not force and next_key == current_context_key: + return + current_context_key = next_key + _present(context, true) + + +func _present(context: VillagerInspectionResult, animate: bool) -> void: + motion_version += 1 + if motion_tween != null and motion_tween.is_valid(): + motion_tween.kill() + motion_tween = null + is_hiding = false + name_label.text = context.npc_name + activity_label.text = context.activity_text() + target_label.text = "Target · %s" % context.target_name + carrying_label.text = "Carrying · %s" % context.carrying_text() + reason_label.text = "Why · %s" % context.decision_reason + visible = true + accent.pivot_offset = accent.size * 0.5 + if not animate: + copy.position = copy_resting_position + modulate.a = 1.0 + accent.scale.y = 1.0 + return + copy.position = copy_resting_position + Vector2(-ENTER_OFFSET, 0.0) + modulate.a = 0.0 + accent.scale.y = 0.18 + motion_tween = create_tween().set_parallel(true) + motion_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + motion_tween.tween_property(copy, "position", copy_resting_position, 0.2) + motion_tween.tween_property(self, "modulate:a", 1.0, 0.16) + motion_tween.tween_property(accent, "scale:y", 1.0, 0.26) + + +func _hide_note() -> void: + if not visible or is_hiding: + return + is_hiding = true + motion_version += 1 + var hide_version := motion_version + if motion_tween != null and motion_tween.is_valid(): + motion_tween.kill() + motion_tween = null + motion_tween = create_tween().set_parallel(true) + motion_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) + motion_tween.tween_property(self, "modulate:a", 0.0, 0.14) + motion_tween.tween_property(copy, "position:x", copy_resting_position.x - EXIT_OFFSET, 0.14) + motion_tween.finished.connect(_on_hide_finished.bind(hide_version), CONNECT_ONE_SHOT) + + +func _on_hide_finished(hide_version: int) -> void: + if motion_version != hide_version: + return + motion_tween = null + is_hiding = false + if current_context_key.is_empty(): + visible = false + + +func _on_state_restored() -> void: + current_context_key = "" + call_deferred("refresh_note", true) diff --git a/world/ui/villager_field_note_hud.gd.uid b/world/ui/villager_field_note_hud.gd.uid new file mode 100644 index 0000000..f419cb6 --- /dev/null +++ b/world/ui/villager_field_note_hud.gd.uid @@ -0,0 +1 @@ +uid://c4hhfxayuo63l diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index 9087e01..dd9ff65 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -130,6 +130,26 @@ func reload_npc_visual(npc_id: int) -> bool: return false +func find_nearest_active_npc_id(origin: Vector3, max_distance: float) -> int: + if not origin.is_finite() or not is_finite(max_distance) or max_distance < 0.0: + return -1 + var nearest_id := -1 + var nearest_distance_squared := max_distance * max_distance + var sorted_ids: Array = active_npc_visuals.keys() + sorted_ids.sort() + for npc_id in sorted_ids: + var visual := active_npc_visuals[npc_id] as Node3D + if visual == null or not is_instance_valid(visual) or bool(visual.get("is_dead_visual")): + continue + var distance_squared := origin.distance_squared_to(visual.global_position) + if distance_squared > nearest_distance_squared: + continue + if nearest_id < 0 or distance_squared < nearest_distance_squared: + nearest_id = int(npc_id) + nearest_distance_squared = distance_squared + return nearest_id + + func _on_npc_target_requested(npc: SimNPC) -> void: var visual = active_npc_visuals.get(npc.id) as Node3D if visual == null: