feat: add missing wood opportunities

This commit is contained in:
Rijad Zuzo
2026-07-16 01:50:19 +02:00
parent f82d9683de
commit 79dd503343
26 changed files with 1054 additions and 175 deletions
+5 -5
View File
@@ -17,7 +17,7 @@ SimulationClock
-> SimulationEventLog records completed facts -> SimulationEventLog records completed facts
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
-> RelationshipSystem applies evidence-gated social consequences -> RelationshipSystem applies evidence-gated social consequences
-> FoodShortageOpportunitySystem projects one known unresolved need -> VillageOpportunitySystem projects one known unresolved need
-> WorldViewManager presents travel, NPC state, and world-state cues -> WorldViewManager presents travel, NPC state, and world-state cues
-> ActiveWorldAdapter supplies loaded-world positions/capacity -> ActiveWorldAdapter supplies loaded-world positions/capacity
-> NpcVisual performs local navigation, animation, and transient reactions -> NpcVisual performs local navigation, animation, and transient reactions
@@ -40,10 +40,10 @@ would otherwise obscure that lifecycle:
retention/importance ranking; retention/importance ranking;
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship - `simulation/relationships/RelationshipSystem.gd` owns directed relationship
queries, event-driven trust changes, and deterministic social tie-breaking; queries, event-driven trust changes, and deterministic social tie-breaking;
- `simulation/opportunities/FoodShortageOpportunitySystem.gd` observes - `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable
immutable event references plus current pantry/NPC state, then owns the event references plus current NPC/storage state, then owns the bounded shared
bounded open/resolved pantry-restock lifecycle without changing resources or open/resolved/invalidated lifecycle for the proven pantry-food and blocked-
assigning tasks; work wood consumers 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
+9 -4
View File
@@ -726,13 +726,18 @@ Completed:
concern cue; a real deposit clears it and briefly animates the refilled concern cue; a real deposit clears it and briefly animates the refilled
pantry. The capture tool writes an edit-ready empty/restocked close pair pantry. The capture tool writes an edit-ready empty/restocked close pair
while leaving gameplay camera defaults and simulation authority unchanged. while leaving gameplay camera defaults and simulation authority unchanged.
27. Missing-wood village need: a real patrol/study shortfall now opens one
wood-specific need from its structured `task_blocked` fact. NPC deposit and
player tree-harvest resolutions use real economy history; interested-party
death and one-day staleness close the need cleanly. The compact UI explains
the blocked action without reusing the pantry's hunger cue.
Next: Next:
1. Resume the second bounded opportunity proof from a real missing-wood 1. Add the first read-only capable-helper query for an open need. Expose one
`task_blocked` fact. Include deterministic interested-party death/staleness deterministic viable villager and the real knowledge, relationship, action,
handling before extracting shared opportunity machinery, as sequenced in inventory, and resource facts behind that result before allowing it to
`LEARNING_ROADMAP.md`. affect autonomous selection, 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
+32 -21
View File
@@ -31,25 +31,28 @@ state changes succeed; `SimulationManager` remains the public signal boundary
used by presentation. used by presentation.
An action whose definition-backed completion cost becomes unavailable records An action whose definition-backed completion cost becomes unavailable records
a zero-amount `task_blocked` narrative fact with the action and shortfall a zero-amount `task_blocked` narrative fact with stable action, source storage,
reason. This makes late contention inspectable without pretending that a resource, and required-amount fields plus the readable shortfall reason. This
makes late contention queryable and inspectable without pretending that a
transfer occurred. transfer occurred.
## Persistence and determinism ## Persistence and determinism
`SimulationStateRecord` schema v8 stores the ordered event stream, `SimulationStateRecord` schema v9 stores the ordered event stream,
`next_event_id`, directed relationships that may reference an exact event, and `next_event_id`, directed relationships that may reference an exact event, and
per-NPC known-event references with first-acquisition provenance, plus per-NPC known-event references with first-acquisition provenance, plus
opportunity records that reference exact trigger/resolution events. Schema v1 opportunity records that reference exact trigger/resolution events. Schema v1
and v2 saves migrate to an empty stream beginning at ID zero; world schemas and v2 saves migrate to an empty stream beginning at ID zero; world schemas
v1v7 migrate to an empty opportunity list. Parsing rejects duplicate event v1v7 migrate to an empty opportunity list, while world schema v8 preserves and
normalizes its pantry opportunity history. Parsing rejects duplicate event
IDs, invalid or duplicate knowledge/opportunity references, impossible IDs, invalid or duplicate knowledge/opportunity references, impossible
communicator sources, relationship causes the observer does not know, and next communicator sources, relationship causes the observer does not know, and next
IDs that could collide with restored history. IDs that could collide with restored history.
Positive food deposits and positive NPC food withdrawals from Positive food deposits, positive NPC food withdrawals from `village_pantry`
`village_pantry` into that actor's matching inventory are currently knowable. into that actor's matching inventory, and definition-backed patrol/study
The actor and living NPCs within the bounded witness radius receive a `task_blocked` facts caused by missing wood at `village_woodpile` are currently
knowable. The actor and living NPCs within the bounded witness radius receive a
`KnownEventStateRecord` at record time. Witness distance uses the event's `KnownEventStateRecord` at record time. Witness distance uses the event's
captured position, never the actor's later location. A successful food deposit captured position, never the actor's later location. A successful food deposit
can raise a hungry familiar witness's directed trust; the relationship stores can raise a hungry familiar witness's directed trust; the relationship stores
@@ -75,19 +78,25 @@ evidence. Conversations prefer lasting direct facts, then the most recently
acquired direct fact. Communicated provenance snapshots how the speaker knew acquired direct fact. Communicated provenance snapshots how the speaker knew
the event, so a listener's memory stays valid after the speaker forgets. the event, so a listener's memory stays valid after the speaker forgets.
`FoodShortageOpportunitySystem` is another read-only consumer of immutable `VillageOpportunitySystem` is another read-only consumer of immutable facts.
facts. A known positive NPC pantry withdrawal, combined with the pantry A known positive NPC pantry withdrawal, combined with the pantry currently
currently being empty and a living critically hungry knower, can open one being empty and a living critically hungry knower, can open one
`restock_empty_pantry` record. The opportunity does not perform a transaction, `restock_empty_pantry` record. A known missing-wood `task_blocked` fact can open
assign an NPC, or append a quest-only event. It resolves only after the pantry one `supply_missing_wood` record for its living performer. At most one need is
actually reaches its target through either: open globally. The system does not perform a transaction, assign an NPC, or
append a quest-only event. A need resolves only after its target storage
actually reaches the one-unit target through either:
- a later positive `storage_deposited` event from a real NPC inventory; or - a later matching positive `storage_deposited` event from a real NPC
- a later player `resource_extracted` event from an existing player-usable food inventory; or
ResourceNode directly into `village_pantry`. - a later player `resource_extracted` event from an existing player-usable
matching ResourceNode directly into the target storage.
The exact trigger and resolution IDs remain in opportunity history. The active The exact trigger and resolution IDs remain in opportunity history. The active
trigger memory is protected until resolution, then returns to normal bounded trigger memory is protected until closure. A wood need is invalidated if its
interested performer dies or if it is still unresolved one simulated day after
opening; invalidation emits no event and stores the close tick and stable
reason. Resolution or invalidation returns the trigger to normal bounded
retention. retention.
The food-loop regression verifies this chain: The food-loop regression verifies this chain:
@@ -110,9 +119,11 @@ acquisition provenance, separately from three objective events the NPC
personally performed. A real trust-changing consequence emits one transient personally performed. A real trust-changing consequence emits one transient
amber blossom above its observer. The cue is presentation-only, remains visible amber blossom above its observer. The cue is presentation-only, remains visible
in cinematic mode, and is neither saved nor replayed after visual rebuild. in cinematic mode, and is neither saved nor replayed after visual rebuild.
The village summary also shows one compact active need with its real food 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/resolved detail and names the exact supplier. 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
wood need as hunger.
## Deliberate limits ## Deliberate limits
@@ -125,5 +136,5 @@ 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 is likewise a single bounded projection, not a generic The current opportunity family is likewise a bounded two-consumer projection,
quest, reward, acceptance, dialogue, or capable-helper framework. not a generic quest, reward, acceptance, dialogue, or capable-helper framework.
+25 -6
View File
@@ -757,11 +757,29 @@ The outcome-defined pantry-crisis demo loop is complete:
replacing state-driven world cues, while a runtime validator measures the replacing state-driven world cues, while a runtime validator measures the
unassisted route and a headless scenario verifies its full event chain. unassisted route and a headless scenario verifies its full event chain.
Milestone 7 is not complete. The immediate next slice is the second bounded The second bounded Milestone 7 consumer is complete:
unresolved-condition consumer from a real missing-wood `task_blocked` fact,
including deterministic interested-party death/staleness handling. Extract - patrol or study that loses its definition-backed wood cost at completion
shared opportunity machinery only after that second consumer proves the common records one zero-transfer `task_blocked` fact with stable action, woodpile,
contract. wood, and required-amount fields, without applying the work effect;
- the performer and nearby witnesses retain that objective fact, and the
performer becomes the interested party for one `supply_missing_wood` need;
- a real later NPC wood deposit or player tree extraction into the woodpile
resolves the record through its exact existing event;
- interested-party death or evidence that remains unresolved for one simulated
day invalidates the need deterministically, emits no quest-only fact, and
releases the protected trigger memory;
- world schema v9, nested opportunity schema v2, wood-specific UI text, and
focused headless/runtime regressions preserve the full open, resolved, and
invalidated lifecycle;
- the two proven consumers now share `VillageOpportunitySystem` and the common
lifecycle fields, while their evidence and care rules remain explicit.
Milestone 7 is not complete. The immediate next slice is a bounded
capable-helper query for an open need: derive one viable helper from existing
knowledge, relationship, action, inventory, and resource facts and expose the
reason for that result. Keep it read-only first; only a later proven slice
should let opportunity awareness influence autonomous task selection.
Recently completed: Recently completed:
@@ -819,7 +837,8 @@ Recently completed:
- Action completion costs are definition-backed: patrol and study share one - Action completion costs are definition-backed: patrol and study share one
stored-resource contract across selection and execution. The inspector shows stored-resource contract across selection and execution. The inspector shows
readable unavailable reasons, and a late shortfall creates a persisted readable unavailable reasons, and a late shortfall creates a persisted
`task_blocked` fact without applying the work effect. `task_blocked` fact with stable action/resource/requirement fields without
applying the work effect.
- Structured event feed: EconomicEventRecord now supports narrative events - Structured event feed: EconomicEventRecord now supports narrative events
(task started, NPC slept, NPC died) alongside economic transfers. (task started, NPC slept, NPC died) alongside economic transfers.
Per-NPC event history with human-readable descriptions exposed in the Per-NPC event history with human-readable descriptions exposed in the
+28 -16
View File
@@ -427,9 +427,9 @@ Focused `RefCounted` collaborators keep rule ownership visible:
importance ranking for presentation/communication queries; importance ranking for presentation/communication queries;
- `RelationshipSystem` owns directed relationship state, event-driven trust - `RelationshipSystem` owns directed relationship state, event-driven trust
consequences, and deterministic social queries; consequences, and deterministic social queries;
- `FoodShortageOpportunitySystem` projects the first knowledge-gated unresolved - `VillageOpportunitySystem` projects the proven pantry-food and blocked-work
condition into a persisted open/resolved record without changing resources wood conditions into persisted open/resolved/invalidated records without
or assigning work. changing resources or assigning work.
`SimulationManager` remains the scene-tree façade and signal boundary rather `SimulationManager` remains the scene-tree façade and signal boundary rather
than duplicating these responsibilities across additional manager nodes. than duplicating these responsibilities across additional manager nodes.
@@ -505,7 +505,7 @@ NpcVisual navigates through the active world
| -> SimulationEventLog appends completed facts | -> SimulationEventLog appends completed facts
| -> EventKnowledgeSystem captures actor/nearby knowledge | -> EventKnowledgeSystem captures actor/nearby knowledge
| -> RelationshipSystem applies evidence-gated social consequences | -> RelationshipSystem applies evidence-gated social consequences
| -> FoodShortageOpportunitySystem may open or resolve a real need | -> VillageOpportunitySystem may open, resolve, or invalidate a real need
| | | |
| v | v
| village_changed signal updates the UI | village_changed signal updates the UI
@@ -560,7 +560,8 @@ NpcVisual navigates through the active world
│ ├── resource_node_player_parity_test.gd │ ├── resource_node_player_parity_test.gd
│ ├── simulation_definitions_test.gd │ ├── simulation_definitions_test.gd
│ ├── simulation_state_serialization_test.gd │ ├── simulation_state_serialization_test.gd
│ ├── unit/test_food_shortage_opportunity_system.gd │ ├── unit/test_village_opportunity_system.gd
│ ├── wood_shortage_opportunity_test.gd
│ └── witnessed_knowledge_consequence_test.gd │ └── witnessed_knowledge_consequence_test.gd
├── terrain/jajce/ Dedicated Terrain3D seed data and assets ├── terrain/jajce/ Dedicated Terrain3D seed data and assets
├── tools/ ├── tools/
@@ -593,7 +594,7 @@ These are expected prototype constraints, not necessarily isolated bugs:
gathering use `ResourceNode` instances with no fallback, food transfer uses gathering use `ResourceNode` instances with no fallback, food transfer uses
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
- Current NPC, village, resource, storage, event, knowledge, relationship, - Current NPC, village, resource, storage, event, knowledge, relationship,
opportunity, clock, and RNG state serialize through world schema v8. F5/F9 opportunity, clock, and RNG state serialize through world schema v9. F5/F9
provide one validated local quicksave; a save menu, metadata, and provide one validated local quicksave; a save menu, metadata, and
player-transform persistence remain deferred. player-transform persistence remain deferred.
- Simulation-owned resource records retain live amounts, reservations, and - Simulation-owned resource records retain live amounts, reservations, and
@@ -608,15 +609,17 @@ These are expected prototype constraints, not necessarily isolated bugs:
located items. located items.
- NPCs have home positions, schedule periods, carried food/wood, and directed - NPCs have home positions, schedule periods, carried food/wood, and directed
familiarity/trust. They retain direct or one-hop communicated knowledge of a familiarity/trust. They retain direct or one-hop communicated knowledge of a
food deposit or NPC pantry withdrawal with first-acquisition provenance. food deposit or NPC pantry withdrawal, plus direct missing-wood blocked-work
facts, with first-acquisition provenance.
Current trust causes and the trigger of an open opportunity are lasting; Current trust causes and the trigger of an open opportunity are lasting;
other knowledge is capped and reviewed after one simulated day. NPCs do not other knowledge is capped and reviewed after one simulated day. NPCs do not
yet have wider social dimensions, goals, line-of-sight/hearing evidence, yet have wider social dimensions, goals, line-of-sight/hearing evidence,
personalized reinforcement/decay, false beliefs, or multi-hop rumours. personalized reinforcement/decay, false beliefs, or multi-hop rumours.
- One persisted `restock_empty_pantry` opportunity can emerge from a known - The bounded opportunity family supports one open `restock_empty_pantry` or
withdrawal plus current emptiness and critical hunger. It has exact NPC and `supply_missing_wood` need from exact known evidence. Both have exact NPC and
player supply resolutions, but no generic opportunity definitions, capable- player supply resolutions; the wood need also closes deterministically on
helper assignment, acceptance, rewards, free-form dialogue, or quest log. interested-party death or one-day staleness. There is still no capable-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.
@@ -872,7 +875,7 @@ produce a short observer-only amber blossom without altering saved state.
The first bounded opportunity proof is complete. A positive NPC pantry The first bounded opportunity proof is complete. A positive NPC pantry
withdrawal plus current pantry emptiness opens one need only for a critically withdrawal plus current pantry emptiness opens one need only for a critically
hungry villager who knows that exact event. The schema-v8 record persists its hungry villager who knows that exact event. The persisted record retains its
stable trigger, interested villager, pantry/food target, status, and later stable trigger, interested villager, pantry/food target, status, and later
resolution event; active evidence remains lasting. A real later NPC deposit or resolution event; active evidence remains lasting. A real later NPC deposit or
player harvest into the pantry resolves it without quest-only history, economy player harvest into the pantry resolves it without quest-only history, economy
@@ -890,10 +893,19 @@ finite berry source, visibly carries its yield, deposits it, and causes the
physical recovery. A headless lifecycle scenario and an unassisted runtime physical recovery. A headless lifecycle scenario and an unassisted runtime
timing validator cover the complete path. timing validator cover the complete path.
Milestone 7 remains in progress. The immediate next slice is the second The second bounded opportunity proof is complete. Patrol or study that cannot
missing-wood consumer from a real `task_blocked` fact, including deterministic pay its wood cost records a stable zero-transfer `task_blocked` fact and opens
interested-party death/staleness handling before common opportunity machinery one `supply_missing_wood` need for its performer. A real NPC wood deposit or
is extracted. player tree harvest resolves it; interested-party death or one simulated day
of unresolved evidence invalidates it without quest-only history. World schema
v9 and nested opportunity schema v2 preserve open, resolved, and invalidated
history. The two consumers now share `VillageOpportunitySystem`, while their
evidence and care rules remain explicit.
Milestone 7 remains in progress. The immediate next slice is a read-only,
deterministic capable-helper query for an open need, derived from existing
knowledge, relationship, action, inventory, and resource facts before it is
allowed to influence autonomous task selection.
The remaining simulation-garden target still aims for: The remaining simulation-garden target still aims for:
+13 -8
View File
@@ -45,15 +45,20 @@ unknown profession or executable action.
## Opportunity vocabulary ## Opportunity vocabulary
`SimulationIds` also defines the first proven opportunity type, `SimulationIds` defines the two proven opportunity types,
`restock_empty_pantry`, and its `open`/`resolved` statuses. These are stable `restock_empty_pantry` and `supply_missing_wood`, plus the stable `open`,
serialized vocabulary, not editor-authored quest definitions. Dynamic trigger, `resolved`, and `invalidated` statuses. Invalidation reasons currently
interested NPC, pantry/food goal, progress, and exact resolution-event identity distinguish `interested_died` from `evidence_stale`. These are serialized
belong to `OpportunityStateRecord` and the focused opportunity system. vocabulary, not editor-authored quest definitions. Dynamic trigger, interested
NPC, storage/resource goal, progress, exact resolution-event identity, and
close reason belong to `OpportunityStateRecord` and
`VillageOpportunitySystem`.
Do not extract a generic quest-definition registry until another real The shared record and lifecycle collaborator were extracted only after the
unresolved-condition consumer proves which lifecycle fields and rules are food and wood consumers proved those fields. Their evidence, care, resolution,
actually shared. and invalidation rules remain explicit branches. Do not add a generic quest-
definition registry until real acceptance, assignment, reward, or dialogue
consumers establish a second shared contract.
## Validation ## Validation
+39 -18
View File
@@ -3,7 +3,7 @@
## Current contract ## Current contract
`SimulationStateRecord` is the versioned JSON boundary for the current `SimulationStateRecord` is the versioned JSON boundary for the current
simulation. The current world schema is v8 and captures: simulation. The current world schema is v9 and captures:
- simulation seed, tick interval, tick count, clock remainder, and elapsed - simulation seed, tick interval, tick count, clock remainder, and elapsed
clock ticks; clock ticks;
@@ -21,14 +21,15 @@ simulation. The current world schema is v8 and captures:
copying it, including first-acquisition method, acquisition tick, and copying it, including first-acquisition method, acquisition tick, and
historical communicator provenance; historical communicator provenance;
- opportunity records with stable type/status, interested NPC, trigger event, - opportunity records with stable type/status, interested NPC, trigger event,
target storage/resource/amount, and exact later resolution event identity. target storage/resource/amount, exact later resolution event identity, or a
deterministic invalidation reason and close tick.
The top-level identity is: The top-level identity is:
```json ```json
{ {
"schema": "the_steward.simulation", "schema": "the_steward.simulation",
"schema_version": 8 "schema_version": 9
} }
``` ```
@@ -61,8 +62,9 @@ It also verifies clock remainder, resource amount/reservation/enabled
round-tripping, presentation unload/rebind, directed relationship/cause round-tripping, presentation unload/rebind, directed relationship/cause
round-tripping, divergent known-event state, communicated provenance, and round-tripping, divergent known-event state, communicated provenance, and
deterministic retention boundaries, and rejection of unsupported schemas. deterministic retention boundaries, and rejection of unsupported schemas.
Active and resolved opportunity round-tripping, contradictory target state, Active, resolved, and invalidated opportunity round-tripping, contradictory
invalid resource sources, and cross-record event references are covered too. target state, invalid resource sources, and cross-record event references are
covered too.
NPCStateRecord v2 adds the resolved travel destination and whether it is NPCStateRecord v2 adds the resolved travel destination and whether it is
active. Nested v1 NPC records migrate explicitly with no invented active active. Nested v1 NPC records migrate explicitly with no invented active
@@ -158,22 +160,41 @@ schema stores `opportunity_id`, `opportunity_type`, `status`, `created_tick`,
`target_amount`, `resolution_event_id`, and `resolved_tick`. World schemas `target_amount`, `resolution_event_id`, and `resolved_tick`. World schemas
v1v7 migrate explicitly to an empty opportunity list with next ID zero. v1v7 migrate explicitly to an empty opportunity list with next ID zero.
The bounded `restock_empty_pantry` contract accepts at most one open record. SimulationStateRecord v9 preserves world-v8 opportunity history and upgrades
the nested record to `OpportunityStateRecord` v2. The nested schema adds
`closed_tick` and `invalidation_reason`, accepts `supply_missing_wood`, and adds
the stable `invalidated` status. Nested opportunity v1 records normalize their
resolved tick into the close tick and remain valid pantry history. Economic
event schema v3 adds normalized `action_id` and `required_amount` fields;
nested v1/v2 events receive empty/zero defaults, while a new `task_blocked`
event requires its stable action, source storage, resource, and positive
requirement contract.
The bounded opportunity family accepts at most one open record globally.
Opportunity, trigger-event, and resolution-event IDs are unique and the next Opportunity, trigger-event, and resolution-event IDs are unique and the next
ID must remain above restored history. The interested NPC, pantry, food ID must remain above restored history. Referenced NPCs, storage, resources, and
resource, and referenced events must exist. Its trigger is a positive NPC food events must exist. A `restock_empty_pantry` trigger is a positive NPC food
withdrawal from `village_pantry` to that actor's matching inventory; an open withdrawal from `village_pantry` to that actor's matching inventory; an open
record additionally requires the pantry to remain below its one-food target record additionally requires the pantry to remain below its one-food target
and the interested NPC to have acquired that fact no later than creation. and its interested NPC to have acquired that fact no later than creation. A
Resolution references an exact later NPC inventory-to-pantry food deposit or a `supply_missing_wood` trigger is the performer's known patrol or study
player `resource_extracted` event from an existing player-usable food resource `task_blocked` fact against `village_woodpile` with a one-wood requirement; an
into the pantry, with a matching resolved tick. Resolved history does not open record additionally requires a living interested performer and the
require the pantry still to contain food because later consumption is valid. woodpile below target.
While the need is open, its interested villager's trigger evidence is treated Resolution references an exact later matching NPC inventory-to-storage deposit
as lasting during deterministic memory maintenance. Resolution releases that or player `resource_extracted` event from an existing player-usable matching
fact back to the normal bounded-retention rules; it does not delete objective resource into the target storage, with matching resolved and closed ticks.
event or opportunity history. Resolved history does not require the storage still to contain the resource
because later use is valid. Invalidation has no resolution event and requires a
stable close tick and is currently valid only for `supply_missing_wood`.
`interested_died` must agree with the restored NPC's death state; the runtime
creates `evidence_stale` at the one-day unresolved boundary.
While a need is open, its interested villager's trigger evidence is treated as
lasting during deterministic memory maintenance. Resolution or invalidation
releases that fact back to the normal bounded-retention rules; neither deletes
objective event or opportunity history.
## Resource authority ## Resource authority
@@ -211,7 +232,7 @@ This phase does not yet provide:
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles; - a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
- migrations from any historical world schema other than the explicitly - migrations from any historical world schema other than the explicitly
supported v1v7 layouts; supported v1v8 layouts;
- player inventory or player relationship records; - player inventory or player relationship records;
- broader relationship dimensions, line-of-sight/hearing evidence, - broader relationship dimensions, line-of-sight/hearing evidence,
continuous or personalized memory decay, reinforcement, false beliefs, or continuous or personalized memory decay, reinforcement, false beliefs, or
+38 -11
View File
@@ -4,9 +4,6 @@ const SimulationEventLogScript := preload("res://simulation/events/SimulationEve
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd") const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
const FoodShortageOpportunitySystemScript := preload(
"res://simulation/opportunities/FoodShortageOpportunitySystem.gd"
)
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName) signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
signal village_changed(village: SimVillage) signal village_changed(village: SimVillage)
@@ -23,6 +20,7 @@ signal event_knowledge_transferred(speaker_id: int, listener_id: int, event: Eco
signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord) signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord)
signal opportunity_opened(opportunity: OpportunityStateRecord) signal opportunity_opened(opportunity: OpportunityStateRecord)
signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord) signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord)
signal opportunity_invalidated(opportunity: OpportunityStateRecord)
var village := SimVillage.new() var village := SimVillage.new()
@@ -42,7 +40,7 @@ var event_log := SimulationEventLogScript.new()
var economy := VillageEconomyScript.new() var economy := VillageEconomyScript.new()
var relationship_system := RelationshipSystemScript.new() var relationship_system := RelationshipSystemScript.new()
var event_knowledge_system := EventKnowledgeSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new()
var opportunity_system := FoodShortageOpportunitySystemScript.new() var opportunity_system := VillageOpportunitySystem.new()
var storage_states: Dictionary: var storage_states: Dictionary:
get: get:
return economy.storage_states return economy.storage_states
@@ -162,12 +160,15 @@ func simulate_tick() -> void:
var village_was_changed := false var village_was_changed := false
for npc in npcs: for npc in npcs:
village_was_changed = _simulate_npc_tick(npc) or village_was_changed village_was_changed = _simulate_npc_tick(npc) or village_was_changed
if village_was_changed: if village_was_changed:
village_changed.emit(village) village_changed.emit(village)
var invalidated: OpportunityStateRecord = opportunity_system.maintain_open_opportunity(
tick_count, get_knowledge_review_interval(), npcs, get_pantry(), get_woodpile()
)
if invalidated != null:
opportunity_invalidated.emit(invalidated)
if tick_count % get_knowledge_review_interval() == 0: if tick_count % get_knowledge_review_interval() == 0:
_maintain_event_knowledge(true) _maintain_event_knowledge(true)
if debug_logs: if debug_logs:
print(village.get_summary()) print(village.get_summary())
@@ -552,14 +553,23 @@ func _record_economic_event_at(
func record_narrative_event( func record_narrative_event(
event_type: StringName, actor_id: int, source_id: StringName = &"", action_display: String = "" event_type: StringName,
actor_id: int,
source_id: StringName = &"",
action_display: String = "",
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> void: ) -> void:
_record_narrative_event_at( _record_narrative_event_at(
event_type, event_type,
actor_id, actor_id,
source_id, source_id,
action_display, action_display,
_get_event_world_position(actor_id, source_id, &"") _get_event_world_position(actor_id, source_id, &""),
action_id,
item_id,
required_amount
) )
@@ -568,10 +578,21 @@ func _record_narrative_event_at(
actor_id: int, actor_id: int,
source_id: StringName, source_id: StringName,
action_display: String, action_display: String,
world_position: Vector3 world_position: Vector3,
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> void: ) -> void:
event_log.record_narrative( event_log.record_narrative(
tick_count, event_type, actor_id, source_id, action_display, world_position tick_count,
event_type,
actor_id,
source_id,
action_display,
world_position,
action_id,
item_id,
required_amount
) )
@@ -673,7 +694,13 @@ func _apply_new_event_knowledge(
func _update_opportunity_from_event(event: EconomicEventRecord) -> void: func _update_opportunity_from_event(event: EconomicEventRecord) -> void:
var changed: OpportunityStateRecord = opportunity_system.consider_event( var changed: OpportunityStateRecord = opportunity_system.consider_event(
event, tick_count, get_pantry(), npcs, event_knowledge_system, resource_states event,
tick_count,
get_pantry(),
npcs,
event_knowledge_system,
resource_states,
get_woodpile()
) )
if changed == null: if changed == null:
return return
+4
View File
@@ -42,8 +42,12 @@ const EVENT_TASK_BLOCKED := &"task_blocked"
const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
const OPPORTUNITY_STATUS_OPEN := &"open" const OPPORTUNITY_STATUS_OPEN := &"open"
const OPPORTUNITY_STATUS_RESOLVED := &"resolved" const OPPORTUNITY_STATUS_RESOLVED := &"resolved"
const OPPORTUNITY_STATUS_INVALIDATED := &"invalidated"
const OPPORTUNITY_INVALIDATED_INTERESTED_DIED := &"interested_died"
const OPPORTUNITY_INVALIDATED_EVIDENCE_STALE := &"evidence_stale"
const KNOWLEDGE_ACQUISITION_PERFORMED := &"performed" const KNOWLEDGE_ACQUISITION_PERFORMED := &"performed"
const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed" const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed"
+11 -2
View File
@@ -10,7 +10,13 @@ signal economic_event_requested(
amount: float amount: float
) )
signal narrative_event_requested( signal narrative_event_requested(
event_type: StringName, actor_id: int, source_id: StringName, action_display: String event_type: StringName,
actor_id: int,
source_id: StringName,
action_display: String,
action_id: StringName,
item_id: StringName,
required_amount: float
) )
var village: SimVillage var village: SimVillage
@@ -178,7 +184,10 @@ func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
SimulationIds.EVENT_TASK_BLOCKED, SimulationIds.EVENT_TASK_BLOCKED,
npc.id, npc.id,
storage.get_storage_id() if storage != null else &"", storage.get_storage_id() if storage != null else &"",
reason reason,
definition.action_id,
resource_id,
required_amount
) )
return false return false
var consumed := storage.withdraw(resource_id, required_amount) var consumed := storage.withdraw(resource_id, required_amount)
+14 -2
View File
@@ -42,10 +42,22 @@ func record_narrative(
actor_id: int, actor_id: int,
source_id: StringName = &"", source_id: StringName = &"",
action_display: String = "", action_display: String = "",
world_position: Vector3 = Vector3.ZERO world_position: Vector3 = Vector3.ZERO,
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> EconomicEventRecord: ) -> EconomicEventRecord:
var event := EconomicEventRecord.create_narrative( var event := EconomicEventRecord.create_narrative(
next_event_id, event_type, tick, actor_id, source_id, action_display, world_position next_event_id,
event_type,
tick,
actor_id,
source_id,
action_display,
world_position,
action_id,
item_id,
required_amount
) )
_append(event) _append(event)
return event return event
+14 -1
View File
@@ -222,10 +222,23 @@ func _remember(
static func is_knowable_event(event: EconomicEventRecord) -> bool: static func is_knowable_event(event: EconomicEventRecord) -> bool:
if event == null or float(event.data["amount"]) <= 0.0: if event == null:
return false return false
var event_type := StringName(event.data["event_type"]) var event_type := StringName(event.data["event_type"])
var item_id := StringName(event.data["item_id"]) var item_id := StringName(event.data["item_id"])
if event_type == SimulationIds.EVENT_TASK_BLOCKED:
return (
int(event.data["actor_id"]) >= 0
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE
and item_id == SimulationIds.RESOURCE_WOOD
and (
StringName(event.data.get("action_id", &""))
in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]
)
and float(event.data.get("required_amount", 0.0)) > 0.0
)
if float(event.data["amount"]) <= 0.0:
return false
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
return item_id == SimulationIds.RESOURCE_FOOD return item_id == SimulationIds.RESOURCE_FOOD
if event_type != SimulationIds.EVENT_STORAGE_WITHDRAWN: if event_type != SimulationIds.EVENT_STORAGE_WITHDRAWN:
@@ -1,4 +1,4 @@
class_name FoodShortageOpportunitySystem class_name VillageOpportunitySystem
extends RefCounted extends RefCounted
const CARE_HUNGER_THRESHOLD := 80.0 const CARE_HUNGER_THRESHOLD := 80.0
@@ -13,17 +13,64 @@ func consider_event(
pantry: StorageStateRecord, pantry: StorageStateRecord,
npcs: Array[SimNPC], npcs: Array[SimNPC],
knowledge_system: EventKnowledgeSystem, knowledge_system: EventKnowledgeSystem,
resource_states: Dictionary resource_states: Dictionary,
woodpile: StorageStateRecord = null
) -> OpportunityStateRecord: ) -> OpportunityStateRecord:
if event == null or pantry == null or current_tick < 0: if event == null or pantry == null or current_tick < 0:
return null return null
var event_tick := int(event.data["tick"]) var event_tick := int(event.data["tick"])
if event_tick < 0 or event_tick > current_tick: if event_tick < 0 or event_tick > current_tick:
return null return null
var resolved := _try_resolve(event, pantry, npcs, resource_states) var resolved := _try_resolve(event, pantry, woodpile, npcs, resource_states)
if resolved != null: if resolved != null:
return resolved return resolved
return _try_open(event, current_tick, pantry, npcs, knowledge_system) var opened := _try_open_food(event, current_tick, pantry, npcs, knowledge_system)
if opened != null:
return opened
if woodpile == null:
return null
return _try_open_wood(event, current_tick, woodpile, npcs, knowledge_system)
func maintain_open_opportunity(
current_tick: int,
max_age: int,
npcs: Array[SimNPC],
pantry: StorageStateRecord,
woodpile: StorageStateRecord
) -> OpportunityStateRecord:
var opportunity := get_open_opportunity()
if opportunity == null or current_tick < opportunity.get_created_tick():
return null
if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
return null
var interested := _find_npc(opportunity.get_interested_npc_id(), npcs)
if interested == null or interested.is_dead:
return (
opportunity
if opportunity.invalidate(
SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED, current_tick
)
else null
)
var target_storage := _get_target_storage(opportunity, pantry, woodpile)
var condition_is_stale := (
target_storage == null
or (
target_storage.get_amount(opportunity.get_resource_id())
>= opportunity.get_target_amount()
)
or current_tick - opportunity.get_created_tick() >= maxi(max_age, 1)
)
if not condition_is_stale:
return null
return (
opportunity
if opportunity.invalidate(
SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE, current_tick
)
else null
)
func restore(records: Array[OpportunityStateRecord], restored_next_id: int) -> void: func restore(records: Array[OpportunityStateRecord], restored_next_id: int) -> void:
@@ -65,7 +112,7 @@ func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord:
return null return null
func _try_open( func _try_open_food(
event: EconomicEventRecord, event: EconomicEventRecord,
current_tick: int, current_tick: int,
pantry: StorageStateRecord, pantry: StorageStateRecord,
@@ -91,18 +138,62 @@ func _try_open(
return opportunity return opportunity
func _try_open_wood(
event: EconomicEventRecord,
current_tick: int,
woodpile: StorageStateRecord,
npcs: Array[SimNPC],
knowledge_system: EventKnowledgeSystem
) -> OpportunityStateRecord:
if knowledge_system == null or get_open_opportunity() != null:
return null
if not _is_missing_wood_block(event):
return null
var target_amount := float(event.data.get("required_amount", 0.0))
if woodpile.get_amount(SimulationIds.RESOURCE_WOOD) >= target_amount:
return null
var actor := _find_npc(int(event.data["actor_id"]), npcs)
if (
actor == null
or actor.is_dead
or not knowledge_system.knows_event(actor.id, int(event.data["event_id"]))
):
return null
var opportunity := OpportunityStateRecord.create(
next_opportunity_id,
current_tick,
int(event.data["event_id"]),
actor.id,
target_amount,
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD,
SimulationIds.STORAGE_VILLAGE_WOODPILE,
SimulationIds.RESOURCE_WOOD
)
next_opportunity_id += 1
opportunities.append(opportunity)
return opportunity
func _try_resolve( func _try_resolve(
event: EconomicEventRecord, event: EconomicEventRecord,
pantry: StorageStateRecord, pantry: StorageStateRecord,
woodpile: StorageStateRecord,
npcs: Array[SimNPC], npcs: Array[SimNPC],
resource_states: Dictionary resource_states: Dictionary
) -> OpportunityStateRecord: ) -> OpportunityStateRecord:
var opportunity := get_open_opportunity() var opportunity := get_open_opportunity()
if opportunity == null: if opportunity == null:
return null return null
if pantry.get_amount(SimulationIds.RESOURCE_FOOD) < opportunity.get_target_amount(): var target_storage := _get_target_storage(opportunity, pantry, woodpile)
if (
target_storage == null
or (
target_storage.get_amount(opportunity.get_resource_id())
< opportunity.get_target_amount()
)
):
return null return null
if not _is_valid_supply(event, npcs, resource_states): if not _is_valid_supply(event, opportunity, npcs, resource_states):
return null return null
if int(event.data["tick"]) < opportunity.get_created_tick(): if int(event.data["tick"]) < opportunity.get_created_tick():
return null return null
@@ -139,12 +230,29 @@ static func _is_food_withdrawal(event: EconomicEventRecord) -> bool:
) )
static func _is_missing_wood_block(event: EconomicEventRecord) -> bool:
return (
StringName(event.data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED
and int(event.data["actor_id"]) >= 0
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_WOOD
and (
StringName(event.data.get("action_id", &""))
in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]
)
and float(event.data.get("required_amount", 0.0)) > 0.0
)
static func _is_valid_supply( static func _is_valid_supply(
event: EconomicEventRecord, npcs: Array[SimNPC], resource_states: Dictionary event: EconomicEventRecord,
opportunity: OpportunityStateRecord,
npcs: Array[SimNPC],
resource_states: Dictionary
) -> bool: ) -> bool:
if ( if (
StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD StringName(event.data["item_id"]) != opportunity.get_resource_id()
or StringName(event.data["destination_id"]) != SimulationIds.STORAGE_VILLAGE_PANTRY or StringName(event.data["destination_id"]) != opportunity.get_target_id()
or float(event.data["amount"]) <= 0.0 or float(event.data["amount"]) <= 0.0
): ):
return false return false
@@ -162,11 +270,22 @@ static func _is_valid_supply(
) )
return ( return (
resource_state != null resource_state != null
and resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD and resource_state.get_resource_id() == opportunity.get_resource_id()
and resource_state.can_player_use_resource() and resource_state.can_player_use_resource()
) )
static func _get_target_storage(
opportunity: OpportunityStateRecord, pantry: StorageStateRecord, woodpile: StorageStateRecord
) -> StorageStateRecord:
match opportunity.get_target_id():
SimulationIds.STORAGE_VILLAGE_PANTRY:
return pantry
SimulationIds.STORAGE_VILLAGE_WOODPILE:
return woodpile
return null
static func _sort_by_care(first: SimNPC, second: SimNPC) -> bool: static func _sort_by_care(first: SimNPC, second: SimNPC) -> bool:
if first.hunger != second.hunger: if first.hunger != second.hunger:
return first.hunger > second.hunger return first.hunger > second.hunger
+22 -4
View File
@@ -1,7 +1,8 @@
class_name EconomicEventRecord class_name EconomicEventRecord
extends RefCounted extends RefCounted
const SCHEMA_VERSION := 2 const SCHEMA_VERSION := 3
const POSITION_LEGACY_SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
var data: Dictionary var data: Dictionary
@@ -33,6 +34,8 @@ static func create(
"destination_id": String(destination_id), "destination_id": String(destination_id),
"item_id": String(item_id), "item_id": String(item_id),
"amount": amount, "amount": amount,
"action_id": "",
"required_amount": 0.0,
"world_position": [world_position.x, world_position.y, world_position.z] "world_position": [world_position.x, world_position.y, world_position.z]
} }
) )
@@ -45,7 +48,10 @@ static func create_narrative(
actor_id: int, actor_id: int,
source_id: StringName, source_id: StringName,
action_display: String = "", action_display: String = "",
world_position: Vector3 = Vector3.ZERO world_position: Vector3 = Vector3.ZERO,
action_id: StringName = &"",
item_id: StringName = &"",
required_amount: float = 0.0
) -> EconomicEventRecord: ) -> EconomicEventRecord:
return EconomicEventRecord.new( return EconomicEventRecord.new(
{ {
@@ -56,9 +62,11 @@ static func create_narrative(
"actor_id": actor_id, "actor_id": actor_id,
"source_id": String(source_id), "source_id": String(source_id),
"destination_id": "", "destination_id": "",
"item_id": "", "item_id": String(item_id),
"amount": 0.0, "amount": 0.0,
"action_name": action_display, "action_name": action_display,
"action_id": String(action_id),
"required_amount": required_amount,
"world_position": [world_position.x, world_position.y, world_position.z] "world_position": [world_position.x, world_position.y, world_position.z]
} }
) )
@@ -66,7 +74,7 @@ static func create_narrative(
static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord: static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
var version := int(record_data.get("schema_version", -1)) var version := int(record_data.get("schema_version", -1))
if version not in [LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]: if version not in [LEGACY_SCHEMA_VERSION, POSITION_LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]:
return null return null
if not record_data.has_all( if not record_data.has_all(
[ [
@@ -93,6 +101,8 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
normalized["destination_id"] = String(record_data["destination_id"]) normalized["destination_id"] = String(record_data["destination_id"])
normalized["item_id"] = String(record_data["item_id"]) normalized["item_id"] = String(record_data["item_id"])
normalized["amount"] = float(record_data["amount"]) normalized["amount"] = float(record_data["amount"])
normalized["action_id"] = String(record_data.get("action_id", ""))
normalized["required_amount"] = float(record_data.get("required_amount", 0.0))
var saved_position = record_data.get("world_position", [0.0, 0.0, 0.0]) var saved_position = record_data.get("world_position", [0.0, 0.0, 0.0])
if not saved_position is Array or saved_position.size() != 3: if not saved_position is Array or saved_position.size() != 3:
return null return null
@@ -101,6 +111,14 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
] ]
if normalized["event_id"] < 0 or normalized["tick"] < 0 or normalized["event_type"].is_empty(): if normalized["event_id"] < 0 or normalized["tick"] < 0 or normalized["event_type"].is_empty():
return null return null
if StringName(normalized["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED:
var has_stable_contract: bool = (
not normalized["action_id"].is_empty()
and not normalized["item_id"].is_empty()
and normalized["required_amount"] > 0.0
)
if version == SCHEMA_VERSION and not has_stable_contract:
return null
return EconomicEventRecord.new(normalized) return EconomicEventRecord.new(normalized)
+111 -24
View File
@@ -1,11 +1,13 @@
class_name OpportunityStateRecord class_name OpportunityStateRecord
extends RefCounted extends RefCounted
const SCHEMA_VERSION := 1 const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
const NO_EVENT_ID := -1 const NO_EVENT_ID := -1
const DEFAULT_TARGET_AMOUNT := 1.0 const DEFAULT_TARGET_AMOUNT := 1.0
const STATUS_OPEN := SimulationIds.OPPORTUNITY_STATUS_OPEN const STATUS_OPEN := SimulationIds.OPPORTUNITY_STATUS_OPEN
const STATUS_RESOLVED := SimulationIds.OPPORTUNITY_STATUS_RESOLVED const STATUS_RESOLVED := SimulationIds.OPPORTUNITY_STATUS_RESOLVED
const STATUS_INVALIDATED := SimulationIds.OPPORTUNITY_STATUS_INVALIDATED
var data: Dictionary var data: Dictionary
@@ -19,7 +21,10 @@ static func create(
created_tick: int, created_tick: int,
trigger_event_id: int, trigger_event_id: int,
interested_npc_id: int, interested_npc_id: int,
target_amount: float = DEFAULT_TARGET_AMOUNT target_amount: float = DEFAULT_TARGET_AMOUNT,
opportunity_type: StringName = SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY,
target_id: StringName = SimulationIds.STORAGE_VILLAGE_PANTRY,
resource_id: StringName = SimulationIds.RESOURCE_FOOD
) -> OpportunityStateRecord: ) -> OpportunityStateRecord:
return ( return (
OpportunityStateRecord OpportunityStateRecord
@@ -27,26 +32,34 @@ static func create(
{ {
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"opportunity_id": opportunity_id, "opportunity_id": opportunity_id,
"opportunity_type": String(SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY), "opportunity_type": String(opportunity_type),
"status": String(STATUS_OPEN), "status": String(STATUS_OPEN),
"created_tick": created_tick, "created_tick": created_tick,
"trigger_event_id": trigger_event_id, "trigger_event_id": trigger_event_id,
"interested_npc_id": interested_npc_id, "interested_npc_id": interested_npc_id,
"target_id": String(SimulationIds.STORAGE_VILLAGE_PANTRY), "target_id": String(target_id),
"resource_id": String(SimulationIds.RESOURCE_FOOD), "resource_id": String(resource_id),
"target_amount": target_amount, "target_amount": target_amount,
"resolution_event_id": NO_EVENT_ID, "resolution_event_id": NO_EVENT_ID,
"resolved_tick": -1, "resolved_tick": -1,
"closed_tick": -1,
"invalidation_reason": "",
} }
) )
) )
static func from_dictionary(record_data: Dictionary) -> OpportunityStateRecord: static func from_dictionary(record_data: Dictionary) -> OpportunityStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: var version := int(record_data.get("schema_version", -1))
if version not in [LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]:
return null return null
var normalized := record_data.duplicate(true)
if version == LEGACY_SCHEMA_VERSION:
normalized["schema_version"] = SCHEMA_VERSION
normalized["closed_tick"] = int(record_data.get("resolved_tick", -1))
normalized["invalidation_reason"] = ""
if not ( if not (
record_data normalized
. has_all( . has_all(
[ [
"opportunity_id", "opportunity_id",
@@ -60,45 +73,91 @@ static func from_dictionary(record_data: Dictionary) -> OpportunityStateRecord:
"target_amount", "target_amount",
"resolution_event_id", "resolution_event_id",
"resolved_tick", "resolved_tick",
"closed_tick",
"invalidation_reason",
] ]
) )
): ):
return null return null
var opportunity_id := int(record_data["opportunity_id"]) var opportunity_id := int(normalized["opportunity_id"])
var opportunity_type := StringName(record_data["opportunity_type"]) var opportunity_type := StringName(normalized["opportunity_type"])
var status := StringName(record_data["status"]) var status := StringName(normalized["status"])
var created_tick := int(record_data["created_tick"]) var created_tick := int(normalized["created_tick"])
var trigger_event_id := int(record_data["trigger_event_id"]) var trigger_event_id := int(normalized["trigger_event_id"])
var interested_npc_id := int(record_data["interested_npc_id"]) var interested_npc_id := int(normalized["interested_npc_id"])
var target_id := StringName(record_data["target_id"]) var target_id := StringName(normalized["target_id"])
var resource_id := StringName(record_data["resource_id"]) var resource_id := StringName(normalized["resource_id"])
var target_amount := float(record_data["target_amount"]) var target_amount := float(normalized["target_amount"])
var resolution_event_id := int(record_data["resolution_event_id"]) var resolution_event_id := int(normalized["resolution_event_id"])
var resolved_tick := int(record_data["resolved_tick"]) var resolved_tick := int(normalized["resolved_tick"])
var closed_tick := int(normalized["closed_tick"])
var invalidation_reason := StringName(normalized["invalidation_reason"])
if opportunity_id < 0 or created_tick < 0 or trigger_event_id < 0 or interested_npc_id < 0: if opportunity_id < 0 or created_tick < 0 or trigger_event_id < 0 or interested_npc_id < 0:
return null return null
if opportunity_type != SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: match opportunity_type:
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY:
if (
target_id != SimulationIds.STORAGE_VILLAGE_PANTRY
or resource_id != SimulationIds.RESOURCE_FOOD
):
return null return null
if target_id != SimulationIds.STORAGE_VILLAGE_PANTRY: SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
if (
target_id != SimulationIds.STORAGE_VILLAGE_WOODPILE
or resource_id != SimulationIds.RESOURCE_WOOD
):
return null return null
if resource_id != SimulationIds.RESOURCE_FOOD: _:
return null return null
if not is_finite(target_amount) or not is_equal_approx(target_amount, DEFAULT_TARGET_AMOUNT): if not is_finite(target_amount) or not is_equal_approx(target_amount, DEFAULT_TARGET_AMOUNT):
return null return null
match status: match status:
STATUS_OPEN: STATUS_OPEN:
if resolution_event_id != NO_EVENT_ID or resolved_tick != -1: if (
resolution_event_id != NO_EVENT_ID
or resolved_tick != -1
or closed_tick != -1
or not invalidation_reason.is_empty()
):
return null return null
STATUS_RESOLVED: STATUS_RESOLVED:
if resolution_event_id <= trigger_event_id or resolved_tick < created_tick: if (
resolution_event_id <= trigger_event_id
or resolved_tick < created_tick
or closed_tick != resolved_tick
or not invalidation_reason.is_empty()
):
return null
STATUS_INVALIDATED:
if (
resolution_event_id != NO_EVENT_ID
or resolved_tick != -1
or closed_tick < created_tick
or (
invalidation_reason
not in [
SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED,
SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE,
]
)
):
return null return null
_: _:
return null return null
var record := create( var record := create(
opportunity_id, created_tick, trigger_event_id, interested_npc_id, target_amount opportunity_id,
created_tick,
trigger_event_id,
interested_npc_id,
target_amount,
opportunity_type,
target_id,
resource_id
) )
if status == STATUS_RESOLVED: if status == STATUS_RESOLVED:
record.resolve(resolution_event_id, resolved_tick) record.resolve(resolution_event_id, resolved_tick)
elif status == STATUS_INVALIDATED:
record.invalidate(invalidation_reason, closed_tick)
return record return record
@@ -112,6 +171,26 @@ func resolve(resolution_event_id: int, resolved_tick: int) -> bool:
data["status"] = String(STATUS_RESOLVED) data["status"] = String(STATUS_RESOLVED)
data["resolution_event_id"] = resolution_event_id data["resolution_event_id"] = resolution_event_id
data["resolved_tick"] = resolved_tick data["resolved_tick"] = resolved_tick
data["closed_tick"] = resolved_tick
return true
func invalidate(reason: StringName, closed_tick: int) -> bool:
if (
not is_open()
or closed_tick < get_created_tick()
or (
reason
not in [
SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED,
SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE,
]
)
):
return false
data["status"] = String(STATUS_INVALIDATED)
data["closed_tick"] = closed_tick
data["invalidation_reason"] = String(reason)
return true return true
@@ -159,6 +238,14 @@ func get_resolved_tick() -> int:
return int(data["resolved_tick"]) return int(data["resolved_tick"])
func get_closed_tick() -> int:
return int(data["closed_tick"])
func get_invalidation_reason() -> StringName:
return StringName(data["invalidation_reason"])
func is_open() -> bool: func is_open() -> bool:
return get_status() == STATUS_OPEN return get_status() == STATUS_OPEN
+75 -12
View File
@@ -2,13 +2,14 @@ class_name SimulationStateRecord
extends RefCounted extends RefCounted
const SCHEMA_NAME := "the_steward.simulation" const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 8 const SCHEMA_VERSION := 9
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const EVENT_LEGACY_SCHEMA_VERSION := 2 const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4 const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4
const PROVENANCE_LEGACY_SCHEMA_VERSION := 5 const PROVENANCE_LEGACY_SCHEMA_VERSION := 5
const RETENTION_LEGACY_SCHEMA_VERSION := 6 const RETENTION_LEGACY_SCHEMA_VERSION := 6
const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8
const PREVIOUS_SCHEMA_VERSION := 7 const PREVIOUS_SCHEMA_VERSION := 7
var simulation: Dictionary var simulation: Dictionary
@@ -86,6 +87,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
PROVENANCE_LEGACY_SCHEMA_VERSION, PROVENANCE_LEGACY_SCHEMA_VERSION,
RETENTION_LEGACY_SCHEMA_VERSION, RETENTION_LEGACY_SCHEMA_VERSION,
PREVIOUS_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION,
OPPORTUNITY_LEGACY_SCHEMA_VERSION,
] ]
): ):
record_data = _migrate_legacy(record_data, version) record_data = _migrate_legacy(record_data, version)
@@ -185,6 +187,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
record.simulation = simulation_data.duplicate(true) record.simulation = simulation_data.duplicate(true)
record.village = village_record record.village = village_record
var npc_ids := {} var npc_ids := {}
var npc_records_by_id := {}
for item in npc_data: for item in npc_data:
if not item is Dictionary: if not item is Dictionary:
@@ -196,6 +199,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if npc_ids.has(npc_id): if npc_ids.has(npc_id):
return null return null
npc_ids[npc_id] = true npc_ids[npc_id] = true
npc_records_by_id[npc_id] = npc_record
record.npcs.append(npc_record) record.npcs.append(npc_record)
var resource_ids := {} var resource_ids := {}
@@ -337,6 +341,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if not _is_valid_opportunity( if not _is_valid_opportunity(
opportunity_record, opportunity_record,
npc_ids, npc_ids,
npc_records_by_id,
storage_records_by_id, storage_records_by_id,
event_records_by_id, event_records_by_id,
knowledge_records_by_key, knowledge_records_by_key,
@@ -398,19 +403,16 @@ static func _is_valid_knowledge_provenance(
static func _is_valid_opportunity( static func _is_valid_opportunity(
opportunity: OpportunityStateRecord, opportunity: OpportunityStateRecord,
npc_ids: Dictionary, npc_ids: Dictionary,
npc_records_by_id: Dictionary,
storage_records_by_id: Dictionary, storage_records_by_id: Dictionary,
event_records_by_id: Dictionary, event_records_by_id: Dictionary,
knowledge_records_by_key: Dictionary, knowledge_records_by_key: Dictionary,
resource_records_by_id: Dictionary, resource_records_by_id: Dictionary,
current_tick: int current_tick: int
) -> bool: ) -> bool:
if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY:
return false
if ( if (
not npc_ids.has(opportunity.get_interested_npc_id()) not npc_ids.has(opportunity.get_interested_npc_id())
or opportunity.get_target_id() != SimulationIds.STORAGE_VILLAGE_PANTRY
or not storage_records_by_id.has(opportunity.get_target_id()) or not storage_records_by_id.has(opportunity.get_target_id())
or opportunity.get_resource_id() != SimulationIds.RESOURCE_FOOD
or not is_finite(opportunity.get_target_amount()) or not is_finite(opportunity.get_target_amount())
or opportunity.get_target_amount() <= 0.0 or opportunity.get_target_amount() <= 0.0
): ):
@@ -418,7 +420,16 @@ static func _is_valid_opportunity(
var trigger_event := ( var trigger_event := (
event_records_by_id.get(opportunity.get_trigger_event_id()) as EconomicEventRecord event_records_by_id.get(opportunity.get_trigger_event_id()) as EconomicEventRecord
) )
if trigger_event == null or not _is_valid_pantry_empty_trigger(trigger_event, npc_ids): if trigger_event == null:
return false
match opportunity.get_opportunity_type():
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY:
if not _is_valid_pantry_empty_trigger(trigger_event, npc_ids):
return false
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
if not _is_valid_missing_wood_trigger(trigger_event, opportunity, npc_ids):
return false
_:
return false return false
var trigger_tick := int(trigger_event.data["tick"]) var trigger_tick := int(trigger_event.data["tick"])
var created_tick := opportunity.get_created_tick() var created_tick := opportunity.get_created_tick()
@@ -428,6 +439,9 @@ static func _is_valid_opportunity(
var status := opportunity.get_status() var status := opportunity.get_status()
var resolution_event_id := opportunity.get_resolution_event_id() var resolution_event_id := opportunity.get_resolution_event_id()
var resolved_tick := opportunity.get_resolved_tick() var resolved_tick := opportunity.get_resolved_tick()
var interested_record := (
npc_records_by_id.get(opportunity.get_interested_npc_id()) as NPCStateRecord
)
if status == OpportunityStateRecord.STATUS_OPEN: if status == OpportunityStateRecord.STATUS_OPEN:
var target_storage := ( var target_storage := (
storage_records_by_id.get(opportunity.get_target_id()) as StorageStateRecord storage_records_by_id.get(opportunity.get_target_id()) as StorageStateRecord
@@ -439,6 +453,11 @@ static func _is_valid_opportunity(
return ( return (
resolution_event_id == OpportunityStateRecord.NO_EVENT_ID resolution_event_id == OpportunityStateRecord.NO_EVENT_ID
and resolved_tick == -1 and resolved_tick == -1
and interested_record != null
and (
opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD
or not bool(interested_record.data["is_dead"])
)
and target_storage != null and target_storage != null
and ( and (
target_storage.get_amount(opportunity.get_resource_id()) target_storage.get_amount(opportunity.get_resource_id())
@@ -447,6 +466,24 @@ static func _is_valid_opportunity(
and evidence != null and evidence != null
and evidence.get_acquired_tick() <= created_tick and evidence.get_acquired_tick() <= created_tick
) )
if status == OpportunityStateRecord.STATUS_INVALIDATED:
if (
opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD
or resolution_event_id != OpportunityStateRecord.NO_EVENT_ID
or resolved_tick != -1
or opportunity.get_closed_tick() < created_tick
or opportunity.get_closed_tick() > current_tick
):
return false
if (
opportunity.get_invalidation_reason()
== SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED
):
return interested_record != null and bool(interested_record.data["is_dead"])
return (
opportunity.get_invalidation_reason()
== SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE
)
if status != OpportunityStateRecord.STATUS_RESOLVED: if status != OpportunityStateRecord.STATUS_RESOLVED:
return false return false
if resolution_event_id == OpportunityStateRecord.NO_EVENT_ID: if resolution_event_id == OpportunityStateRecord.NO_EVENT_ID:
@@ -454,7 +491,9 @@ static func _is_valid_opportunity(
var resolution_event := event_records_by_id.get(resolution_event_id) as EconomicEventRecord var resolution_event := event_records_by_id.get(resolution_event_id) as EconomicEventRecord
if ( if (
resolution_event == null resolution_event == null
or not _is_valid_pantry_supply_resolution(resolution_event, npc_ids, resource_records_by_id) or not _is_valid_supply_resolution(
resolution_event, opportunity, npc_ids, resource_records_by_id
)
): ):
return false return false
var resolution_tick := int(resolution_event.data["tick"]) var resolution_tick := int(resolution_event.data["tick"])
@@ -466,6 +505,26 @@ static func _is_valid_opportunity(
) )
static func _is_valid_missing_wood_trigger(
event: EconomicEventRecord, opportunity: OpportunityStateRecord, npc_ids: Dictionary
) -> bool:
var actor_id := int(event.data["actor_id"])
return (
npc_ids.has(actor_id)
and actor_id == opportunity.get_interested_npc_id()
and StringName(event.data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_WOOD
and (
StringName(event.data.get("action_id", &""))
in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]
)
and is_equal_approx(
float(event.data.get("required_amount", 0.0)), opportunity.get_target_amount()
)
)
static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids: Dictionary) -> bool: static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids: Dictionary) -> bool:
var actor_id := int(event.data["actor_id"]) var actor_id := int(event.data["actor_id"])
return ( return (
@@ -478,14 +537,17 @@ static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids:
) )
static func _is_valid_pantry_supply_resolution( static func _is_valid_supply_resolution(
event: EconomicEventRecord, npc_ids: Dictionary, resource_records_by_id: Dictionary event: EconomicEventRecord,
opportunity: OpportunityStateRecord,
npc_ids: Dictionary,
resource_records_by_id: Dictionary
) -> bool: ) -> bool:
var actor_id := int(event.data["actor_id"]) var actor_id := int(event.data["actor_id"])
var event_type := StringName(event.data["event_type"]) var event_type := StringName(event.data["event_type"])
if ( if (
StringName(event.data["destination_id"]) != SimulationIds.STORAGE_VILLAGE_PANTRY StringName(event.data["destination_id"]) != opportunity.get_target_id()
or StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD or StringName(event.data["item_id"]) != opportunity.get_resource_id()
or float(event.data["amount"]) <= 0.0 or float(event.data["amount"]) <= 0.0
): ):
return false return false
@@ -500,7 +562,7 @@ static func _is_valid_pantry_supply_resolution(
) )
return ( return (
resource_state != null resource_state != null
and resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD and resource_state.get_resource_id() == opportunity.get_resource_id()
and resource_state.can_player_use_resource() and resource_state.can_player_use_resource()
) )
return false return false
@@ -559,6 +621,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
migrated.get("economic_events", []), migrated.get("economic_events", []),
int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0)) int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0))
) )
if version != OPPORTUNITY_LEGACY_SCHEMA_VERSION:
migrated["opportunities"] = [] migrated["opportunities"] = []
var opportunity_simulation_data: Dictionary = migrated.get("simulation", {}) var opportunity_simulation_data: Dictionary = migrated.get("simulation", {})
opportunity_simulation_data["next_opportunity_id"] = 0 opportunity_simulation_data["next_opportunity_id"] = 0
+1 -1
View File
@@ -105,7 +105,7 @@ func _run() -> void:
var active_restored := _create_manager(902) var active_restored := _create_manager(902)
_check( _check(
active_restored.restore_state_from_json(active_json), active_restored.restore_state_from_json(active_json),
"An active opportunity should survive schema-v8 save and restore" "An active opportunity should survive schema-v9 save and restore"
) )
_check( _check(
( (
+38 -1
View File
@@ -235,9 +235,10 @@ func _run() -> void:
var old_contributor_visual := contributor_visual var old_contributor_visual := contributor_visual
_check( _check(
simulation_manager.restore_state_from_json(active_need_json), simulation_manager.restore_state_from_json(active_need_json),
"The visible shortage should survive a valid schema-v8 restore" "The visible shortage should survive a valid schema-v9 restore"
) )
await process_frame await process_frame
contributor = simulation_manager.npcs[contributor.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(
@@ -270,6 +271,42 @@ func _run() -> void:
), ),
"Player gathering should resolve the same need and be named from its real event" "Player gathering should resolve the same need and be named from its real event"
) )
var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile()
woodpile_state.withdraw(
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
)
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
contributor.start_working()
simulation_manager.simulate_tick()
village_ui.selected_npc_index = contributor.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"◆ Supply wood for blocked work" in village_stats_label.text
and "could not finish Study" in village_stats_label.text
and "◆ Find wood for Study" in inspector_label.text
and not contributor_visual.get_node("OpportunityConcernRoot").visible
),
"A real missing-wood fact should surface through wood-specific UI without a food cue"
)
contributor.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
_check(
is_equal_approx(
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_WOOD),
1.0
),
"Runtime opportunity presentation should resolve from a real wood deposit"
)
village_ui.call("_refresh_npc_inspector")
_check(
(
"Village need" not in village_stats_label.text
and "%s supplied the woodpile" % contributor.npc_name in inspector_label.text
),
"The resolved wood need should name its exact supplier and leave the active summary"
)
_check( _check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"), main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
"Playable runtime should instance the Jajce Terrain3D world" "Playable runtime should instance the Jajce Terrain3D world"
@@ -457,6 +457,27 @@ func _test_previous_world_opportunity_migration() -> void:
), ),
"World v7 migration should initialize deterministic opportunity identity" "World v7 migration should initialize deterministic opportunity identity"
) )
var v8_data: Dictionary = _build_opportunity_world(manager, false)
v8_data["schema_version"] = SimulationStateRecord.OPPORTUNITY_LEGACY_SCHEMA_VERSION
var legacy_opportunity: Dictionary = v8_data["opportunities"][0]
legacy_opportunity["schema_version"] = OpportunityStateRecord.LEGACY_SCHEMA_VERSION
legacy_opportunity.erase("closed_tick")
legacy_opportunity.erase("invalidation_reason")
for event_data in v8_data["economic_events"]:
event_data["schema_version"] = EconomicEventRecord.POSITION_LEGACY_SCHEMA_VERSION
event_data.erase("action_id")
event_data.erase("required_amount")
var migrated_v8 := SimulationStateRecord.from_dictionary(v8_data)
_check(migrated_v8 != null, "World schema v8 should preserve its pantry opportunity history")
if migrated_v8 != null:
_check(
(
migrated_v8.opportunities.size() == 1
and migrated_v8.opportunities[0].get_status() == OpportunityStateRecord.STATUS_OPEN
and migrated_v8.opportunities[0].get_closed_tick() == -1
),
"World v8 migration should normalize the nested opportunity lifecycle fields"
)
manager.free() manager.free()
@@ -1,12 +1,12 @@
extends GutTest extends GutTest
const FoodShortageOpportunitySystemScript := preload( const VillageOpportunitySystemScript := preload(
"res://simulation/opportunities/FoodShortageOpportunitySystem.gd" "res://simulation/opportunities/VillageOpportunitySystem.gd"
) )
func test_opening_requires_current_knowledge_and_care() -> void: func test_opening_requires_current_knowledge_and_care() -> void:
var system := FoodShortageOpportunitySystemScript.new() var system := VillageOpportunitySystemScript.new()
var pantry := _empty_pantry() var pantry := _empty_pantry()
var npc := _npc(0, 90.0) var npc := _npc(0, 90.0)
var npcs: Array[SimNPC] = [npc] var npcs: Array[SimNPC] = [npc]
@@ -15,14 +15,14 @@ func test_opening_requires_current_knowledge_and_care() -> void:
assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {})) assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {}))
_remember(knowledge, npc.id, 4, 8) _remember(knowledge, npc.id, 4, 8)
npc.hunger = FoodShortageOpportunitySystem.CARE_HUNGER_THRESHOLD - 0.01 npc.hunger = VillageOpportunitySystem.CARE_HUNGER_THRESHOLD - 0.01
assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {})) assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {}))
assert_true(system.get_all_sorted().is_empty()) assert_true(system.get_all_sorted().is_empty())
assert_eq(system.next_opportunity_id, 0) assert_eq(system.next_opportunity_id, 0)
func test_highest_hunger_then_lowest_id_deterministically_owns_the_opportunity() -> void: func test_highest_hunger_then_lowest_id_deterministically_owns_the_opportunity() -> void:
var system := FoodShortageOpportunitySystemScript.new() var system := VillageOpportunitySystemScript.new()
var pantry := _empty_pantry() var pantry := _empty_pantry()
var lower_hunger := _npc(1, 85.0) var lower_hunger := _npc(1, 85.0)
var high_id := _npc(5, 92.0) var high_id := _npc(5, 92.0)
@@ -58,7 +58,7 @@ func test_highest_hunger_then_lowest_id_deterministically_owns_the_opportunity()
func test_near_equal_hunger_still_uses_the_exact_highest_value() -> void: func test_near_equal_hunger_still_uses_the_exact_highest_value() -> void:
var system := FoodShortageOpportunitySystemScript.new() var system := VillageOpportunitySystemScript.new()
var pantry := _empty_pantry() var pantry := _empty_pantry()
var lower_id := _npc(1, 92.0) var lower_id := _npc(1, 92.0)
var slightly_hungrier := _npc(9, 92.000001) var slightly_hungrier := _npc(9, 92.000001)
@@ -77,7 +77,7 @@ func test_near_equal_hunger_still_uses_the_exact_highest_value() -> void:
func test_late_knowledge_can_open_old_event_and_open_opportunity_dedupes() -> void: func test_late_knowledge_can_open_old_event_and_open_opportunity_dedupes() -> void:
var system := FoodShortageOpportunitySystemScript.new() var system := VillageOpportunitySystemScript.new()
var pantry := _empty_pantry() var pantry := _empty_pantry()
var npc := _npc(3, 88.0) var npc := _npc(3, 88.0)
var npcs: Array[SimNPC] = [npc] var npcs: Array[SimNPC] = [npc]
@@ -101,7 +101,7 @@ func test_late_knowledge_can_open_old_event_and_open_opportunity_dedupes() -> vo
func test_only_strict_later_npc_deposit_resolves_without_mutating_supply() -> void: func test_only_strict_later_npc_deposit_resolves_without_mutating_supply() -> void:
var fixture := _open_fixture() var fixture := _open_fixture()
var system: FoodShortageOpportunitySystem = fixture["system"] var system: VillageOpportunitySystem = fixture["system"]
var pantry: StorageStateRecord = fixture["pantry"] var pantry: StorageStateRecord = fixture["pantry"]
var npcs: Array[SimNPC] = fixture["npcs"] var npcs: Array[SimNPC] = fixture["npcs"]
var knowledge: EventKnowledgeSystem = fixture["knowledge"] var knowledge: EventKnowledgeSystem = fixture["knowledge"]
@@ -161,7 +161,7 @@ func test_only_strict_later_npc_deposit_resolves_without_mutating_supply() -> vo
func test_player_extraction_into_pantry_is_the_other_valid_resolution() -> void: func test_player_extraction_into_pantry_is_the_other_valid_resolution() -> void:
var fixture := _open_fixture() var fixture := _open_fixture()
var system: FoodShortageOpportunitySystem = fixture["system"] var system: VillageOpportunitySystem = fixture["system"]
var pantry: StorageStateRecord = fixture["pantry"] var pantry: StorageStateRecord = fixture["pantry"]
var npcs: Array[SimNPC] = fixture["npcs"] var npcs: Array[SimNPC] = fixture["npcs"]
var knowledge: EventKnowledgeSystem = fixture["knowledge"] var knowledge: EventKnowledgeSystem = fixture["knowledge"]
@@ -179,11 +179,24 @@ func test_player_extraction_into_pantry_is_the_other_valid_resolution() -> void:
assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED) assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED)
func test_missing_wood_maintenance_does_not_change_the_existing_food_lifecycle() -> void:
var fixture := _open_fixture()
var system: VillageOpportunitySystem = fixture["system"]
var pantry: StorageStateRecord = fixture["pantry"]
var npcs: Array[SimNPC] = fixture["npcs"]
var food_need: OpportunityStateRecord = system.get_open_opportunity()
npcs[0].is_dead = true
assert_null(system.maintain_open_opportunity(1000, 10, npcs, pantry, null))
assert_eq(food_need.get_status(), OpportunityStateRecord.STATUS_OPEN)
assert_eq(food_need.get_closed_tick(), -1)
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)
var restored_records: Array[OpportunityStateRecord] = [newer, older] var restored_records: Array[OpportunityStateRecord] = [newer, older]
var system := FoodShortageOpportunitySystemScript.new() var system := VillageOpportunitySystemScript.new()
system.restore(restored_records, 6) system.restore(restored_records, 6)
assert_eq(system.next_opportunity_id, 6) assert_eq(system.next_opportunity_id, 6)
@@ -229,7 +242,7 @@ func test_record_parser_rejects_malformed_state_combinations() -> void:
func _open_fixture() -> Dictionary: func _open_fixture() -> Dictionary:
var system := FoodShortageOpportunitySystemScript.new() var system := VillageOpportunitySystemScript.new()
var pantry := _empty_pantry() var pantry := _empty_pantry()
var npc := _npc(0, 90.0) var npc := _npc(0, 90.0)
var npcs: Array[SimNPC] = [npc] var npcs: Array[SimNPC] = [npc]
+318
View File
@@ -0,0 +1,318 @@
extends SceneTree
var failures: Array[String] = []
var invalidated_ids: Array[int] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager(1201)
manager.opportunity_invalidated.connect(_on_opportunity_invalidated)
var actor: SimNPC = manager.npcs[0]
var witness: SimNPC = manager.npcs[1]
var supplier: SimNPC = manager.npcs[2]
actor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0)
supplier.position = Vector3(30.0, 0.0, 0.0)
_set_wood_amount(manager, 0.5)
var safety_before: float = manager.village.safety
var event_count_before: int = manager.economic_events.size()
actor.set_task(SimulationIds.ACTION_PATROL, 1.0)
actor.start_working()
manager.simulate_tick()
var trigger := _latest_event_of_type(manager, SimulationIds.EVENT_TASK_BLOCKED, actor.id)
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
_check(trigger != null, "Missing wood should create one objective task_blocked fact")
_check(opportunity != null, "The performer's known blocked-work fact should open one need")
if trigger == null or opportunity == null:
manager.free()
_finish()
return
var trigger_id := int(trigger.data["event_id"])
_check(
(
StringName(trigger.data.get("action_id", &"")) == SimulationIds.ACTION_PATROL
and StringName(trigger.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE
and StringName(trigger.data["item_id"]) == SimulationIds.RESOURCE_WOOD
and is_equal_approx(float(trigger.data.get("required_amount", 0.0)), 1.0)
and is_equal_approx(float(trigger.data["amount"]), 0.0)
),
"The blocked fact should retain stable action, storage, resource, and requirement fields"
)
_check(
(
is_equal_approx(manager.village.safety, safety_before)
and is_equal_approx(manager.get_woodpile().get_amount(SimulationIds.RESOURCE_WOOD), 0.5)
and manager.economic_events.size() == event_count_before + 1
),
"Blocked work should apply no effect and opening should add no quest-only event"
)
_check(
(
manager.npc_knows_event(actor.id, trigger_id)
and manager.npc_knows_event(witness.id, trigger_id)
and opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD
and opportunity.get_interested_npc_id() == actor.id
and opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_WOODPILE
and opportunity.get_resource_id() == SimulationIds.RESOURCE_WOOD
and is_equal_approx(opportunity.get_target_amount(), 1.0)
and manager.is_known_event_lasting(actor.id, trigger_id)
),
"The performer and nearby witness should know the exact fact while its owner retains it"
)
var active_json: String = manager.serialize_state()
var active_restored := _create_manager(1202)
_check(
active_restored.restore_state_from_json(active_json),
"An active missing-wood need should survive schema-v9 restore"
)
_check(
(
active_restored.get_state_checksum() == manager.get_state_checksum()
and active_restored.get_active_opportunity() != null
and active_restored.is_known_event_lasting(actor.id, trigger_id)
),
"Active restore should preserve identity, checksum, and protected evidence"
)
active_restored.free()
supplier.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
_check(
is_equal_approx(
manager.economy.deposit_inventory(supplier, SimulationIds.RESOURCE_WOOD), 1.0
),
"A real NPC inventory deposit should supply the woodpile"
)
var resolved: OpportunityStateRecord = manager.get_latest_opportunity_for_npc(actor.id)
var resolution_event: EconomicEventRecord = manager.get_opportunity_resolution_event(resolved)
_check(
(
resolved != null
and resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and manager.get_active_opportunity() == null
and resolution_event != null
and (
StringName(resolution_event.data["event_type"])
== SimulationIds.EVENT_STORAGE_DEPOSITED
)
and StringName(resolution_event.data["item_id"]) == SimulationIds.RESOURCE_WOOD
and resolved.get_resolution_event_id() == int(resolution_event.data["event_id"])
and not manager.is_known_event_lasting(actor.id, trigger_id)
),
"The exact later wood deposit should resolve the need and release its trigger memory"
)
var resolved_json: String = manager.serialize_state()
var resolved_restored := _create_manager(1203)
_check(
resolved_restored.restore_state_from_json(resolved_json),
"Resolved missing-wood history should restore"
)
_check(
resolved_restored.get_state_checksum() == manager.get_state_checksum(),
"Resolved restore should preserve the deterministic checksum"
)
resolved_restored.free()
manager.free()
var player_manager := _create_manager(1204)
var player_actor := _open_missing_wood_need(player_manager)
var tree := _create_player_tree()
root.add_child(tree)
await process_frame
_check(player_manager.register_resource_node(tree), "Player branch should bind a real tree")
_check(
is_equal_approx(player_manager.harvest_resource_node(tree), 1.0),
"Player tree extraction should supply the authoritative woodpile"
)
var player_resolved: OpportunityStateRecord = player_manager.get_latest_opportunity_for_npc(
player_actor.id
)
var player_event: EconomicEventRecord = player_manager.get_opportunity_resolution_event(
player_resolved
)
_check(
(
player_resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED
and (
StringName(player_event.data["event_type"])
== SimulationIds.EVENT_RESOURCE_EXTRACTED
)
and int(player_event.data["actor_id"]) == -1
and (
StringName(player_event.data["destination_id"])
== SimulationIds.STORAGE_VILLAGE_WOODPILE
)
),
"Player extraction should resolve through its exact existing economy event"
)
tree.queue_free()
player_manager.free()
invalidated_ids.clear()
var death_manager := _create_manager(1205)
death_manager.opportunity_invalidated.connect(_on_opportunity_invalidated)
var doomed := _open_missing_wood_need(death_manager)
var death_opportunity: OpportunityStateRecord = death_manager.get_active_opportunity()
var death_trigger_id := death_opportunity.get_trigger_event_id()
doomed.hunger = 100.0
doomed.is_starving = true
doomed.starvation_ticks = doomed.starvation_death_threshold - 1
death_manager.simulate_tick()
_check(
(
doomed.is_dead
and death_opportunity.get_status() == OpportunityStateRecord.STATUS_INVALIDATED
and (
death_opportunity.get_invalidation_reason()
== SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED
)
and death_opportunity.get_closed_tick() == death_manager.tick_count
and invalidated_ids == [death_opportunity.get_opportunity_id()]
and not death_manager.is_known_event_lasting(doomed.id, death_trigger_id)
),
"Interested-party death should deterministically invalidate once and release evidence"
)
var death_json: String = death_manager.serialize_state()
var death_restored := _create_manager(1206)
_check(
death_restored.restore_state_from_json(death_json),
"Death-invalidated opportunity history should restore"
)
death_restored.free()
death_manager.free()
var stale_manager := _create_manager(1207)
var stale_actor := _open_missing_wood_need(stale_manager)
var stale_opportunity: OpportunityStateRecord = stale_manager.get_active_opportunity()
var review_interval: int = stale_manager.get_knowledge_review_interval()
_check(
(
stale_manager.opportunity_system.maintain_open_opportunity(
stale_opportunity.get_created_tick() + review_interval - 1,
review_interval,
stale_manager.npcs,
stale_manager.get_pantry(),
stale_manager.get_woodpile()
)
== null
),
"The need should remain open until the exact daily staleness boundary"
)
var stale_closed: OpportunityStateRecord = (
stale_manager
. opportunity_system
. maintain_open_opportunity(
stale_opportunity.get_created_tick() + review_interval,
review_interval,
stale_manager.npcs,
stale_manager.get_pantry(),
stale_manager.get_woodpile()
)
)
_check(
(
stale_closed == stale_opportunity
and stale_closed.get_status() == OpportunityStateRecord.STATUS_INVALIDATED
and (
stale_closed.get_invalidation_reason()
== SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE
)
and not stale_manager.is_known_event_lasting(
stale_actor.id, stale_closed.get_trigger_event_id()
)
),
"Unresolved evidence should expire deterministically at one simulated day"
)
stale_manager.free()
_finish()
func _open_missing_wood_need(manager: Node) -> SimNPC:
var actor: SimNPC = manager.npcs[0]
actor.position = Vector3.ZERO
_set_wood_amount(manager, 0.0)
actor.set_task(SimulationIds.ACTION_STUDY, 1.0)
actor.start_working()
manager.simulate_tick()
_check(manager.get_active_opportunity() != null, "Setup should open a missing-wood need")
return actor
func _set_wood_amount(manager: Node, amount: float) -> void:
var woodpile: StorageStateRecord = manager.get_woodpile()
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD))
woodpile.deposit(SimulationIds.RESOURCE_WOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
func _latest_event_of_type(
manager: Node, event_type: StringName, actor_id: int
) -> EconomicEventRecord:
var events: Array = manager.get_npc_events(actor_id, 8)
for index in range(events.size() - 1, -1, -1):
var event := events[index] as EconomicEventRecord
if StringName(event.data["event_type"]) == event_type:
return event
return null
func _create_player_tree() -> ResourceNode:
var tree := ResourceNode.new()
tree.name = "OpportunityTree"
tree.node_id = &"opportunity_tree"
tree.action_id = SimulationIds.ACTION_GATHER_WOOD
tree.resource_id = SimulationIds.RESOURCE_WOOD
tree.initial_amount = 1.0
tree.yield_per_action = 1.0
tree.can_player_use = true
tree.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
tree.add_child(interaction_point)
return tree
func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(2.0, 0.0, 0.0),
Vector3(20.0, 0.0, 0.0),
Vector3(30.0, 0.0, 0.0),
Vector3(40.0, 0.0, 0.0),
Vector3(50.0, 0.0, 0.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
for npc in manager.npcs:
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
npc.start_working()
return manager
func _on_opportunity_invalidated(opportunity: OpportunityStateRecord) -> void:
invalidated_ids.append(opportunity.get_opportunity_id())
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Missing wood opportunity passed: blocked work -> supply or invalidation")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://dfxcllud4ghy0
+59 -8
View File
@@ -37,6 +37,8 @@ func _ready() -> void:
simulation_manager.opportunity_opened.connect(_on_opportunity_opened) simulation_manager.opportunity_opened.connect(_on_opportunity_opened)
if simulation_manager.has_signal("opportunity_resolved"): if simulation_manager.has_signal("opportunity_resolved"):
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved) simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
if simulation_manager.has_signal("opportunity_invalidated"):
simulation_manager.opportunity_invalidated.connect(_on_opportunity_invalidated)
if "village" in simulation_manager: if "village" in simulation_manager:
_on_village_changed(simulation_manager.village) _on_village_changed(simulation_manager.village)
@@ -158,6 +160,11 @@ func _on_opportunity_resolved(
_refresh_npc_inspector() _refresh_npc_inspector()
func _on_opportunity_invalidated(_opportunity: RefCounted) -> void:
_refresh_village_display()
_refresh_npc_inspector()
func _refresh_village_display() -> void: func _refresh_village_display() -> void:
if simulation_manager != null and "village" in simulation_manager: if simulation_manager != null and "village" in simulation_manager:
_on_village_changed(simulation_manager.village) _on_village_changed(simulation_manager.village)
@@ -256,10 +263,15 @@ func _build_active_opportunity_display() -> String:
return "" return ""
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 := 0.0 var current_amount := _get_opportunity_current_amount(opportunity)
var pantry: StorageStateRecord = simulation_manager.get_pantry() if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
if pantry != null: var action_name := _get_opportunity_action_name(opportunity)
current_amount = pantry.get_amount(opportunity.get_resource_id()) return (
"\n\nVillage need\n"
+ "◆ Supply wood for blocked work\n"
+ "%s could not finish %s\n" % [interested_name, action_name]
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
)
return ( return (
"\n\nVillage need\n" "\n\nVillage need\n"
+ "◆ Restock the empty pantry\n" + "◆ Restock the empty pantry\n"
@@ -276,10 +288,16 @@ func _build_npc_opportunity_display(npc: SimNPC) -> String:
return "" return ""
var resource_name := String(opportunity.get_resource_id()).capitalize() var resource_name := String(opportunity.get_resource_id()).capitalize()
if opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN: if opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN:
var pantry: StorageStateRecord = simulation_manager.get_pantry() var current_amount := _get_opportunity_current_amount(opportunity)
var current_amount := 0.0 if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
if pantry != null: return (
current_amount = pantry.get_amount(opportunity.get_resource_id()) "Open village need\n"
+ "◆ Find wood for %s\n" % _get_opportunity_action_name(opportunity)
+ (
"%s %.0f / %.0f\n\n"
% [resource_name, current_amount, opportunity.get_target_amount()]
)
)
return ( return (
"Open village need\n" "Open village need\n"
+ "◆ Restock the empty pantry\n" + "◆ Restock the empty pantry\n"
@@ -288,6 +306,16 @@ func _build_npc_opportunity_display(npc: SimNPC) -> String:
% [resource_name, current_amount, opportunity.get_target_amount()] % [resource_name, current_amount, opportunity.get_target_amount()]
) )
) )
if opportunity.get_status() == OpportunityStateRecord.STATUS_INVALIDATED:
var reason := (
"The interested villager died"
if (
opportunity.get_invalidation_reason()
== SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED
)
else "The evidence became stale"
)
return "Closed village need\n%s\n\n" % reason
if opportunity.get_status() != OpportunityStateRecord.STATUS_RESOLVED: if opportunity.get_status() != OpportunityStateRecord.STATUS_RESOLVED:
return "" return ""
var resolution_event: EconomicEventRecord = simulation_manager.get_opportunity_resolution_event( var resolution_event: EconomicEventRecord = simulation_manager.get_opportunity_resolution_event(
@@ -297,6 +325,12 @@ func _build_npc_opportunity_display(npc: SimNPC) -> String:
if resolution_event != null: if resolution_event != null:
var actor_id := int(resolution_event.data["actor_id"]) var actor_id := int(resolution_event.data["actor_id"])
actor_name = "Player" if actor_id < 0 else _get_npc_name(actor_id) actor_name = "Player" if actor_id < 0 else _get_npc_name(actor_id)
if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
return (
"Resolved village need\n"
+ "%s supplied the woodpile\n" % actor_name
+ "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()]
)
return ( return (
"Resolved village need\n" "Resolved village need\n"
+ "%s restocked the pantry\n" % actor_name + "%s restocked the pantry\n" % actor_name
@@ -304,6 +338,23 @@ func _build_npc_opportunity_display(npc: SimNPC) -> String:
) )
func _get_opportunity_current_amount(opportunity: OpportunityStateRecord) -> float:
var storage: StorageStateRecord
match opportunity.get_target_id():
SimulationIds.STORAGE_VILLAGE_PANTRY:
storage = simulation_manager.get_pantry()
SimulationIds.STORAGE_VILLAGE_WOODPILE:
storage = simulation_manager.get_woodpile()
return storage.get_amount(opportunity.get_resource_id()) if storage != null else 0.0
func _get_opportunity_action_name(opportunity: OpportunityStateRecord) -> String:
var trigger: EconomicEventRecord = simulation_manager.get_opportunity_trigger_event(opportunity)
if trigger == null:
return "work"
return _get_action_name(StringName(trigger.data.get("action_id", &"")))
func _get_npc_name(npc_id: int) -> String: func _get_npc_name(npc_id: int) -> String:
for npc in simulation_manager.npcs: for npc in simulation_manager.npcs:
if npc.id == npc_id: if npc.id == npc_id:
+15 -2
View File
@@ -57,6 +57,8 @@ func initialize_world_view() -> void:
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved) simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
else: else:
push_error("WorldViewManager: SimulationManager has no opportunity_resolved signal") push_error("WorldViewManager: SimulationManager has no opportunity_resolved signal")
if simulation_manager.has_signal("opportunity_invalidated"):
simulation_manager.opportunity_invalidated.connect(_on_opportunity_invalidated)
if simulation_manager.has_signal("state_restored"): if simulation_manager.has_signal("state_restored"):
simulation_manager.state_restored.connect(_on_simulation_state_restored) simulation_manager.state_restored.connect(_on_simulation_state_restored)
else: else:
@@ -229,6 +231,10 @@ func _on_opportunity_resolved(
_refresh_world_state_presentation(true) _refresh_world_state_presentation(true)
func _on_opportunity_invalidated(_opportunity: OpportunityStateRecord) -> void:
_refresh_world_state_presentation(false)
func _on_simulation_state_restored() -> void: func _on_simulation_state_restored() -> void:
for visual in active_npc_visuals.values(): for visual in active_npc_visuals.values():
visual.queue_free() visual.queue_free()
@@ -257,7 +263,10 @@ func _refresh_opportunity_concern() -> void:
if visual.has_method("set_opportunity_concern_visible"): if visual.has_method("set_opportunity_concern_visible"):
visual.set_opportunity_concern_visible(false) visual.set_opportunity_concern_visible(false)
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
if opportunity == null: if (
opportunity == null
or opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY
):
return return
var interested_visual = active_npc_visuals.get(opportunity.get_interested_npc_id()) var interested_visual = active_npc_visuals.get(opportunity.get_interested_npc_id())
if ( if (
@@ -272,5 +281,9 @@ func _sync_npc_opportunity_concern(npc_id: int, visual: Node3D) -> void:
return return
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
visual.set_opportunity_concern_visible( visual.set_opportunity_concern_visible(
opportunity != null and opportunity.get_interested_npc_id() == npc_id (
opportunity != null
and opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY
and opportunity.get_interested_npc_id() == npc_id
)
) )