feat: add nearby villager field notes
This commit is contained in:
@@ -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
|
simulation and loaded-world facts. The HUD is separate from development
|
||||||
overlays so the embodied action remains legible in cinematic mode.
|
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
|
## Compound animal-care delivery contract
|
||||||
|
|
||||||
NPC `feed_animal` remains one task and one exact animal claim across two travel
|
NPC `feed_animal` remains one task and one exact animal claim across two travel
|
||||||
|
|||||||
@@ -829,15 +829,22 @@ Completed:
|
|||||||
food sack; delivery consumes that unit and records the inventory-to-animal
|
food sack; delivery consumes that unit and records the inventory-to-animal
|
||||||
fact. One-unit contention, both route legs, and post-pickup continuation
|
fact. One-unit contention, both route legs, and post-pickup continuation
|
||||||
prove conservation without adding a saved phase field.
|
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:
|
Next:
|
||||||
|
|
||||||
1. Give the player one restrained nearby-villager inspection context backed by
|
1. Let an inspected villager who owns an open opportunity surface that exact
|
||||||
the authoritative task, exact target, carried item, and recorded decision
|
village need plus the authoritative direct-help route, capable helper, or
|
||||||
reason when it currently exists.
|
honest unavailable state that currently exists.
|
||||||
2. Prove that the field note follows live task changes and, after restore,
|
2. Prove that the ordinary finite-resource-to-storage harvest resolves an
|
||||||
re-derives the persistent facts while honestly awaiting the next decision
|
available player route and updates the context without dialogue branching,
|
||||||
reason. It must not become dialogue, quest, or saved presentation state.
|
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
|
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
|
mechanics. The next proof is a beautiful stage for the systems that already
|
||||||
|
|||||||
@@ -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
|
destination, event count, and final checksum without a new saved phase field
|
||||||
or RNG draw.
|
or RNG draw.
|
||||||
|
|
||||||
The next bounded slice should advance Milestone 9 with player-facing villager
|
Player-facing villager inspection now advances Milestone 9 without stealing the
|
||||||
inspection. One nearby-person context should show the authoritative name,
|
existing `E` action. A quiet cardless field note chooses the nearest loaded,
|
||||||
current action, exact target, carried item, and current recorded decision reason
|
living villager with stable-ID tie order, then re-reads that NPC's authoritative
|
||||||
when available. After restore it should honestly say that it is awaiting the
|
name, action, exact target, task state, and carried inventory. It includes the
|
||||||
next decision while the other facts re-derive. Do not add dialogue, trade,
|
latest decision reason only while that trace still matches the live action.
|
||||||
reputation, quest state, breeding, products, herd scheduling, animal indexing,
|
Presentation unload clears the note; restore reacquires the replacement NPC
|
||||||
or active/abstract animal LOD in that slice.
|
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:
|
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
|
- `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
|
action from the real pantry through caretaker inventory to the exact reserved
|
||||||
goat. Carried presentation, ordered transfer facts, late contention,
|
goat. Carried presentation, ordered transfer facts, late contention,
|
||||||
|
|||||||
+16
-9
@@ -491,10 +491,11 @@ that receives the command.
|
|||||||
The UI subscribes to village, task, event, knowledge, relationship, and
|
The UI subscribes to village, task, event, knowledge, relationship, and
|
||||||
opportunity changes. It formats aggregate state and resolves known facts,
|
opportunity changes. It formats aggregate state and resolves known facts,
|
||||||
relationship causes, and opportunity lifecycle details through manager queries
|
relationship causes, and opportunity lifecycle details through manager queries
|
||||||
without owning or recomputing simulation facts. `village_whisper_hud.gd` and
|
without owning or recomputing simulation facts. `village_whisper_hud.gd`,
|
||||||
`player_interaction_hud.gd` are separate transient player layers that format
|
`player_interaction_hud.gd`, and `villager_field_note_hud.gd` are separate
|
||||||
authoritative signals or the current exact context and remain independent of
|
transient player layers. They format authoritative signals, the current exact
|
||||||
the development overlays.
|
action context, or one nearby villager's current facts while remaining
|
||||||
|
independent of the development overlays.
|
||||||
|
|
||||||
## Current runtime flow
|
## 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
|
pantry feedback and no persistent UI state. NPC care now completes that
|
||||||
physical proof with one compound pantry-to-inventory-to-goat action, exact
|
physical proof with one compound pantry-to-inventory-to-goat action, exact
|
||||||
claim continuity, visible carrying, late-contention safety, and post-pickup
|
claim continuity, visible carrying, late-contention safety, and post-pickup
|
||||||
deterministic continuation. The next bounded proof should let the player
|
deterministic continuation. Nearby villager inspection now selects one loaded
|
||||||
inspect one nearby villager's real action, target, carried item, and recorded
|
living presentation, reacquires its authoritative simulation record, and shows
|
||||||
decision reason when available. After restore it should re-derive persistent
|
the live action, task state, exact target, carried inventory, and only a
|
||||||
facts and honestly await the next decision reason without introducing dialogue
|
currently matching decision reason. It remains separate from `E`, clears on
|
||||||
or saved UI state.
|
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:
|
The remaining simulation-garden target still aims for:
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
[ext_resource type="PackedScene" path="res://player/PlayerVisual.tscn" id="14_player_visual"]
|
[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" 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/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"]
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
|
||||||
radius = 0.4
|
radius = 0.4
|
||||||
@@ -23,11 +24,12 @@ height = 1.7
|
|||||||
|
|
||||||
[node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
|
[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)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
|
||||||
script = ExtResource("1_h2yge")
|
script = ExtResource("1_h2yge")
|
||||||
camera_rig = NodePath("../CameraRig")
|
camera_rig = NodePath("../CameraRig")
|
||||||
simulation_manager = NodePath("../SimulationManager")
|
simulation_manager = NodePath("../SimulationManager")
|
||||||
|
world_view_manager = NodePath("../WorldViewManager")
|
||||||
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry")
|
pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry")
|
||||||
guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost")
|
guard_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/GuardPost")
|
||||||
study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk")
|
study_site = NodePath("../JajceWorld/WorldObjects/ActivitySites/StudyDesk")
|
||||||
@@ -193,6 +195,101 @@ theme_override_font_sizes/font_size = 13
|
|||||||
text = "Context"
|
text = "Context"
|
||||||
horizontal_alignment = 1
|
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="."]
|
[node name="VillageWhisperLayer" type="CanvasLayer" parent="."]
|
||||||
layer = 3
|
layer = 3
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dec7j7j08cipj
|
||||||
@@ -8,6 +8,7 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool)
|
|||||||
@export var camera_rig: Node3D
|
@export var camera_rig: Node3D
|
||||||
|
|
||||||
@export var simulation_manager: Node
|
@export var simulation_manager: Node
|
||||||
|
@export var world_view_manager: Node
|
||||||
|
|
||||||
@export var pantry_storage: StorageNode
|
@export var pantry_storage: StorageNode
|
||||||
@export var guard_site: ActivitySite
|
@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 stomach_capacity_for_food := 5
|
||||||
@export var interaction_range := 3.0
|
@export var interaction_range := 3.0
|
||||||
|
@export var villager_inspection_range := 3.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -115,6 +117,44 @@ func get_interaction_context() -> PlayerInteractionResult:
|
|||||||
return null
|
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:
|
func _find_feed_animal() -> AnimalNode:
|
||||||
if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null:
|
if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null:
|
||||||
push_error("Player: SimulationManager has no animal-care service")
|
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)
|
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:
|
func _build_animal_context(node: AnimalNode) -> PlayerInteractionResult:
|
||||||
var cost := _get_animal_feed_cost()
|
var cost := _get_animal_feed_cost()
|
||||||
var pantry_food := _get_pantry_food()
|
var pantry_food := _get_pantry_food()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -638,6 +638,7 @@ visible = false
|
|||||||
[node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
|
[node name="VillageWoodpile" parent="WorldObjects/StorageSites" instance=ExtResource("14_storage")]
|
||||||
position = Vector3(8, 0, -12)
|
position = Vector3(8, 0, -12)
|
||||||
storage_id = &"village_woodpile"
|
storage_id = &"village_woodpile"
|
||||||
|
display_name = "Village Woodpile"
|
||||||
|
|
||||||
[node name="PantrySignPole" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
|
[node name="PantrySignPole" type="MeshInstance3D" parent="WorldObjects/StorageSites/VillagePantry"]
|
||||||
position = Vector3(-1.05, 1.1, 1.25)
|
position = Vector3(-1.05, 1.1, 1.25)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c4hhfxayuo63l
|
||||||
@@ -130,6 +130,26 @@ func reload_npc_visual(npc_id: int) -> bool:
|
|||||||
return false
|
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:
|
func _on_npc_target_requested(npc: SimNPC) -> void:
|
||||||
var visual = active_npc_visuals.get(npc.id) as Node3D
|
var visual = active_npc_visuals.get(npc.id) as Node3D
|
||||||
if visual == null:
|
if visual == null:
|
||||||
|
|||||||
Reference in New Issue
Block a user