feat: surface open opportunity need on villager field note
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ds7o7atkwyfon
|
||||||
+54
-1
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user