feat: add person history and trust reactions

This commit is contained in:
Rijad Zuzo
2026-07-12 11:42:10 +02:00
parent 99b8146f75
commit 9fcf6a0a58
19 changed files with 543 additions and 90 deletions
+5 -2
View File
@@ -19,7 +19,7 @@ SimulationClock
-> RelationshipSystem applies evidence-gated social consequences
-> WorldViewManager presents travel and NPC state
-> ActiveWorldAdapter supplies loaded-world positions/capacity
-> NpcVisual performs local navigation and animation
-> NpcVisual performs local navigation, animation, and transient reactions
```
`SimulationManager` is the scene-tree façade for the simulation. It owns the
@@ -35,7 +35,7 @@ would otherwise obscure that lifecycle:
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
known objective events, immutable acquisition provenance, proximity witnesses
at record time, one-hop direct fact transfer, and deterministic recent-fact
retention;
retention/importance ranking;
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship
queries, event-driven trust changes, and deterministic social tie-breaking;
- `simulation/persistence/` owns save-slot file safety;
@@ -81,6 +81,9 @@ improving ownership.
- Presentation may report facts and submit commands; it does not choose NPC
actions or own resource, storage, inventory, event, knowledge, relationship,
or reservation state.
- Inspector history is a read-only façade query over objective events and
retained knowledge. Relationship cues consume state-change signals and are
intentionally absent from saves and checksums.
- Resource changes go through `ResourceStateRecord`, NPC inventory, and
`VillageEconomy`; `village.food` and `village.wood` are synchronized views.
- New mutable features define serialization and deterministic continuation at
+7 -2
View File
@@ -700,11 +700,16 @@ Completed:
recent facts. A deterministic daily-sized review fades old routine facts,
conversations prefer lasting evidence, and the inspector shows both memory
counts and the displayed fact's retention class.
24. Person history and trust blossom: the selected-NPC inspector now separates
up to four lasting-first retained memories from three objective personal
actions. A real evidence-caused trust gain produces one short amber blossom
above its observer, including in cinematic mode, without persistent or
simulation-owned animation state.
Next:
1. Add a compact retained-memory/person-history view and one visible social
reaction cue, as sequenced in `LEARNING_ROADMAP.md`.
1. Implement one bounded Milestone 7 rumour/opportunity proof, as sequenced in
`LEARNING_ROADMAP.md`.
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
+5 -2
View File
@@ -87,8 +87,11 @@ save/restore continuity.
`NpcVisual` shows a small food sack whenever its authoritative inventory
contains food. A newly loaded visual derives the same state directly from the
NPC record, so unloading presentation does not lose the fact.
The selected-NPC inspector shows lasting/recent counts and labels the displayed
fact with both acquisition provenance and retention class.
The selected-NPC inspector shows up to four lasting-first retained facts with
acquisition provenance, separately from three objective events the NPC
personally performed. A real trust-changing consequence emits one transient
amber blossom above its observer. The cue is presentation-only, remains visible
in cinematic mode, and is neither saved nor replayed after visual rebuild.
## Deliberate limits
+16 -5
View File
@@ -698,11 +698,22 @@ The first importance/retention slice is complete:
boundary, capacity, cause-protection, save/checksum, and informed-choice
regressions cover the slice.
This advances the first Milestone 6 evidence-to-choice path without claiming
full belief simulation. The practical next sequence is a compact retained
memory/person-history view and a visible social-reaction cue, then one bounded
Milestone 7 rumour/opportunity proof without generalized distortion or
free-form dialogue.
The compact history/reaction slice is complete:
- the selected-person inspector separates lasting-first retained memories from
the NPC's own objective event history and keeps both lists bounded;
- remembered facts show performed, witnessed, communicated, or legacy
provenance without copying authoritative events into presentation state;
- a real trust change blooms once above its observer, remains visible in
cinematic mode, and is never persisted or replayed after visual rebuild;
- ranking, forgetting/objective-history separation, observer-only routing,
checksum neutrality, restore, cinematic, and death regressions cover the
slice.
This completes the first bounded Milestone 6 evidence-to-choice presentation
path without claiming full belief simulation. The practical next slice is one
bounded Milestone 7 rumour/opportunity proof without generalized distortion,
free-form dialogue, or a generic quest framework.
Recently completed:
+17 -12
View File
@@ -373,13 +373,13 @@ A small village panel displays:
- a Tab-cycled NPC inspector with profession, needs, task state, destination,
decision reason, utility scores, directed familiarity/trust, and the exact
completed event that last changed trust;
- the selected NPC's latest known objective fact, resolved from its own
persisted knowledge rather than omniscient event history.
- a compact selected-person history that separates up to four importance-ranked
retained memories from the NPC's three latest objective personal actions.
NPC name/profession labels, definition-driven colors and props, carried-food
visuals, and compact task glyphs make active simulation state readable in the
world. The F10 cinematic mode hides debug labels while preserving the task
glyphs.
visuals, compact task glyphs, and a brief amber blossom on real trust gains make
active simulation state readable in the world. The F10 cinematic mode hides
debug labels while preserving task glyphs and transient social reactions.
## Runtime architecture
@@ -416,7 +416,8 @@ Focused `RefCounted` collaborators keep rule ownership visible:
- `SimulationEventLog` owns deterministic event history and queries;
- `EventKnowledgeSystem` owns per-NPC known-event references, immutable
first-acquisition time/provenance, actor/nearby evidence capture, bounded
one-hop communication, and deterministic recent-memory retention;
one-hop communication, deterministic recent-memory retention, and stable
importance ranking for presentation/communication queries;
- `RelationshipSystem` owns directed relationship state, event-driven trust
consequences, and deterministic social queries.
@@ -432,7 +433,9 @@ than duplicating these responsibilities across additional manager nodes.
- sends resolved travel destinations to visuals;
- reports arrival and navigation failure back to `SimulationManager`;
- synchronizes successful visual movement into authoritative NPC position;
- applies visual death state.
- applies visual death state;
- turns authoritative relationship-change signals into observer-only transient
presentation cues without queuing or persisting them.
### `player/npc/NpcVisual.gd`
@@ -443,7 +446,7 @@ than duplicating these responsibilities across additional manager nodes.
- moves and rotates toward path points;
- reports arrival;
- presents definition-driven profession silhouettes, carried resources, task
glyphs, walk motion, and death state.
glyphs, walk motion, brief trust-gain blossoms, and death state.
### `player/player.gd` and `player/camera_rig.gd`
@@ -593,8 +596,8 @@ These are expected prototype constraints, not necessarily isolated bugs:
evidence, personalized reinforcement/decay, false beliefs, or multi-hop
rumours.
- The reason inspector exposes current decisions, utility rejections, one exact
relationship cause, memory counts, and the latest known fact with its speaker
and retention class, but a broader person-history view is not yet present.
relationship cause, and a compact person-history view that distinguishes
importance-ranked retained memories from objective personal actions.
- Active navigation is used as if all agents are local; no simulation LOD exists.
- Unloaded traveling NPCs preserve their state but do not yet advance through
abstract travel time.
@@ -841,8 +844,10 @@ and relationship cause, the inspector names the speaker, and the listener's
future work can change without duplicating event history. Importance and
retention now keep current relationship causes lasting, cap other memories,
and forget routine facts at deterministic daily-sized reviews without deleting
the objective event. A compact retained-memory/person-history view and visible
social-reaction cue are the next systems slice.
the objective event. The selected-person inspector now shows lasting-first
retained memories beside objective personal history, and real trust changes
produce a short observer-only amber blossom without altering saved state. The
next systems slice is one bounded rumour/opportunity proof.
The remaining simulation-garden target still aims for:
+18 -8
View File
@@ -1,6 +1,6 @@
# Simulation Garden 01
Captured: 2026-07-11
Captured: 2026-07-12
## Files
@@ -29,8 +29,10 @@ $env:LOCALAPPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot
```
The script warms up `main.tscn`, verifies that the simulation and active NPC
visuals exist, captures the F10 debug presentation, then captures the cinematic
presentation with development labels and UI hidden.
visuals exist, stages a real food-deposit/trust consequence plus one wind gust,
captures the F10 debug presentation, then captures the cinematic presentation
with development labels and UI hidden. The trust blossom is restarted only for
the second framing so both captures show the same real consequence clearly.
## Review
@@ -83,11 +85,9 @@ share the prevailing direction used by deterministic tree-canopy motion and
chimney smoke. The capture tool stages one gust so the next visual comparison
does not randomly land during the intended quiet interval.
The PNGs listed above still predate that gust-controller pass. A fresh Metal
capture was unavailable when the code and headless mesh/shader checks landed,
so do not use those images as evidence of the final ribbon opacity or terrain
contact. Replace both images after running the capture command on the normal
renderer and reviewing the staged mid-sweep gust.
The PNGs listed above have now been refreshed on the Metal renderer and include
the staged mid-sweep gust, so they can be used to assess ribbon opacity and
terrain contact.
The day/night controller also reconnects to the authoritative simulation clock
after scene startup and uses the correct sunrise/sunset color order, preventing
@@ -99,3 +99,13 @@ and the elevated staggered follow camera are unchanged. The next visible gains
should come from replacing remaining blockout architecture, strengthening work
props, and improving terrain/color hierarchy—not from changing the camera
language again.
## July 12 social-history follow-up
The debug capture now stages Tarik's real witnessed food-deposit memory and
trust consequence. The inspector distinguishes that lasting subjective memory
from Tarik's own objective actions in a shorter, more legible panel. The same
consequence produces one brief amber halo-and-mote blossom above Tarik; it
remains visible when F10 removes the debug UI but does not become permanent
world clutter. Both refreshed images preserve the established elevated,
slightly staggered top-down camera.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 446 KiB

+75
View File
@@ -4,6 +4,9 @@ signal arrived_at_target(sim_id: int)
signal navigation_failed(sim_id: int)
signal position_changed(sim_id: int, active_position: Vector3)
const TRUST_REACTION_BASE_POSITION := Vector3(0.0, 2.72, 0.0)
const TRUST_REACTION_START_SCALE := Vector3(0.24, 0.24, 0.24)
@export var debug_logs := false
@export var move_speed := 3.0
@@ -25,6 +28,13 @@ signal position_changed(sim_id: int, active_position: Vector3)
@onready var carried_wood_visual: Node3D = $CarriedWood
@onready var task_glyph_root: Node3D = $TaskGlyphRoot
@onready var task_glyph: MeshInstance3D = $TaskGlyphRoot/TaskGlyph
@onready var trust_reaction_root: Node3D = $TrustReactionRoot
@onready var trust_reaction_visuals: Array[MeshInstance3D] = [
$TrustReactionRoot/Halo,
$TrustReactionRoot/MoteLeft,
$TrustReactionRoot/MoteCenter,
$TrustReactionRoot/MoteRight,
]
var has_reported_arrival := false
var sim_id: int = -1
@@ -44,6 +54,7 @@ var dead_material := StandardMaterial3D.new()
var debug_overlay_visible := true
var walk_phase := 0.0
var glyph_phase := 0.0
var trust_reaction_tween: Tween
func debug_log(message: String) -> void:
@@ -53,6 +64,7 @@ func debug_log(message: String) -> void:
func _ready() -> void:
dead_material.albedo_color = Color(0.25, 0.25, 0.25, 1.0)
_stop_trust_gain_reaction()
func _process(delta: float) -> void:
@@ -91,6 +103,7 @@ func setup_from_sim(npc: SimNPC) -> void:
npc_name = npc.npc_name
profession = npc.profession
name = "NPC_%s_%s" % [sim_id, npc_name]
_stop_trust_gain_reaction()
_apply_personal_details()
_apply_profession_presentation()
set_carried_food_visible(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0)
@@ -196,6 +209,67 @@ func set_debug_overlay_visible(is_visible: bool) -> void:
profession_label.visible = is_visible and not is_dead_visual
func play_trust_gain_reaction() -> void:
if is_dead_visual or trust_reaction_root == null:
return
_stop_trust_gain_reaction()
trust_reaction_root.visible = true
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION
trust_reaction_root.rotation = Vector3.ZERO
trust_reaction_root.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0)
trust_reaction_tween = create_tween()
trust_reaction_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
trust_reaction_tween.tween_property(trust_reaction_root, "scale", Vector3.ONE, 0.22)
trust_reaction_tween.parallel().tween_method(_set_trust_reaction_transparency, 1.0, 0.0, 0.22)
trust_reaction_tween.tween_property(
trust_reaction_root,
"position",
TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.16, 0.0),
0.68
)
trust_reaction_tween.parallel().tween_property(trust_reaction_root, "rotation:y", 0.32, 0.68)
trust_reaction_tween.tween_property(
trust_reaction_root,
"position",
TRUST_REACTION_BASE_POSITION + Vector3(0.0, 0.44, 0.0),
0.55
)
trust_reaction_tween.parallel().tween_property(
trust_reaction_root, "scale", Vector3.ONE * 1.16, 0.55
)
trust_reaction_tween.parallel().tween_method(_set_trust_reaction_transparency, 0.0, 1.0, 0.55)
trust_reaction_tween.tween_callback(_finish_trust_gain_reaction)
func _set_trust_reaction_transparency(value: float) -> void:
for visual in trust_reaction_visuals:
visual.transparency = clampf(value, 0.0, 1.0)
func _finish_trust_gain_reaction() -> void:
trust_reaction_root.visible = false
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION
trust_reaction_root.rotation = Vector3.ZERO
trust_reaction_root.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0)
trust_reaction_tween = null
func _stop_trust_gain_reaction() -> void:
if trust_reaction_tween != null:
trust_reaction_tween.kill()
trust_reaction_tween = null
if trust_reaction_root == null:
return
trust_reaction_root.visible = false
trust_reaction_root.position = TRUST_REACTION_BASE_POSITION
trust_reaction_root.rotation = Vector3.ZERO
trust_reaction_root.scale = TRUST_REACTION_START_SCALE
_set_trust_reaction_transparency(1.0)
func set_target_position(pos: Vector3) -> void:
path_request_id += 1
var request_id := path_request_id
@@ -324,6 +398,7 @@ func _report_navigation_failure_once() -> void:
func apply_dead_visual_state() -> void:
is_dead_visual = true
_stop_trust_gain_reaction()
carried_food_visual.visible = false
carried_wood_visual.visible = false
profession_prop.visible = false
+48 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=18 format=3 uid="uid://dhxxyprqflotq"]
[gd_scene load_steps=21 format=3 uid="uid://dhxxyprqflotq"]
[ext_resource type="Script" uid="uid://bha8uf40p3sa0" path="res://player/npc/NpcVisual.gd" id="1_ixfoq"]
@@ -84,6 +84,28 @@ top_radius = 0.09
bottom_radius = 0.11
height = 0.62
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_trust_reaction"]
transparency = 1
shading_mode = 0
albedo_color = Color(1, 0.67, 0.24, 0.9)
emission_enabled = true
emission = Color(1, 0.52, 0.16, 1)
emission_energy_multiplier = 0.45
[sub_resource type="TorusMesh" id="TorusMesh_trust_reaction"]
material = SubResource("StandardMaterial3D_trust_reaction")
inner_radius = 0.17
outer_radius = 0.26
rings = 16
ring_segments = 8
[sub_resource type="SphereMesh" id="SphereMesh_trust_reaction"]
material = SubResource("StandardMaterial3D_trust_reaction")
radius = 0.075
height = 0.14
radial_segments = 8
rings = 4
[node name="NpcVisual" type="CharacterBody3D"]
collision_layer = 2
collision_mask = 1
@@ -183,6 +205,31 @@ position = Vector3(0, 2.35, 0)
[node name="TaskGlyph" type="MeshInstance3D" parent="TaskGlyphRoot"]
[node name="TrustReactionRoot" type="Node3D" parent="."]
visible = false
position = Vector3(0, 2.72, 0)
scale = Vector3(0.24, 0.24, 0.24)
[node name="Halo" type="MeshInstance3D" parent="TrustReactionRoot"]
mesh = SubResource("TorusMesh_trust_reaction")
[node name="MoteLeft" type="MeshInstance3D" parent="TrustReactionRoot"]
position = Vector3(-0.22, 0.08, 0)
rotation = Vector3(0, 0, -0.436332)
scale = Vector3(0.72, 1.25, 0.72)
mesh = SubResource("SphereMesh_trust_reaction")
[node name="MoteCenter" type="MeshInstance3D" parent="TrustReactionRoot"]
position = Vector3(0, 0.19, 0)
scale = Vector3(0.8, 1.35, 0.8)
mesh = SubResource("SphereMesh_trust_reaction")
[node name="MoteRight" type="MeshInstance3D" parent="TrustReactionRoot"]
position = Vector3(0.22, 0.08, 0)
rotation = Vector3(0, 0, 0.436332)
scale = Vector3(0.72, 1.25, 0.72)
mesh = SubResource("SphereMesh_trust_reaction")
[node name="NavigationAgent3D" type="NavigationAgent3D" parent="."]
path_desired_distance = 0.3
target_desired_distance = 0.5
+12
View File
@@ -730,6 +730,18 @@ func get_known_events(npc_id: int, max_count: int = 3) -> Array[EconomicEventRec
return known
func get_retained_memory_events(npc_id: int, max_count: int = 4) -> Array[EconomicEventRecord]:
var retained: Array[EconomicEventRecord] = []
var event_ids := event_knowledge_system.get_retained_event_ids(
npc_id, _get_lasting_event_ids(npc_id), max_count
)
for event_id in event_ids:
var event := event_log.get_by_id(event_id)
if event != null:
retained.append(event)
return retained
func npc_knows_event(npc_id: int, event_id: int) -> bool:
return event_knowledge_system.knows_event(npc_id, event_id)
+23 -3
View File
@@ -103,12 +103,30 @@ func get_known_event_ids(knower_id: int, max_count: int = 3) -> Array[int]:
return recent_ids
func get_retained_event_ids(
knower_id: int, lasting_event_ids: Array[int], max_count: int = 4
) -> Array[int]:
return _get_ranked_event_ids(knower_id, lasting_event_ids, false, max_count)
func get_communicable_event_ids(knower_id: int, lasting_event_ids: Array[int]) -> Array[int]:
return _get_ranked_event_ids(knower_id, lasting_event_ids, true, 0)
func _get_ranked_event_ids(
knower_id: int, lasting_event_ids: Array[int], direct_only: bool, max_count: int
) -> Array[int]:
var ranked: Array[Dictionary] = []
for record in known_events.values():
if record.get_knower_id() != knower_id:
continue
if record.get_acquisition_method() not in KnownEventStateRecord.DIRECT_ACQUISITION_METHODS:
if (
direct_only
and (
record.get_acquisition_method()
not in KnownEventStateRecord.DIRECT_ACQUISITION_METHODS
)
):
continue
(
ranked
@@ -120,10 +138,12 @@ func get_communicable_event_ids(knower_id: int, lasting_event_ids: Array[int]) -
}
)
)
ranked.sort_custom(_sort_communicable)
ranked.sort_custom(_sort_by_importance)
var event_ids: Array[int] = []
for item in ranked:
event_ids.append(int(item["event_id"]))
if max_count > 0 and event_ids.size() >= max_count:
break
return event_ids
@@ -234,7 +254,7 @@ static func _sort_by_acquisition(
return first.get_event_id() < second.get_event_id()
static func _sort_communicable(first: Dictionary, second: Dictionary) -> bool:
static func _sort_by_importance(first: Dictionary, second: Dictionary) -> bool:
if bool(first["lasting"]) != bool(second["lasting"]):
return bool(first["lasting"])
if int(first["acquired_tick"]) != int(second["acquired_tick"]):
+34 -8
View File
@@ -59,18 +59,38 @@ func _run() -> void:
),
"Runtime knowledge UI check should use a completed simulation transaction"
)
var world_view := main_scene.get_node("WorldViewManager")
var contributor_visual: Node3D = world_view.active_npc_visuals[contributor.id]
var witness_visual: Node3D = world_view.active_npc_visuals[witness.id]
var listener_visual: Node3D = world_view.active_npc_visuals[listener.id]
_check(
(
witness_visual.get_node("TrustReactionRoot").visible
and not contributor_visual.get_node("TrustReactionRoot").visible
),
"A real trust consequence should blossom only above the reacting observer"
)
var reaction_checksum: String = simulation_manager.get_state_checksum()
witness_visual.play_trust_gain_reaction()
_check(
simulation_manager.get_state_checksum() == reaction_checksum,
"Restarting the transient trust blossom must not mutate simulation state"
)
var village_ui := main_scene.get_node("UI")
village_ui.selected_npc_index = witness.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Known fact: %s deposited" % contributor.npc_name in inspector_label.text
and "witnessed · lasting" in inspector_label.text
and "Memory: 1 lasting · 0 recent" in inspector_label.text
"Memories 1 lasting · 0 recent" in inspector_label.text
and (
" lasting · %s stocked 1 food · witnessed" % contributor.npc_name
in inspector_label.text
)
and "Relationship: %s" % contributor.npc_name in inspector_label.text
and "Because:" in inspector_label.text
and "Own history" in inspector_label.text
),
"NPC inspector should show the witnessed fact and its relationship consequence"
"NPC history should show the lasting witnessed fact and its relationship consequence"
)
var guard_site := (
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
@@ -87,11 +107,17 @@ func _run() -> void:
village_ui.call("_refresh_npc_inspector")
_check(
(
"Known fact: %s deposited" % contributor.npc_name in inspector_label.text
and "heard from %s · recent" % witness.npc_name in inspector_label.text
and "Memory: 0 lasting · 1 recent" in inspector_label.text
"Memories 0 lasting · 1 recent" in inspector_label.text
and (
"• recent · %s stocked 1 food · heard %s" % [contributor.npc_name, witness.npc_name]
in inspector_label.text
)
),
"NPC inspector should identify who communicated a known fact"
"NPC history should identify who communicated a retained fact"
)
_check(
not listener_visual.get_node("TrustReactionRoot").visible,
"Learning a fact without a relationship consequence should not show a trust blossom"
)
_check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
@@ -84,6 +84,17 @@ func _run() -> void:
),
"Memory summaries should distinguish lasting and recent knowledge"
)
var lasting_history: Array = manager.get_retained_memory_events(lasting_listener.id, 4)
var recent_history: Array = manager.get_retained_memory_events(recent_listener.id, 4)
_check(
(
lasting_history.size() == 1
and lasting_history[0] == deposit_event
and recent_history.size() == 1
and recent_history[0] == deposit_event
),
"Person-history queries should resolve each retained fact to objective history"
)
var objective_event_count: int = manager.economic_events.size()
var objective_next_event_id: int = manager.next_event_id
@@ -148,6 +159,13 @@ func _run() -> void:
),
"Forgetting knowledge must not prune or replay objective event history"
)
_check(
(
manager.get_retained_memory_events(recent_listener.id, 4).is_empty()
and deposit_event in manager.get_npc_events(contributor.id, 3)
),
"Forgotten subjective facts should leave the contributor's objective personal history intact"
)
var lasting_relationship: RelationshipStateRecord = (
manager.relationship_system.get_relationship(lasting_listener.id, contributor.id)
)
+56
View File
@@ -23,6 +23,8 @@ func _run() -> void:
visual.set_physics_process(false)
var profession_definition := SimulationDefinitions.get_profession(npc.profession)
var body: MeshInstance3D = visual.get_node("MeshInstance3D")
var trust_reaction_root: Node3D = visual.get_node("TrustReactionRoot")
_check(not trust_reaction_root.visible, "A newly spawned visual should not replay reactions")
_check(
body.material_override.albedo_color == profession_definition.visual_color,
"NPC body color should come from its profession definition"
@@ -56,12 +58,56 @@ func _run() -> void:
not visual.get_node("ProfessionLabel").visible and visual.get_node("TaskGlyphRoot").visible,
"Cinematic mode should hide debug labels while preserving task glyphs"
)
visual.play_trust_gain_reaction()
_check(trust_reaction_root.visible, "A direct trust reaction should reveal the blossom")
visual.play_trust_gain_reaction()
_check(
trust_reaction_root.visible and trust_reaction_root.get_child_count() == 4,
"Restarting a trust reaction should reuse one blossom without stacking visuals"
)
visual.set_debug_overlay_visible(false)
_check(
trust_reaction_root.visible,
"The trust reaction should remain visible in cinematic presentation mode"
)
var active_reaction_tween: Tween = visual.get("trust_reaction_tween")
await active_reaction_tween.finished
_check(
not trust_reaction_root.visible,
"The trust reaction should fade away instead of becoming permanent world clutter"
)
visual.set_debug_overlay_visible(true)
visual.set_task_presentation(SimulationIds.ACTION_WANDER, SimNPC.TASK_STATE_TRAVELING)
_check(
not visual.get_node("TaskGlyphRoot").visible,
"Ambient wander should not display a task glyph"
)
var routed_observer: SimNPC = manager.npcs[1]
var routed_subject: SimNPC = manager.npcs[2]
var observer_visual: Node3D = view.active_npc_visuals[routed_observer.id]
var subject_visual: Node3D = view.active_npc_visuals[routed_subject.id]
var routed_relationship := RelationshipStateRecord.create(
routed_observer.id, routed_subject.id, 0.5, 0.65, 9001
)
var routed_event := EconomicEventRecord.create(
9001,
SimulationIds.EVENT_STORAGE_DEPOSITED,
manager.tick_count,
routed_subject.id,
&"test_source",
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
manager.emit_signal("relationship_changed", routed_relationship, routed_event)
_check(
observer_visual.get_node("TrustReactionRoot").visible,
"The authoritative relationship signal should route a reaction to its observer"
)
_check(
not subject_visual.get_node("TrustReactionRoot").visible,
"A routed trust reaction should not play on the relationship subject"
)
var test_decision := ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD,
-1.0,
@@ -176,6 +222,16 @@ func _run() -> void:
visual_after_restore.global_position.is_equal_approx(npc.position),
"Rebuilt presentation should use the restored authoritative position"
)
_check(
not visual_after_restore.get_node("TrustReactionRoot").visible,
"Restoring state should rebuild the visual without replaying transient reactions"
)
visual_after_restore.play_trust_gain_reaction()
visual_after_restore.apply_dead_visual_state()
_check(
not visual_after_restore.get_node("TrustReactionRoot").visible,
"Death presentation should suppress an active trust reaction"
)
if failures.is_empty():
print("[TEST] NPC visual lifecycle passed")
+44 -4
View File
@@ -4,6 +4,7 @@ const SimulationEventLogScript := preload("res://simulation/events/SimulationEve
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
const SimulationManagerScript := preload("res://simulation/SimulationManager.gd")
func test_storage_never_accepts_more_than_its_capacity() -> void:
@@ -211,9 +212,48 @@ func test_knowledge_retention_is_bounded_stable_and_importance_ranked() -> void:
var ranked_system := EventKnowledgeSystemScript.new()
var ranked_records: Array[KnownEventStateRecord] = [
KnownEventStateRecord.create(1, 1, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 10),
KnownEventStateRecord.create(1, 2, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 20),
KnownEventStateRecord.create(1, 10, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 5),
KnownEventStateRecord.create(1, 20, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 20),
KnownEventStateRecord.create(
1,
30,
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED,
2,
20,
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
),
KnownEventStateRecord.create(1, 40, SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY, -1, 10),
]
ranked_system.restore(ranked_records)
var lasting_event_ids: Array[int] = [1]
assert_eq(ranked_system.get_communicable_event_ids(1, lasting_event_ids), [1, 2])
var lasting_event_ids: Array[int] = [10]
assert_eq(ranked_system.get_retained_event_ids(1, lasting_event_ids, 0), [10, 30, 20, 40])
assert_eq(ranked_system.get_retained_event_ids(1, lasting_event_ids, 2), [10, 30])
assert_eq(ranked_system.get_communicable_event_ids(1, lasting_event_ids), [10, 20])
var objective_events: Array[EconomicEventRecord] = []
for event_id in [10, 20, 30, 40]:
objective_events.append(
EconomicEventRecord.create(
event_id,
SimulationIds.EVENT_STORAGE_DEPOSITED,
0,
0,
SimulationIds.npc_inventory_id(0),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
)
var manager := SimulationManagerScript.new()
manager.event_log.restore(objective_events, 41)
manager.event_knowledge_system.restore(ranked_records)
var relationships: Array[RelationshipStateRecord] = [
RelationshipStateRecord.create(1, 0, 0.5, 0.65, 10)
]
manager.relationship_system.restore(relationships)
var retained_events: Array[EconomicEventRecord] = manager.get_retained_memory_events(1, 3)
var retained_ids: Array[int] = []
for event in retained_events:
retained_ids.append(int(event.data["event_id"]))
assert_eq(retained_ids, [10, 30, 20])
manager.free()
+90
View File
@@ -41,7 +41,11 @@ func _run() -> void:
if not demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay()
if not _stage_social_history(main_scene):
quit(1)
return
await _stage_wind_gust(main_scene)
await _settle_trust_reaction()
var debug_analysis := _capture(DEBUG_OUTPUT_PATH)
if debug_analysis.is_empty():
quit(1)
@@ -49,7 +53,11 @@ func _run() -> void:
if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay()
if not _restart_trust_reaction(main_scene):
quit(1)
return
await _stage_wind_gust(main_scene)
await _settle_trust_reaction()
var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH)
if cinematic_analysis.is_empty():
@@ -65,6 +73,83 @@ func _run() -> void:
quit(0)
func _stage_social_history(main_scene: Node) -> bool:
var simulation_manager := main_scene.get_node("SimulationManager")
var world_view := main_scene.get_node("WorldViewManager")
if simulation_manager.npcs.size() < 2:
push_error("Simulation Garden social-history staging needs two NPCs")
return false
var contributor: SimNPC = simulation_manager.npcs[0]
var observer: SimNPC = simulation_manager.npcs[1]
var contributor_visual: Node3D = world_view.active_npc_visuals.get(contributor.id)
var observer_visual: Node3D = world_view.active_npc_visuals.get(observer.id)
if contributor_visual == null or observer_visual == null:
push_error("Simulation Garden social-history staging needs both active NPC visuals")
return false
var relationship: RelationshipStateRecord = (
simulation_manager.relationship_system.get_relationship(observer.id, contributor.id)
)
if relationship == null:
push_error("Simulation Garden staging pair needs an existing directed relationship")
return false
var previous_trust := relationship.get_trust()
simulation_manager.set_process(false)
contributor_visual.set_physics_process(false)
observer_visual.set_physics_process(false)
observer_visual.global_position = contributor_visual.global_position + Vector3(1.0, 0.0, 0.0)
simulation_manager.synchronize_npc_position(contributor.id, contributor_visual.global_position)
simulation_manager.synchronize_npc_position(observer.id, observer_visual.global_position)
observer.hunger = 85.0
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
var deposited: float = simulation_manager.economy.deposit_inventory(
contributor, SimulationIds.RESOURCE_FOOD
)
if not is_equal_approx(deposited, 1.0):
push_error("Simulation Garden staging could not deposit one food")
return false
simulation_manager.emit_signal("village_changed", simulation_manager.village)
var contributor_events: Array = simulation_manager.get_npc_events(contributor.id, 1)
if contributor_events.is_empty():
push_error("Simulation Garden staging did not record the deposit event")
return false
var deposit_event := contributor_events[0] as EconomicEventRecord
var retained_memories: Array = simulation_manager.get_retained_memory_events(observer.id, 1)
var reaction_root := observer_visual.get_node_or_null("TrustReactionRoot") as Node3D
if (
StringName(deposit_event.data["event_type"]) != SimulationIds.EVENT_STORAGE_DEPOSITED
or relationship.get_trust() <= previous_trust
or relationship.get_last_trust_cause_event_id() != int(deposit_event.data["event_id"])
or retained_memories.is_empty()
or retained_memories[0] != deposit_event
or not simulation_manager.is_known_event_lasting(
observer.id, int(deposit_event.data["event_id"])
)
or reaction_root == null
or not reaction_root.visible
):
push_error("Simulation Garden staging did not produce the retained trust consequence")
return false
var village_ui := main_scene.get_node("UI")
village_ui.selected_npc_index = simulation_manager.npcs.find(observer)
village_ui.call("_refresh_npc_inspector")
return true
func _restart_trust_reaction(main_scene: Node) -> bool:
var simulation_manager := main_scene.get_node("SimulationManager")
var world_view := main_scene.get_node("WorldViewManager")
if simulation_manager.npcs.size() < 2:
push_error("Simulation Garden trust-reaction staging needs two NPCs")
return false
var observer: SimNPC = simulation_manager.npcs[1]
var observer_visual: Node3D = world_view.active_npc_visuals.get(observer.id)
if observer_visual == null or not observer_visual.has_method("play_trust_gain_reaction"):
push_error("Simulation Garden trust-reaction staging needs an active observer visual")
return false
observer_visual.play_trust_gain_reaction()
return observer_visual.get_node("TrustReactionRoot").visible
func _stage_wind_gust(main_scene: Node) -> void:
var gust_field := (
main_scene.get_node_or_null("JajceWorld/AtmosphereRoot/WindGustField") as WindGustField
@@ -76,6 +161,11 @@ func _stage_wind_gust(main_scene: Node) -> void:
await process_frame
func _settle_trust_reaction() -> void:
for _frame in 12:
await process_frame
func _capture(output_path: String) -> Dictionary:
var image := root.get_texture().get_image()
if image == null or image.is_empty():
+56 -42
View File
@@ -188,8 +188,7 @@ func _refresh_npc_inspector() -> void:
score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows)
var event_text := _build_event_history(npc)
var relationship_text := _build_relationship_display(npc)
var memory_text := _build_memory_display(npc)
var known_fact_text := _build_known_fact_display(npc)
var memory_history_text := _build_memory_history_display(npc)
npc_inspector_label.text = (
(
"%s · %s\n"
@@ -199,9 +198,8 @@ func _refresh_npc_inspector() -> void:
+ "Carrying food: %.0f wood: %.0f\n"
+ "%s\n"
+ "%s\n"
+ "%s\n"
+ "Why\n%s%s\n\n"
+ "Recent\n%s\n\n"
+ "Own history\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
)
% [
@@ -215,8 +213,7 @@ func _refresh_npc_inspector() -> void:
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
relationship_text,
memory_text,
known_fact_text,
memory_history_text,
reason_text,
score_text,
event_text
@@ -245,7 +242,7 @@ func set_debug_overlay_visible(is_visible: bool) -> void:
func _build_event_history(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_npc_events"):
return ""
var events: Array = simulation_manager.get_npc_events(npc.id, 5)
var events: Array = simulation_manager.get_npc_events(npc.id, 3)
if events.is_empty():
return "No recorded events yet"
var name_map := {}
@@ -287,48 +284,65 @@ func _build_relationship_display(npc: SimNPC) -> String:
return "%s\nBecause: %s" % [display, cause.description(name_map)]
func _build_known_fact_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_known_events"):
return ""
var known_events: Array = simulation_manager.get_known_events(npc.id, 1)
if known_events.is_empty():
return "Known fact: none yet"
func _build_memory_history_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_retained_memory_events"):
return "Memories unavailable"
var summary: Dictionary = simulation_manager.get_memory_summary(npc.id)
var header := (
"Memories %d lasting · %d recent"
% [int(summary.get("lasting", 0)), int(summary.get("recent", 0))]
)
var retained_events: Array = simulation_manager.get_retained_memory_events(npc.id, 4)
if retained_events.is_empty():
return header + "\nNo retained memories"
var name_map := {}
for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name
var latest_event := known_events[known_events.size() - 1] as EconomicEventRecord
var provenance := "source not recorded"
var retention := "recent"
if simulation_manager.has_method("get_known_event_record"):
var lines: Array[String] = [header]
for event in retained_events:
var event_id := int(event.data["event_id"])
var known_record: KnownEventStateRecord = simulation_manager.get_known_event_record(
npc.id, int(latest_event.data["event_id"])
npc.id, event_id
)
if known_record != null:
match known_record.get_acquisition_method():
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED:
provenance = "performed personally"
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED:
provenance = "witnessed"
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
var source_name: String = name_map.get(
known_record.get_source_npc_id(), "someone"
if known_record == null:
continue
var lasting: bool = simulation_manager.is_known_event_lasting(npc.id, event_id)
var marker := "" if lasting else ""
var retention := "lasting" if lasting else "recent"
(
lines
. append(
(
"%s %s · %s · %s"
% [
marker,
retention,
_build_compact_memory_summary(event, name_map),
_build_memory_provenance(known_record, name_map),
]
)
provenance = "heard from %s" % source_name
SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY:
provenance = "source not recorded"
)
)
return "\n".join(lines)
func _build_compact_memory_summary(event: EconomicEventRecord, name_map: Dictionary) -> String:
if (
simulation_manager.has_method("is_known_event_lasting")
and (simulation_manager.is_known_event_lasting(npc.id, int(latest_event.data["event_id"])))
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
):
retention = "lasting"
return "Known fact: %s (%s · %s)" % [latest_event.description(name_map), provenance, retention]
var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone")
return "%s stocked %.0f food" % [actor_name, float(event.data["amount"])]
return event.description(name_map)
func _build_memory_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_memory_summary"):
return ""
var summary: Dictionary = simulation_manager.get_memory_summary(npc.id)
return (
"Memory: %d lasting · %d recent"
% [int(summary.get("lasting", 0)), int(summary.get("recent", 0))]
)
func _build_memory_provenance(record: KnownEventStateRecord, name_map: Dictionary) -> String:
match record.get_acquisition_method():
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED:
return "own action"
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED:
return "witnessed"
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
var source_name: String = name_map.get(record.get_source_npc_id(), "someone")
return "heard %s" % source_name
return "source unknown"
+18
View File
@@ -40,6 +40,10 @@ func initialize_world_view() -> void:
simulation_manager.npc_task_changed.connect(_on_npc_task_changed)
else:
push_error("WorldViewManager: SimulationManager has no npc_task_changed signal")
if simulation_manager.has_signal("relationship_changed"):
simulation_manager.relationship_changed.connect(_on_relationship_changed)
else:
push_error("WorldViewManager: SimulationManager has no relationship_changed signal")
if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_simulation_state_restored)
else:
@@ -182,6 +186,20 @@ func _on_npc_task_changed(npc: SimNPC, _old_task: StringName, _new_task: StringN
push_error("WorldViewManager: NPCVisual has no set_task_presentation method")
func _on_relationship_changed(
relationship: RelationshipStateRecord, _cause_event: EconomicEventRecord
) -> void:
if relationship == null:
return
var visual = active_npc_visuals.get(relationship.get_observer_id())
if visual == null:
return
if visual.has_method("play_trust_gain_reaction"):
visual.play_trust_gain_reaction()
else:
push_error("WorldViewManager: NPCVisual has no play_trust_gain_reaction method")
func _on_simulation_state_restored() -> void:
for visual in active_npc_visuals.values():
visual.queue_free()