feat: derive capable opportunity helpers
This commit is contained in:
@@ -43,7 +43,9 @@ would otherwise obscure that lifecycle:
|
|||||||
- `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable
|
- `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable
|
||||||
event references plus current NPC/storage state, then owns the bounded shared
|
event references plus current NPC/storage state, then owns the bounded shared
|
||||||
open/resolved/invalidated lifecycle for the proven pantry-food and blocked-
|
open/resolved/invalidated lifecycle for the proven pantry-food and blocked-
|
||||||
work wood consumers without changing resources or assigning tasks;
|
work wood consumers. It also derives one ephemeral `OpportunityHelperResult`
|
||||||
|
from knowledge, directed relationships, inventory, action definitions, and
|
||||||
|
finite-resource state without changing resources or assigning tasks;
|
||||||
- `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
|
||||||
|
|||||||
@@ -731,13 +731,18 @@ Completed:
|
|||||||
player tree-harvest resolutions use real economy history; interested-party
|
player tree-harvest resolutions use real economy history; interested-party
|
||||||
death and one-day staleness close the need cleanly. The compact UI explains
|
death and one-day staleness close the need cleanly. The compact UI explains
|
||||||
the blocked action without reusing the pantry's hunger cue.
|
the blocked action without reusing the pantry's hunger cue.
|
||||||
|
28. Explainable capable helper: an open food or wood need now names one
|
||||||
|
deterministic informed and trusted villager plus the ordinary gather or
|
||||||
|
deposit action and real inventory/resource route that makes them capable.
|
||||||
|
The result is re-derived after restore and does not assign work or alter the
|
||||||
|
simulation.
|
||||||
|
|
||||||
Next:
|
Next:
|
||||||
|
|
||||||
1. Add the first read-only capable-helper query for an open need. Expose one
|
1. Let the currently derived helper consume the query through ordinary
|
||||||
deterministic viable villager and the real knowledge, relationship, action,
|
autonomous action selection, after urgent self-care and schedule precedence.
|
||||||
inventory, and resource facts behind that result before allowing it to
|
Re-query instead of persisting assignment, and keep acceptance/rewards out
|
||||||
affect autonomous selection, as sequenced in `LEARNING_ROADMAP.md`.
|
of scope, as sequenced in `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
|
||||||
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
-3
@@ -99,6 +99,19 @@ opening; invalidation emits no event and stores the close tick and stable
|
|||||||
reason. Resolution or invalidation returns the trigger to normal bounded
|
reason. Resolution or invalidation returns the trigger to normal bounded
|
||||||
retention.
|
retention.
|
||||||
|
|
||||||
|
For an open need, the same system can derive one capable helper without
|
||||||
|
assigning work. A candidate must know the exact trigger and have a familiar
|
||||||
|
directed relationship with at least 0.6 trust toward the interested villager.
|
||||||
|
Enough carried inventory produces the matching deposit route; otherwise a
|
||||||
|
matching enabled, NPC-usable, sufficiently stocked and available finite
|
||||||
|
ResourceNode state produces the gather route. Ranking is deterministic and
|
||||||
|
prefers ready inventory, trust, profession fit, familiarity, then stable NPC
|
||||||
|
ID. The returned opportunity/trigger/helper/action/resource, inventory source
|
||||||
|
or available finite-source count, and reason are an ephemeral query result. It
|
||||||
|
does not select an exact gather target; that remains with `ActiveWorldAdapter`
|
||||||
|
and `ActionTargetResolver`. No event, reservation, RNG draw, task mutation, or
|
||||||
|
serialized assignment is created.
|
||||||
|
|
||||||
The food-loop regression verifies this chain:
|
The food-loop regression verifies this chain:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -123,7 +136,9 @@ The village summary also shows one compact active need with its real target
|
|||||||
progress and interested villager; only that villager's inspector retains its
|
progress and interested villager; only that villager's inspector retains its
|
||||||
open/closed detail and names the exact supplier for a resolution. The empty-bowl
|
open/closed detail and names the exact supplier for a resolution. The empty-bowl
|
||||||
world cue remains specific to the pantry shortage rather than representing a
|
world cue remains specific to the pantry shortage rather than representing a
|
||||||
wood need as hunger.
|
wood need as hunger. The active summary additionally names the currently
|
||||||
|
derived helper, supply action, trust fact, and inventory or finite-source route,
|
||||||
|
or states that no informed trusted villager is currently able to supply it.
|
||||||
|
|
||||||
## Deliberate limits
|
## Deliberate limits
|
||||||
|
|
||||||
@@ -136,5 +151,6 @@ hearing, personalized reinforcement/decay, multi-hop rumours, secrecy, false
|
|||||||
beliefs, and multi-event causal graphs belong in later event/history slices.
|
beliefs, and multi-event causal graphs belong in later event/history slices.
|
||||||
They should extend this record family without making prose authoritative or
|
They should extend this record family without making prose authoritative or
|
||||||
recomputing old evidence from current positions.
|
recomputing old evidence from current positions.
|
||||||
The current opportunity family is likewise a bounded two-consumer projection,
|
The current opportunity family is likewise a bounded two-consumer projection
|
||||||
not a generic quest, reward, acceptance, dialogue, or capable-helper framework.
|
with one read-only helper query, not a generic quest, reward, acceptance,
|
||||||
|
dialogue, helper assignment, or capable-helper framework.
|
||||||
|
|||||||
@@ -775,11 +775,28 @@ The second bounded Milestone 7 consumer is complete:
|
|||||||
- the two proven consumers now share `VillageOpportunitySystem` and the common
|
- the two proven consumers now share `VillageOpportunitySystem` and the common
|
||||||
lifecycle fields, while their evidence and care rules remain explicit.
|
lifecycle fields, while their evidence and care rules remain explicit.
|
||||||
|
|
||||||
Milestone 7 is not complete. The immediate next slice is a bounded
|
The first bounded capable-helper query is complete:
|
||||||
capable-helper query for an open need: derive one viable helper from existing
|
|
||||||
knowledge, relationship, action, inventory, and resource facts and expose the
|
- an open need derives at most one living helper who knows its exact trigger,
|
||||||
reason for that result. Keep it read-only first; only a later proven slice
|
has an existing familiar directed relationship with at least 0.6 trust toward
|
||||||
should let opportunity awareness influence autonomous task selection.
|
the interested villager, and is not that villager;
|
||||||
|
- the helper must either carry enough matching inventory to reach the target or
|
||||||
|
have a matching enabled, NPC-usable, sufficiently stocked finite resource
|
||||||
|
that is not reserved by someone else;
|
||||||
|
- ready inventory wins first, followed by higher trust, matching gather
|
||||||
|
profession, familiarity, and lowest stable NPC ID;
|
||||||
|
- `OpportunityHelperResult` exposes the exact opportunity, trigger, helper,
|
||||||
|
action, inventory source or finite-source count, resource, relationship
|
||||||
|
values, and concise reason without mutating RNG, tasks, reservations,
|
||||||
|
resources, or persisted state;
|
||||||
|
- food and wood scenarios re-derive the same result after restore, and the
|
||||||
|
village summary explains the current helper route from real simulation facts.
|
||||||
|
|
||||||
|
Milestone 7 is not complete. The immediate next slice is one bounded autonomous
|
||||||
|
consumer of this query: let the currently derived helper choose the reported
|
||||||
|
gather/deposit action through ordinary selection while preserving urgent
|
||||||
|
self-care and schedule precedence. Re-query rather than persisting assignment,
|
||||||
|
and do not add quest acceptance or reward state.
|
||||||
|
|
||||||
Recently completed:
|
Recently completed:
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -618,8 +618,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
|||||||
- The bounded opportunity family supports one open `restock_empty_pantry` or
|
- The bounded opportunity family supports one open `restock_empty_pantry` or
|
||||||
`supply_missing_wood` need from exact known evidence. Both have exact NPC and
|
`supply_missing_wood` need from exact known evidence. Both have exact NPC and
|
||||||
player supply resolutions; the wood need also closes deterministically on
|
player supply resolutions; the wood need also closes deterministically on
|
||||||
interested-party death or one-day staleness. There is still no capable-helper
|
interested-party death or one-day staleness. One read-only query derives and
|
||||||
assignment, acceptance, rewards, free-form dialogue, or quest log.
|
explains a capable helper, but there is still no helper assignment,
|
||||||
|
acceptance, rewards, free-form dialogue, or quest log.
|
||||||
- The reason inspector exposes current decisions, utility rejections, one exact
|
- The reason inspector exposes current decisions, utility rejections, one exact
|
||||||
relationship cause, and a compact person-history view that distinguishes
|
relationship cause, and a compact person-history view that distinguishes
|
||||||
importance-ranked retained memories from objective personal actions.
|
importance-ranked retained memories from objective personal actions.
|
||||||
@@ -902,10 +903,18 @@ v9 and nested opportunity schema v2 preserve open, resolved, and invalidated
|
|||||||
history. The two consumers now share `VillageOpportunitySystem`, while their
|
history. The two consumers now share `VillageOpportunitySystem`, while their
|
||||||
evidence and care rules remain explicit.
|
evidence and care rules remain explicit.
|
||||||
|
|
||||||
Milestone 7 remains in progress. The immediate next slice is a read-only,
|
The first capable-helper query is complete. An open food or wood need now
|
||||||
deterministic capable-helper query for an open need, derived from existing
|
derives one living informed villager with a familiar trusted directed tie and a
|
||||||
knowledge, relationship, action, inventory, and resource facts before it is
|
real inventory-deposit or finite-resource gather route. Inventory readiness,
|
||||||
allowed to influence autonomous task selection.
|
trust, profession fit, familiarity, finite-source availability, and stable IDs
|
||||||
|
produce one deterministic result. The village summary explains that result;
|
||||||
|
save/load re-derives it, and querying changes no task, reservation, RNG stream,
|
||||||
|
resource, event, or checksum.
|
||||||
|
|
||||||
|
Milestone 7 remains in progress. The immediate next slice is one bounded
|
||||||
|
autonomous consumer: allow the currently derived helper to choose its reported
|
||||||
|
ordinary supply action after urgent self-care and schedule rules, without
|
||||||
|
persisted assignment, quest acceptance, or reward state.
|
||||||
|
|
||||||
The remaining simulation-garden target still aims for:
|
The remaining simulation-garden target still aims for:
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,12 @@ 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
|
||||||
|
re-derived from the open opportunity plus current NPC knowledge, directed
|
||||||
|
relationship, inventory, action-definition, storage-capacity, and finite-
|
||||||
|
resource records. Save/restore regressions require the same helper/action/route
|
||||||
|
result and an unchanged checksum before and after querying.
|
||||||
|
|
||||||
## Resource authority
|
## Resource authority
|
||||||
|
|
||||||
`SimulationManager` owns `ResourceStateRecord` instances independently of the
|
`SimulationManager` owns `ResourceStateRecord` instances independently of the
|
||||||
@@ -212,7 +218,9 @@ defaults.
|
|||||||
ResourceStateRecord v3 adds `safety_risk`, `comfort_distance`, and
|
ResourceStateRecord v3 adds `safety_risk`, `comfort_distance`, and
|
||||||
`discovery_priority` so unloaded resources keep the same target-selection
|
`discovery_priority` so unloaded resources keep the same target-selection
|
||||||
meaning after save/restore. Nested v1 and v2 resource records are migrated
|
meaning after save/restore. Nested v1 and v2 resource records are migrated
|
||||||
explicitly.
|
explicitly. Current-schema parsing also canonicalizes numeric, boolean, and ID
|
||||||
|
variants so a freshly registered finite source retains a byte-stable checksum
|
||||||
|
after a JSON round trip.
|
||||||
|
|
||||||
## Local quicksave boundary
|
## Local quicksave boundary
|
||||||
|
|
||||||
|
|||||||
@@ -806,23 +806,32 @@ func get_active_opportunity() -> OpportunityStateRecord:
|
|||||||
return opportunity_system.get_open_opportunity()
|
return opportunity_system.get_open_opportunity()
|
||||||
|
|
||||||
|
|
||||||
|
func get_active_opportunity_helper() -> OpportunityHelperResult:
|
||||||
|
var active := get_active_opportunity()
|
||||||
|
if active == null:
|
||||||
|
return null
|
||||||
|
return opportunity_system.find_capable_helper(
|
||||||
|
active,
|
||||||
|
economy.get_storage(active.get_target_id()),
|
||||||
|
npcs,
|
||||||
|
event_knowledge_system,
|
||||||
|
relationship_system,
|
||||||
|
resource_states
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
if opportunity == null:
|
var event_id := opportunity.get_trigger_event_id() if opportunity != null else -1
|
||||||
return null
|
return event_log.get_by_id(event_id)
|
||||||
return event_log.get_by_id(opportunity.get_trigger_event_id())
|
|
||||||
|
|
||||||
|
|
||||||
func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
|
func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
|
||||||
if (
|
var event_id := opportunity.get_resolution_event_id() if opportunity != null else -1
|
||||||
opportunity == null
|
return event_log.get_by_id(event_id)
|
||||||
or opportunity.get_resolution_event_id() == OpportunityStateRecord.NO_EVENT_ID
|
|
||||||
):
|
|
||||||
return null
|
|
||||||
return event_log.get_by_id(opportunity.get_resolution_event_id())
|
|
||||||
|
|
||||||
|
|
||||||
func npc_knows_event(npc_id: int, event_id: int) -> bool:
|
func npc_knows_event(npc_id: int, event_id: int) -> bool:
|
||||||
@@ -1106,7 +1115,6 @@ func create_state_record() -> SimulationStateRecord:
|
|||||||
wander_streams.append(
|
wander_streams.append(
|
||||||
{"npc_id": int(npc_id), "seed": str(source.seed), "state": str(source.state)}
|
{"npc_id": int(npc_id), "seed": str(source.seed), "state": str(source.state)}
|
||||||
)
|
)
|
||||||
|
|
||||||
record.simulation = {
|
record.simulation = {
|
||||||
"seed": simulation_seed,
|
"seed": simulation_seed,
|
||||||
"tick_interval": tick_interval,
|
"tick_interval": tick_interval,
|
||||||
@@ -1121,7 +1129,6 @@ func create_state_record() -> SimulationStateRecord:
|
|||||||
record.village = VillageStateRecord.capture(village)
|
record.village = VillageStateRecord.capture(village)
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
record.npcs.append(NPCStateRecord.capture(npc))
|
record.npcs.append(NPCStateRecord.capture(npc))
|
||||||
|
|
||||||
var sorted_resource_ids: Array = resource_states.keys()
|
var sorted_resource_ids: Array = resource_states.keys()
|
||||||
sorted_resource_ids.sort()
|
sorted_resource_ids.sort()
|
||||||
for resource_id in sorted_resource_ids:
|
for resource_id in sorted_resource_ids:
|
||||||
@@ -1158,7 +1165,6 @@ func restore_state_from_json(json_text: String) -> bool:
|
|||||||
func restore_state(record: SimulationStateRecord) -> bool:
|
func restore_state(record: SimulationStateRecord) -> bool:
|
||||||
if record == null:
|
if record == null:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
simulation_seed = int(record.simulation["seed"])
|
simulation_seed = int(record.simulation["seed"])
|
||||||
tick_interval = float(record.simulation["tick_interval"])
|
tick_interval = float(record.simulation["tick_interval"])
|
||||||
tick_count = int(record.simulation["tick_count"])
|
tick_count = int(record.simulation["tick_count"])
|
||||||
@@ -1171,7 +1177,6 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
economy.restore_storage(record.storages)
|
economy.restore_storage(record.storages)
|
||||||
event_log.restore(record.economic_events, int(record.simulation["next_event_id"]))
|
event_log.restore(record.economic_events, int(record.simulation["next_event_id"]))
|
||||||
latest_decisions.clear()
|
latest_decisions.clear()
|
||||||
|
|
||||||
npcs.clear()
|
npcs.clear()
|
||||||
for npc_record in record.npcs:
|
for npc_record in record.npcs:
|
||||||
npcs.append(npc_record.restore(debug_logs))
|
npcs.append(npc_record.restore(debug_logs))
|
||||||
@@ -1179,7 +1184,6 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
event_knowledge_system.restore(record.event_knowledge)
|
event_knowledge_system.restore(record.event_knowledge)
|
||||||
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
|
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
|
||||||
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
|
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
|
||||||
|
|
||||||
wander_random_sources.clear()
|
wander_random_sources.clear()
|
||||||
var wander_streams: Array = record.simulation["wander_random_streams"]
|
var wander_streams: Array = record.simulation["wander_random_streams"]
|
||||||
for stream_data in wander_streams:
|
for stream_data in wander_streams:
|
||||||
@@ -1187,12 +1191,10 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
source.seed = String(stream_data["seed"]).to_int()
|
source.seed = String(stream_data["seed"]).to_int()
|
||||||
source.state = String(stream_data["state"]).to_int()
|
source.state = String(stream_data["state"]).to_int()
|
||||||
wander_random_sources[int(stream_data["npc_id"])] = source
|
wander_random_sources[int(stream_data["npc_id"])] = source
|
||||||
|
|
||||||
resource_states.clear()
|
resource_states.clear()
|
||||||
for resource_record in record.resources:
|
for resource_record in record.resources:
|
||||||
resource_states[resource_record.get_node_id()] = resource_record
|
resource_states[resource_record.get_node_id()] = resource_record
|
||||||
register_loaded_resource_nodes()
|
register_loaded_resource_nodes()
|
||||||
|
|
||||||
village_changed.emit(village)
|
village_changed.emit(village)
|
||||||
state_restored.emit()
|
state_restored.emit()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
class_name OpportunityHelperResult
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var opportunity_id: int
|
||||||
|
var helper_npc_id: int
|
||||||
|
var action_id: StringName
|
||||||
|
var source_id: StringName
|
||||||
|
var resource_id: StringName
|
||||||
|
var trigger_event_id: int
|
||||||
|
var trust: float
|
||||||
|
var familiarity: float
|
||||||
|
var uses_inventory: bool
|
||||||
|
var available_source_count: int
|
||||||
|
var profession_match: bool
|
||||||
|
var reason: String
|
||||||
|
|
||||||
|
|
||||||
|
func _init(result: Dictionary) -> void:
|
||||||
|
opportunity_id = int(result["opportunity_id"])
|
||||||
|
helper_npc_id = int(result["helper_npc_id"])
|
||||||
|
action_id = StringName(result["action_id"])
|
||||||
|
source_id = StringName(result["source_id"])
|
||||||
|
resource_id = StringName(result["resource_id"])
|
||||||
|
trigger_event_id = int(result["trigger_event_id"])
|
||||||
|
trust = float(result["trust"])
|
||||||
|
familiarity = float(result["familiarity"])
|
||||||
|
uses_inventory = bool(result["uses_inventory"])
|
||||||
|
available_source_count = int(result["available_source_count"])
|
||||||
|
profession_match = bool(result["profession_match"])
|
||||||
|
reason = String(result["reason"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://o8t4og3iq8dv
|
||||||
@@ -112,6 +112,196 @@ func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func find_capable_helper(
|
||||||
|
opportunity: OpportunityStateRecord,
|
||||||
|
target_storage: StorageStateRecord,
|
||||||
|
npcs: Array[SimNPC],
|
||||||
|
knowledge_system: EventKnowledgeSystem,
|
||||||
|
relationship_system: RefCounted,
|
||||||
|
resource_states: Dictionary
|
||||||
|
) -> OpportunityHelperResult:
|
||||||
|
if (
|
||||||
|
opportunity == null
|
||||||
|
or not opportunity.is_open()
|
||||||
|
or target_storage == null
|
||||||
|
or knowledge_system == null
|
||||||
|
or relationship_system == null
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
var interested := _find_npc(opportunity.get_interested_npc_id(), npcs)
|
||||||
|
if interested == null or interested.is_dead:
|
||||||
|
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"])
|
||||||
|
var deposit_definition := SimulationDefinitions.get_action(action_ids["deposit"])
|
||||||
|
if gather_definition == null or deposit_definition == null:
|
||||||
|
return null
|
||||||
|
var candidates: Array[Dictionary] = []
|
||||||
|
for npc in npcs:
|
||||||
|
var candidate := _build_helper_candidate(
|
||||||
|
npc,
|
||||||
|
opportunity,
|
||||||
|
remaining,
|
||||||
|
gather_definition,
|
||||||
|
deposit_definition,
|
||||||
|
interested.npc_name,
|
||||||
|
knowledge_system,
|
||||||
|
relationship_system,
|
||||||
|
resource_states
|
||||||
|
)
|
||||||
|
if not candidate.is_empty():
|
||||||
|
candidates.append(candidate)
|
||||||
|
if candidates.is_empty():
|
||||||
|
return null
|
||||||
|
candidates.sort_custom(_sort_helper_candidates)
|
||||||
|
var best := candidates[0]
|
||||||
|
return (
|
||||||
|
OpportunityHelperResult
|
||||||
|
. new(
|
||||||
|
{
|
||||||
|
"opportunity_id": opportunity.get_opportunity_id(),
|
||||||
|
"helper_npc_id": (best["npc"] as SimNPC).id,
|
||||||
|
"action_id": best["action_id"],
|
||||||
|
"source_id": best["source_id"],
|
||||||
|
"resource_id": opportunity.get_resource_id(),
|
||||||
|
"trigger_event_id": opportunity.get_trigger_event_id(),
|
||||||
|
"trust": best["trust"],
|
||||||
|
"familiarity": best["familiarity"],
|
||||||
|
"uses_inventory": best["uses_inventory"],
|
||||||
|
"available_source_count": best["available_source_count"],
|
||||||
|
"profession_match": best["profession_match"],
|
||||||
|
"reason": best["reason"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_helper_candidate(
|
||||||
|
npc: SimNPC,
|
||||||
|
opportunity: OpportunityStateRecord,
|
||||||
|
remaining: float,
|
||||||
|
gather_definition: ActionDefinition,
|
||||||
|
deposit_definition: ActionDefinition,
|
||||||
|
interested_name: String,
|
||||||
|
knowledge_system: EventKnowledgeSystem,
|
||||||
|
relationship_system: RefCounted,
|
||||||
|
resource_states: Dictionary
|
||||||
|
) -> Dictionary:
|
||||||
|
if npc.is_dead or npc.id == opportunity.get_interested_npc_id():
|
||||||
|
return {}
|
||||||
|
if not knowledge_system.knows_event(npc.id, opportunity.get_trigger_event_id()):
|
||||||
|
return {}
|
||||||
|
var relationship: RelationshipStateRecord = relationship_system.get_relationship(
|
||||||
|
npc.id, opportunity.get_interested_npc_id()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
relationship == null
|
||||||
|
or not relationship_system.is_trusted_for_help(npc.id, opportunity.get_interested_npc_id())
|
||||||
|
):
|
||||||
|
return {}
|
||||||
|
var carried := npc.get_inventory_amount(opportunity.get_resource_id())
|
||||||
|
var uses_inventory := carried >= remaining
|
||||||
|
var available_source_count := 0
|
||||||
|
if not uses_inventory:
|
||||||
|
available_source_count = _count_available_resource_sources(
|
||||||
|
npc.id, remaining, opportunity.get_resource_id(), gather_definition, resource_states
|
||||||
|
)
|
||||||
|
if available_source_count == 0:
|
||||||
|
return {}
|
||||||
|
var action_definition := deposit_definition if uses_inventory else gather_definition
|
||||||
|
var source_id := SimulationIds.npc_inventory_id(npc.id) if uses_inventory else &""
|
||||||
|
var profession_match := (
|
||||||
|
not gather_definition.preferred_profession_id.is_empty()
|
||||||
|
and npc.profession == gather_definition.preferred_profession_id
|
||||||
|
)
|
||||||
|
var supply_fact := (
|
||||||
|
"carries %.1f %s" % [carried, String(opportunity.get_resource_id()).capitalize()]
|
||||||
|
if uses_inventory
|
||||||
|
else (
|
||||||
|
"has %d available finite %s source%s"
|
||||||
|
% [
|
||||||
|
available_source_count,
|
||||||
|
String(opportunity.get_resource_id()).capitalize(),
|
||||||
|
"" if available_source_count == 1 else "s",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"npc": npc,
|
||||||
|
"action_id": action_definition.action_id,
|
||||||
|
"source_id": source_id,
|
||||||
|
"trust": relationship.get_trust(),
|
||||||
|
"familiarity": relationship.get_familiarity(),
|
||||||
|
"uses_inventory": uses_inventory,
|
||||||
|
"available_source_count": available_source_count,
|
||||||
|
"profession_match": profession_match,
|
||||||
|
"reason":
|
||||||
|
(
|
||||||
|
"Knows the need; trust %.2f toward %s; %s"
|
||||||
|
% [relationship.get_trust(), interested_name, supply_fact]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func _get_supply_action_ids(resource_id: StringName) -> Dictionary:
|
||||||
|
match resource_id:
|
||||||
|
SimulationIds.RESOURCE_FOOD:
|
||||||
|
return {
|
||||||
|
"gather": SimulationIds.ACTION_GATHER_FOOD,
|
||||||
|
"deposit": SimulationIds.ACTION_DEPOSIT_FOOD,
|
||||||
|
}
|
||||||
|
SimulationIds.RESOURCE_WOOD:
|
||||||
|
return {
|
||||||
|
"gather": SimulationIds.ACTION_GATHER_WOOD,
|
||||||
|
"deposit": SimulationIds.ACTION_DEPOSIT_WOOD,
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
static func _count_available_resource_sources(
|
||||||
|
npc_id: int,
|
||||||
|
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_npc_use()
|
||||||
|
or not state.is_available_for(npc_id)
|
||||||
|
or state.get_yield_per_action() <= 0.0
|
||||||
|
or state.get_amount_remaining() < remaining
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
static func _sort_helper_candidates(first: Dictionary, second: Dictionary) -> bool:
|
||||||
|
if first["uses_inventory"] != second["uses_inventory"]:
|
||||||
|
return bool(first["uses_inventory"])
|
||||||
|
if first["trust"] != second["trust"]:
|
||||||
|
return float(first["trust"]) > float(second["trust"])
|
||||||
|
if first["profession_match"] != second["profession_match"]:
|
||||||
|
return bool(first["profession_match"])
|
||||||
|
if first["familiarity"] != second["familiarity"]:
|
||||||
|
return float(first["familiarity"]) > float(second["familiarity"])
|
||||||
|
return (first["npc"] as SimNPC).id < (second["npc"] as SimNPC).id
|
||||||
|
|
||||||
|
|
||||||
func _try_open_food(
|
func _try_open_food(
|
||||||
event: EconomicEventRecord,
|
event: EconomicEventRecord,
|
||||||
current_tick: int,
|
current_tick: int,
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ func get_relationship(observer_id: int, subject_id: int) -> RelationshipStateRec
|
|||||||
return relationships.get(_key(observer_id, subject_id)) as RelationshipStateRecord
|
return relationships.get(_key(observer_id, subject_id)) as RelationshipStateRecord
|
||||||
|
|
||||||
|
|
||||||
|
func is_trusted_for_help(observer_id: int, subject_id: int) -> bool:
|
||||||
|
var relationship := get_relationship(observer_id, subject_id)
|
||||||
|
return (
|
||||||
|
relationship != null
|
||||||
|
and relationship.get_familiarity() > 0.0
|
||||||
|
and relationship.get_trust() >= TRUSTED_HELP_THRESHOLD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func increase_shared_work_familiarity(first_id: int, second_id: int) -> void:
|
func increase_shared_work_familiarity(first_id: int, second_id: int) -> void:
|
||||||
var first_to_second := _get_or_create(first_id, second_id)
|
var first_to_second := _get_or_create(first_id, second_id)
|
||||||
var second_to_first := _get_or_create(second_id, first_id)
|
var second_to_first := _get_or_create(second_id, first_id)
|
||||||
|
|||||||
@@ -66,7 +66,21 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord:
|
|||||||
var action_id := StringName(record_data["action_id"])
|
var action_id := StringName(record_data["action_id"])
|
||||||
if not action_id.is_empty() and SimulationDefinitions.get_action(action_id) == null:
|
if not action_id.is_empty() and SimulationDefinitions.get_action(action_id) == null:
|
||||||
return null
|
return null
|
||||||
return ResourceStateRecord.new(record_data)
|
var normalized := record_data.duplicate(true)
|
||||||
|
normalized["schema_version"] = SCHEMA_VERSION
|
||||||
|
normalized["node_id"] = String(record_data["node_id"])
|
||||||
|
normalized["action_id"] = String(record_data["action_id"])
|
||||||
|
normalized["resource_id"] = String(record_data["resource_id"])
|
||||||
|
normalized["amount_remaining"] = float(record_data["amount_remaining"])
|
||||||
|
normalized["yield_per_action"] = float(record_data["yield_per_action"])
|
||||||
|
normalized["reserved_by"] = int(record_data["reserved_by"])
|
||||||
|
normalized["enabled"] = bool(record_data["enabled"])
|
||||||
|
normalized["can_npcs_use"] = bool(record_data["can_npcs_use"])
|
||||||
|
normalized["can_player_use"] = bool(record_data["can_player_use"])
|
||||||
|
normalized["safety_risk"] = float(record_data["safety_risk"])
|
||||||
|
normalized["comfort_distance"] = float(record_data["comfort_distance"])
|
||||||
|
normalized["discovery_priority"] = float(record_data["discovery_priority"])
|
||||||
|
return ResourceStateRecord.new(normalized)
|
||||||
|
|
||||||
|
|
||||||
static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
|
static func _migrate_v1(legacy_data: Dictionary) -> Dictionary:
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
await process_frame
|
await process_frame
|
||||||
contributor = simulation_manager.npcs[contributor.id]
|
contributor = simulation_manager.npcs[contributor.id]
|
||||||
|
witness = simulation_manager.npcs[witness.id]
|
||||||
|
listener = simulation_manager.npcs[listener.id]
|
||||||
contributor_visual = world_view.active_npc_visuals[contributor.id]
|
contributor_visual = world_view.active_npc_visuals[contributor.id]
|
||||||
player_opportunity = simulation_manager.get_active_opportunity()
|
player_opportunity = simulation_manager.get_active_opportunity()
|
||||||
_check(
|
_check(
|
||||||
@@ -277,15 +279,39 @@ func _run() -> void:
|
|||||||
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
|
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||||
)
|
)
|
||||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||||
|
var woodpile_node := (
|
||||||
|
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||||
|
)
|
||||||
|
contributor.position = woodpile_node.get_interaction_position()
|
||||||
|
witness.position = contributor.position
|
||||||
|
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||||
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||||
|
contributor.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||||
|
contributor.travel_target_position = contributor.position
|
||||||
contributor.start_working()
|
contributor.start_working()
|
||||||
simulation_manager.simulate_tick()
|
simulation_manager.simulate_tick()
|
||||||
village_ui.selected_npc_index = contributor.id
|
village_ui.selected_npc_index = contributor.id
|
||||||
village_ui.call("_refresh_npc_inspector")
|
village_ui.call("_refresh_npc_inspector")
|
||||||
|
var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
runtime_helper != null
|
||||||
|
and runtime_helper.helper_npc_id == witness.id
|
||||||
|
and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||||
|
),
|
||||||
|
"Runtime helper query should derive the informed trusted woodcutter"
|
||||||
|
)
|
||||||
|
var runtime_helper_reason := (
|
||||||
|
"Knows the need; trust %.2f toward %s" % [runtime_helper.trust, contributor.npc_name]
|
||||||
|
if runtime_helper != null
|
||||||
|
else "missing helper"
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
"◆ Supply wood for blocked work" in village_stats_label.text
|
"◆ Supply wood for blocked work" in village_stats_label.text
|
||||||
and "could not finish Study" in village_stats_label.text
|
and "could not finish Study" in village_stats_label.text
|
||||||
|
and "Possible helper: %s — Gather Wood" % witness.npc_name in village_stats_label.text
|
||||||
|
and runtime_helper_reason 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
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ func _run() -> void:
|
|||||||
not caption.visible,
|
not caption.visible,
|
||||||
"The cinematic caption should remain hidden until the full sequence starts"
|
"The cinematic caption should remain hidden until the full sequence starts"
|
||||||
)
|
)
|
||||||
|
var derived_helper: OpportunityHelperResult = manager.get_active_opportunity_helper()
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
derived_helper != null
|
||||||
|
and derived_helper.helper_npc_id == helper.id
|
||||||
|
and derived_helper.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||||
|
and derived_helper.resource_id == SimulationIds.RESOURCE_FOOD
|
||||||
|
and derived_helper.source_id.is_empty()
|
||||||
|
and derived_helper.available_source_count > 0
|
||||||
|
and not derived_helper.uses_inventory
|
||||||
|
),
|
||||||
|
"The open need should read-only derive the staged trusted helper and a finite food route"
|
||||||
|
)
|
||||||
|
|
||||||
_check(demo.begin_pantry_restock(), "A trusted helper should begin the real food run")
|
_check(demo.begin_pantry_restock(), "A trusted helper should begin the real food run")
|
||||||
var decision: ActionSelectionResult = manager.get_latest_decision(helper.id)
|
var decision: ActionSelectionResult = manager.get_latest_decision(helper.id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ extends GutTest
|
|||||||
const VillageOpportunitySystemScript := preload(
|
const VillageOpportunitySystemScript := preload(
|
||||||
"res://simulation/opportunities/VillageOpportunitySystem.gd"
|
"res://simulation/opportunities/VillageOpportunitySystem.gd"
|
||||||
)
|
)
|
||||||
|
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||||
|
|
||||||
|
|
||||||
func test_opening_requires_current_knowledge_and_care() -> void:
|
func test_opening_requires_current_knowledge_and_care() -> void:
|
||||||
@@ -192,6 +193,173 @@ func test_missing_wood_maintenance_does_not_change_the_existing_food_lifecycle()
|
|||||||
assert_eq(food_need.get_closed_tick(), -1)
|
assert_eq(food_need.get_closed_tick(), -1)
|
||||||
|
|
||||||
|
|
||||||
|
func test_capable_helper_requires_exact_knowledge_trust_and_a_real_supply_route() -> void:
|
||||||
|
var fixture := _open_fixture()
|
||||||
|
var system: VillageOpportunitySystem = fixture["system"]
|
||||||
|
var pantry: StorageStateRecord = fixture["pantry"]
|
||||||
|
var knowledge: EventKnowledgeSystem = fixture["knowledge"]
|
||||||
|
var interested: SimNPC = fixture["npcs"][0]
|
||||||
|
var helper := _npc(1, 20.0)
|
||||||
|
var npcs: Array[SimNPC] = [interested, helper]
|
||||||
|
var relationships := RelationshipSystemScript.new()
|
||||||
|
var trusted := RelationshipStateRecord.create(helper.id, interested.id, 0.8, 0.7)
|
||||||
|
relationships.restore([trusted])
|
||||||
|
var source := _resource_state(
|
||||||
|
&"orchard_berries", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 2.0, 3.0
|
||||||
|
)
|
||||||
|
var resource_states := {source.get_node_id(): source}
|
||||||
|
var opportunity: OpportunityStateRecord = system.get_open_opportunity()
|
||||||
|
|
||||||
|
assert_null(
|
||||||
|
system.find_capable_helper(
|
||||||
|
opportunity, pantry, npcs, knowledge, relationships, resource_states
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10)
|
||||||
|
relationships.restore([RelationshipStateRecord.create(helper.id, interested.id, 0.8, 0.59)])
|
||||||
|
assert_null(
|
||||||
|
system.find_capable_helper(
|
||||||
|
opportunity, pantry, npcs, knowledge, relationships, resource_states
|
||||||
|
)
|
||||||
|
)
|
||||||
|
relationships.restore([trusted])
|
||||||
|
var opportunity_before := opportunity.to_dictionary()
|
||||||
|
var source_before := source.to_dictionary()
|
||||||
|
var helper_inventory_before := helper.inventory.duplicate(true)
|
||||||
|
var result: OpportunityHelperResult = system.find_capable_helper(
|
||||||
|
opportunity, pantry, npcs, knowledge, relationships, resource_states
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not_null(result)
|
||||||
|
assert_eq(result.opportunity_id, opportunity.get_opportunity_id())
|
||||||
|
assert_eq(result.helper_npc_id, helper.id)
|
||||||
|
assert_eq(result.action_id, SimulationIds.ACTION_GATHER_FOOD)
|
||||||
|
assert_true(result.source_id.is_empty())
|
||||||
|
assert_eq(result.available_source_count, 1)
|
||||||
|
assert_eq(result.resource_id, SimulationIds.RESOURCE_FOOD)
|
||||||
|
assert_eq(result.trigger_event_id, opportunity.get_trigger_event_id())
|
||||||
|
assert_eq(result.trust, 0.7)
|
||||||
|
assert_true(result.profession_match)
|
||||||
|
assert_false(result.uses_inventory)
|
||||||
|
assert_string_contains(result.reason, "Knows the need")
|
||||||
|
assert_eq(opportunity.to_dictionary(), opportunity_before)
|
||||||
|
assert_eq(source.to_dictionary(), source_before)
|
||||||
|
assert_eq(helper.inventory, helper_inventory_before)
|
||||||
|
|
||||||
|
|
||||||
|
func test_ready_inventory_then_trust_and_stable_id_rank_helpers_deterministically() -> void:
|
||||||
|
var fixture := _open_fixture()
|
||||||
|
var system: VillageOpportunitySystem = fixture["system"]
|
||||||
|
var pantry: StorageStateRecord = fixture["pantry"]
|
||||||
|
var knowledge: EventKnowledgeSystem = fixture["knowledge"]
|
||||||
|
var interested: SimNPC = fixture["npcs"][0]
|
||||||
|
var trusted_gatherer := _npc(1, 20.0)
|
||||||
|
var lower_id_carrier := _npc(2, 20.0)
|
||||||
|
var stronger_carrier := _npc(5, 20.0)
|
||||||
|
lower_id_carrier.profession = SimulationIds.PROFESSION_GUARD
|
||||||
|
stronger_carrier.profession = SimulationIds.PROFESSION_GUARD
|
||||||
|
lower_id_carrier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
|
||||||
|
stronger_carrier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
|
||||||
|
var npcs: Array[SimNPC] = [interested, trusted_gatherer, lower_id_carrier, stronger_carrier]
|
||||||
|
var opportunity: OpportunityStateRecord = system.get_open_opportunity()
|
||||||
|
for helper in [trusted_gatherer, lower_id_carrier, stronger_carrier]:
|
||||||
|
_remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10)
|
||||||
|
var relationships := RelationshipSystemScript.new()
|
||||||
|
(
|
||||||
|
relationships
|
||||||
|
. restore(
|
||||||
|
[
|
||||||
|
RelationshipStateRecord.create(trusted_gatherer.id, interested.id, 0.9, 0.95),
|
||||||
|
RelationshipStateRecord.create(lower_id_carrier.id, interested.id, 0.7, 0.7),
|
||||||
|
RelationshipStateRecord.create(stronger_carrier.id, interested.id, 0.7, 0.8),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var source := _resource_state(
|
||||||
|
&"berry_source", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 2.0
|
||||||
|
)
|
||||||
|
var result: OpportunityHelperResult = system.find_capable_helper(
|
||||||
|
opportunity, pantry, npcs, knowledge, relationships, {source.get_node_id(): source}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_eq(result.helper_npc_id, stronger_carrier.id)
|
||||||
|
assert_eq(result.action_id, SimulationIds.ACTION_DEPOSIT_FOOD)
|
||||||
|
assert_eq(result.source_id, SimulationIds.npc_inventory_id(stronger_carrier.id))
|
||||||
|
assert_true(result.uses_inventory)
|
||||||
|
assert_eq(result.available_source_count, 0)
|
||||||
|
assert_false(result.profession_match)
|
||||||
|
assert_eq(result.trust, 0.8)
|
||||||
|
|
||||||
|
(
|
||||||
|
relationships
|
||||||
|
. restore(
|
||||||
|
[
|
||||||
|
RelationshipStateRecord.create(trusted_gatherer.id, interested.id, 0.9, 0.95),
|
||||||
|
RelationshipStateRecord.create(lower_id_carrier.id, interested.id, 0.7, 0.8),
|
||||||
|
RelationshipStateRecord.create(stronger_carrier.id, interested.id, 0.7, 0.8),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = system.find_capable_helper(
|
||||||
|
opportunity, pantry, npcs, knowledge, relationships, {source.get_node_id(): source}
|
||||||
|
)
|
||||||
|
assert_eq(result.helper_npc_id, lower_id_carrier.id)
|
||||||
|
|
||||||
|
|
||||||
|
func test_wood_helper_uses_available_finite_source_and_respects_reservations() -> void:
|
||||||
|
var system := VillageOpportunitySystemScript.new()
|
||||||
|
var interested := _npc(0, 20.0)
|
||||||
|
var helper := _npc(1, 20.0)
|
||||||
|
helper.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||||
|
var npcs: Array[SimNPC] = [interested, helper]
|
||||||
|
var knowledge := EventKnowledgeSystem.new()
|
||||||
|
var opportunity := OpportunityStateRecord.create(
|
||||||
|
0,
|
||||||
|
10,
|
||||||
|
4,
|
||||||
|
interested.id,
|
||||||
|
1.0,
|
||||||
|
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD,
|
||||||
|
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||||
|
SimulationIds.RESOURCE_WOOD
|
||||||
|
)
|
||||||
|
system.restore([opportunity], 1)
|
||||||
|
_remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10)
|
||||||
|
var relationships := RelationshipSystemScript.new()
|
||||||
|
relationships.restore([RelationshipStateRecord.create(helper.id, interested.id, 0.7, 0.7)])
|
||||||
|
var preferred_source := _resource_state(
|
||||||
|
&"safe_grove", SimulationIds.ACTION_GATHER_WOOD, SimulationIds.RESOURCE_WOOD, 2.0, 5.0, 0.1
|
||||||
|
)
|
||||||
|
var fallback_source := _resource_state(
|
||||||
|
&"rough_grove", SimulationIds.ACTION_GATHER_WOOD, SimulationIds.RESOURCE_WOOD, 2.0, 1.0, 0.8
|
||||||
|
)
|
||||||
|
var sources := {
|
||||||
|
fallback_source.get_node_id(): fallback_source,
|
||||||
|
preferred_source.get_node_id(): preferred_source,
|
||||||
|
}
|
||||||
|
var woodpile := StorageStateRecord.create(
|
||||||
|
SimulationIds.STORAGE_VILLAGE_WOODPILE, {String(SimulationIds.RESOURCE_WOOD): 0.0}
|
||||||
|
)
|
||||||
|
var result: OpportunityHelperResult = system.find_capable_helper(
|
||||||
|
opportunity, woodpile, npcs, knowledge, relationships, sources
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_eq(result.helper_npc_id, helper.id)
|
||||||
|
assert_eq(result.action_id, SimulationIds.ACTION_GATHER_WOOD)
|
||||||
|
assert_true(result.source_id.is_empty())
|
||||||
|
assert_eq(result.available_source_count, 2)
|
||||||
|
assert_true(result.profession_match)
|
||||||
|
assert_true(preferred_source.reserve(99))
|
||||||
|
result = system.find_capable_helper(
|
||||||
|
opportunity, woodpile, npcs, knowledge, relationships, sources
|
||||||
|
)
|
||||||
|
assert_eq(result.available_source_count, 1)
|
||||||
|
assert_true(fallback_source.reserve(99))
|
||||||
|
assert_null(
|
||||||
|
system.find_capable_helper(opportunity, woodpile, npcs, knowledge, relationships, sources)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func test_restore_preserves_ids_sorted_queries_and_resolves_only_oldest_open() -> void:
|
func test_restore_preserves_ids_sorted_queries_and_resolves_only_oldest_open() -> void:
|
||||||
var older := OpportunityStateRecord.create(2, 5, 4, 7)
|
var older := OpportunityStateRecord.create(2, 5, 4, 7)
|
||||||
var newer := OpportunityStateRecord.create(5, 8, 7, 9)
|
var newer := OpportunityStateRecord.create(5, 8, 7, 9)
|
||||||
@@ -309,6 +477,27 @@ func _player_resource_states() -> Dictionary:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _resource_state(
|
||||||
|
node_id: StringName,
|
||||||
|
action_id: StringName,
|
||||||
|
resource_id: StringName,
|
||||||
|
amount: float,
|
||||||
|
discovery_priority: float = 0.0,
|
||||||
|
safety_risk: float = 0.0
|
||||||
|
) -> ResourceStateRecord:
|
||||||
|
var node := ResourceNode.new()
|
||||||
|
node.node_id = node_id
|
||||||
|
node.action_id = action_id
|
||||||
|
node.resource_id = resource_id
|
||||||
|
node.initial_amount = amount
|
||||||
|
node.yield_per_action = 1.0
|
||||||
|
node.discovery_priority = discovery_priority
|
||||||
|
node.safety_risk = safety_risk
|
||||||
|
var state := ResourceStateRecord.create_from_node(node)
|
||||||
|
node.free()
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
func _remember(
|
func _remember(
|
||||||
knowledge: EventKnowledgeSystem, knower_id: int, event_id: int, acquired_tick: int
|
knowledge: EventKnowledgeSystem, knower_id: int, event_id: int, acquired_tick: int
|
||||||
) -> void:
|
) -> void:
|
||||||
|
|||||||
@@ -64,6 +64,49 @@ func _run() -> void:
|
|||||||
),
|
),
|
||||||
"The performer and nearby witness should know the exact fact while its owner retains it"
|
"The performer and nearby witness should know the exact fact while its owner retains it"
|
||||||
)
|
)
|
||||||
|
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||||
|
var helper_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
|
||||||
|
witness.id, actor.id
|
||||||
|
)
|
||||||
|
_check(helper_relationship != null, "Nearby household witnesses should have a directed tie")
|
||||||
|
if helper_relationship != null:
|
||||||
|
var relationship_records: Array[RelationshipStateRecord] = []
|
||||||
|
for relationship in manager.relationship_system.get_all_sorted():
|
||||||
|
if relationship == helper_relationship:
|
||||||
|
relationship_records.append(
|
||||||
|
RelationshipStateRecord.create(
|
||||||
|
witness.id, actor.id, helper_relationship.get_familiarity(), 0.7
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
relationship_records.append(relationship)
|
||||||
|
manager.relationship_system.restore(relationship_records)
|
||||||
|
var helper_tree := _create_helper_tree()
|
||||||
|
root.add_child(helper_tree)
|
||||||
|
await process_frame
|
||||||
|
_check(
|
||||||
|
manager.register_resource_node(helper_tree), "Helper query should bind a finite NPC tree"
|
||||||
|
)
|
||||||
|
var checksum_before_helper_query: String = manager.get_state_checksum()
|
||||||
|
var helper_result: OpportunityHelperResult = manager.get_active_opportunity_helper()
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
helper_result != null
|
||||||
|
and helper_result.helper_npc_id == witness.id
|
||||||
|
and helper_result.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||||
|
and helper_result.source_id.is_empty()
|
||||||
|
and helper_result.available_source_count == 1
|
||||||
|
and helper_result.trigger_event_id == trigger_id
|
||||||
|
and helper_result.profession_match
|
||||||
|
and not helper_result.uses_inventory
|
||||||
|
and "Knows the need" in helper_result.reason
|
||||||
|
),
|
||||||
|
"The query should identify one informed trusted woodcutter with finite tree supply"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
manager.get_state_checksum() == checksum_before_helper_query,
|
||||||
|
"Capable-helper discovery should not mutate authoritative simulation state"
|
||||||
|
)
|
||||||
|
|
||||||
var active_json: String = manager.serialize_state()
|
var active_json: String = manager.serialize_state()
|
||||||
var active_restored := _create_manager(1202)
|
var active_restored := _create_manager(1202)
|
||||||
@@ -71,13 +114,21 @@ func _run() -> void:
|
|||||||
active_restored.restore_state_from_json(active_json),
|
active_restored.restore_state_from_json(active_json),
|
||||||
"An active missing-wood need should survive schema-v9 restore"
|
"An active missing-wood need should survive schema-v9 restore"
|
||||||
)
|
)
|
||||||
|
var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper()
|
||||||
|
_check(
|
||||||
|
active_restored.get_state_checksum() == manager.get_state_checksum(),
|
||||||
|
"Active restore should preserve the authoritative checksum"
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
active_restored.get_state_checksum() == manager.get_state_checksum()
|
active_restored.get_active_opportunity() != null
|
||||||
and active_restored.get_active_opportunity() != null
|
|
||||||
and active_restored.is_known_event_lasting(actor.id, trigger_id)
|
and active_restored.is_known_event_lasting(actor.id, trigger_id)
|
||||||
),
|
),
|
||||||
"Active restore should preserve identity, checksum, and protected evidence"
|
"Active restore should preserve opportunity identity and protected evidence"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
restored_helper != null and restored_helper.helper_npc_id == witness.id,
|
||||||
|
"Active restore should re-derive the same capable helper"
|
||||||
)
|
)
|
||||||
active_restored.free()
|
active_restored.free()
|
||||||
|
|
||||||
@@ -118,6 +169,8 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
resolved_restored.free()
|
resolved_restored.free()
|
||||||
manager.free()
|
manager.free()
|
||||||
|
helper_tree.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
var player_manager := _create_manager(1204)
|
var player_manager := _create_manager(1204)
|
||||||
var player_actor := _open_missing_wood_need(player_manager)
|
var player_actor := _open_missing_wood_need(player_manager)
|
||||||
@@ -278,6 +331,15 @@ func _create_player_tree() -> ResourceNode:
|
|||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
func _create_helper_tree() -> ResourceNode:
|
||||||
|
var tree := _create_player_tree()
|
||||||
|
tree.name = "HelperTree"
|
||||||
|
tree.node_id = &"helper_tree"
|
||||||
|
tree.can_player_use = false
|
||||||
|
tree.discovery_priority = 4.0
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
func _create_manager(seed_value: int) -> Node:
|
func _create_manager(seed_value: int) -> Node:
|
||||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||||
manager.simulation_seed = seed_value
|
manager.simulation_seed = seed_value
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ func _build_active_opportunity_display() -> String:
|
|||||||
var interested_name := _get_npc_name(opportunity.get_interested_npc_id())
|
var interested_name := _get_npc_name(opportunity.get_interested_npc_id())
|
||||||
var resource_name := String(opportunity.get_resource_id()).capitalize()
|
var resource_name := String(opportunity.get_resource_id()).capitalize()
|
||||||
var current_amount := _get_opportunity_current_amount(opportunity)
|
var current_amount := _get_opportunity_current_amount(opportunity)
|
||||||
|
var helper_text := _build_opportunity_helper_display()
|
||||||
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
|
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
|
||||||
var action_name := _get_opportunity_action_name(opportunity)
|
var action_name := _get_opportunity_action_name(opportunity)
|
||||||
return (
|
return (
|
||||||
@@ -271,12 +272,26 @@ func _build_active_opportunity_display() -> String:
|
|||||||
+ "◆ Supply wood for blocked work\n"
|
+ "◆ Supply wood for blocked work\n"
|
||||||
+ "%s could not finish %s\n" % [interested_name, action_name]
|
+ "%s could not finish %s\n" % [interested_name, action_name]
|
||||||
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
|
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
|
||||||
|
+ helper_text
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
"\n\nVillage need\n"
|
"\n\nVillage need\n"
|
||||||
+ "◆ Restock the empty pantry\n"
|
+ "◆ Restock the empty pantry\n"
|
||||||
+ "%s is worried about the shortage\n" % interested_name
|
+ "%s is worried about the shortage\n" % interested_name
|
||||||
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
|
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
|
||||||
|
+ helper_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_opportunity_helper_display() -> String:
|
||||||
|
if not simulation_manager.has_method("get_active_opportunity_helper"):
|
||||||
|
return ""
|
||||||
|
var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
|
||||||
|
if helper == null:
|
||||||
|
return "\nPossible helper: none informed, trusted, and able to supply"
|
||||||
|
return (
|
||||||
|
"\nPossible helper: %s — %s\n%s"
|
||||||
|
% [_get_npc_name(helper.helper_npc_id), _get_action_name(helper.action_id), helper.reason]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user