feat: surface open opportunity need on villager field note

This commit is contained in:
2026-08-08 16:18:39 +02:00
parent 8a7e6b12eb
commit 4d2a230c5b
12 changed files with 525 additions and 4 deletions
+11 -1
View File
@@ -841,10 +841,20 @@ Next:
1. Let an inspected villager who owns an open opportunity surface that exact 1. Let an inspected villager who owns an open opportunity surface that exact
village need plus the authoritative direct-help route, capable helper, or 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 2. Prove that the ordinary finite-resource-to-storage harvest resolves an
available player route and updates the context without dialogue branching, available player route and updates the context without dialogue branching,
acceptance state, rewards, a quest log, or saved presentation state. 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 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 mechanics. The next proof is a beautiful stage for the systems that already
+19
View File
@@ -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 quest log, reputation, a generic conversation framework, or saved presentation
state in that slice. 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: Recently completed:
- `Jajce Villager Field Note 12`: a separate player-facing note selects the - `Jajce Villager Field Note 12`: a separate player-facing note selects the
+13 -1
View File
@@ -204,7 +204,7 @@ anchors_preset = 2
anchor_top = 1.0 anchor_top = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 34.0 offset_left = 34.0
offset_top = -194.0 offset_top = -246.0
offset_right = 430.0 offset_right = 430.0
offset_bottom = -46.0 offset_bottom = -46.0
grow_vertical = 0 grow_vertical = 0
@@ -290,6 +290,18 @@ theme_override_font_sizes/font_size = 13
text = "Why · Awaiting the next decision" text = "Why · Awaiting the next decision"
autowrap_mode = 2 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="."] [node name="VillageWhisperLayer" type="CanvasLayer" parent="."]
layer = 3 layer = 3
+6 -1
View File
@@ -13,6 +13,7 @@ var target_name: String
var carried_amounts: Dictionary var carried_amounts: Dictionary
var decision_reason: String var decision_reason: String
var _has_decision_reason: bool var _has_decision_reason: bool
var opportunity_note: VillagerOpportunityNote
func _init( func _init(
@@ -24,7 +25,8 @@ func _init(
inspection_target_id: StringName, inspection_target_id: StringName,
inspection_target_name: String, inspection_target_name: String,
inspection_carried_amounts: Dictionary, inspection_carried_amounts: Dictionary,
inspection_decision_reason := "" inspection_decision_reason := "",
inspection_opportunity_note: VillagerOpportunityNote = null
) -> void: ) -> void:
npc_id = inspection_npc_id npc_id = inspection_npc_id
npc_name = inspection_npc_name npc_name = inspection_npc_name
@@ -36,6 +38,7 @@ func _init(
carried_amounts = inspection_carried_amounts.duplicate(true) carried_amounts = inspection_carried_amounts.duplicate(true)
_has_decision_reason = not inspection_decision_reason.strip_edges().is_empty() _has_decision_reason = not inspection_decision_reason.strip_edges().is_empty()
decision_reason = (inspection_decision_reason if _has_decision_reason else AWAITING_REASON) decision_reason = (inspection_decision_reason if _has_decision_reason else AWAITING_REASON)
opportunity_note = inspection_opportunity_note
func has_decision_reason() -> bool: func has_decision_reason() -> bool:
@@ -67,6 +70,7 @@ func cache_key() -> String:
var carried_parts: Array[String] = [] var carried_parts: Array[String] = []
for item_id in carried_keys: for item_id in carried_keys:
carried_parts.append("%s=%.3f" % [String(item_id), float(carried_amounts[item_id])]) 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 ( return (
"|" "|"
. join( . join(
@@ -80,6 +84,7 @@ func cache_key() -> String:
target_name, target_name,
",".join(carried_parts), ",".join(carried_parts),
decision_reason, decision_reason,
note_part,
] ]
) )
) )
+79
View File
@@ -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."
)
+1
View File
@@ -0,0 +1 @@
uid://ds7o7atkwyfon
+54 -1
View File
@@ -142,6 +142,7 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult:
var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id) var decision: ActionSelectionResult = simulation_manager.get_latest_decision(npc.id)
if decision != null and decision.action_id == npc.current_task: if decision != null and decision.action_id == npc.current_task:
reason = decision.reason reason = decision.reason
var opportunity_note := _build_opportunity_note(npc)
return VillagerInspectionResult.new( return VillagerInspectionResult.new(
npc.id, npc.id,
npc.npc_name, npc.npc_name,
@@ -151,10 +152,62 @@ func get_nearby_villager_inspection() -> VillagerInspectionResult:
npc.target_id, npc.target_id,
target_name, target_name,
carried_amounts, 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: func _find_feed_animal() -> AnimalNode:
if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null: if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null:
push_error("Player: SimulationManager has no animal-care service") push_error("Player: SimulationManager has no animal-care service")
@@ -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)
@@ -0,0 +1 @@
uid://dg66jcyhajmxp
@@ -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
)
@@ -0,0 +1 @@
uid://1chfro4iw1o5
+11
View File
@@ -14,6 +14,7 @@ const EXIT_OFFSET := 4.0
@onready var target_label: Label = $Copy/Target @onready var target_label: Label = $Copy/Target
@onready var carrying_label: Label = $Copy/Carrying @onready var carrying_label: Label = $Copy/Carrying
@onready var reason_label: Label = $Copy/Reason @onready var reason_label: Label = $Copy/Reason
@onready var need_label: Label = $Copy/Need
var copy_resting_position: Vector2 var copy_resting_position: Vector2
var current_context_key := "" var current_context_key := ""
@@ -64,6 +65,7 @@ func _present(context: VillagerInspectionResult, animate: bool) -> void:
target_label.text = "Target · %s" % context.target_name target_label.text = "Target · %s" % context.target_name
carrying_label.text = "Carrying · %s" % context.carrying_text() carrying_label.text = "Carrying · %s" % context.carrying_text()
reason_label.text = "Why · %s" % context.decision_reason reason_label.text = "Why · %s" % context.decision_reason
_present_opportunity_note(context.opportunity_note)
visible = true visible = true
accent.pivot_offset = accent.size * 0.5 accent.pivot_offset = accent.size * 0.5
if not animate: 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) 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: func _hide_note() -> void:
if not visible or is_hiding: if not visible or is_hiding:
return return