feat: surface player routes for village needs

This commit is contained in:
Rijad Zuzo
2026-07-16 12:36:50 +02:00
parent d6e6a63048
commit 4f88d15e12
15 changed files with 417 additions and 47 deletions
+7 -3
View File
@@ -48,7 +48,10 @@ would otherwise obscure that lifecycle:
work wood consumers. It also derives one ephemeral `OpportunityHelperResult` work wood consumers. It also derives one ephemeral `OpportunityHelperResult`
from knowledge, directed relationships, inventory, action definitions, and from knowledge, directed relationships, inventory, action definitions, and
finite-resource state without changing resources or assigning tasks. The 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/persistence/` owns save-slot file safety;
- `simulation/state/` owns versioned serialized record contracts; - `simulation/state/` owns versioned serialized record contracts;
- `simulation/definitions/` owns stable IDs and immutable action/profession - `simulation/definitions/` owns stable IDs and immutable action/profession
@@ -98,8 +101,9 @@ improving ownership.
Relationship cues, interested-villager concern, physical pantry stock, and Relationship cues, interested-villager concern, physical pantry stock, and
refill feedback consume authoritative state or state-change signals and are refill feedback consume authoritative state or state-change signals and are
intentionally absent from saves and checksums. The transient village-whisper intentionally absent from saves and checksums. The transient village-whisper
HUD likewise formats need, transfer, response, and resolution signals without HUD likewise formats need, player route, transfer, response, and resolution
owning them. Stable visuals are rebuilt from state after restore; transient 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. reactions are not replayed.
- Resource changes go through `ResourceStateRecord`, NPC inventory, and - Resource changes go through `ResourceStateRecord`, NPC inventory, and
`VillageEconomy`; `village.food` and `village.wood` are synchronized views. `VillageEconomy`; `village.food` and `village.wood` are synchronized views.
+8 -4
View File
@@ -746,13 +746,17 @@ Completed:
informed trusted helper to respond autonomously. One cardless warm-amber HUD informed trusted helper to respond autonomously. One cardless warm-amber HUD
ribbon gently rises, pulses, and fades through need, named report, response, ribbon gently rises, pulses, and fades through need, named report, response,
and resolution beats while remaining separate from development overlays. 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: Next:
1. When no capable helper exists, show one contextual player response hint 1. Begin Milestone 8 with a deterministic headless scaling baseline over
derived from a current player-usable finite source and the real storage increasing NPC and event counts. Measure current tick throughput,
target. Clear it as soon as help emerges or the need closes; do not add quest serialized-state growth, and retained history before choosing a target
acceptance, waypoint state, or a generic tracker, as sequenced in population or adding spatial partitions and simulation LOD, as sequenced in
`LEARNING_ROADMAP.md`. `LEARNING_ROADMAP.md`.
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
+32 -6
View File
@@ -822,12 +822,38 @@ The first direct information-to-help path is complete:
helper response, and resolution. It is signal-driven, survives debug-overlay helper response, and resolution. It is signal-driven, survives debug-overlay
hiding, and neither serializes nor replays after restore. hiding, and neither serializes nor replays after restore.
Milestone 7 is not complete. The immediate next slice should expose one bounded The bounded Milestone 7 player-response affordance is complete:
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 - an open need with no capable helper derives one ephemeral
storage target, then clear it when a helper emerges or the need closes. Reuse `OpportunityPlayerResponseResult` only when its real storage target has room
the current interaction/economy rules; do not add quest acceptance, waypoint and at least one matching enabled, stocked, player-usable finite source
state, or a generic objective tracker. 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: Recently completed:
+12 -5
View File
@@ -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, single cardless warm-amber HUD ribbon presents the real need, named report,
helper response, and resolution without entering simulation or save state. helper response, and resolution without entering simulation or save state.
Milestone 7 remains in progress. The immediate next slice should add one The bounded Milestone 7 player response is complete. When no capable helper
bounded player response hint only when no capable helper exists, derived from exists, an ephemeral query now proves an enabled player-usable finite source and
the current player-usable finite source and real destination. It should clear real destination with capacity. The warm-amber whisper briefly explains the
when help emerges or the need closes, without quest acceptance, waypoint state, ordinary direct harvest route, then yields immediately when a helper emerges or
or a generic tracker. 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: The remaining simulation-garden target still aims for:
+9 -6
View File
@@ -196,10 +196,13 @@ lasting during deterministic memory maintenance. Resolution or invalidation
releases that fact back to the normal bounded-retention rules; neither deletes releases that fact back to the normal bounded-retention rules; neither deletes
objective event or opportunity history. objective event or opportunity history.
`OpportunityHelperResult` is deliberately absent from this schema. It is `OpportunityHelperResult` and `OpportunityPlayerResponseResult` are deliberately
re-derived from the open opportunity plus current NPC knowledge, directed absent from this schema. The helper result is re-derived from the open
relationship, inventory, action-definition, storage-capacity, and finite- opportunity plus current NPC knowledge, directed relationship, inventory,
resource records. Save/restore regressions require the same helper/action/route 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. result and an unchanged checksum before and after querying.
When an idle derived helper chooses the reported supply action, only the 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 Active-need communication adds no schema fields. It persists through the
existing `KnownEventStateRecord` event, listener, source NPC, direct source existing `KnownEventStateRecord` event, listener, source NPC, direct source
method, and acquisition-tick fields. Communication/HUD animations are transient method, and acquisition-tick fields. Communication and player-response HUD
and are cleared rather than replayed after restore. animations are transient and are cleared rather than replayed after restore.
## Resource authority ## Resource authority
+14 -16
View File
@@ -782,24 +782,14 @@ func get_relationship_cause(relationship: RelationshipStateRecord) -> EconomicEv
func get_known_events(npc_id: int, max_count: int = 3) -> Array[EconomicEventRecord]: func get_known_events(npc_id: int, max_count: int = 3) -> Array[EconomicEventRecord]:
var known: Array[EconomicEventRecord] = [] return event_log.get_by_ids(event_knowledge_system.get_known_event_ids(npc_id, max_count))
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
func get_retained_memory_events(npc_id: int, max_count: int = 4) -> Array[EconomicEventRecord]: 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( var event_ids := event_knowledge_system.get_retained_event_ids(
npc_id, _get_lasting_event_ids(npc_id), max_count npc_id, _get_lasting_event_ids(npc_id), max_count
) )
for event_id in event_ids: return event_log.get_by_ids(event_ids)
var event := event_log.get_by_id(event_id)
if event != null:
retained.append(event)
return retained
func get_active_opportunity() -> OpportunityStateRecord: 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: func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord:
return opportunity_system.get_latest_for_npc(npc_id) return opportunity_system.get_latest_for_npc(npc_id)
func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: 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(opportunity.get_trigger_event_id() if opportunity != null else -1)
return event_log.get_by_id(event_id)
func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: 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(opportunity.get_resolution_event_id() if opportunity != null else -1)
return event_log.get_by_id(event_id)
func npc_knows_event(npc_id: int, event_id: int) -> bool: func npc_knows_event(npc_id: int, event_id: int) -> bool:
+9
View File
@@ -91,6 +91,15 @@ func get_by_id(event_id: int) -> EconomicEventRecord:
return null 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( func get_consumption_rates(
current_tick: int, current_tick: int,
window_ticks: int = DEFAULT_RATE_WINDOW_TICKS, window_ticks: int = DEFAULT_RATE_WINDOW_TICKS,
@@ -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"])
@@ -0,0 +1 @@
uid://2nyepaa3b77v
@@ -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( func _build_helper_candidate(
npc: SimNPC, npc: SimNPC,
opportunity: OpportunityStateRecord, opportunity: OpportunityStateRecord,
@@ -299,6 +355,29 @@ static func _count_available_resource_sources(
return count 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: static func _sort_helper_candidates(first: Dictionary, second: Dictionary) -> bool:
if first["uses_inventory"] != second["uses_inventory"]: if first["uses_inventory"] != second["uses_inventory"]:
return bool(first["uses_inventory"]) return bool(first["uses_inventory"])
+16 -4
View File
@@ -311,16 +311,25 @@ func _run() -> void:
runtime_opportunity.get_trigger_event_id() if runtime_opportunity != null else -1 runtime_opportunity.get_trigger_event_id() if runtime_opportunity != null else -1
) )
var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
var runtime_player_response: OpportunityPlayerResponseResult = (
simulation_manager.get_active_opportunity_player_response()
)
_check( _check(
( (
runtime_opportunity != null runtime_opportunity != null
and runtime_helper == 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 not simulation_manager.npc_knows_event(witness.id, runtime_trigger_id)
and whisper_kicker.text == "VILLAGE NEED" and whisper_kicker.text == "YOU CAN HELP"
and contributor.npc_name in whisper_message.text and (
and "needs wood" in whisper_message.text "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( _check(
( (
@@ -330,6 +339,7 @@ func _run() -> void:
"Possible helper: none informed, trusted, and able to supply" "Possible helper: none informed, trusted, and able to supply"
in village_stats_label.text 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 "◆ Find wood for Study" in inspector_label.text
and not contributor_visual.get_node("OpportunityConcernRoot").visible and not contributor_visual.get_node("OpportunityConcernRoot").visible
), ),
@@ -348,6 +358,7 @@ func _run() -> void:
witness.id, runtime_trigger_id witness.id, runtime_trigger_id
) )
runtime_helper = simulation_manager.get_active_opportunity_helper() runtime_helper = simulation_manager.get_active_opportunity_helper()
runtime_player_response = simulation_manager.get_active_opportunity_player_response()
_check( _check(
( (
communicated_trigger != null communicated_trigger != null
@@ -357,6 +368,7 @@ func _run() -> void:
) )
and communicated_trigger.get_source_npc_id() == contributor.id and communicated_trigger.get_source_npc_id() == contributor.id
and runtime_helper != null and runtime_helper != null
and runtime_player_response == null
and runtime_helper.helper_npc_id == witness.id and runtime_helper.helper_npc_id == witness.id
and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD
and whisper_kicker.text == "NEWS TRAVELS" and whisper_kicker.text == "NEWS TRAVELS"
+59 -1
View File
@@ -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) 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: func test_wood_helper_uses_available_finite_source_and_respects_reservations() -> void:
var system := VillageOpportunitySystemScript.new() var system := VillageOpportunitySystemScript.new()
var interested := _npc(0, 20.0) var interested := _npc(0, 20.0)
@@ -483,7 +537,9 @@ func _resource_state(
resource_id: StringName, resource_id: StringName,
amount: float, amount: float,
discovery_priority: float = 0.0, 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: ) -> ResourceStateRecord:
var node := ResourceNode.new() var node := ResourceNode.new()
node.node_id = node_id node.node_id = node_id
@@ -493,6 +549,8 @@ func _resource_state(
node.yield_per_action = 1.0 node.yield_per_action = 1.0
node.discovery_priority = discovery_priority node.discovery_priority = discovery_priority
node.safety_risk = safety_risk 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) var state := ResourceStateRecord.create_from_node(node)
node.free() node.free()
return state return state
+48
View File
@@ -202,6 +202,48 @@ func _run() -> void:
root.add_child(tree) root.add_child(tree)
await process_frame await process_frame
_check(player_manager.register_resource_node(tree), "Player branch should bind a real tree") _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( _check(
is_equal_approx(player_manager.harvest_resource_node(tree), 1.0), is_equal_approx(player_manager.harvest_resource_node(tree), 1.0),
"Player tree extraction should supply the authoritative woodpile" "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" "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() tree.queue_free()
player_manager.free() player_manager.free()
@@ -347,6 +393,7 @@ func _create_player_tree() -> ResourceNode:
tree.resource_id = SimulationIds.RESOURCE_WOOD tree.resource_id = SimulationIds.RESOURCE_WOOD
tree.initial_amount = 1.0 tree.initial_amount = 1.0
tree.yield_per_action = 1.0 tree.yield_per_action = 1.0
tree.can_npcs_use = false
tree.can_player_use = true tree.can_player_use = true
tree.debug_label_enabled = false tree.debug_label_enabled = false
var interaction_point := Marker3D.new() var interaction_point := Marker3D.new()
@@ -359,6 +406,7 @@ func _create_helper_tree() -> ResourceNode:
var tree := _create_player_tree() var tree := _create_player_tree()
tree.name = "HelperTree" tree.name = "HelperTree"
tree.node_id = &"helper_tree" tree.node_id = &"helper_tree"
tree.can_npcs_use = true
tree.can_player_use = false tree.can_player_use = false
tree.discovery_priority = 4.0 tree.discovery_priority = 4.0
return tree return tree
+22 -1
View File
@@ -291,13 +291,34 @@ func _build_opportunity_helper_display() -> String:
return "" return ""
var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
if helper == null: 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 ( return (
"\nPossible helper: %s%s\n%s" "\nPossible helper: %s%s\n%s"
% [_get_npc_name(helper.helper_npc_id), _get_action_name(helper.action_id), helper.reason] % [_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: func _build_npc_opportunity_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_latest_opportunity_for_npc"): if not simulation_manager.has_method("get_latest_opportunity_for_npc"):
return "" return ""
+81 -1
View File
@@ -3,6 +3,7 @@ extends Control
const DISPLAY_SECONDS := 4.2 const DISPLAY_SECONDS := 4.2
const ENTER_OFFSET := 8.0 const ENTER_OFFSET := 8.0
const FEEDBACK_PLAYER_RESPONSE := &"player_response"
@export var simulation_manager: Node @export var simulation_manager: Node
@@ -15,6 +16,8 @@ var resting_position: Vector2
var feedback_version := 0 var feedback_version := 0
var active_trigger_event_id := OpportunityStateRecord.NO_EVENT_ID var active_trigger_event_id := OpportunityStateRecord.NO_EVENT_ID
var informed_listener_id := -1 var informed_listener_id := -1
var feedback_kind := &""
var player_response_available := false
func _ready() -> void: func _ready() -> void:
@@ -28,12 +31,29 @@ func _ready() -> void:
simulation_manager.opportunity_invalidated.connect(_on_opportunity_invalidated) simulation_manager.opportunity_invalidated.connect(_on_opportunity_invalidated)
simulation_manager.event_knowledge_transferred.connect(_on_event_knowledge_transferred) simulation_manager.event_knowledge_transferred.connect(_on_event_knowledge_transferred)
simulation_manager.npc_decision_recorded.connect(_on_npc_decision_recorded) 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) 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: func _on_opportunity_opened(opportunity: OpportunityStateRecord) -> void:
active_trigger_event_id = opportunity.get_trigger_event_id() active_trigger_event_id = opportunity.get_trigger_event_id()
informed_listener_id = -1 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)) _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( func _on_opportunity_resolved(
opportunity: OpportunityStateRecord, resolution_event: EconomicEventRecord opportunity: OpportunityStateRecord, resolution_event: EconomicEventRecord
) -> void: ) -> void:
@@ -92,6 +150,7 @@ func _on_opportunity_resolved(
) )
_show_feedback("Need met", "%s %s." % [actor_name, outcome]) _show_feedback("Need met", "%s %s." % [actor_name, outcome])
informed_listener_id = -1 informed_listener_id = -1
player_response_available = false
func _on_opportunity_invalidated(opportunity: OpportunityStateRecord) -> void: func _on_opportunity_invalidated(opportunity: OpportunityStateRecord) -> void:
@@ -99,6 +158,7 @@ func _on_opportunity_invalidated(opportunity: OpportunityStateRecord) -> void:
return return
_show_feedback("Need closed", "The village could no longer act on this report.") _show_feedback("Need closed", "The village could no longer act on this report.")
informed_listener_id = -1 informed_listener_id = -1
player_response_available = false
func _on_state_restored() -> void: func _on_state_restored() -> void:
@@ -110,6 +170,7 @@ func _on_state_restored() -> void:
else OpportunityStateRecord.NO_EVENT_ID else OpportunityStateRecord.NO_EVENT_ID
) )
informed_listener_id = -1 informed_listener_id = -1
player_response_available = simulation_manager.get_active_opportunity_player_response() != null
func _build_need_message(opportunity: OpportunityStateRecord) -> String: 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] 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: func _get_need_name(opportunity: OpportunityStateRecord) -> String:
return ( return (
"empty-pantry worry" "empty-pantry worry"
@@ -143,13 +220,14 @@ func _get_npc_name(npc_id: int) -> String:
return "Someone" return "Someone"
func _show_feedback(heading: String, body: String) -> void: func _show_feedback(heading: String, body: String, kind: StringName = &"") -> void:
feedback_version += 1 feedback_version += 1
var version := feedback_version var version := feedback_version
if feedback_tween != null: if feedback_tween != null:
feedback_tween.kill() feedback_tween.kill()
kicker.text = heading.to_upper() kicker.text = heading.to_upper()
message.text = body message.text = body
feedback_kind = kind
visible = true visible = true
position = resting_position + Vector2(0.0, ENTER_OFFSET) position = resting_position + Vector2(0.0, ENTER_OFFSET)
modulate.a = 0.0 modulate.a = 0.0
@@ -174,6 +252,7 @@ func _fade_after_delay(version: int) -> void:
await feedback_tween.finished await feedback_tween.finished
if version == feedback_version: if version == feedback_version:
visible = false visible = false
feedback_kind = &""
func _reset_feedback() -> void: func _reset_feedback() -> void:
@@ -181,6 +260,7 @@ func _reset_feedback() -> void:
if feedback_tween != null: if feedback_tween != null:
feedback_tween.kill() feedback_tween.kill()
visible = false visible = false
feedback_kind = &""
position = resting_position position = resting_position
modulate.a = 0.0 modulate.a = 0.0
if accent != null: if accent != null: