diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index f4685e8..070eb18 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -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 diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 76cb90e..8b8f8b1 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -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 diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 278f518..663a148 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -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 diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index 9d7815a..0d34b6f 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -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 diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 65711f3..3b01963 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -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: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 216db39..75c369e 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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: diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index abf484e..d34bb41 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -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 diff --git a/main.tscn b/main.tscn index 87980d9..eb97783 100644 --- a/main.tscn +++ b/main.tscn @@ -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 diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 3b1b843..e578ecf 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -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) diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index d5186c7..e14a6a8 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -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( diff --git a/simulation/opportunities/VillageOpportunitySystem.gd b/simulation/opportunities/VillageOpportunitySystem.gd index aee2a32..3944107 100644 --- a/simulation/opportunities/VillageOpportunitySystem.gd +++ b/simulation/opportunities/VillageOpportunitySystem.gd @@ -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 diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index fd62521..4d5b33d 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -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"), diff --git a/tests/opportunity_communication_helper_test.gd b/tests/opportunity_communication_helper_test.gd new file mode 100644 index 0000000..35bd0e7 --- /dev/null +++ b/tests/opportunity_communication_helper_test.gd @@ -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) diff --git a/tests/opportunity_communication_helper_test.gd.uid b/tests/opportunity_communication_helper_test.gd.uid new file mode 100644 index 0000000..f97db62 --- /dev/null +++ b/tests/opportunity_communication_helper_test.gd.uid @@ -0,0 +1 @@ +uid://c3aeauhllrh1m diff --git a/world/ui/ui.gd b/world/ui/ui.gd index def068c..4f01811 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -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() diff --git a/world/ui/village_whisper_hud.gd b/world/ui/village_whisper_hud.gd new file mode 100644 index 0000000..1cc01eb --- /dev/null +++ b/world/ui/village_whisper_hud.gd @@ -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 diff --git a/world/ui/village_whisper_hud.gd.uid b/world/ui/village_whisper_hud.gd.uid new file mode 100644 index 0000000..3e0e304 --- /dev/null +++ b/world/ui/village_whisper_hud.gd.uid @@ -0,0 +1 @@ +uid://dwwxux24jc6yq