feat: share village needs with helpers

This commit is contained in:
Rijad Zuzo
2026-07-16 12:13:54 +02:00
parent 0332c57a89
commit d6e6a63048
17 changed files with 716 additions and 41 deletions
+10 -7
View File
@@ -75,11 +75,13 @@ It also publishes the latest `ActionSelectionResult` for presentation; the UI
does not recompute decisions. Each idle selection re-derives the capable helper
instead of consulting persisted assignment state. At completion it atomically
pays any definition-backed stored-resource cost before applying the action
effect. A late shortfall suppresses the effect and records a `task_blocked` fact. The
manager also captures actor/nearby knowledge before forwarding newly recorded
events into `RelationshipSystem`. When an NPC arrives beside a worker at the
same non-storage activity site, the manager may transfer one direct known fact
and applies its consequence only to that newly informed listener. It exposes
effect. A late shortfall suppresses the effect and records a `task_blocked`
fact. The manager also captures actor/nearby knowledge before forwarding newly
recorded events into `RelationshipSystem`. When an NPC arrives beside a worker
at the same non-storage activity site, the manager may transfer one direct
known fact and applies its consequence only to that newly informed listener. An
open opportunity's exact trigger receives bounded priority in that conversation
before normal lasting/recent ranking. It exposes
knowledge, provenance, relationship, and cause queries without moving social
authority into UI. After consequences are known, it protects current causal
facts, enforces the recent-memory cap, and runs age review from authoritative
@@ -108,8 +110,9 @@ simulation ticks.
legacy, with a stable source NPC ID for direct communication;
- permits one performed/witnessed fact to cross one co-located social hop, but
does not relay communicated or ambiguous legacy facts;
- orders communication by lasting relationship relevance, then acquisition
recency;
- tries the active opportunity trigger first when the speaker directly knows
it, then orders remaining communication by lasting relationship relevance
and acquisition recency;
- retains current relationship causes and at most three other recent facts per
NPC, expiring day-old recent facts at deterministic review boundaries;
- captures evidence only when the event happens, never from current positions
+6 -3
View File
@@ -38,7 +38,8 @@ 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/importance ranking;
retention/importance ranking. It can place the active opportunity trigger
first without weakening its direct-source or one-hop checks;
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship
queries, event-driven trust changes, and deterministic social tie-breaking;
- `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable
@@ -96,8 +97,10 @@ improving ownership.
retained knowledge. Opportunity presentation is likewise query-only.
Relationship cues, interested-villager concern, physical pantry stock, and
refill feedback consume authoritative state or state-change signals and are
intentionally absent from saves and checksums. Stable visuals are rebuilt
from state after restore; transient reactions are not replayed.
intentionally absent from saves and checksums. The transient village-whisper
HUD likewise formats need, transfer, response, and resolution signals without
owning them. Stable visuals are rebuilt from state after restore; transient
reactions are not replayed.
- 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
+9 -4
View File
@@ -741,13 +741,18 @@ Completed:
and sleep/meal precedence. Existing target resolution chooses and reserves
the finite source, while no quest acceptance, reward, or helper assignment
is introduced.
30. Village whisper: direct witnesses/performers now report the active need's
exact trigger before unrelated facts at shared work, allowing a newly
informed trusted helper to respond autonomously. One cardless warm-amber HUD
ribbon gently rises, pulses, and fades through need, named report, response,
and resolution beats while remaining separate from development overlays.
Next:
1. Prove one direct information-to-help chain: transfer the exact open-need
trigger at an existing shared activity, then let the newly informed trusted
villager be re-derived and respond through the same autonomous selector.
Keep generic dialogue and multi-hop rumours out of scope, as sequenced in
1. When no capable helper exists, show one contextual player response hint
derived from a current player-usable finite source and the real storage
target. Clear it as soon as help emerges or the need closes; do not add quest
acceptance, waypoint state, or a generic tracker, as sequenced in
`LEARNING_ROADMAP.md`.
Do not start with GIS data, a full city, a large asset pack, or more NPC
+12
View File
@@ -99,6 +99,14 @@ opening; invalidation emits no event and stores the close tick and stable
reason. Resolution or invalidation returns the trigger to normal bounded
retention.
When a performer or direct witness meets another villager at an eligible shared
activity, the active opportunity's trigger is attempted before normal
lasting/recent communication ranking. `EventKnowledgeSystem` still requires
performed/witnessed source provenance and records only the existing event ID,
speaker ID, source method, listener, and acquisition tick. A newer unrelated
fact cannot displace the active report, while communicated listeners still
cannot relay it onward.
For an open need, the same system can derive one capable helper without
assigning work. A candidate must know the exact trigger and have a familiar
directed relationship with at least 0.6 trust toward the interested villager.
@@ -148,6 +156,10 @@ world cue remains specific to the pantry shortage rather than representing a
wood need as hunger. The active summary additionally names the currently
derived helper, supply action, trust fact, and inventory or finite-source route,
or states that no informed trusted villager is currently able to supply it.
One separate cardless player HUD gives transient warm-amber feedback for the
authoritative need opening, exact named report, newly informed helper decision,
and real resolution. It stays available when development overlays are hidden,
but clears on restore and never becomes objective or save state.
## Deliberate limits
+21 -7
View File
@@ -807,13 +807,27 @@ The first bounded autonomous helper consumer is complete:
- focused headless and Jajce runtime regressions prove autonomous selection,
precedence, finite-source targeting, reservation, and restore-time re-query.
Milestone 7 is not complete. The immediate next slice should connect the
existing one-hop fact transfer to this path: prove that a direct witness or
performer can communicate an open need's exact trigger at a real shared
activity, after which the newly informed trusted villager is re-derived as the
helper and responds through the same autonomous selection contract. Keep this
as a truthful event-reference transfer, not generic dialogue or multi-hop
rumour infrastructure.
The first direct information-to-help path is complete:
- at an eligible shared activity, a speaker who performed or witnessed the
active need's exact trigger discusses it before other retained direct facts;
- the transfer still references the original event, preserves the speaker and
direct source method, stops after one hop, and creates no dialogue/event copy;
- a regression places a newer unrelated fact ahead under normal ranking, then
proves active-need priority transfers only the trigger to the trusted capable
listener;
- the newly informed villager is immediately re-derived as helper and, once
idle, chooses the same ordinary autonomous gather/deposit path;
- a cardless warm-amber player HUD briefly shows the real need, named report,
helper response, and resolution. It is signal-driven, survives debug-overlay
hiding, and neither serializes nor replays after restore.
Milestone 7 is not complete. The immediate next slice should expose one bounded
player response affordance when an open need has no capable helper: derive a
concise contextual hint from the existing player-usable finite source and real
storage target, then clear it when a helper emerges or the need closes. Reuse
the current interaction/economy rules; do not add quest acceptance, waypoint
state, or a generic objective tracker.
Recently completed:
+20 -7
View File
@@ -472,7 +472,9 @@ follow camera.
The UI subscribes to village, task, event, knowledge, relationship, and
opportunity changes. It formats aggregate state and resolves known facts,
relationship causes, and opportunity lifecycle details through manager queries
without owning or recomputing simulation facts.
without owning or recomputing simulation facts. `village_whisper_hud.gd` is a
separate transient player layer that formats the same authoritative signals and
remains independent of the development overlays.
## Current runtime flow
@@ -556,6 +558,7 @@ NpcVisual navigates through the active world
│ ├── knowledge_retention_consequence_test.gd
│ ├── jajce_runtime_integration_test.gd
│ ├── npc_visual_lifecycle_test.gd
│ ├── opportunity_communication_helper_test.gd
│ ├── relationship_consequence_test.gd
│ ├── resource_node_player_parity_test.gd
│ ├── simulation_definitions_test.gd
@@ -579,7 +582,9 @@ NpcVisual navigates through the active world
│ │ └── ResourceNode.tscn
│ ├── active_world_adapter.gd
│ ├── world_view_manager.gd
│ └── ui/ui.gd
│ └── ui/
│ ├── ui.gd
│ └── village_whisper_hud.gd
├── main.tscn
└── project.godot
```
@@ -922,11 +927,19 @@ The ordinary in-progress NPC task can save normally, but no helper assignment,
quest acceptance, reward, or opportunity-specific event is persisted; ordinary
task-start history remains unchanged.
Milestone 7 remains in progress. The immediate next slice should prove the
combined information-to-help path: communicate an open need's exact trigger
once at a real shared activity, then re-derive the newly informed trusted NPC as
the autonomous helper. Keep the transfer direct and source-backed rather than
introducing generic dialogue or multi-hop rumours.
The first direct information-to-help path is complete. At shared activity, a
performer or direct witness now discusses the active need's exact trigger before
newer unrelated facts. The original event and first-source provenance remain
authoritative and one-hop. A newly informed trusted, supplied villager is then
re-derived and autonomously responds through ordinary action selection. A
single cardless warm-amber HUD ribbon presents the real need, named report,
helper response, and resolution without entering simulation or save state.
Milestone 7 remains in progress. The immediate next slice should add one
bounded player response hint only when no capable helper exists, derived from
the current player-usable finite source and real destination. It should clear
when help emerges or the need closes, without quest acceptance, waypoint state,
or a generic tracker.
The remaining simulation-garden target still aims for:
+5
View File
@@ -207,6 +207,11 @@ existing NPC task, task-state, target, travel, and reservation fields serialize.
The helper result remains absent: restore retains any in-progress ordinary task
and independently re-derives current capability from restored facts.
Active-need communication adds no schema fields. It persists through the
existing `KnownEventStateRecord` event, listener, source NPC, direct source
method, and acquisition-tick fields. Communication/HUD animations are transient
and are cleared rather than replayed after restore.
## Resource authority
`SimulationManager` owns `ResourceStateRecord` instances independently of the
+61
View File
@@ -12,6 +12,7 @@
[ext_resource type="Script" uid="uid://cascjhf8lsvay" path="res://world/ui/time_dial.gd" id="12_timedial"]
[ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"]
[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"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
radius = 0.4
@@ -126,6 +127,66 @@ offset_right = 940.0
offset_bottom = 66.0
script = ExtResource("12_timedial")
[node name="VillageWhisperLayer" type="CanvasLayer" parent="."]
layer = 3
[node name="VillageWhisper" type="Control" parent="VillageWhisperLayer" node_paths=PackedStringArray("simulation_manager")]
anchors_preset = 10
anchor_left = 0.5
anchor_right = 0.5
offset_left = -310.0
offset_top = 76.0
offset_right = 310.0
offset_bottom = 142.0
grow_horizontal = 2
mouse_filter = 2
script = ExtResource("15_whisper")
simulation_manager = NodePath("../../SimulationManager")
[node name="Accent" type="ColorRect" parent="VillageWhisperLayer/VillageWhisper"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 2.0
grow_horizontal = 2
mouse_filter = 2
color = Color(0.86, 0.61, 0.25, 0.92)
[node name="Copy" type="VBoxContainer" parent="VillageWhisperLayer/VillageWhisper"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 8.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_constants/separation = 1
alignment = 1
[node name="Kicker" type="Label" parent="VillageWhisperLayer/VillageWhisper/Copy"]
layout_mode = 2
mouse_filter = 2
theme_override_colors/font_color = Color(0.93, 0.7, 0.36, 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 = 11
text = "VILLAGE NEED"
horizontal_alignment = 1
[node name="Message" type="Label" parent="VillageWhisperLayer/VillageWhisper/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_colors/font_shadow_color = Color(0.02, 0.01, 0.005, 0.7)
theme_override_constants/outline_size = 7
theme_override_constants/shadow_offset_x = 1
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 18
text = "A village need has surfaced."
horizontal_alignment = 1
[node name="CrisisCaptionLayer" type="CanvasLayer" parent="."]
layer = 4
+3 -3
View File
@@ -127,11 +127,9 @@ func generate_npcs() -> void:
npc_random.randf_range(1.0, 10.0),
npc_random
)
npc.debug_logs = debug_logs
npcs.append(npc)
wander_random_sources[i] = _create_random_source(i, 1)
var home_count := home_positions.size()
if home_count > 0:
for i in range(npcs.size()):
@@ -632,7 +630,9 @@ func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bo
if not _can_communicate_at_shared_activity(speaker, listener):
return false
var known_event_ids := event_knowledge_system.get_communicable_event_ids(
speaker.id, _get_lasting_event_ids(speaker.id)
speaker.id,
_get_lasting_event_ids(speaker.id),
opportunity_system.get_open_trigger_event_id()
)
for event_id in known_event_ids:
var event := event_log.get_by_id(event_id)
+8 -2
View File
@@ -109,8 +109,14 @@ func get_retained_event_ids(
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_communicable_event_ids(
knower_id: int, lasting_event_ids: Array[int], preferred_event_id: int = -1
) -> Array[int]:
var event_ids := _get_ranked_event_ids(knower_id, lasting_event_ids, true, 0)
if preferred_event_id in event_ids:
event_ids.erase(preferred_event_id)
event_ids.push_front(preferred_event_id)
return event_ids
func _get_ranked_event_ids(
@@ -99,6 +99,15 @@ func get_open_opportunity() -> OpportunityStateRecord:
return open_records[0] if not open_records.is_empty() else null
func get_open_trigger_event_id() -> int:
var opportunity := get_open_opportunity()
return (
opportunity.get_trigger_event_id()
if opportunity != null
else OpportunityStateRecord.NO_EVENT_ID
)
func get_latest() -> OpportunityStateRecord:
var sorted := get_all_sorted()
return sorted[-1] if not sorted.is_empty() else null
+84 -8
View File
@@ -77,6 +77,15 @@ func _run() -> void:
"Restarting the transient trust blossom must not mutate simulation state"
)
var village_ui := main_scene.get_node("UI")
var village_whisper := (
main_scene.get_node("VillageWhisperLayer/VillageWhisper") as VillageWhisperHud
)
var whisper_kicker := village_whisper.get_node("Copy/Kicker") as Label
var whisper_message := village_whisper.get_node("Copy/Message") as Label
_check(
not village_whisper.visible,
"The player-facing village whisper should begin quiet instead of replaying history"
)
village_ui.selected_npc_index = witness.id
village_ui.call("_refresh_npc_inspector")
_check(
@@ -250,8 +259,9 @@ func _run() -> void:
and pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.EMPTY
and pantry_visual.get_node("EmptyStock").visible
and not pantry_visual.get_node("RefillResponse").visible
and not village_whisper.visible
),
"Restoration should rebuild concern and empty stock without replaying the refill response"
"Restore should rebuild stable cues without replaying refill or player-HUD feedback"
)
var player := main_scene.get_node("Player") as Node3D
var opportunity_bush := (
@@ -270,8 +280,10 @@ func _run() -> void:
player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and "Resolved village need" in inspector_label.text
and "◆ Player restocked the pantry" in inspector_label.text
and whisper_kicker.text == "NEED MET"
and "The player restocked the pantry" in whisper_message.text
),
"Player gathering should resolve the same need and be named from its real event"
"Player gathering should resolve the restored need in debug detail and transient HUD"
)
var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile()
@@ -283,7 +295,9 @@ func _run() -> void:
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
)
contributor.position = woodpile_node.get_interaction_position()
witness.position = contributor.position
for npc in simulation_manager.npcs:
if npc.id != contributor.id:
npc.position = contributor.position + Vector3(20.0 + npc.id * 2.0, 0.0, 0.0)
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
contributor.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
@@ -292,14 +306,64 @@ func _run() -> void:
simulation_manager.simulate_tick()
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
var runtime_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
var runtime_trigger_id := (
runtime_opportunity.get_trigger_event_id() if runtime_opportunity != null else -1
)
var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
_check(
(
runtime_helper != null
runtime_opportunity != null
and runtime_helper == null
and not simulation_manager.npc_knows_event(witness.id, runtime_trigger_id)
and whisper_kicker.text == "VILLAGE NEED"
and contributor.npc_name in whisper_message.text
and "needs wood" in whisper_message.text
),
"The real blocked-work need should surface while the distant helper remains uninformed"
)
_check(
(
"◆ Supply wood for blocked work" in village_stats_label.text
and "could not finish Study" in village_stats_label.text
and (
"Possible helper: none informed, trusted, and able to supply"
in village_stats_label.text
)
and "◆ Find wood for Study" in inspector_label.text
and not contributor_visual.get_node("OpportunityConcernRoot").visible
),
"The missing-wood UI should honestly show that no capable informed helper exists yet"
)
contributor.set_task(SimulationIds.ACTION_PATROL)
contributor.target_id = guard_site.site_id
contributor.position = guard_site.get_interaction_position()
contributor.start_working()
witness.set_task(SimulationIds.ACTION_PATROL)
witness.target_id = guard_site.site_id
witness.position = contributor.position + Vector3(1.0, 0.0, 0.0)
simulation_manager.notify_npc_arrived(witness.id)
var communicated_trigger: KnownEventStateRecord = simulation_manager.get_known_event_record(
witness.id, runtime_trigger_id
)
runtime_helper = simulation_manager.get_active_opportunity_helper()
_check(
(
communicated_trigger != null
and (
communicated_trigger.get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED
)
and communicated_trigger.get_source_npc_id() == contributor.id
and runtime_helper != null
and runtime_helper.helper_npc_id == witness.id
and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD
and whisper_kicker.text == "NEWS TRAVELS"
and contributor.npc_name in whisper_message.text
and witness.npc_name in whisper_message.text
),
"Runtime helper query should derive the informed trusted woodcutter"
"A real shared activity should report the exact need and reveal the trusted helper"
)
var runtime_helper_reason := (
"Knows the need; trust %.2f toward %s" % [runtime_helper.trust, contributor.npc_name]
@@ -315,7 +379,7 @@ func _run() -> void:
and "◆ Find wood for Study" in inspector_label.text
and not contributor_visual.get_node("OpportunityConcernRoot").visible
),
"A real missing-wood fact should surface through wood-specific UI without a food cue"
"The compact summary should update from the newly communicated simulation fact"
)
witness.current_task = SimulationIds.ACTION_IDLE
witness.task_state = SimNPC.TASK_STATE_IDLE
@@ -338,9 +402,19 @@ func _run() -> void:
and witness.task_state == SimNPC.TASK_STATE_TRAVELING
and helper_resource != null
and helper_resource.get_reserved_by() == witness.id
and whisper_kicker.text == "HELP IS ON THE WAY"
and witness.npc_name in whisper_message.text
and "gather wood" in whisper_message.text
),
"The runtime helper should autonomously enter normal target resolution and reservation"
"The informed helper and cozy HUD should reflect ordinary target resolution and reservation"
)
var demo_controller := main_scene.get_node("DemoController")
demo_controller.set_debug_overlay_visible(false)
_check(
not village_ui.visible and village_whisper.visible,
"The transient player HUD should remain legible when development overlays are hidden"
)
demo_controller.set_debug_overlay_visible(true)
contributor.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
_check(
is_equal_approx(
@@ -354,8 +428,10 @@ func _run() -> void:
(
"Village need" not in village_stats_label.text
and "%s supplied the woodpile" % contributor.npc_name in inspector_label.text
and whisper_kicker.text == "NEED MET"
and "supplied the woodpile" in whisper_message.text
),
"The resolved wood need should name its exact supplier and leave the active summary"
"Resolution should name its supplier in both debug detail and the transient player HUD"
)
_check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
@@ -0,0 +1,276 @@
extends SceneTree
var failures: Array[String] = []
var transfers: Array[Dictionary] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager(1301)
manager.event_knowledge_transferred.connect(_on_event_knowledge_transferred)
var interested: SimNPC = manager.npcs[0]
var speaker: SimNPC = manager.npcs[1]
var helper: SimNPC = manager.npcs[2]
interested.position = Vector3.ZERO
speaker.position = Vector3(4.0, 0.0, 0.0)
helper.position = Vector3(30.0, 0.0, 0.0)
for npc in manager.npcs:
npc.hunger = 20.0
npc.energy = 80.0
_set_wood_amount(manager, 0.5)
interested.set_task(SimulationIds.ACTION_STUDY, 1.0)
interested.start_working()
manager.simulate_tick()
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
var trigger := _latest_event_of_type(manager, SimulationIds.EVENT_TASK_BLOCKED, interested.id)
_check(opportunity != null and trigger != null, "Blocked work should open a real wood need")
if opportunity == null or trigger == null:
manager.free()
_finish()
return
var trigger_id := int(trigger.data["event_id"])
_check(
(
manager.npc_knows_event(interested.id, trigger_id)
and manager.npc_knows_event(speaker.id, trigger_id)
and not manager.npc_knows_event(helper.id, trigger_id)
and (
manager.get_known_event_record(speaker.id, trigger_id).get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
)
),
"The future speaker should directly witness the need while the helper remains uninformed"
)
helper.profession = SimulationIds.PROFESSION_WOODCUTTER
_add_trusted_relationship(manager, helper.id, interested.id)
var tree := _create_helper_tree()
root.add_child(tree)
await process_frame
_check(manager.register_resource_node(tree), "The helper route should use a finite tree")
_check(
manager.get_active_opportunity_helper() == null,
"Trust and supply should not make an uninformed villager a helper"
)
interested.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
_check(
manager.economy.deposit_inventory(interested, SimulationIds.RESOURCE_FOOD) > 0.0,
"Setup should create one newer directly witnessed distractor fact"
)
var distractor := _latest_event_of_type(
manager, SimulationIds.EVENT_STORAGE_DEPOSITED, interested.id
)
_check(distractor != null, "The distractor deposit should enter objective history")
if distractor == null:
tree.queue_free()
manager.free()
_finish()
return
var distractor_id := int(distractor.data["event_id"])
var no_lasting_events: Array[int] = []
var default_order: Array[int] = manager.event_knowledge_system.get_communicable_event_ids(
speaker.id, no_lasting_events
)
_check(
not default_order.is_empty() and default_order[0] == distractor_id,
"Without active-need priority, the newer direct fact would be discussed first"
)
_prepare_shared_activity(speaker, helper)
var event_count_before: int = manager.economic_events.size()
var next_event_id_before: int = manager.next_event_id
manager.notify_npc_arrived(helper.id)
var communicated: KnownEventStateRecord = manager.get_known_event_record(helper.id, trigger_id)
_check(
(
communicated != null
and (
communicated.get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED
)
and communicated.get_source_npc_id() == speaker.id
and (
communicated.get_source_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
)
and not manager.npc_knows_event(helper.id, distractor_id)
and (
transfers
== [{"speaker_id": speaker.id, "listener_id": helper.id, "event_id": trigger_id}]
)
),
"Shared work should communicate the exact active trigger before newer unrelated facts"
)
_check(
(
manager.economic_events.size() == event_count_before
and manager.next_event_id == next_event_id_before
),
"Reporting the need should reference existing history without adding an event"
)
var helper_result: OpportunityHelperResult = manager.get_active_opportunity_helper()
_check(
(
helper_result != null
and helper_result.helper_npc_id == helper.id
and helper_result.trigger_event_id == trigger_id
and helper_result.action_id == SimulationIds.ACTION_GATHER_WOOD
),
"The newly informed trusted woodcutter should be re-derived as the capable helper"
)
helper.current_task = SimulationIds.ACTION_IDLE
helper.task_state = SimNPC.TASK_STATE_IDLE
helper.task_complete = true
helper.target_id = &""
helper.has_travel_target = false
manager.simulate_tick()
var decision: ActionSelectionResult = manager.get_latest_decision(helper.id)
_check(
(
decision != null
and decision.action_id == SimulationIds.ACTION_GATHER_WOOD
and "Responding to village need" in decision.reason
and helper.current_task == SimulationIds.ACTION_GATHER_WOOD
),
"The communicated fact should flow into ordinary autonomous helper selection"
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(1302)
_check(
restored.restore_state_from_json(saved_json), "The communicated helper path should restore"
)
var restored_record: KnownEventStateRecord = restored.get_known_event_record(
helper.id, trigger_id
)
var restored_helper: OpportunityHelperResult = restored.get_active_opportunity_helper()
_check(
(
restored.get_state_checksum() == manager.get_state_checksum()
and restored_record != null
and restored_record.get_source_npc_id() == speaker.id
and restored_helper != null
and restored_helper.helper_npc_id == helper.id
),
"Restore should preserve provenance and re-derive capability without assignment state"
)
restored.free()
manager.free()
tree.queue_free()
await process_frame
_finish()
func _prepare_shared_activity(speaker: SimNPC, listener: SimNPC) -> void:
speaker.set_task(SimulationIds.ACTION_PATROL)
speaker.target_id = &"opportunity_guard_post"
speaker.position = Vector3(20.0, 0.0, 0.0)
speaker.start_working()
listener.set_task(SimulationIds.ACTION_PATROL)
listener.target_id = speaker.target_id
listener.position = speaker.position + Vector3(1.0, 0.0, 0.0)
func _add_trusted_relationship(manager: Node, observer_id: int, subject_id: int) -> void:
var records: Array[RelationshipStateRecord] = manager.relationship_system.get_all_sorted()
for index in records.size():
var relationship := records[index]
if (
relationship.get_observer_id() == observer_id
and relationship.get_subject_id() == subject_id
):
records[index] = RelationshipStateRecord.create(observer_id, subject_id, 0.7, 0.7)
manager.relationship_system.restore(records)
return
records.append(RelationshipStateRecord.create(observer_id, subject_id, 0.7, 0.7))
manager.relationship_system.restore(records)
func _set_wood_amount(manager: Node, amount: float) -> void:
var woodpile: StorageStateRecord = manager.get_woodpile()
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD))
woodpile.deposit(SimulationIds.RESOURCE_WOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
func _latest_event_of_type(
manager: Node, event_type: StringName, actor_id: int
) -> EconomicEventRecord:
var events: Array = manager.get_npc_events(actor_id, 8)
for index in range(events.size() - 1, -1, -1):
var event := events[index] as EconomicEventRecord
if StringName(event.data["event_type"]) == event_type:
return event
return null
func _create_helper_tree() -> ResourceNode:
var tree := ResourceNode.new()
tree.name = "CommunicatedHelperTree"
tree.node_id = &"communicated_helper_tree"
tree.action_id = SimulationIds.ACTION_GATHER_WOOD
tree.resource_id = SimulationIds.RESOURCE_WOOD
tree.initial_amount = 2.0
tree.yield_per_action = 1.0
tree.can_npcs_use = true
tree.can_player_use = false
tree.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
tree.add_child(interaction_point)
return tree
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(2.0, 0.0, 0.0),
Vector3(20.0, 0.0, 0.0),
Vector3(30.0, 0.0, 0.0),
Vector3(40.0, 0.0, 0.0),
Vector3(50.0, 0.0, 0.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
for npc in manager.npcs:
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
npc.start_working()
return manager
func _on_event_knowledge_transferred(
speaker_id: int, listener_id: int, event: EconomicEventRecord
) -> void:
transfers.append(
{
"speaker_id": speaker_id,
"listener_id": listener_id,
"event_id": int(event.data["event_id"])
}
)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Opportunity communication helper passed: report -> response")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://c3aeauhllrh1m
+3
View File
@@ -137,14 +137,17 @@ func _on_speed_changed(_multiplier: float) -> void:
func _on_relationship_changed(
_relationship: RelationshipStateRecord, _cause_event: EconomicEventRecord
) -> void:
_refresh_village_display()
_refresh_npc_inspector()
func _on_event_knowledge_changed(_knower_id: int, _event: EconomicEventRecord) -> void:
_refresh_village_display()
_refresh_npc_inspector()
func _on_event_knowledge_forgotten(_knower_id: int, _event: EconomicEventRecord) -> void:
_refresh_village_display()
_refresh_npc_inspector()
+187
View File
@@ -0,0 +1,187 @@
class_name VillageWhisperHud
extends Control
const DISPLAY_SECONDS := 4.2
const ENTER_OFFSET := 8.0
@export var simulation_manager: Node
@onready var accent: ColorRect = $Accent
@onready var kicker: Label = $Copy/Kicker
@onready var message: Label = $Copy/Message
var feedback_tween: Tween
var resting_position: Vector2
var feedback_version := 0
var active_trigger_event_id := OpportunityStateRecord.NO_EVENT_ID
var informed_listener_id := -1
func _ready() -> void:
resting_position = position
_reset_feedback()
if simulation_manager == null:
push_error("VillageWhisperHud: simulation_manager missing")
return
simulation_manager.opportunity_opened.connect(_on_opportunity_opened)
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
simulation_manager.opportunity_invalidated.connect(_on_opportunity_invalidated)
simulation_manager.event_knowledge_transferred.connect(_on_event_knowledge_transferred)
simulation_manager.npc_decision_recorded.connect(_on_npc_decision_recorded)
simulation_manager.state_restored.connect(_on_state_restored)
func _on_opportunity_opened(opportunity: OpportunityStateRecord) -> void:
active_trigger_event_id = opportunity.get_trigger_event_id()
informed_listener_id = -1
_show_feedback("Village need", _build_need_message(opportunity))
func _on_event_knowledge_transferred(
speaker_id: int, listener_id: int, event: EconomicEventRecord
) -> void:
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
if opportunity == null or int(event.data["event_id"]) != opportunity.get_trigger_event_id():
return
active_trigger_event_id = opportunity.get_trigger_event_id()
informed_listener_id = listener_id
_show_feedback(
"News travels",
(
"%s shares the %s with %s."
% [
_get_npc_name(speaker_id),
_get_need_name(opportunity),
_get_npc_name(listener_id),
]
)
)
func _on_npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult) -> void:
if npc.id != informed_listener_id:
return
var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
if (
helper == null
or helper.trigger_event_id != active_trigger_event_id
or helper.helper_npc_id != npc.id
or helper.action_id != decision.action_id
):
return
_show_feedback(
"Help is on the way",
(
"%s heard the need and chose to %s."
% [npc.npc_name, _get_action_phrase(decision.action_id)]
)
)
func _on_opportunity_resolved(
opportunity: OpportunityStateRecord, resolution_event: EconomicEventRecord
) -> void:
if opportunity.get_trigger_event_id() != active_trigger_event_id:
return
var actor_id := int(resolution_event.data["actor_id"])
var actor_name := "The player" if actor_id < 0 else _get_npc_name(actor_id)
var outcome := (
"restocked the pantry"
if opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD
else "supplied the woodpile"
)
_show_feedback("Need met", "%s %s." % [actor_name, outcome])
informed_listener_id = -1
func _on_opportunity_invalidated(opportunity: OpportunityStateRecord) -> void:
if opportunity.get_trigger_event_id() != active_trigger_event_id:
return
_show_feedback("Need closed", "The village could no longer act on this report.")
informed_listener_id = -1
func _on_state_restored() -> void:
_reset_feedback()
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
active_trigger_event_id = (
opportunity.get_trigger_event_id()
if opportunity != null
else OpportunityStateRecord.NO_EVENT_ID
)
informed_listener_id = -1
func _build_need_message(opportunity: OpportunityStateRecord) -> String:
var interested_name := _get_npc_name(opportunity.get_interested_npc_id())
if opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD:
return "%s worries over the empty pantry." % interested_name
var trigger: EconomicEventRecord = simulation_manager.get_opportunity_trigger_event(opportunity)
var action_name := "work"
if trigger != null:
action_name = _get_action_phrase(StringName(trigger.data.get("action_id", &"")))
return "%s needs wood to finish %s." % [interested_name, action_name]
func _get_need_name(opportunity: OpportunityStateRecord) -> String:
return (
"empty-pantry worry"
if opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD
else "missing-wood report"
)
func _get_action_phrase(action_id: StringName) -> String:
var definition := SimulationDefinitions.get_action(action_id)
return definition.display_name.to_lower() if definition != null else String(action_id)
func _get_npc_name(npc_id: int) -> String:
for npc in simulation_manager.npcs:
if npc.id == npc_id:
return npc.npc_name
return "Someone"
func _show_feedback(heading: String, body: String) -> void:
feedback_version += 1
var version := feedback_version
if feedback_tween != null:
feedback_tween.kill()
kicker.text = heading.to_upper()
message.text = body
visible = true
position = resting_position + Vector2(0.0, ENTER_OFFSET)
modulate.a = 0.0
accent.pivot_offset = accent.size * 0.5
accent.scale.x = 0.15
feedback_tween = create_tween().set_parallel(true)
feedback_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
feedback_tween.tween_property(self, "position", resting_position, 0.32)
feedback_tween.tween_property(self, "modulate:a", 1.0, 0.24)
feedback_tween.tween_property(accent, "scale:x", 1.0, 0.38)
_fade_after_delay(version)
func _fade_after_delay(version: int) -> void:
await get_tree().create_timer(DISPLAY_SECONDS).timeout
if version != feedback_version or not is_inside_tree():
return
feedback_tween = create_tween().set_parallel(true)
feedback_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
feedback_tween.tween_property(self, "modulate:a", 0.0, 0.45)
feedback_tween.tween_property(self, "position:y", resting_position.y - 4.0, 0.45)
await feedback_tween.finished
if version == feedback_version:
visible = false
func _reset_feedback() -> void:
feedback_version += 1
if feedback_tween != null:
feedback_tween.kill()
visible = false
position = resting_position
modulate.a = 0.0
if accent != null:
accent.scale.x = 1.0
+1
View File
@@ -0,0 +1 @@
uid://dwwxux24jc6yq