diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index f1e1176..723422d 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -17,7 +17,7 @@ SimulationClock -> SimulationEventLog records completed facts -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> 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 -> ActiveWorldAdapter supplies loaded-world positions/capacity -> NpcVisual performs local navigation, animation, and transient reactions @@ -40,10 +40,10 @@ would otherwise obscure that lifecycle: retention/importance ranking; - `simulation/relationships/RelationshipSystem.gd` owns directed relationship queries, event-driven trust changes, and deterministic social tie-breaking; -- `simulation/opportunities/FoodShortageOpportunitySystem.gd` observes - immutable event references plus current pantry/NPC state, then owns the - bounded open/resolved pantry-restock lifecycle without changing resources or - assigning tasks; +- `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable + event references plus current NPC/storage state, then owns the bounded shared + open/resolved/invalidated lifecycle for the proven pantry-food and blocked- + work wood consumers without changing resources or assigning tasks; - `simulation/persistence/` owns save-slot file safety; - `simulation/state/` owns versioned serialized record contracts; - `simulation/definitions/` owns stable IDs and immutable action/profession diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 195850a..a1a18ea 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -726,13 +726,18 @@ Completed: concern cue; a real deposit clears it and briefly animates the refilled pantry. The capture tool writes an edit-ready empty/restocked close pair 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: -1. Resume the second bounded opportunity proof from a real missing-wood - `task_blocked` fact. Include deterministic interested-party death/staleness - handling before extracting shared opportunity machinery, as sequenced in - `LEARNING_ROADMAP.md`. +1. Add the first read-only capable-helper query for an open need. Expose one + deterministic viable villager and the real knowledge, relationship, action, + inventory, and resource facts behind that result before allowing it to + 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 mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index a77d751..430b3b1 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -31,25 +31,28 @@ state changes succeed; `SimulationManager` remains the public signal boundary used by presentation. An action whose definition-backed completion cost becomes unavailable records -a zero-amount `task_blocked` narrative fact with the action and shortfall -reason. This makes late contention inspectable without pretending that a +a zero-amount `task_blocked` narrative fact with stable action, source storage, +resource, and required-amount fields plus the readable shortfall reason. This +makes late contention queryable and inspectable without pretending that a transfer occurred. ## 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 per-NPC known-event references with first-acquisition provenance, plus opportunity records that reference exact trigger/resolution events. Schema v1 and v2 saves migrate to an empty stream beginning at ID zero; world schemas -v1–v7 migrate to an empty opportunity list. Parsing rejects duplicate event +v1–v7 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 communicator sources, relationship causes the observer does not know, and next IDs that could collide with restored history. -Positive food deposits and positive NPC food withdrawals from -`village_pantry` into that actor's matching inventory are currently knowable. -The actor and living NPCs within the bounded witness radius receive a +Positive food deposits, positive NPC food withdrawals from `village_pantry` +into that actor's matching inventory, and definition-backed patrol/study +`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 captured position, never the actor's later location. A successful food deposit 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 the event, so a listener's memory stays valid after the speaker forgets. -`FoodShortageOpportunitySystem` is another read-only consumer of immutable -facts. A known positive NPC pantry withdrawal, combined with the pantry -currently being empty and a living critically hungry knower, can open one -`restock_empty_pantry` record. The opportunity does not perform a transaction, -assign an NPC, or append a quest-only event. It resolves only after the pantry -actually reaches its target through either: +`VillageOpportunitySystem` is another read-only consumer of immutable facts. +A known positive NPC pantry withdrawal, combined with the pantry currently +being empty and a living critically hungry knower, can open one +`restock_empty_pantry` record. A known missing-wood `task_blocked` fact can open +one `supply_missing_wood` record for its living performer. At most one need is +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 player `resource_extracted` event from an existing player-usable food - ResourceNode directly into `village_pantry`. +- a later matching positive `storage_deposited` event from a real NPC + inventory; or +- 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 -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. 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 amber blossom above its observer. The cue is presentation-only, remains visible 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 -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 @@ -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. They should extend this record family without making prose authoritative or recomputing old evidence from current positions. -The current opportunity is likewise a single bounded projection, not a generic -quest, reward, acceptance, dialogue, or capable-helper framework. +The current opportunity family is likewise a bounded two-consumer projection, +not a generic quest, reward, acceptance, dialogue, or capable-helper framework. diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index df3973e..5ee6431 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -757,11 +757,29 @@ The outcome-defined pantry-crisis demo loop is complete: replacing state-driven world cues, while a runtime validator measures the unassisted route and a headless scenario verifies its full event chain. -Milestone 7 is not complete. The immediate next slice is the second bounded -unresolved-condition consumer from a real missing-wood `task_blocked` fact, -including deterministic interested-party death/staleness handling. Extract -shared opportunity machinery only after that second consumer proves the common -contract. +The second bounded Milestone 7 consumer is complete: + +- patrol or study that loses its definition-backed wood cost at completion + records one zero-transfer `task_blocked` fact with stable action, woodpile, + 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: @@ -819,7 +837,8 @@ Recently completed: - Action completion costs are definition-backed: patrol and study share one stored-resource contract across selection and execution. The inspector shows 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 (task started, NPC slept, NPC died) alongside economic transfers. Per-NPC event history with human-readable descriptions exposed in the diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index e3045ce..3d10665 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -427,9 +427,9 @@ Focused `RefCounted` collaborators keep rule ownership visible: importance ranking for presentation/communication queries; - `RelationshipSystem` owns directed relationship state, event-driven trust consequences, and deterministic social queries; -- `FoodShortageOpportunitySystem` projects the first knowledge-gated unresolved - condition into a persisted open/resolved record without changing resources - or assigning work. +- `VillageOpportunitySystem` projects the proven pantry-food and blocked-work + wood conditions into persisted open/resolved/invalidated records without + changing resources or assigning work. `SimulationManager` remains the scene-tree façade and signal boundary rather than duplicating these responsibilities across additional manager nodes. @@ -505,7 +505,7 @@ NpcVisual navigates through the active world | -> SimulationEventLog appends completed facts | -> EventKnowledgeSystem captures actor/nearby knowledge | -> RelationshipSystem applies evidence-gated social consequences - | -> FoodShortageOpportunitySystem may open or resolve a real need + | -> VillageOpportunitySystem may open, resolve, or invalidate a real need | | | v | village_changed signal updates the UI @@ -560,7 +560,8 @@ NpcVisual navigates through the active world │ ├── resource_node_player_parity_test.gd │ ├── simulation_definitions_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 ├── terrain/jajce/ Dedicated Terrain3D seed data and assets ├── tools/ @@ -593,7 +594,7 @@ These are expected prototype constraints, not necessarily isolated bugs: gathering use `ResourceNode` instances with no fallback, food transfer uses the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. - 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 player-transform persistence remain deferred. - Simulation-owned resource records retain live amounts, reservations, and @@ -608,15 +609,17 @@ These are expected prototype constraints, not necessarily isolated bugs: located items. - NPCs have home positions, schedule periods, carried food/wood, and directed 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; other knowledge is capped and reviewed after one simulated day. NPCs do not yet have wider social dimensions, goals, line-of-sight/hearing evidence, personalized reinforcement/decay, false beliefs, or multi-hop rumours. -- One persisted `restock_empty_pantry` opportunity can emerge from a known - withdrawal plus current emptiness and critical hunger. It has exact NPC and - player supply resolutions, but no generic opportunity definitions, capable- - helper assignment, acceptance, rewards, free-form dialogue, or quest log. +- The bounded opportunity family supports one open `restock_empty_pantry` or + `supply_missing_wood` need from exact known evidence. Both have exact NPC and + player supply resolutions; the wood need also closes deterministically on + 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 relationship cause, and a compact person-history view that distinguishes 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 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 resolution event; active evidence remains lasting. A real later NPC deposit or 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 timing validator cover the complete path. -Milestone 7 remains in progress. The immediate next slice is the second -missing-wood consumer from a real `task_blocked` fact, including deterministic -interested-party death/staleness handling before common opportunity machinery -is extracted. +The second bounded opportunity proof is complete. Patrol or study that cannot +pay its wood cost records a stable zero-transfer `task_blocked` fact and opens +one `supply_missing_wood` need for its performer. A real NPC wood deposit or +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: diff --git a/docs/SIMULATION_DEFINITIONS.md b/docs/SIMULATION_DEFINITIONS.md index bb4c7d3..a7ac89d 100644 --- a/docs/SIMULATION_DEFINITIONS.md +++ b/docs/SIMULATION_DEFINITIONS.md @@ -45,15 +45,20 @@ unknown profession or executable action. ## Opportunity vocabulary -`SimulationIds` also defines the first proven opportunity type, -`restock_empty_pantry`, and its `open`/`resolved` statuses. These are stable -serialized vocabulary, not editor-authored quest definitions. Dynamic trigger, -interested NPC, pantry/food goal, progress, and exact resolution-event identity -belong to `OpportunityStateRecord` and the focused opportunity system. +`SimulationIds` defines the two proven opportunity types, +`restock_empty_pantry` and `supply_missing_wood`, plus the stable `open`, +`resolved`, and `invalidated` statuses. Invalidation reasons currently +distinguish `interested_died` from `evidence_stale`. These are serialized +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 -unresolved-condition consumer proves which lifecycle fields and rules are -actually shared. +The shared record and lifecycle collaborator were extracted only after the +food and wood consumers proved those fields. Their evidence, care, resolution, +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 diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 950d477..e53fe0c 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -3,7 +3,7 @@ ## Current contract `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 clock ticks; @@ -21,14 +21,15 @@ simulation. The current world schema is v8 and captures: copying it, including first-acquisition method, acquisition tick, and historical communicator provenance; - 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: ```json { "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, divergent known-event state, communicated provenance, and deterministic retention boundaries, and rejection of unsupported schemas. -Active and resolved opportunity round-tripping, contradictory target state, -invalid resource sources, and cross-record event references are covered too. +Active, resolved, and invalidated opportunity round-tripping, contradictory +target state, invalid resource sources, and cross-record event references are +covered too. NPCStateRecord v2 adds the resolved travel destination and whether it is 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 v1–v7 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 -ID must remain above restored history. The interested NPC, pantry, food -resource, and referenced events must exist. Its trigger is a positive NPC food +ID must remain above restored history. Referenced NPCs, storage, resources, and +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 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. -Resolution references an exact later NPC inventory-to-pantry food deposit or a -player `resource_extracted` event from an existing player-usable food resource -into the pantry, with a matching resolved tick. Resolved history does not -require the pantry still to contain food because later consumption is valid. +and its interested NPC to have acquired that fact no later than creation. A +`supply_missing_wood` trigger is the performer's known patrol or study +`task_blocked` fact against `village_woodpile` with a one-wood requirement; an +open record additionally requires a living interested performer and the +woodpile below target. -While the need is open, its interested villager's trigger evidence is treated -as lasting during deterministic memory maintenance. Resolution releases that -fact back to the normal bounded-retention rules; it does not delete objective -event or opportunity history. +Resolution references an exact later matching NPC inventory-to-storage deposit +or player `resource_extracted` event from an existing player-usable matching +resource into the target storage, with matching resolved and closed ticks. +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 @@ -211,7 +232,7 @@ This phase does not yet provide: - a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles; - migrations from any historical world schema other than the explicitly - supported v1–v7 layouts; + supported v1–v8 layouts; - player inventory or player relationship records; - broader relationship dimensions, line-of-sight/hearing evidence, continuous or personalized memory decay, reinforcement, false beliefs, or diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index a713900..fa14ea3 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -4,9 +4,6 @@ const SimulationEventLogScript := preload("res://simulation/events/SimulationEve const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.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 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 opportunity_opened(opportunity: OpportunityStateRecord) signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord) +signal opportunity_invalidated(opportunity: OpportunityStateRecord) var village := SimVillage.new() @@ -42,7 +40,7 @@ var event_log := SimulationEventLogScript.new() var economy := VillageEconomyScript.new() var relationship_system := RelationshipSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new() -var opportunity_system := FoodShortageOpportunitySystemScript.new() +var opportunity_system := VillageOpportunitySystem.new() var storage_states: Dictionary: get: return economy.storage_states @@ -162,12 +160,15 @@ func simulate_tick() -> void: var village_was_changed := false for npc in npcs: village_was_changed = _simulate_npc_tick(npc) or village_was_changed - if village_was_changed: 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: _maintain_event_knowledge(true) - if debug_logs: print(village.get_summary()) @@ -552,14 +553,23 @@ func _record_economic_event_at( 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: _record_narrative_event_at( event_type, actor_id, source_id, 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, source_id: StringName, action_display: String, - world_position: Vector3 + world_position: Vector3, + action_id: StringName = &"", + item_id: StringName = &"", + required_amount: float = 0.0 ) -> void: 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: 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: return diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 136e194..79bd724 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -42,8 +42,12 @@ const EVENT_TASK_BLOCKED := &"task_blocked" const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" +const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" const OPPORTUNITY_STATUS_OPEN := &"open" 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_WITNESSED := &"witnessed" diff --git a/simulation/economy/VillageEconomy.gd b/simulation/economy/VillageEconomy.gd index ca40006..859141a 100644 --- a/simulation/economy/VillageEconomy.gd +++ b/simulation/economy/VillageEconomy.gd @@ -10,7 +10,13 @@ signal economic_event_requested( amount: float ) 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 @@ -178,7 +184,10 @@ func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool: SimulationIds.EVENT_TASK_BLOCKED, npc.id, storage.get_storage_id() if storage != null else &"", - reason + reason, + definition.action_id, + resource_id, + required_amount ) return false var consumed := storage.withdraw(resource_id, required_amount) diff --git a/simulation/events/SimulationEventLog.gd b/simulation/events/SimulationEventLog.gd index 447b018..e405e6c 100644 --- a/simulation/events/SimulationEventLog.gd +++ b/simulation/events/SimulationEventLog.gd @@ -42,10 +42,22 @@ func record_narrative( actor_id: int, source_id: StringName = &"", 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: 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) return event diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index ed99fcb..d5186c7 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -222,10 +222,23 @@ func _remember( static func is_knowable_event(event: EconomicEventRecord) -> bool: - if event == null or float(event.data["amount"]) <= 0.0: + if event == null: return false var event_type := StringName(event.data["event_type"]) 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: return item_id == SimulationIds.RESOURCE_FOOD if event_type != SimulationIds.EVENT_STORAGE_WITHDRAWN: diff --git a/simulation/opportunities/FoodShortageOpportunitySystem.gd b/simulation/opportunities/VillageOpportunitySystem.gd similarity index 55% rename from simulation/opportunities/FoodShortageOpportunitySystem.gd rename to simulation/opportunities/VillageOpportunitySystem.gd index ff0d921..480df38 100644 --- a/simulation/opportunities/FoodShortageOpportunitySystem.gd +++ b/simulation/opportunities/VillageOpportunitySystem.gd @@ -1,4 +1,4 @@ -class_name FoodShortageOpportunitySystem +class_name VillageOpportunitySystem extends RefCounted const CARE_HUNGER_THRESHOLD := 80.0 @@ -13,17 +13,64 @@ func consider_event( pantry: StorageStateRecord, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem, - resource_states: Dictionary + resource_states: Dictionary, + woodpile: StorageStateRecord = null ) -> OpportunityStateRecord: if event == null or pantry == null or current_tick < 0: return null var event_tick := int(event.data["tick"]) if event_tick < 0 or event_tick > current_tick: return null - var resolved := _try_resolve(event, pantry, npcs, resource_states) + var resolved := _try_resolve(event, pantry, woodpile, npcs, resource_states) if resolved != null: 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: @@ -65,7 +112,7 @@ func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord: return null -func _try_open( +func _try_open_food( event: EconomicEventRecord, current_tick: int, pantry: StorageStateRecord, @@ -91,18 +138,62 @@ func _try_open( 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( event: EconomicEventRecord, pantry: StorageStateRecord, + woodpile: StorageStateRecord, npcs: Array[SimNPC], resource_states: Dictionary ) -> OpportunityStateRecord: var opportunity := get_open_opportunity() if opportunity == 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 - if not _is_valid_supply(event, npcs, resource_states): + if not _is_valid_supply(event, opportunity, npcs, resource_states): return null if int(event.data["tick"]) < opportunity.get_created_tick(): 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( - event: EconomicEventRecord, npcs: Array[SimNPC], resource_states: Dictionary + event: EconomicEventRecord, + opportunity: OpportunityStateRecord, + npcs: Array[SimNPC], + resource_states: Dictionary ) -> bool: if ( - StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD - or StringName(event.data["destination_id"]) != SimulationIds.STORAGE_VILLAGE_PANTRY + StringName(event.data["item_id"]) != opportunity.get_resource_id() + or StringName(event.data["destination_id"]) != opportunity.get_target_id() or float(event.data["amount"]) <= 0.0 ): return false @@ -162,11 +270,22 @@ static func _is_valid_supply( ) return ( 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() ) +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: if first.hunger != second.hunger: return first.hunger > second.hunger diff --git a/simulation/opportunities/FoodShortageOpportunitySystem.gd.uid b/simulation/opportunities/VillageOpportunitySystem.gd.uid similarity index 100% rename from simulation/opportunities/FoodShortageOpportunitySystem.gd.uid rename to simulation/opportunities/VillageOpportunitySystem.gd.uid diff --git a/simulation/state/EconomicEventRecord.gd b/simulation/state/EconomicEventRecord.gd index ce81d2a..c0ae6d5 100644 --- a/simulation/state/EconomicEventRecord.gd +++ b/simulation/state/EconomicEventRecord.gd @@ -1,7 +1,8 @@ class_name EconomicEventRecord extends RefCounted -const SCHEMA_VERSION := 2 +const SCHEMA_VERSION := 3 +const POSITION_LEGACY_SCHEMA_VERSION := 2 const LEGACY_SCHEMA_VERSION := 1 var data: Dictionary @@ -33,6 +34,8 @@ static func create( "destination_id": String(destination_id), "item_id": String(item_id), "amount": amount, + "action_id": "", + "required_amount": 0.0, "world_position": [world_position.x, world_position.y, world_position.z] } ) @@ -45,7 +48,10 @@ static func create_narrative( actor_id: int, source_id: StringName, 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: return EconomicEventRecord.new( { @@ -56,9 +62,11 @@ static func create_narrative( "actor_id": actor_id, "source_id": String(source_id), "destination_id": "", - "item_id": "", + "item_id": String(item_id), "amount": 0.0, "action_name": action_display, + "action_id": String(action_id), + "required_amount": required_amount, "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: 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 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["item_id"] = String(record_data["item_id"]) 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]) if not saved_position is Array or saved_position.size() != 3: 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(): 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) diff --git a/simulation/state/OpportunityStateRecord.gd b/simulation/state/OpportunityStateRecord.gd index 3f686c2..9fa79f1 100644 --- a/simulation/state/OpportunityStateRecord.gd +++ b/simulation/state/OpportunityStateRecord.gd @@ -1,11 +1,13 @@ class_name OpportunityStateRecord extends RefCounted -const SCHEMA_VERSION := 1 +const SCHEMA_VERSION := 2 +const LEGACY_SCHEMA_VERSION := 1 const NO_EVENT_ID := -1 const DEFAULT_TARGET_AMOUNT := 1.0 const STATUS_OPEN := SimulationIds.OPPORTUNITY_STATUS_OPEN const STATUS_RESOLVED := SimulationIds.OPPORTUNITY_STATUS_RESOLVED +const STATUS_INVALIDATED := SimulationIds.OPPORTUNITY_STATUS_INVALIDATED var data: Dictionary @@ -19,7 +21,10 @@ static func create( created_tick: int, trigger_event_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: return ( OpportunityStateRecord @@ -27,26 +32,34 @@ static func create( { "schema_version": SCHEMA_VERSION, "opportunity_id": opportunity_id, - "opportunity_type": String(SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY), + "opportunity_type": String(opportunity_type), "status": String(STATUS_OPEN), "created_tick": created_tick, "trigger_event_id": trigger_event_id, "interested_npc_id": interested_npc_id, - "target_id": String(SimulationIds.STORAGE_VILLAGE_PANTRY), - "resource_id": String(SimulationIds.RESOURCE_FOOD), + "target_id": String(target_id), + "resource_id": String(resource_id), "target_amount": target_amount, "resolution_event_id": NO_EVENT_ID, "resolved_tick": -1, + "closed_tick": -1, + "invalidation_reason": "", } ) ) 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 + 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 ( - record_data + normalized . has_all( [ "opportunity_id", @@ -60,45 +73,91 @@ static func from_dictionary(record_data: Dictionary) -> OpportunityStateRecord: "target_amount", "resolution_event_id", "resolved_tick", + "closed_tick", + "invalidation_reason", ] ) ): return null - var opportunity_id := int(record_data["opportunity_id"]) - var opportunity_type := StringName(record_data["opportunity_type"]) - var status := StringName(record_data["status"]) - var created_tick := int(record_data["created_tick"]) - var trigger_event_id := int(record_data["trigger_event_id"]) - var interested_npc_id := int(record_data["interested_npc_id"]) - var target_id := StringName(record_data["target_id"]) - var resource_id := StringName(record_data["resource_id"]) - var target_amount := float(record_data["target_amount"]) - var resolution_event_id := int(record_data["resolution_event_id"]) - var resolved_tick := int(record_data["resolved_tick"]) + var opportunity_id := int(normalized["opportunity_id"]) + var opportunity_type := StringName(normalized["opportunity_type"]) + var status := StringName(normalized["status"]) + var created_tick := int(normalized["created_tick"]) + var trigger_event_id := int(normalized["trigger_event_id"]) + var interested_npc_id := int(normalized["interested_npc_id"]) + var target_id := StringName(normalized["target_id"]) + var resource_id := StringName(normalized["resource_id"]) + var target_amount := float(normalized["target_amount"]) + var resolution_event_id := int(normalized["resolution_event_id"]) + 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: return null - if opportunity_type != SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: - return null - if target_id != SimulationIds.STORAGE_VILLAGE_PANTRY: - return null - if resource_id != SimulationIds.RESOURCE_FOOD: - return null + match opportunity_type: + SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: + if ( + target_id != SimulationIds.STORAGE_VILLAGE_PANTRY + or resource_id != SimulationIds.RESOURCE_FOOD + ): + return null + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + if ( + target_id != SimulationIds.STORAGE_VILLAGE_WOODPILE + or resource_id != SimulationIds.RESOURCE_WOOD + ): + return null + _: + return null if not is_finite(target_amount) or not is_equal_approx(target_amount, DEFAULT_TARGET_AMOUNT): return null match status: 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 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 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: record.resolve(resolution_event_id, resolved_tick) + elif status == STATUS_INVALIDATED: + record.invalidate(invalidation_reason, closed_tick) return record @@ -112,6 +171,26 @@ func resolve(resolution_event_id: int, resolved_tick: int) -> bool: data["status"] = String(STATUS_RESOLVED) data["resolution_event_id"] = resolution_event_id 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 @@ -159,6 +238,14 @@ func get_resolved_tick() -> int: 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: return get_status() == STATUS_OPEN diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index a5142ea..4cbdafd 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,13 +2,14 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 8 +const SCHEMA_VERSION := 9 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4 const PROVENANCE_LEGACY_SCHEMA_VERSION := 5 const RETENTION_LEGACY_SCHEMA_VERSION := 6 +const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8 const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary @@ -86,6 +87,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: PROVENANCE_LEGACY_SCHEMA_VERSION, RETENTION_LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, + OPPORTUNITY_LEGACY_SCHEMA_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.village = village_record var npc_ids := {} + var npc_records_by_id := {} for item in npc_data: if not item is Dictionary: @@ -196,6 +199,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if npc_ids.has(npc_id): return null npc_ids[npc_id] = true + npc_records_by_id[npc_id] = npc_record record.npcs.append(npc_record) var resource_ids := {} @@ -337,6 +341,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if not _is_valid_opportunity( opportunity_record, npc_ids, + npc_records_by_id, storage_records_by_id, event_records_by_id, knowledge_records_by_key, @@ -398,19 +403,16 @@ static func _is_valid_knowledge_provenance( static func _is_valid_opportunity( opportunity: OpportunityStateRecord, npc_ids: Dictionary, + npc_records_by_id: Dictionary, storage_records_by_id: Dictionary, event_records_by_id: Dictionary, knowledge_records_by_key: Dictionary, resource_records_by_id: Dictionary, current_tick: int ) -> bool: - if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: - return false if ( 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 opportunity.get_resource_id() != SimulationIds.RESOURCE_FOOD or not is_finite(opportunity.get_target_amount()) or opportunity.get_target_amount() <= 0.0 ): @@ -418,8 +420,17 @@ static func _is_valid_opportunity( var trigger_event := ( 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 var trigger_tick := int(trigger_event.data["tick"]) var created_tick := opportunity.get_created_tick() if created_tick < trigger_tick or created_tick > current_tick: @@ -428,6 +439,9 @@ static func _is_valid_opportunity( var status := opportunity.get_status() var resolution_event_id := opportunity.get_resolution_event_id() 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: var target_storage := ( storage_records_by_id.get(opportunity.get_target_id()) as StorageStateRecord @@ -439,6 +453,11 @@ static func _is_valid_opportunity( return ( resolution_event_id == OpportunityStateRecord.NO_EVENT_ID 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.get_amount(opportunity.get_resource_id()) @@ -447,6 +466,24 @@ static func _is_valid_opportunity( and evidence != null 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: return false 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 if ( 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 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: var actor_id := int(event.data["actor_id"]) return ( @@ -478,14 +537,17 @@ static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids: ) -static func _is_valid_pantry_supply_resolution( - event: EconomicEventRecord, npc_ids: Dictionary, resource_records_by_id: Dictionary +static func _is_valid_supply_resolution( + event: EconomicEventRecord, + opportunity: OpportunityStateRecord, + npc_ids: Dictionary, + resource_records_by_id: Dictionary ) -> bool: var actor_id := int(event.data["actor_id"]) var event_type := StringName(event.data["event_type"]) if ( - StringName(event.data["destination_id"]) != SimulationIds.STORAGE_VILLAGE_PANTRY - or StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD + StringName(event.data["destination_id"]) != opportunity.get_target_id() + or StringName(event.data["item_id"]) != opportunity.get_resource_id() or float(event.data["amount"]) <= 0.0 ): return false @@ -500,7 +562,7 @@ static func _is_valid_pantry_supply_resolution( ) return ( 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() ) return false @@ -559,10 +621,11 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary migrated.get("economic_events", []), int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0)) ) - migrated["opportunities"] = [] - var opportunity_simulation_data: Dictionary = migrated.get("simulation", {}) - opportunity_simulation_data["next_opportunity_id"] = 0 - migrated["simulation"] = opportunity_simulation_data + if version != OPPORTUNITY_LEGACY_SCHEMA_VERSION: + migrated["opportunities"] = [] + var opportunity_simulation_data: Dictionary = migrated.get("simulation", {}) + opportunity_simulation_data["next_opportunity_id"] = 0 + migrated["simulation"] = opportunity_simulation_data return migrated diff --git a/tests/food_shortage_opportunity_test.gd b/tests/food_shortage_opportunity_test.gd index 0134cab..95eb920 100644 --- a/tests/food_shortage_opportunity_test.gd +++ b/tests/food_shortage_opportunity_test.gd @@ -105,7 +105,7 @@ func _run() -> void: var active_restored := _create_manager(902) _check( 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( ( diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 1c06e23..aecff57 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -235,9 +235,10 @@ func _run() -> void: var old_contributor_visual := contributor_visual _check( 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 + contributor = simulation_manager.npcs[contributor.id] contributor_visual = world_view.active_npc_visuals[contributor.id] player_opportunity = simulation_manager.get_active_opportunity() _check( @@ -270,6 +271,42 @@ func _run() -> void: ), "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( main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"), "Playable runtime should instance the Jajce Terrain3D world" diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index 51320e2..8c7193d 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -457,6 +457,27 @@ func _test_previous_world_opportunity_migration() -> void: ), "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() diff --git a/tests/unit/test_food_shortage_opportunity_system.gd b/tests/unit/test_village_opportunity_system.gd similarity index 90% rename from tests/unit/test_food_shortage_opportunity_system.gd rename to tests/unit/test_village_opportunity_system.gd index af17f69..6d833fb 100644 --- a/tests/unit/test_food_shortage_opportunity_system.gd +++ b/tests/unit/test_village_opportunity_system.gd @@ -1,12 +1,12 @@ extends GutTest -const FoodShortageOpportunitySystemScript := preload( - "res://simulation/opportunities/FoodShortageOpportunitySystem.gd" +const VillageOpportunitySystemScript := preload( + "res://simulation/opportunities/VillageOpportunitySystem.gd" ) func test_opening_requires_current_knowledge_and_care() -> void: - var system := FoodShortageOpportunitySystemScript.new() + var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var npc := _npc(0, 90.0) 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, {})) _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_true(system.get_all_sorted().is_empty()) assert_eq(system.next_opportunity_id, 0) 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 lower_hunger := _npc(1, 85.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: - var system := FoodShortageOpportunitySystemScript.new() + var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var lower_id := _npc(1, 92.0) 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: - var system := FoodShortageOpportunitySystemScript.new() + var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var npc := _npc(3, 88.0) 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: var fixture := _open_fixture() - var system: FoodShortageOpportunitySystem = fixture["system"] + var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var npcs: Array[SimNPC] = fixture["npcs"] 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: var fixture := _open_fixture() - var system: FoodShortageOpportunitySystem = fixture["system"] + var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var npcs: Array[SimNPC] = fixture["npcs"] 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) +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: var older := OpportunityStateRecord.create(2, 5, 4, 7) var newer := OpportunityStateRecord.create(5, 8, 7, 9) var restored_records: Array[OpportunityStateRecord] = [newer, older] - var system := FoodShortageOpportunitySystemScript.new() + var system := VillageOpportunitySystemScript.new() system.restore(restored_records, 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: - var system := FoodShortageOpportunitySystemScript.new() + var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var npc := _npc(0, 90.0) var npcs: Array[SimNPC] = [npc] diff --git a/tests/unit/test_food_shortage_opportunity_system.gd.uid b/tests/unit/test_village_opportunity_system.gd.uid similarity index 100% rename from tests/unit/test_food_shortage_opportunity_system.gd.uid rename to tests/unit/test_village_opportunity_system.gd.uid diff --git a/tests/wood_shortage_opportunity_test.gd b/tests/wood_shortage_opportunity_test.gd new file mode 100644 index 0000000..e6cb3bd --- /dev/null +++ b/tests/wood_shortage_opportunity_test.gd @@ -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) diff --git a/tests/wood_shortage_opportunity_test.gd.uid b/tests/wood_shortage_opportunity_test.gd.uid new file mode 100644 index 0000000..8c90392 --- /dev/null +++ b/tests/wood_shortage_opportunity_test.gd.uid @@ -0,0 +1 @@ +uid://dfxcllud4ghy0 diff --git a/world/ui/ui.gd b/world/ui/ui.gd index 8ed9038..c3421c6 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -37,6 +37,8 @@ func _ready() -> void: simulation_manager.opportunity_opened.connect(_on_opportunity_opened) if simulation_manager.has_signal("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: _on_village_changed(simulation_manager.village) @@ -158,6 +160,11 @@ func _on_opportunity_resolved( _refresh_npc_inspector() +func _on_opportunity_invalidated(_opportunity: RefCounted) -> void: + _refresh_village_display() + _refresh_npc_inspector() + + func _refresh_village_display() -> void: if simulation_manager != null and "village" in simulation_manager: _on_village_changed(simulation_manager.village) @@ -256,10 +263,15 @@ func _build_active_opportunity_display() -> String: return "" var interested_name := _get_npc_name(opportunity.get_interested_npc_id()) var resource_name := String(opportunity.get_resource_id()).capitalize() - var current_amount := 0.0 - var pantry: StorageStateRecord = simulation_manager.get_pantry() - if pantry != null: - current_amount = pantry.get_amount(opportunity.get_resource_id()) + var current_amount := _get_opportunity_current_amount(opportunity) + if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + var action_name := _get_opportunity_action_name(opportunity) + 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 ( "\n\nVillage need\n" + "◆ Restock the empty pantry\n" @@ -276,10 +288,16 @@ func _build_npc_opportunity_display(npc: SimNPC) -> String: return "" var resource_name := String(opportunity.get_resource_id()).capitalize() if opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN: - var pantry: StorageStateRecord = simulation_manager.get_pantry() - var current_amount := 0.0 - if pantry != null: - current_amount = pantry.get_amount(opportunity.get_resource_id()) + var current_amount := _get_opportunity_current_amount(opportunity) + if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + return ( + "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 ( "Open village need\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()] ) ) + 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: return "" 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: var actor_id := int(resolution_event.data["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 ( "Resolved village need\n" + "◆ %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: for npc in simulation_manager.npcs: if npc.id == npc_id: diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index ec12c99..9087e01 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -57,6 +57,8 @@ func initialize_world_view() -> void: simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved) else: 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"): simulation_manager.state_restored.connect(_on_simulation_state_restored) else: @@ -229,6 +231,10 @@ func _on_opportunity_resolved( _refresh_world_state_presentation(true) +func _on_opportunity_invalidated(_opportunity: OpportunityStateRecord) -> void: + _refresh_world_state_presentation(false) + + func _on_simulation_state_restored() -> void: for visual in active_npc_visuals.values(): visual.queue_free() @@ -257,7 +263,10 @@ func _refresh_opportunity_concern() -> void: if visual.has_method("set_opportunity_concern_visible"): visual.set_opportunity_concern_visible(false) 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 var interested_visual = active_npc_visuals.get(opportunity.get_interested_npc_id()) if ( @@ -272,5 +281,9 @@ func _sync_npc_opportunity_concern(npc_id: int, visual: Node3D) -> void: return var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() 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 + ) )