diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 8b8f8b1..d5aa8fc 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -48,7 +48,10 @@ would otherwise obscure that lifecycle: work wood consumers. It also derives one ephemeral `OpportunityHelperResult` from knowledge, directed relationships, inventory, action definitions, and finite-resource state without changing resources or assigning tasks. The - ordinary action selector consumes that result only for the matching idle NPC; + ordinary action selector consumes that result only for the matching idle NPC. + When no such helper exists, it can separately derive an ephemeral + `OpportunityPlayerResponseResult` from the real destination capacity and + enabled player-usable finite sources; - `simulation/persistence/` owns save-slot file safety; - `simulation/state/` owns versioned serialized record contracts; - `simulation/definitions/` owns stable IDs and immutable action/profession @@ -98,8 +101,9 @@ improving ownership. 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. 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 + HUD likewise formats need, player route, transfer, response, and resolution + signals without owning them. It drops the player route when a capable helper + emerges. 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. diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 663a148..bf8e785 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -746,13 +746,17 @@ Completed: 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. +31. Player response whisper: when no capable helper exists, the same ribbon now + derives one concise ordinary harvest route from current player-usable finite + resources and the real pantry or woodpile. It clears when help emerges or + the need closes, and adds no quest, waypoint, tracker, or saved UI state. Next: -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 +1. Begin Milestone 8 with a deterministic headless scaling baseline over + increasing NPC and event counts. Measure current tick throughput, + serialized-state growth, and retained history before choosing a target + population or adding spatial partitions and simulation LOD, 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/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 3b01963..4b18d7e 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -822,12 +822,38 @@ The first direct information-to-help path is complete: 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. +The bounded Milestone 7 player-response affordance is complete: + +- an open need with no capable helper derives one ephemeral + `OpportunityPlayerResponseResult` only when its real storage target has room + and at least one matching enabled, stocked, player-usable finite source + exists; +- the result exposes the ordinary gather action, resource, destination, source + count, opportunity, and trigger IDs without selecting a waypoint, accepting a + quest, drawing RNG, or mutating simulation state; +- the existing cardless village-whisper ribbon says `YOU CAN HELP` and explains + the direct resource-to-storage interaction in one line, while the development + summary exposes the exact derived route; +- knowledge, relationship, inventory, or village-resource changes track the + derived route in both directions: a newly unassisted need can surface the + hint once, a capable helper removes it immediately, and resolution or + invalidation replaces it with the existing close beat; +- reserved resources remain honestly player-usable because the current player + extraction contract does not consume NPC reservations, while disabled, + depleted, wrong-resource, and capacity-blocked sources do not qualify; +- unit, headless lifecycle, restore/checksum, real player extraction, and Jajce + runtime regressions cover the slice without persisting or replaying HUD state. + +This completes the bounded Milestone 7 simulation-garden proof. A known and +cared-about shortage now produces an inspectable opportunity whose NPC and +player responses both use the originating resource, relationship, knowledge, +and event systems rather than quest-only duplicates. + +The immediate next slice should begin Milestone 8 with a deterministic headless +scaling baseline: measure tick throughput, serialized-state growth, and retained +history across increasing NPC/event counts using the current full-fidelity +simulation. Set an evidence-backed target before adding spatial partitions, +batching, or active/abstract LOD transitions. Recently completed: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 75c369e..8c4137a 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -935,11 +935,18 @@ 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 bounded Milestone 7 player response is complete. When no capable helper +exists, an ephemeral query now proves an enabled player-usable finite source and +real destination with capacity. The warm-amber whisper briefly explains the +ordinary direct harvest route, then yields immediately when a helper emerges or +the need closes. The result and HUD add no quest acceptance, waypoint, tracker, +save field, RNG draw, or simulation mutation. This completes the Milestone 7 +simulation-garden exit proof. + +The next slice should start Milestone 8 with a deterministic headless scaling +baseline over increasing NPC and event counts. Measure tick throughput, +serialized-state growth, and retained history before selecting a target +population or implementing spatial partitions and active/abstract LOD. The remaining simulation-garden target still aims for: diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index d34bb41..4decc5b 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -196,10 +196,13 @@ lasting during deterministic memory maintenance. Resolution or invalidation releases that fact back to the normal bounded-retention rules; neither deletes objective event or opportunity history. -`OpportunityHelperResult` is deliberately absent from this schema. It is -re-derived from the open opportunity plus current NPC knowledge, directed -relationship, inventory, action-definition, storage-capacity, and finite- -resource records. Save/restore regressions require the same helper/action/route +`OpportunityHelperResult` and `OpportunityPlayerResponseResult` are deliberately +absent from this schema. The helper result is re-derived from the open +opportunity plus current NPC knowledge, directed relationship, inventory, +action-definition, storage-capacity, and finite-resource records. The player +result additionally requires no capable helper and derives its ordinary gather +action, real destination, and source count from current enabled player-usable +finite resources. Save/restore regressions require the same applicable route result and an unchanged checksum before and after querying. When an idle derived helper chooses the reported supply action, only the @@ -209,8 +212,8 @@ 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. +method, and acquisition-tick fields. Communication and player-response HUD +animations are transient and are cleared rather than replayed after restore. ## Resource authority diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index e578ecf..649bf20 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -782,24 +782,14 @@ func get_relationship_cause(relationship: RelationshipStateRecord) -> EconomicEv func get_known_events(npc_id: int, max_count: int = 3) -> Array[EconomicEventRecord]: - var known: Array[EconomicEventRecord] = [] - for event_id in event_knowledge_system.get_known_event_ids(npc_id, max_count): - var event := event_log.get_by_id(event_id) - if event != null: - known.append(event) - return known + return event_log.get_by_ids(event_knowledge_system.get_known_event_ids(npc_id, max_count)) 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 + return event_log.get_by_ids(event_ids) func get_active_opportunity() -> OpportunityStateRecord: @@ -820,18 +810,26 @@ func get_active_opportunity_helper() -> OpportunityHelperResult: ) +func get_active_opportunity_player_response() -> OpportunityPlayerResponseResult: + var active := get_active_opportunity() + if active == null: + return null + var helper := get_active_opportunity_helper() + return opportunity_system.find_player_response( + active, economy.get_storage(active.get_target_id()), resource_states, helper != null + ) + + func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord: return opportunity_system.get_latest_for_npc(npc_id) func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: - var event_id := opportunity.get_trigger_event_id() if opportunity != null else -1 - return event_log.get_by_id(event_id) + return event_log.get_by_id(opportunity.get_trigger_event_id() if opportunity != null else -1) func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: - var event_id := opportunity.get_resolution_event_id() if opportunity != null else -1 - return event_log.get_by_id(event_id) + return event_log.get_by_id(opportunity.get_resolution_event_id() if opportunity != null else -1) func npc_knows_event(npc_id: int, event_id: int) -> bool: diff --git a/simulation/events/SimulationEventLog.gd b/simulation/events/SimulationEventLog.gd index e405e6c..3a97689 100644 --- a/simulation/events/SimulationEventLog.gd +++ b/simulation/events/SimulationEventLog.gd @@ -91,6 +91,15 @@ func get_by_id(event_id: int) -> EconomicEventRecord: return null +func get_by_ids(event_ids: Array[int]) -> Array[EconomicEventRecord]: + var results: Array[EconomicEventRecord] = [] + for event_id in event_ids: + var event := get_by_id(event_id) + if event != null: + results.append(event) + return results + + func get_consumption_rates( current_tick: int, window_ticks: int = DEFAULT_RATE_WINDOW_TICKS, diff --git a/simulation/opportunities/OpportunityPlayerResponseResult.gd b/simulation/opportunities/OpportunityPlayerResponseResult.gd new file mode 100644 index 0000000..fca4cfa --- /dev/null +++ b/simulation/opportunities/OpportunityPlayerResponseResult.gd @@ -0,0 +1,20 @@ +class_name OpportunityPlayerResponseResult +extends RefCounted + +var opportunity_id: int +var trigger_event_id: int +var action_id: StringName +var resource_id: StringName +var target_id: StringName +var available_source_count: int +var reason: String + + +func _init(result: Dictionary) -> void: + opportunity_id = int(result["opportunity_id"]) + trigger_event_id = int(result["trigger_event_id"]) + action_id = StringName(result["action_id"]) + resource_id = StringName(result["resource_id"]) + target_id = StringName(result["target_id"]) + available_source_count = int(result["available_source_count"]) + reason = String(result["reason"]) diff --git a/simulation/opportunities/OpportunityPlayerResponseResult.gd.uid b/simulation/opportunities/OpportunityPlayerResponseResult.gd.uid new file mode 100644 index 0000000..241cf37 --- /dev/null +++ b/simulation/opportunities/OpportunityPlayerResponseResult.gd.uid @@ -0,0 +1 @@ +uid://2nyepaa3b77v diff --git a/simulation/opportunities/VillageOpportunitySystem.gd b/simulation/opportunities/VillageOpportunitySystem.gd index 3944107..d12a5dd 100644 --- a/simulation/opportunities/VillageOpportunitySystem.gd +++ b/simulation/opportunities/VillageOpportunitySystem.gd @@ -193,6 +193,62 @@ func find_capable_helper( ) +func find_player_response( + opportunity: OpportunityStateRecord, + target_storage: StorageStateRecord, + resource_states: Dictionary, + has_capable_helper: bool +) -> OpportunityPlayerResponseResult: + if ( + opportunity == null + or not opportunity.is_open() + or target_storage == null + or target_storage.get_storage_id() != opportunity.get_target_id() + or has_capable_helper + ): + return null + var remaining := maxf( + opportunity.get_target_amount() - target_storage.get_amount(opportunity.get_resource_id()), + 0.0 + ) + if remaining <= 0.0 or target_storage.get_available_capacity() < remaining: + return null + var action_ids := _get_supply_action_ids(opportunity.get_resource_id()) + if action_ids.is_empty(): + return null + var gather_definition := SimulationDefinitions.get_action(action_ids["gather"]) + if gather_definition == null: + return null + var available_source_count := _count_player_resource_sources( + remaining, opportunity.get_resource_id(), gather_definition, resource_states + ) + if available_source_count == 0: + return null + return ( + OpportunityPlayerResponseResult + . new( + { + "opportunity_id": opportunity.get_opportunity_id(), + "trigger_event_id": opportunity.get_trigger_event_id(), + "action_id": gather_definition.action_id, + "resource_id": opportunity.get_resource_id(), + "target_id": target_storage.get_storage_id(), + "available_source_count": available_source_count, + "reason": + ( + "No capable helper; %d player-usable finite %s source%s can supply %s" + % [ + available_source_count, + String(opportunity.get_resource_id()).capitalize(), + "" if available_source_count == 1 else "s", + String(target_storage.get_storage_id()), + ] + ), + } + ) + ) + + func _build_helper_candidate( npc: SimNPC, opportunity: OpportunityStateRecord, @@ -299,6 +355,29 @@ static func _count_available_resource_sources( return count +static func _count_player_resource_sources( + remaining: float, + resource_id: StringName, + gather_definition: ActionDefinition, + resource_states: Dictionary +) -> int: + var count := 0 + for value in resource_states.values(): + var state := value as ResourceStateRecord + if ( + state == null + or state.get_action_id() != gather_definition.resource_action_id + or state.get_resource_id() != resource_id + or not state.can_player_use_resource() + or not state.can_extract() + or state.get_yield_per_action() <= 0.0 + or state.get_amount_remaining() < remaining + ): + continue + count += 1 + return count + + static func _sort_helper_candidates(first: Dictionary, second: Dictionary) -> bool: if first["uses_inventory"] != second["uses_inventory"]: return bool(first["uses_inventory"]) diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 4d5b33d..b9e38c3 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -311,16 +311,25 @@ func _run() -> void: runtime_opportunity.get_trigger_event_id() if runtime_opportunity != null else -1 ) var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() + var runtime_player_response: OpportunityPlayerResponseResult = ( + simulation_manager.get_active_opportunity_player_response() + ) _check( ( runtime_opportunity != null and runtime_helper == null + and runtime_player_response != null + and runtime_player_response.action_id == SimulationIds.ACTION_GATHER_WOOD + and runtime_player_response.target_id == SimulationIds.STORAGE_VILLAGE_WOODPILE + and runtime_player_response.available_source_count > 0 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 + and whisper_kicker.text == "YOU CAN HELP" + and ( + "Harvest a tree; wood goes straight to the village woodpile." + in whisper_message.text + ) ), - "The real blocked-work need should surface while the distant helper remains uninformed" + "An unassisted blocked-work need should surface its real player harvest route" ) _check( ( @@ -330,6 +339,7 @@ func _run() -> void: "Possible helper: none informed, trusted, and able to supply" in village_stats_label.text ) + and "Player route: Gather Wood → Village Woodpile" in village_stats_label.text and "◆ Find wood for Study" in inspector_label.text and not contributor_visual.get_node("OpportunityConcernRoot").visible ), @@ -348,6 +358,7 @@ func _run() -> void: witness.id, runtime_trigger_id ) runtime_helper = simulation_manager.get_active_opportunity_helper() + runtime_player_response = simulation_manager.get_active_opportunity_player_response() _check( ( communicated_trigger != null @@ -357,6 +368,7 @@ func _run() -> void: ) and communicated_trigger.get_source_npc_id() == contributor.id and runtime_helper != null + and runtime_player_response == null and runtime_helper.helper_npc_id == witness.id and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD and whisper_kicker.text == "NEWS TRAVELS" diff --git a/tests/unit/test_village_opportunity_system.gd b/tests/unit/test_village_opportunity_system.gd index d12494f..5dfe936 100644 --- a/tests/unit/test_village_opportunity_system.gd +++ b/tests/unit/test_village_opportunity_system.gd @@ -306,6 +306,60 @@ func test_ready_inventory_then_trust_and_stable_id_rank_helpers_deterministicall assert_eq(result.helper_npc_id, lower_id_carrier.id) +func test_player_response_requires_no_helper_and_a_real_player_supply_route() -> void: + var fixture := _open_fixture() + var system: VillageOpportunitySystem = fixture["system"] + var pantry: StorageStateRecord = fixture["pantry"] + var opportunity: OpportunityStateRecord = system.get_open_opportunity() + var player_source := _resource_state( + &"player_berries", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 1.0 + ) + var npc_only_source := _resource_state( + &"npc_berries", + SimulationIds.ACTION_GATHER_FOOD, + SimulationIds.RESOURCE_FOOD, + 2.0, + 0.0, + 0.0, + false + ) + var resource_states := { + player_source.get_node_id(): player_source, + npc_only_source.get_node_id(): npc_only_source, + } + assert_true(player_source.reserve(99)) + var opportunity_before := opportunity.to_dictionary() + var pantry_before := pantry.to_dictionary() + var source_before := player_source.to_dictionary() + var response: OpportunityPlayerResponseResult = system.find_player_response( + opportunity, pantry, resource_states, false + ) + + assert_not_null(response) + assert_eq(response.opportunity_id, opportunity.get_opportunity_id()) + assert_eq(response.trigger_event_id, opportunity.get_trigger_event_id()) + assert_eq(response.action_id, SimulationIds.ACTION_GATHER_FOOD) + assert_eq(response.resource_id, SimulationIds.RESOURCE_FOOD) + assert_eq(response.target_id, SimulationIds.STORAGE_VILLAGE_PANTRY) + assert_eq(response.available_source_count, 1) + assert_string_contains(response.reason, "No capable helper") + assert_string_contains(response.reason, "player-usable finite Food source") + assert_eq(opportunity.to_dictionary(), opportunity_before) + assert_eq(pantry.to_dictionary(), pantry_before) + assert_eq(player_source.to_dictionary(), source_before) + assert_null(system.find_player_response(opportunity, pantry, resource_states, true)) + + player_source.set_enabled(false) + assert_null(system.find_player_response(opportunity, pantry, resource_states, false)) + var full_pantry := StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_PANTRY, {String(SimulationIds.RESOURCE_WOOD): 1.0}, 1.0 + ) + player_source.set_enabled(true) + assert_null(system.find_player_response(opportunity, full_pantry, resource_states, false)) + var wrong_target := StorageStateRecord.create(&"not_the_pantry", {}, 1.0) + assert_null(system.find_player_response(opportunity, wrong_target, resource_states, false)) + + func test_wood_helper_uses_available_finite_source_and_respects_reservations() -> void: var system := VillageOpportunitySystemScript.new() var interested := _npc(0, 20.0) @@ -483,7 +537,9 @@ func _resource_state( resource_id: StringName, amount: float, discovery_priority: float = 0.0, - safety_risk: float = 0.0 + safety_risk: float = 0.0, + can_player_use: bool = true, + can_npcs_use: bool = true ) -> ResourceStateRecord: var node := ResourceNode.new() node.node_id = node_id @@ -493,6 +549,8 @@ func _resource_state( node.yield_per_action = 1.0 node.discovery_priority = discovery_priority node.safety_risk = safety_risk + node.can_player_use = can_player_use + node.can_npcs_use = can_npcs_use var state := ResourceStateRecord.create_from_node(node) node.free() return state diff --git a/tests/wood_shortage_opportunity_test.gd b/tests/wood_shortage_opportunity_test.gd index 45f4e3e..fdc0a9e 100644 --- a/tests/wood_shortage_opportunity_test.gd +++ b/tests/wood_shortage_opportunity_test.gd @@ -202,6 +202,48 @@ func _run() -> void: root.add_child(tree) await process_frame _check(player_manager.register_resource_node(tree), "Player branch should bind a real tree") + var checksum_before_player_query: String = player_manager.get_state_checksum() + var player_response: OpportunityPlayerResponseResult = ( + player_manager.get_active_opportunity_player_response() + ) + _check( + ( + player_manager.get_active_opportunity_helper() == null + and player_response != null + and player_response.action_id == SimulationIds.ACTION_GATHER_WOOD + and player_response.resource_id == SimulationIds.RESOURCE_WOOD + and player_response.target_id == SimulationIds.STORAGE_VILLAGE_WOODPILE + and player_response.available_source_count == 1 + ), + "An unassisted wood need should derive one ordinary player tree-to-woodpile route" + ) + _check( + player_manager.get_state_checksum() == checksum_before_player_query, + "Player-response discovery should not mutate authoritative simulation state" + ) + var player_active_json: String = player_manager.serialize_state() + var player_active_restored := _create_manager(1210) + _check( + player_active_restored.restore_state_from_json(player_active_json), + "An active player-response route should restore through its existing facts" + ) + var restored_player_response: OpportunityPlayerResponseResult = ( + player_active_restored.get_active_opportunity_player_response() + ) + _check( + ( + player_active_restored.get_state_checksum() == checksum_before_player_query + and restored_player_response != null + and restored_player_response.action_id == player_response.action_id + and restored_player_response.target_id == player_response.target_id + and ( + restored_player_response.available_source_count + == player_response.available_source_count + ) + ), + "Restore should re-derive the same player response without persisted hint state" + ) + player_active_restored.free() _check( is_equal_approx(player_manager.harvest_resource_node(tree), 1.0), "Player tree extraction should supply the authoritative woodpile" @@ -227,6 +269,10 @@ func _run() -> void: ), "Player extraction should resolve through its exact existing economy event" ) + _check( + player_manager.get_active_opportunity_player_response() == null, + "Resolving the need should remove the derived player response" + ) tree.queue_free() player_manager.free() @@ -347,6 +393,7 @@ func _create_player_tree() -> ResourceNode: tree.resource_id = SimulationIds.RESOURCE_WOOD tree.initial_amount = 1.0 tree.yield_per_action = 1.0 + tree.can_npcs_use = false tree.can_player_use = true tree.debug_label_enabled = false var interaction_point := Marker3D.new() @@ -359,6 +406,7 @@ func _create_helper_tree() -> ResourceNode: var tree := _create_player_tree() tree.name = "HelperTree" tree.node_id = &"helper_tree" + tree.can_npcs_use = true tree.can_player_use = false tree.discovery_priority = 4.0 return tree diff --git a/world/ui/ui.gd b/world/ui/ui.gd index 4f01811..866b619 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -291,13 +291,34 @@ func _build_opportunity_helper_display() -> String: return "" var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() if helper == null: - return "\nPossible helper: none informed, trusted, and able to supply" + var player_text := _build_opportunity_player_response_display() + return "\nPossible helper: none informed, trusted, and able to supply" + player_text return ( "\nPossible helper: %s — %s\n%s" % [_get_npc_name(helper.helper_npc_id), _get_action_name(helper.action_id), helper.reason] ) +func _build_opportunity_player_response_display() -> String: + if not simulation_manager.has_method("get_active_opportunity_player_response"): + return "" + var response: OpportunityPlayerResponseResult = ( + simulation_manager.get_active_opportunity_player_response() + ) + if response == null: + return "" + var source_suffix := "" if response.available_source_count == 1 else "s" + return ( + "\nPlayer route: %s → %s · %d finite source%s" + % [ + _get_action_name(response.action_id), + String(response.target_id).replace("_", " ").capitalize(), + response.available_source_count, + source_suffix, + ] + ) + + func _build_npc_opportunity_display(npc: SimNPC) -> String: if not simulation_manager.has_method("get_latest_opportunity_for_npc"): return "" diff --git a/world/ui/village_whisper_hud.gd b/world/ui/village_whisper_hud.gd index 1cc01eb..fa184e8 100644 --- a/world/ui/village_whisper_hud.gd +++ b/world/ui/village_whisper_hud.gd @@ -3,6 +3,7 @@ extends Control const DISPLAY_SECONDS := 4.2 const ENTER_OFFSET := 8.0 +const FEEDBACK_PLAYER_RESPONSE := &"player_response" @export var simulation_manager: Node @@ -15,6 +16,8 @@ var resting_position: Vector2 var feedback_version := 0 var active_trigger_event_id := OpportunityStateRecord.NO_EVENT_ID var informed_listener_id := -1 +var feedback_kind := &"" +var player_response_available := false func _ready() -> void: @@ -28,12 +31,29 @@ func _ready() -> void: 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.event_knowledge_changed.connect(_on_helper_facts_changed) + simulation_manager.event_knowledge_forgotten.connect(_on_helper_fact_forgotten) + simulation_manager.relationship_changed.connect(_on_helper_relationship_changed) + simulation_manager.npc_inventory_changed.connect(_on_helper_inventory_changed) + simulation_manager.village_changed.connect(_on_village_changed) simulation_manager.state_restored.connect(_on_state_restored) + player_response_available = simulation_manager.get_active_opportunity_player_response() != null func _on_opportunity_opened(opportunity: OpportunityStateRecord) -> void: active_trigger_event_id = opportunity.get_trigger_event_id() informed_listener_id = -1 + var player_response: OpportunityPlayerResponseResult = ( + simulation_manager.get_active_opportunity_player_response() + ) + player_response_available = player_response != null + if player_response != null: + _show_feedback( + "You can help", + _build_player_response_message(player_response), + FEEDBACK_PLAYER_RESPONSE + ) + return _show_feedback("Village need", _build_need_message(opportunity)) @@ -78,6 +98,44 @@ func _on_npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult) -> ) +func _on_helper_facts_changed(_knower_id: int, _event: EconomicEventRecord) -> void: + _refresh_player_response() + + +func _on_helper_fact_forgotten(_knower_id: int, _event: EconomicEventRecord) -> void: + _refresh_player_response() + + +func _on_helper_relationship_changed( + _relationship: RelationshipStateRecord, _cause_event: EconomicEventRecord +) -> void: + _refresh_player_response() + + +func _on_helper_inventory_changed(_npc: SimNPC, _item_id: StringName, _amount: float) -> void: + _refresh_player_response() + + +func _on_village_changed(_village: SimVillage) -> void: + _refresh_player_response() + + +func _refresh_player_response() -> void: + var response: OpportunityPlayerResponseResult = ( + simulation_manager.get_active_opportunity_player_response() + ) + var is_available := response != null + if is_available == player_response_available: + return + player_response_available = is_available + if response != null: + _show_feedback( + "You can help", _build_player_response_message(response), FEEDBACK_PLAYER_RESPONSE + ) + elif feedback_kind == FEEDBACK_PLAYER_RESPONSE: + _reset_feedback() + + func _on_opportunity_resolved( opportunity: OpportunityStateRecord, resolution_event: EconomicEventRecord ) -> void: @@ -92,6 +150,7 @@ func _on_opportunity_resolved( ) _show_feedback("Need met", "%s %s." % [actor_name, outcome]) informed_listener_id = -1 + player_response_available = false func _on_opportunity_invalidated(opportunity: OpportunityStateRecord) -> void: @@ -99,6 +158,7 @@ func _on_opportunity_invalidated(opportunity: OpportunityStateRecord) -> void: return _show_feedback("Need closed", "The village could no longer act on this report.") informed_listener_id = -1 + player_response_available = false func _on_state_restored() -> void: @@ -110,6 +170,7 @@ func _on_state_restored() -> void: else OpportunityStateRecord.NO_EVENT_ID ) informed_listener_id = -1 + player_response_available = simulation_manager.get_active_opportunity_player_response() != null func _build_need_message(opportunity: OpportunityStateRecord) -> String: @@ -123,6 +184,22 @@ func _build_need_message(opportunity: OpportunityStateRecord) -> String: return "%s needs wood to finish %s." % [interested_name, action_name] +func _build_player_response_message(response: OpportunityPlayerResponseResult) -> String: + var destination := _get_destination_name(response.target_id) + if response.resource_id == SimulationIds.RESOURCE_FOOD: + return "Forage food; it goes straight to the %s." % destination + return "Harvest a tree; wood goes straight to the %s." % destination + + +func _get_destination_name(target_id: StringName) -> String: + match target_id: + SimulationIds.STORAGE_VILLAGE_PANTRY: + return "village pantry" + SimulationIds.STORAGE_VILLAGE_WOODPILE: + return "village woodpile" + return String(target_id).replace("_", " ") + + func _get_need_name(opportunity: OpportunityStateRecord) -> String: return ( "empty-pantry worry" @@ -143,13 +220,14 @@ func _get_npc_name(npc_id: int) -> String: return "Someone" -func _show_feedback(heading: String, body: String) -> void: +func _show_feedback(heading: String, body: String, kind: StringName = &"") -> void: feedback_version += 1 var version := feedback_version if feedback_tween != null: feedback_tween.kill() kicker.text = heading.to_upper() message.text = body + feedback_kind = kind visible = true position = resting_position + Vector2(0.0, ENTER_OFFSET) modulate.a = 0.0 @@ -174,6 +252,7 @@ func _fade_after_delay(version: int) -> void: await feedback_tween.finished if version == feedback_version: visible = false + feedback_kind = &"" func _reset_feedback() -> void: @@ -181,6 +260,7 @@ func _reset_feedback() -> void: if feedback_tween != null: feedback_tween.kill() visible = false + feedback_kind = &"" position = resting_position modulate.a = 0.0 if accent != null: