feat: add nearby villager field notes

This commit is contained in:
Rijad Zuzo
2026-08-03 12:47:54 +02:00
parent ead75fca68
commit 2082773d31
14 changed files with 978 additions and 23 deletions
+18
View File
@@ -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
+13 -6
View File
@@ -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
+24 -7
View File
@@ -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,
+16 -9
View File
@@ -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:
+98 -1
View File
@@ -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
+89
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dec7j7j08cipj
+78
View File
@@ -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()
+507
View File
@@ -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)
@@ -0,0 +1 @@
uid://uiedaakmwr2r
+1
View File
@@ -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)
+111
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://c4hhfxayuo63l
+20
View File
@@ -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: