diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 6126b66..68d66ee 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -841,10 +841,20 @@ Next: 1. Let an inspected villager who owns an open opportunity surface that exact village need plus the authoritative direct-help route, capable helper, or - honest unavailable state that currently exists. + honest unavailable state that currently exists. **Complete:** the quiet + `VillagerFieldNoteHud` now shows a restrained `Need` line for exactly that + note, derived from the existing player-response/helper queries and the real + storage state. It names the helper when one is capable, shows the direct + finite-resource-to-storage route when the player response derives it, or + honestly states why help is unavailable, and it re-derives from live facts + after restore without entering saves. 2. Prove that the ordinary finite-resource-to-storage harvest resolves an available player route and updates the context without dialogue branching, acceptance state, rewards, a quest log, or saved presentation state. + **Complete:** a runtime scenario stages the real pantry crisis, surfaces the + need for its interested villager, and proves that one ordinary + `harvest_resource_node` extraction resolves the same open opportunity and + clears the need segment on the next refresh. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 55df851..debc738 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -984,6 +984,25 @@ resolve the need without adding a dialogue tree, quest acceptance, rewards, a quest log, reputation, a generic conversation framework, or saved presentation state in that slice. +The first bounded field-note opportunity surfacing slice is complete: + +- `VillagerInspectionResult` now carries an optional read-only + `VillagerOpportunityNote` that is present only when the inspected villager is + the interested party of an open village opportunity; +- `VillagerOpportunityNote.derive` decides the honest help state from the + existing authoritative queries: the derived player-response route wins, then + the capable helper by name, then a real unavailable reason (need already met, + storage has no room, no stocked source in reach, or no storage at all); +- the quiet `VillagerFieldNoteHud` shows a restrained `Need` line for exactly + that note, coexists with the existing `E` prompt and cinematic mode, and + re-derives it from live state without entering saves or drawing RNG; +- a real player finite-resource harvest resolves the same open + `OpportunityStateRecord` through the existing economy/event history, and the + need segment clears on the next refresh; +- unit, runtime, and query-neutrality checks cover the derive branches, the + staged pantry-crisis surfacing, helper naming, ordinary harvest resolution, + and the absence of saved presentation state. + Recently completed: - `Jajce Villager Field Note 12`: a separate player-facing note selects the diff --git a/main.tscn b/main.tscn index 1c9fc90..3f34b07 100644 --- a/main.tscn +++ b/main.tscn @@ -204,7 +204,7 @@ anchors_preset = 2 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 34.0 -offset_top = -194.0 +offset_top = -246.0 offset_right = 430.0 offset_bottom = -46.0 grow_vertical = 0 @@ -290,6 +290,18 @@ theme_override_font_sizes/font_size = 13 text = "Why · Awaiting the next decision" autowrap_mode = 2 +[node name="Need" type="Label" parent="VillagerInspectionLayer/VillagerFieldNote/Copy"] +custom_minimum_size = Vector2(380, 0) +layout_mode = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.93, 0.7, 0.32, 1) +theme_override_colors/font_outline_color = Color(0.07, 0.045, 0.025, 0.94) +theme_override_constants/outline_size = 5 +theme_override_font_sizes/font_size = 14 +text = "Need · " +autowrap_mode = 2 +visible = false + [node name="VillageWhisperLayer" type="CanvasLayer" parent="."] layer = 3 diff --git a/player/VillagerInspectionResult.gd b/player/VillagerInspectionResult.gd index 21df7d2..f845a8f 100644 --- a/player/VillagerInspectionResult.gd +++ b/player/VillagerInspectionResult.gd @@ -13,6 +13,7 @@ var target_name: String var carried_amounts: Dictionary var decision_reason: String var _has_decision_reason: bool +var opportunity_note: VillagerOpportunityNote func _init( @@ -24,7 +25,8 @@ func _init( inspection_target_id: StringName, inspection_target_name: String, inspection_carried_amounts: Dictionary, - inspection_decision_reason := "" + inspection_decision_reason := "", + inspection_opportunity_note: VillagerOpportunityNote = null ) -> void: npc_id = inspection_npc_id npc_name = inspection_npc_name @@ -36,6 +38,7 @@ func _init( carried_amounts = inspection_carried_amounts.duplicate(true) _has_decision_reason = not inspection_decision_reason.strip_edges().is_empty() decision_reason = (inspection_decision_reason if _has_decision_reason else AWAITING_REASON) + opportunity_note = inspection_opportunity_note func has_decision_reason() -> bool: @@ -67,6 +70,7 @@ func cache_key() -> String: var carried_parts: Array[String] = [] for item_id in carried_keys: carried_parts.append("%s=%.3f" % [String(item_id), float(carried_amounts[item_id])]) + var note_part := opportunity_note.cache_key() if opportunity_note != null else "-" return ( "|" . join( @@ -80,6 +84,7 @@ func cache_key() -> String: target_name, ",".join(carried_parts), decision_reason, + note_part, ] ) ) diff --git a/player/VillagerOpportunityNote.gd b/player/VillagerOpportunityNote.gd new file mode 100644 index 0000000..53b4826 --- /dev/null +++ b/player/VillagerOpportunityNote.gd @@ -0,0 +1,79 @@ +class_name VillagerOpportunityNote +extends RefCounted + +const HELP_PLAYER_ROUTE := &"player_route" +const HELP_HELPER := &"helper" +const HELP_UNAVAILABLE := &"unavailable" + +var has_need := false +var need_text := "" +var help_kind: StringName = &"" +var help_text := "" + + +func _init( + note_has_need := false, + note_need_text := "", + note_help_kind: StringName = &"", + note_help_text := "" +) -> void: + has_need = note_has_need + need_text = note_need_text + help_kind = note_help_kind + help_text = note_help_text + + +func cache_key() -> String: + return "%s|%s|%s|%s" % [has_need, need_text, String(help_kind), help_text] + + +static func derive( + opportunity: OpportunityStateRecord, + player_response: OpportunityPlayerResponseResult, + helper: OpportunityHelperResult, + helper_npc_name: String, + storage: StorageStateRecord, + target_display_name: String, + resource_display_name: String +) -> VillagerOpportunityNote: + if opportunity == null: + return VillagerOpportunityNote.new() + var need_text := "The %s needs %s" % [target_display_name, resource_display_name] + if player_response != null: + return ( + VillagerOpportunityNote + . new( + true, + need_text, + HELP_PLAYER_ROUTE, + "You can help · forage %s to the %s" % [resource_display_name, target_display_name], + ) + ) + if helper != null: + return VillagerOpportunityNote.new( + true, + need_text, + HELP_HELPER, + "%s is bringing %s" % [helper_npc_name, resource_display_name] + ) + if storage == null: + return VillagerOpportunityNote.new( + true, need_text, HELP_UNAVAILABLE, "No storage is within reach right now." + ) + var remaining := maxf( + opportunity.get_target_amount() - storage.get_amount(opportunity.get_resource_id()), 0.0 + ) + if remaining <= 0.0: + return VillagerOpportunityNote.new( + true, + need_text, + HELP_UNAVAILABLE, + "The %s already has enough %s." % [target_display_name, resource_display_name] + ) + if storage.get_available_capacity() < remaining: + return VillagerOpportunityNote.new( + true, need_text, HELP_UNAVAILABLE, "The %s has no room right now." % target_display_name + ) + return VillagerOpportunityNote.new( + true, need_text, HELP_UNAVAILABLE, "No stocked source is in reach right now." + ) diff --git a/player/VillagerOpportunityNote.gd.uid b/player/VillagerOpportunityNote.gd.uid new file mode 100644 index 0000000..56d2924 --- /dev/null +++ b/player/VillagerOpportunityNote.gd.uid @@ -0,0 +1 @@ +uid://ds7o7atkwyfon diff --git a/player/player.gd b/player/player.gd index c02415d..2a6485d 100644 --- a/player/player.gd +++ b/player/player.gd @@ -142,6 +142,7 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult: var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id) if decision != null and decision.action_id == npc.current_task: reason = decision.reason + var opportunity_note := _build_opportunity_note(npc) return VillagerInspectionResult.new( npc.id, npc.npc_name, @@ -151,10 +152,62 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult: npc.target_id, target_name, carried_amounts, - reason + reason, + opportunity_note ) +func _build_opportunity_note(npc: SimNPC) -> VillagerOpportunityNote: + if ( + simulation_manager == null + or not simulation_manager.has_method("get_active_opportunity") + or not simulation_manager.has_method("get_active_opportunity_player_response") + or not simulation_manager.has_method("get_active_opportunity_helper") + ): + return null + var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() + if ( + opportunity == null + or opportunity.get_interested_npc_id() != npc.id + or ( + opportunity.get_target_id() + not in [SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.STORAGE_VILLAGE_WOODPILE] + ) + ): + return null + var player_response: OpportunityPlayerResponseResult = ( + simulation_manager.get_active_opportunity_player_response() + ) + var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() + var helper_name := "" + if helper != null: + var helper_npc := _find_npc(helper.helper_npc_id) + if helper_npc != null: + helper_name = helper_npc.npc_name + return VillagerOpportunityNote.derive( + opportunity, + player_response, + helper, + helper_name, + _get_opportunity_storage(opportunity), + _get_target_display_name(opportunity.get_target_id()), + _display_id(opportunity.get_resource_id()).to_lower() + ) + + +func _get_opportunity_storage(opportunity: OpportunityStateRecord) -> StorageStateRecord: + if simulation_manager == null: + return null + if not simulation_manager.has_method("get_pantry"): + return null + if opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY: + return simulation_manager.get_pantry() as StorageStateRecord + if opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_WOODPILE: + if simulation_manager.has_method("get_woodpile"): + return simulation_manager.get_woodpile() as StorageStateRecord + return null + + func _find_feed_animal() -> AnimalNode: if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null: push_error("Player: SimulationManager has no animal-care service") diff --git a/tests/field_note_opportunity_surface_test.gd b/tests/field_note_opportunity_surface_test.gd new file mode 100644 index 0000000..d2eec36 --- /dev/null +++ b/tests/field_note_opportunity_surface_test.gd @@ -0,0 +1,161 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main_scene: Node = load("res://main.tscn").instantiate() + root.add_child(main_scene) + await process_frame + for _frame in 10: + await physics_frame + + var manager: Node = main_scene.get_node("SimulationManager") + var view: Node = main_scene.get_node("WorldViewManager") + var player: CharacterBody3D = main_scene.get_node("Player") + var demo: Node = main_scene.get_node("DemoController") + var field_note := main_scene.get_node("VillagerInspectionLayer/VillagerFieldNote") + manager.set_process(false) + player.set_physics_process(false) + _freeze_visuals(view) + + var staged: Dictionary = demo.call("stage_pantry_crisis") + _check(not staged.is_empty(), "The runtime proof should stage a real open pantry need") + if staged.is_empty(): + _finish() + return + var interested: SimNPC = staged["interested"] + var pantry_position: Vector3 = ( + StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY).get_interaction_position() + ) + player.global_position = pantry_position + _move_population_away(manager, view, interested.id, pantry_position) + _set_loaded_position(manager, view, interested, pantry_position + Vector3(1.0, 0.0, 0.0)) + + var checksum_before: String = manager.get_state_checksum() + var serialized_before: String = manager.serialize_state() + var events_before: int = manager.economic_events.size() + for _query in 3: + player.call("get_nearby_villager_inspection") + field_note.call("refresh_note", true) + var need_label := field_note.get_node("Copy/Need") as Label + _check( + ( + manager.get_state_checksum() == checksum_before + and manager.serialize_state() == serialized_before + and manager.economic_events.size() == events_before + and field_note.visible + and need_label.visible + and "Need" in need_label.text + ), + "The inspected interested villager should surface the real need without mutating state", + ) + if not need_label.visible: + _finish() + return + + var helper: OpportunityHelperResult = manager.get_active_opportunity_helper() + var helper_npc := _find_npc(manager.npcs, helper.helper_npc_id) if helper != null else null + _check( + ( + helper != null + and helper_npc != null + and helper_npc.npc_name in need_label.text + and "bringing" in need_label.text.to_lower() + ), + "The open need with a capable helper should name that helper on the field note", + ) + + var checksum_after_helper: String = manager.get_state_checksum() + var food_node: ResourceNode = _find_food_node(manager) + _check(food_node != null, "The resolution proof needs a stocked player-usable food source") + if food_node == null: + _finish() + return + var harvested: float = manager.harvest_resource_node(food_node) + _check( + ( + harvested > 0.0 + and manager.get_active_opportunity() == null + and ( + manager.opportunity_system.get_latest().get_status() + == OpportunityStateRecord.STATUS_RESOLVED + ) + ), + "An ordinary finite-resource harvest should resolve the same open need", + ) + field_note.call("refresh_note", true) + _check( + not need_label.visible and manager.get_state_checksum() != checksum_after_helper, + "Resolving the need should clear the surfaced need segment from the field note", + ) + + _finish() + + +func _find_food_node(manager: Node) -> ResourceNode: + for node in ResourceNode.get_all(): + var state: ResourceStateRecord = ( + manager.get_resource_state(node.node_id) as ResourceStateRecord + ) + if ( + node.resource_id == SimulationIds.RESOURCE_FOOD + and state != null + and state.can_player_use_resource() + and state.can_extract() + and state.get_amount_remaining() >= 1.0 + ): + return node + return null + + +func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC: + for npc in npcs: + if npc.id == npc_id: + return npc + return null + + +func _move_population_away(manager: Node, view: Node, keep_id: int, origin: Vector3) -> void: + for npc in manager.npcs: + if npc.id == keep_id: + continue + _set_loaded_position( + manager, view, npc, origin + Vector3(40.0 + float(npc.id) * 2.0, 0.0, 20.0) + ) + + +func _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void: + manager.synchronize_npc_position(npc.id, position) + var visual := view.active_npc_visuals.get(npc.id) as Node3D + if visual != null: + visual.set_physics_process(false) + if visual.has_method("stop_travel"): + visual.stop_travel() + visual.global_position = position + + +func _freeze_visuals(view: Node) -> void: + for visual in view.active_npc_visuals.values(): + visual.set_physics_process(false) + if visual.has_method("stop_travel"): + visual.stop_travel() + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Field-note opportunity surfacing passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/field_note_opportunity_surface_test.gd.uid b/tests/field_note_opportunity_surface_test.gd.uid new file mode 100644 index 0000000..64b8c76 --- /dev/null +++ b/tests/field_note_opportunity_surface_test.gd.uid @@ -0,0 +1 @@ +uid://dg66jcyhajmxp diff --git a/tests/unit/test_villager_opportunity_note.gd b/tests/unit/test_villager_opportunity_note.gd new file mode 100644 index 0000000..bf11032 --- /dev/null +++ b/tests/unit/test_villager_opportunity_note.gd @@ -0,0 +1,168 @@ +extends GutTest + +const VillagerOpportunityNoteScript := preload("res://player/VillagerOpportunityNote.gd") + + +func test_no_opportunity_produces_no_need() -> void: + var note := VillagerOpportunityNote.derive(null, null, null, "", null, "", "") + + assert_not_null(note) + assert_false(note.has_need) + assert_eq(note.help_kind, &"") + + +func test_player_route_wins_over_helper_when_derivable() -> void: + var opportunity := _open_pantry_opportunity() + var response := _player_response() + var helper := _helper(7) + var pantry := _pantry(0.0, 100.0) + + var note := VillagerOpportunityNote.derive( + opportunity, response, helper, "Tarik", pantry, "Village Pantry", "food" + ) + + assert_true(note.has_need) + assert_eq(note.help_kind, VillagerOpportunityNote.HELP_PLAYER_ROUTE) + assert_eq(note.need_text, "The Village Pantry needs food") + assert_true(note.help_text.contains("You can help")) + assert_true(note.help_text.contains("Village Pantry")) + + +func test_helper_is_named_when_no_player_route() -> void: + var opportunity := _open_pantry_opportunity() + var pantry := _pantry(0.0, 100.0) + + var note := VillagerOpportunityNote.derive( + opportunity, null, _helper(7), "Tarik", pantry, "Village Pantry", "food" + ) + + assert_true(note.has_need) + assert_eq(note.help_kind, VillagerOpportunityNote.HELP_HELPER) + assert_true(note.help_text.contains("Tarik")) + assert_true(note.help_text.contains("food")) + + +func test_unavailable_reports_no_room_when_storage_is_full() -> void: + var opportunity := _open_pantry_opportunity() + var pantry := _pantry(0.0, 0.5) + + var note := VillagerOpportunityNote.derive( + opportunity, null, null, "", pantry, "Village Pantry", "food" + ) + + assert_true(note.has_need) + assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE) + assert_true(note.help_text.contains("no room")) + + +func test_unavailable_reports_need_already_met() -> void: + var opportunity := _open_pantry_opportunity() + var pantry := _pantry(1.0, 100.0) + + var note := VillagerOpportunityNote.derive( + opportunity, null, null, "", pantry, "Village Pantry", "food" + ) + + assert_true(note.has_need) + assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE) + assert_true(note.help_text.contains("already has enough")) + + +func test_unavailable_reports_no_stocked_source_when_storage_has_room() -> void: + var opportunity := _open_pantry_opportunity() + var pantry := _pantry(0.0, 100.0) + + var note := VillagerOpportunityNote.derive( + opportunity, null, null, "", pantry, "Village Pantry", "food" + ) + + assert_true(note.has_need) + assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE) + assert_true(note.help_text.contains("No stocked source")) + + +func test_unavailable_reports_missing_storage() -> void: + var opportunity := _open_pantry_opportunity() + + var note := VillagerOpportunityNote.derive( + opportunity, null, null, "", null, "Village Pantry", "food" + ) + + assert_true(note.has_need) + assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE) + assert_true(note.help_text.contains("No storage")) + + +func test_cache_key_uses_need_and_help_state() -> void: + var opportunity := _open_pantry_opportunity() + var pantry := _pantry(0.0, 100.0) + var helper_note := VillagerOpportunityNote.derive( + opportunity, null, _helper(7), "Tarik", pantry, "Village Pantry", "food" + ) + var unavailable_note := VillagerOpportunityNote.derive( + opportunity, null, null, "", pantry, "Village Pantry", "food" + ) + var no_need := VillagerOpportunityNote.derive(null, null, null, "", null, "", "") + + assert_ne(helper_note.cache_key(), unavailable_note.cache_key()) + assert_ne(no_need.cache_key(), helper_note.cache_key()) + + +func _open_pantry_opportunity() -> OpportunityStateRecord: + return OpportunityStateRecord.create( + 0, + 10, + 5, + 3, + 1.0, + SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY, + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD + ) + + +func _player_response() -> OpportunityPlayerResponseResult: + return ( + OpportunityPlayerResponseResult + . new( + { + "opportunity_id": 0, + "trigger_event_id": 5, + "action_id": SimulationIds.ACTION_GATHER_FOOD, + "resource_id": SimulationIds.RESOURCE_FOOD, + "target_id": SimulationIds.STORAGE_VILLAGE_PANTRY, + "available_source_count": 2, + "reason": + "No capable helper; 2 player-usable finite Food sources can supply village_pantry", + } + ) + ) + + +func _helper(helper_id: int) -> OpportunityHelperResult: + return ( + OpportunityHelperResult + . new( + { + "opportunity_id": 0, + "helper_npc_id": helper_id, + "action_id": SimulationIds.ACTION_GATHER_FOOD, + "source_id": "", + "resource_id": SimulationIds.RESOURCE_FOOD, + "trigger_event_id": 5, + "trust": 0.8, + "familiarity": 0.5, + "uses_inventory": false, + "available_source_count": 1, + "profession_match": true, + "reason": + "Knows the need; trust 0.80 toward Amina; has 1 available finite Food source", + } + ) + ) + + +func _pantry(amount: float, capacity: float) -> StorageStateRecord: + return StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_PANTRY, {SimulationIds.RESOURCE_FOOD: amount}, capacity + ) diff --git a/tests/unit/test_villager_opportunity_note.gd.uid b/tests/unit/test_villager_opportunity_note.gd.uid new file mode 100644 index 0000000..60f8092 --- /dev/null +++ b/tests/unit/test_villager_opportunity_note.gd.uid @@ -0,0 +1 @@ +uid://1chfro4iw1o5 diff --git a/world/ui/villager_field_note_hud.gd b/world/ui/villager_field_note_hud.gd index 55f7d34..636d6fb 100644 --- a/world/ui/villager_field_note_hud.gd +++ b/world/ui/villager_field_note_hud.gd @@ -14,6 +14,7 @@ const EXIT_OFFSET := 4.0 @onready var target_label: Label = $Copy/Target @onready var carrying_label: Label = $Copy/Carrying @onready var reason_label: Label = $Copy/Reason +@onready var need_label: Label = $Copy/Need var copy_resting_position: Vector2 var current_context_key := "" @@ -64,6 +65,7 @@ func _present(context: VillagerInspectionResult, animate: bool) -> void: target_label.text = "Target · %s" % context.target_name carrying_label.text = "Carrying · %s" % context.carrying_text() reason_label.text = "Why · %s" % context.decision_reason + _present_opportunity_note(context.opportunity_note) visible = true accent.pivot_offset = accent.size * 0.5 if not animate: @@ -81,6 +83,15 @@ func _present(context: VillagerInspectionResult, animate: bool) -> void: motion_tween.tween_property(accent, "scale:y", 1.0, 0.26) +func _present_opportunity_note(note: VillagerOpportunityNote) -> void: + if note == null or not note.has_need: + need_label.visible = false + need_label.text = "Need · " + return + need_label.visible = true + need_label.text = "Need · %s\n%s" % [note.need_text, note.help_text] + + func _hide_note() -> void: if not visible or is_hiding: return