diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 402e49b..02574f9 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -17,6 +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 -> WorldViewManager presents travel and NPC state -> ActiveWorldAdapter supplies loaded-world positions/capacity -> NpcVisual performs local navigation, animation, and transient reactions @@ -38,6 +39,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/persistence/` owns save-slot file safety; - `simulation/state/` owns versioned serialized record contracts; - `simulation/definitions/` owns stable IDs and immutable action/profession @@ -58,6 +63,7 @@ hard to read. | `simulation/events/` | Immutable event history and derived event queries | | `simulation/knowledge/` | Per-NPC knowledge of objective event IDs | | `simulation/relationships/` | Directed social consequences and relationship queries | +| `simulation/opportunities/` | Knowledge-gated unresolved-condition projections | | `simulation/state/` | Versioned, serializable mutable records | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions | | `simulation/persistence/` | Validated local save-file storage | @@ -80,15 +86,19 @@ improving ownership. ownership. - Presentation may report facts and submit commands; it does not choose NPC actions or own resource, storage, inventory, event, knowledge, relationship, - or reservation state. + opportunity, or reservation state. - Inspector history is a read-only façade query over objective events and - retained knowledge. Relationship cues consume state-change signals and are - intentionally absent from saves and checksums. + retained knowledge. Opportunity presentation is likewise query-only. + Relationship cues consume state-change signals and are intentionally absent + from saves and checksums. - Resource changes go through `ResourceStateRecord`, NPC inventory, and `VillageEconomy`; `village.food` and `village.wood` are synchronized views. - New mutable features define serialization and deterministic continuation at the same time as their first gameplay use. Cross-record causes use stable event IDs rather than object references or prose. +- Opportunity records reference stable NPC, storage, resource, trigger-event, + and resolution-event IDs. Their generator may observe authoritative state + and history, but it does not mutate the economy or command NPC behavior. - Prefer one tested vertical behavior over a generic framework with no proven consumers. diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index dabb395..472711b 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -705,10 +705,17 @@ Completed: actions. A real evidence-caused trust gain produces one short amber blossom above its observer, including in cinematic mode, without persistent or simulation-owned animation state. +25. Knowledge-gated village need: a compact, cardless `Village need` line now + shows the real pantry target, food progress, and interested villager. That + person's inspector retains the open/resolved lifecycle and names the exact + NPC or player supply actor. The repeatable debug capture stages this from + live simulation state; world framing and the elevated follow camera remain + unchanged. Next: -1. Implement one bounded Milestone 7 rumour/opportunity proof, as sequenced in +1. Prove the same opportunity lifecycle with one missing-wood `task_blocked` + condition before extracting shared generation rules, as sequenced in `LEARNING_ROADMAP.md`. Do not start with GIS data, a full city, a large asset pack, or more NPC diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index d81ec88..a77d751 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -37,22 +37,25 @@ transfer occurred. ## Persistence and determinism -`SimulationStateRecord` schema v7 stores the ordered event stream, +`SimulationStateRecord` schema v8 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. Schema v1 and -v2 saves migrate to an empty stream beginning at ID zero. Parsing rejects -duplicate event IDs, invalid or duplicate knowledge references, impossible -communicator sources, relationship causes the observer does not know, and a -next ID that could collide with restored history. +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 +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. -A successful food deposit can currently raise a hungry familiar NPC's directed -trust in its contributor only when that NPC knows the event. 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. The relationship stores the same deposit event ID -rather than copied prose, so the inspector can resolve and display the real -completed fact. This is a first evidence-gated causal consumer of the event -stream, not general event sourcing. +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 +`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 +that same deposit event ID rather than copied prose, so the inspector can +resolve and display the real completed fact. This is evidence-gated use of the +event stream, not general event sourcing. Knowledge now records whether an NPC performed, witnessed, heard, or inherited a fact from a legacy save. When an NPC arrives beside an already-working NPC at @@ -72,6 +75,21 @@ 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: + +- 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`. + +The exact trigger and resolution IDs remain in opportunity history. The active +trigger memory is protected until resolution, then returns to normal bounded +retention. + The food-loop regression verifies this chain: ```text @@ -92,6 +110,9 @@ 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 +progress and interested villager; only that villager's inspector retains its +open/resolved detail and names the exact supplier. ## Deliberate limits @@ -104,3 +125,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. diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 2a89942..332706f 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -711,9 +711,30 @@ The compact history/reaction slice is complete: slice. This completes the first bounded Milestone 6 evidence-to-choice presentation -path without claiming full belief simulation. The practical next slice is one -bounded Milestone 7 rumour/opportunity proof without generalized distortion, -free-form dialogue, or a generic quest framework. +path without claiming full belief simulation. + +The first bounded Milestone 7 opportunity proof is complete: + +- a positive NPC food withdrawal from `village_pantry`, combined with the + pantry currently being empty, is the exact shortage evidence; +- the need opens only when a living critically hungry villager knows that + event through performance, witnessing, or the existing one-hop + communication contract; +- highest hunger and then lowest stable NPC ID select one interested villager, + with at most one open pantry need; +- schema-v8 opportunity records retain stable NPC, event, storage, resource, + and resolution IDs, and protect the trigger memory while the need is open; +- an exact later NPC food deposit or player resource extraction into the + pantry resolves the same record through the real economy/event history; +- generation neither mutates the economy nor commands NPC work, and it adds no + quest-only event, acceptance, reward, dialogue, multi-hop rumour, or generic + quest framework. + +Milestone 7 is not complete. The practical next slice is a second bounded +unresolved-condition consumer: surface missing-wood `task_blocked` facts from +patrol or study as a cared-about woodpile-restock need, resolve it through real +NPC/player wood supply, define deterministic interested-party death/staleness +handling, and only then extract the lifecycle shared by two proven consumers. Recently completed: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index ac2de30..764dce2 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -2,7 +2,7 @@ > Agent-facing context for understanding the project quickly. > -> Snapshot basis: repository state on July 11, 2026. Treat the code as the +> Snapshot basis: repository state on July 13, 2026. Treat the code as the > source of truth when this document and the implementation differ. See the [documentation map](README.md) for the authority and scope of each plan. @@ -374,7 +374,10 @@ A small village panel displays: decision reason, utility scores, directed familiarity/trust, and the exact completed event that last changed trust; - a compact selected-person history that separates up to four importance-ranked - retained memories from the NPC's three latest objective personal actions. + retained memories from the NPC's three latest objective personal actions; +- a cardless active `Village need` line sourced from the real pantry target, + food progress, and interested villager, with open/resolved detail only in + that person's inspector. NPC name/profession labels, definition-driven colors and props, carried-food visuals, compact task glyphs, and a brief amber blossom on real trust gains make @@ -419,7 +422,10 @@ Focused `RefCounted` collaborators keep rule ownership visible: one-hop communication, deterministic recent-memory retention, and stable importance ranking for presentation/communication queries; - `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 + condition into a persisted open/resolved record 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. @@ -456,9 +462,10 @@ follow camera. ### `world/ui/ui.gd` -The UI subscribes to village, task, event, knowledge, and relationship changes. -It formats aggregate state and resolves known facts/relationship causes through -the event log without owning or recomputing simulation facts. +The UI subscribes to village, task, event, knowledge, relationship, and +opportunity changes. It formats aggregate state and resolves known facts, +relationship causes, and opportunity lifecycle details through manager queries +without owning or recomputing simulation facts. ## Current runtime flow @@ -491,6 +498,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 | | | v | village_changed signal updates the UI @@ -527,6 +535,7 @@ NpcVisual navigates through the active world │ ├── economy/ Inventory and storage transactions │ ├── events/ Ordered event history and queries │ ├── knowledge/ Per-NPC facts, provenance, transfer, and retention +│ ├── opportunities/ Knowledge-gated unresolved-condition projection │ ├── persistence/ Validated local save-slot storage │ ├── relationships/ Directed social consequences and queries │ └── state/ Versioned simulation-state records @@ -535,6 +544,7 @@ NpcVisual navigates through the active world │ ├── communicated_knowledge_consequence_test.gd │ ├── deterministic_simulation_test.gd │ ├── food_storage_loop_test.gd +│ ├── food_shortage_opportunity_test.gd │ ├── jajce_world_scaffold_test.gd │ ├── knowledge_retention_consequence_test.gd │ ├── jajce_runtime_integration_test.gd @@ -543,6 +553,7 @@ 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 │ └── witnessed_knowledge_consequence_test.gd ├── terrain/jajce/ Dedicated Terrain3D seed data and assets ├── tools/ @@ -575,9 +586,9 @@ 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, - clock, and RNG state serialize through world schema v7. F5/F9 provide one - validated local quicksave; a save menu, metadata, and player-transform - persistence remain deferred. + opportunity, clock, and RNG state serialize through world schema v8. 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 usage definitions while ResourceNode scenes are unloaded. - An explicit fixed-step clock converts frame delta into simulation ticks, but @@ -590,11 +601,15 @@ 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 with first-acquisition provenance. Current trust causes 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. + food deposit or NPC pantry withdrawal 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 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. @@ -613,8 +628,8 @@ These are expected prototype constraints, not necessarily isolated bugs: - Terrain3D and the bounded Jajce beauty baseline now run in the main game scene; `Jajce Lookdev 01` and the runtime `Simulation Garden 01` are captured as reproducible presentation baselines. -- Combat, companions, factions, politics, trade, rumours, quests, persistence, - and regional travel do not yet exist. +- Combat, companions, factions, politics, trade, generalized/multi-hop rumours, + a general quest framework, and regional travel do not yet exist. - Stylized player/NPC silhouettes, water, foliage, and VFX support the current build-in-public baseline, while blockout buildings and several work/resource props remain visibly prototype-grade. @@ -846,8 +861,21 @@ retention now keep current relationship causes lasting, cap other memories, and forget routine facts at deterministic daily-sized reviews without deleting the objective event. The selected-person inspector now shows lasting-first retained memories beside objective personal history, and real trust changes -produce a short observer-only amber blossom without altering saved state. The -next systems slice is one bounded rumour/opportunity proof. +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 +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 +mutation by the generator, or AI assignment. The compact village/inspector UI +shows this lifecycle without changing the elevated follow camera. + +Milestone 7 remains in progress. The next systems slice should prove the same +lifecycle with a missing-wood `task_blocked` condition and deterministic +interested-party death/staleness handling before extracting common opportunity +machinery. The remaining simulation-garden target still aims for: diff --git a/docs/SIMULATION_DEFINITIONS.md b/docs/SIMULATION_DEFINITIONS.md index 1746758..bb4c7d3 100644 --- a/docs/SIMULATION_DEFINITIONS.md +++ b/docs/SIMULATION_DEFINITIONS.md @@ -43,6 +43,18 @@ NPC generation chooses from the registry's stable IDs. NPC state stores and restores those IDs as `StringName`, and rejects records that reference an 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. + +Do not extract a generic quest-definition registry until another real +unresolved-condition consumer proves which lifecycle fields and rules are +actually shared. + ## Validation The registry rejects: diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index f985617..950d477 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 v7 and captures: +simulation. The current world schema is v8 and captures: - simulation seed, tick interval, tick count, clock remainder, and elapsed clock ticks; @@ -19,14 +19,16 @@ simulation. The current world schema is v7 and captures: ID that last changed trust; - per-NPC known-event records that reference objective event history without 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, + target storage/resource/amount, and exact later resolution event identity. The top-level identity is: ```json { "schema": "the_steward.simulation", - "schema_version": 7 + "schema_version": 8 } ``` @@ -59,6 +61,8 @@ 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. NPCStateRecord v2 adds the resolved travel destination and whether it is active. Nested v1 NPC records migrate explicitly with no invented active @@ -147,6 +151,30 @@ facts, and recent facts at least one simulated day old are removed at the next deterministic daily-sized review. The objective event stream is never pruned by this rule. +SimulationStateRecord v8 adds the top-level `opportunities` array and +`simulation.next_opportunity_id`. The first nested `OpportunityStateRecord` +schema stores `opportunity_id`, `opportunity_type`, `status`, `created_tick`, +`trigger_event_id`, `interested_npc_id`, `target_id`, `resource_id`, +`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. +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 +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. + +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. + ## Resource authority `SimulationManager` owns `ResourceStateRecord` instances independently of the @@ -183,11 +211,13 @@ 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–v6 layouts; + supported v1–v7 layouts; - player inventory or player relationship records; - broader relationship dimensions, line-of-sight/hearing evidence, continuous or personalized memory decay, reinforcement, false beliefs, or multi-hop rumours; +- generalized opportunity/quest definitions, acceptance, rewards, assignment, + dialogue, or player-facing quest-log state; - persistence for the player transform or presentation-only scene state. Those features should build on this boundary rather than inventing parallel diff --git a/docs/baselines/SIMULATION_GARDEN_01.md b/docs/baselines/SIMULATION_GARDEN_01.md index 6ae592a..8774db5 100644 --- a/docs/baselines/SIMULATION_GARDEN_01.md +++ b/docs/baselines/SIMULATION_GARDEN_01.md @@ -1,6 +1,6 @@ # Simulation Garden 01 -Captured: 2026-07-12 +Captured: 2026-07-13 ## Files @@ -29,10 +29,12 @@ $env:LOCALAPPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot ``` The script warms up `main.tscn`, verifies that the simulation and active NPC -visuals exist, stages a real food-deposit/trust consequence plus one wind gust, -captures the F10 debug presentation, then captures the cinematic presentation -with development labels and UI hidden. The trust blossom is restarted only for -the second framing so both captures show the same real consequence clearly. +visuals exist, stages a real food-deposit/trust consequence, a one-food pantry +shortage, the hungry interested villager's ordinary gather-food replan, and one +wind gust. It captures the F10 debug presentation, then the cinematic +presentation with development labels and UI hidden. The trust blossom is +restarted only for the second framing so both captures show the same real +consequence clearly. ## Review @@ -109,3 +111,18 @@ consequence produces one brief amber halo-and-mote blossom above Tarik; it remains visible when F10 removes the debug UI but does not become permanent world clutter. Both refreshed images preserve the established elevated, slightly staggered top-down camera. + +## July 13 opportunity follow-up + +The debug capture now leaves one real `restock_empty_pantry` need open. The +left summary puts the cardless need above routine recent events and names Tarik +as the critically hungry interested villager; his inspector shows the same +zero-of-one pantry target next to the exact withdrawal memory. Capture staging +then asks the existing utility selector to replan Tarik, producing `Gather +Food`, a real finite food target, and the honest reason `Critically hungry; must +find food` instead of a stale pre-shortage decision. + +The inspector still fits the 1280×720 frame without adding another panel, and +the cinematic capture remains free of UI while preserving the task glyph, +trust blossom, and sparse calligraphic gust. Neither gameplay camera defaults +nor the established capture framing changed. diff --git a/docs/baselines/simulation_garden_01_cinematic.png b/docs/baselines/simulation_garden_01_cinematic.png index 7efb7c1..6687529 100644 Binary files a/docs/baselines/simulation_garden_01_cinematic.png and b/docs/baselines/simulation_garden_01_cinematic.png differ diff --git a/docs/baselines/simulation_garden_01_debug.png b/docs/baselines/simulation_garden_01_debug.png index 0c8eff1..e07be4d 100644 Binary files a/docs/baselines/simulation_garden_01_debug.png and b/docs/baselines/simulation_garden_01_debug.png differ diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 1d3149c..a713900 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -4,6 +4,9 @@ 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) @@ -18,6 +21,8 @@ signal relationship_changed(relationship: RelationshipStateRecord, cause_event: signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord) signal event_knowledge_transferred(speaker_id: int, listener_id: int, event: EconomicEventRecord) signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord) +signal opportunity_opened(opportunity: OpportunityStateRecord) +signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord) var village := SimVillage.new() @@ -37,6 +42,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 storage_states: Dictionary: get: return economy.storage_states @@ -595,6 +601,7 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void: ) for learned_record in learned_records: _apply_new_event_knowledge(learned_record, event) + _update_opportunity_from_event(event) economic_event_recorded.emit(event) @@ -617,6 +624,7 @@ func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bo continue _apply_new_event_knowledge(learned_record, event) event_knowledge_transferred.emit(speaker.id, listener.id, event) + _update_opportunity_from_event(event) return true return false @@ -663,6 +671,19 @@ func _apply_new_event_knowledge( _maintain_event_knowledge(false) +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 + ) + if changed == null: + return + if changed.get_status() == OpportunityStateRecord.STATUS_OPEN: + opportunity_opened.emit(changed) + return + opportunity_resolved.emit(changed, event) + _maintain_event_knowledge(false) + + func _maintain_event_knowledge(expire_by_age: bool) -> void: var forgotten := event_knowledge_system.maintain_retention( tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age @@ -688,6 +709,18 @@ func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]: continue seen[key] = true lasting.append(record) + var open_opportunity: OpportunityStateRecord = opportunity_system.get_open_opportunity() + if open_opportunity != null: + var opportunity_record := event_knowledge_system.get_record( + open_opportunity.get_interested_npc_id(), open_opportunity.get_trigger_event_id() + ) + if opportunity_record != null: + var opportunity_key := ( + "%d:%d" % [opportunity_record.get_knower_id(), opportunity_record.get_event_id()] + ) + if not seen.has(opportunity_key): + seen[opportunity_key] = true + lasting.append(opportunity_record) return lasting @@ -742,6 +775,29 @@ func get_retained_memory_events(npc_id: int, max_count: int = 4) -> Array[Econom return retained +func get_active_opportunity() -> OpportunityStateRecord: + return opportunity_system.get_open_opportunity() + + +func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord: + return opportunity_system.get_latest_for_npc(npc_id) + + +func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: + if opportunity == null: + return null + return event_log.get_by_id(opportunity.get_trigger_event_id()) + + +func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: + if ( + opportunity == null + or opportunity.get_resolution_event_id() == OpportunityStateRecord.NO_EVENT_ID + ): + return null + return event_log.get_by_id(opportunity.get_resolution_event_id()) + + func npc_knows_event(npc_id: int, event_id: int) -> bool: return event_knowledge_system.knows_event(npc_id, event_id) @@ -1032,6 +1088,7 @@ func create_state_record() -> SimulationStateRecord: "clock_elapsed_ticks": clock.elapsed_ticks, "wander_random_streams": wander_streams, "next_event_id": next_event_id, + "next_opportunity_id": opportunity_system.next_opportunity_id, "cycle_duration_seconds": clock.cycle_duration_seconds } record.village = VillageStateRecord.capture(village) @@ -1054,6 +1111,8 @@ func create_state_record() -> SimulationStateRecord: record.relationships.append(relationship) for known_event in event_knowledge_system.get_all_sorted(): record.event_knowledge.append(known_event) + for opportunity in opportunity_system.get_all_sorted(): + record.opportunities.append(opportunity) return record @@ -1091,6 +1150,7 @@ func restore_state(record: SimulationStateRecord) -> bool: npcs.append(npc_record.restore(debug_logs)) relationship_system.restore(record.relationships) event_knowledge_system.restore(record.event_knowledge) + opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"])) _maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0) wander_random_sources.clear() diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 782fe30..136e194 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -41,6 +41,10 @@ const EVENT_TASK_STARTED := &"task_started" const EVENT_TASK_BLOCKED := &"task_blocked" const EVENT_RESOURCE_DEPLETED := &"resource_depleted" +const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" +const OPPORTUNITY_STATUS_OPEN := &"open" +const OPPORTUNITY_STATUS_RESOLVED := &"resolved" + const KNOWLEDGE_ACQUISITION_PERFORMED := &"performed" const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed" const KNOWLEDGE_ACQUISITION_COMMUNICATED := &"communicated" diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index a03b656..ed99fcb 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -9,7 +9,7 @@ var known_events: Dictionary = {} func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[KnownEventStateRecord]: var learned: Array[KnownEventStateRecord] = [] - if not _is_witnessable(event): + if not is_knowable_event(event): return learned var actor := _find_npc(int(event.data["actor_id"]), npcs) if actor == null: @@ -44,7 +44,7 @@ func communicate_event( event == null or speaker_id == listener_id or listener_id == int(event.data["actor_id"]) - or not _is_witnessable(event) + or not is_knowable_event(event) ): return null var event_id := int(event.data["event_id"]) @@ -221,11 +221,21 @@ func _remember( return record -func _is_witnessable(event: EconomicEventRecord) -> bool: +static func is_knowable_event(event: EconomicEventRecord) -> bool: + if event == null or float(event.data["amount"]) <= 0.0: + return false + var event_type := StringName(event.data["event_type"]) + var item_id := StringName(event.data["item_id"]) + if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: + return item_id == SimulationIds.RESOURCE_FOOD + if event_type != SimulationIds.EVENT_STORAGE_WITHDRAWN: + return false + var actor_id := int(event.data["actor_id"]) return ( - StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED - and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD - and float(event.data["amount"]) > 0.0 + actor_id >= 0 + and item_id == SimulationIds.RESOURCE_FOOD + and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY + and (StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id)) ) diff --git a/simulation/opportunities/FoodShortageOpportunitySystem.gd b/simulation/opportunities/FoodShortageOpportunitySystem.gd new file mode 100644 index 0000000..ff0d921 --- /dev/null +++ b/simulation/opportunities/FoodShortageOpportunitySystem.gd @@ -0,0 +1,184 @@ +class_name FoodShortageOpportunitySystem +extends RefCounted + +const CARE_HUNGER_THRESHOLD := 80.0 + +var opportunities: Array[OpportunityStateRecord] = [] +var next_opportunity_id := 0 + + +func consider_event( + event: EconomicEventRecord, + current_tick: int, + pantry: StorageStateRecord, + npcs: Array[SimNPC], + knowledge_system: EventKnowledgeSystem, + resource_states: Dictionary +) -> 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) + if resolved != null: + return resolved + return _try_open(event, current_tick, pantry, npcs, knowledge_system) + + +func restore(records: Array[OpportunityStateRecord], restored_next_id: int) -> void: + opportunities = records.duplicate() + opportunities.sort_custom(_sort_by_id) + next_opportunity_id = maxi(restored_next_id, 0) + + +func get_all_sorted() -> Array[OpportunityStateRecord]: + var sorted := opportunities.duplicate() + sorted.sort_custom(_sort_by_id) + return sorted + + +func get_open_sorted() -> Array[OpportunityStateRecord]: + var open_records: Array[OpportunityStateRecord] = [] + for opportunity in opportunities: + if opportunity.is_open(): + open_records.append(opportunity) + open_records.sort_custom(_sort_by_id) + return open_records + + +func get_open_opportunity() -> OpportunityStateRecord: + var open_records := get_open_sorted() + return open_records[0] if not open_records.is_empty() else null + + +func get_latest() -> OpportunityStateRecord: + var sorted := get_all_sorted() + return sorted[-1] if not sorted.is_empty() else null + + +func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord: + for index in range(opportunities.size() - 1, -1, -1): + var opportunity := opportunities[index] + if opportunity.get_interested_npc_id() == npc_id: + return opportunity + return null + + +func _try_open( + event: EconomicEventRecord, + current_tick: int, + pantry: StorageStateRecord, + npcs: Array[SimNPC], + knowledge_system: EventKnowledgeSystem +) -> OpportunityStateRecord: + if knowledge_system == null or get_open_opportunity() != null: + return null + if pantry.get_amount(SimulationIds.RESOURCE_FOOD) > 0.0: + return null + if not _is_food_withdrawal(event): + return null + var interested_npc := _select_interested_npc( + int(event.data["event_id"]), npcs, knowledge_system + ) + if interested_npc == null: + return null + var opportunity := OpportunityStateRecord.create( + next_opportunity_id, current_tick, int(event.data["event_id"]), interested_npc.id + ) + next_opportunity_id += 1 + opportunities.append(opportunity) + return opportunity + + +func _try_resolve( + event: EconomicEventRecord, + pantry: 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(): + return null + if not _is_valid_supply(event, npcs, resource_states): + return null + if int(event.data["tick"]) < opportunity.get_created_tick(): + return null + if int(event.data["event_id"]) <= opportunity.get_trigger_event_id(): + return null + if not opportunity.resolve(int(event.data["event_id"]), int(event.data["tick"])): + return null + return opportunity + + +func _select_interested_npc( + event_id: int, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem +) -> SimNPC: + var candidates: Array[SimNPC] = [] + for npc in npcs: + if npc.is_dead or npc.hunger < CARE_HUNGER_THRESHOLD: + continue + if not knowledge_system.knows_event(npc.id, event_id): + continue + candidates.append(npc) + candidates.sort_custom(_sort_by_care) + return candidates[0] if not candidates.is_empty() else null + + +static func _is_food_withdrawal(event: EconomicEventRecord) -> bool: + var actor_id := int(event.data["actor_id"]) + return ( + StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_WITHDRAWN + and actor_id >= 0 + and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY + and StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id) + and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and float(event.data["amount"]) > 0.0 + ) + + +static func _is_valid_supply( + event: EconomicEventRecord, 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 + or float(event.data["amount"]) <= 0.0 + ): + return false + var event_type := StringName(event.data["event_type"]) + var actor_id := int(event.data["actor_id"]) + if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: + return ( + _find_npc(actor_id, npcs) != null + and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) + ) + if event_type != SimulationIds.EVENT_RESOURCE_EXTRACTED or actor_id != -1: + return false + var resource_state := ( + resource_states.get(StringName(event.data["source_id"])) as ResourceStateRecord + ) + return ( + resource_state != null + and resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD + and resource_state.can_player_use_resource() + ) + + +static func _sort_by_care(first: SimNPC, second: SimNPC) -> bool: + if first.hunger != second.hunger: + return first.hunger > second.hunger + return first.id < second.id + + +static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC: + for npc in npcs: + if npc.id == npc_id: + return npc + return null + + +static func _sort_by_id(first: OpportunityStateRecord, second: OpportunityStateRecord) -> bool: + return first.get_opportunity_id() < second.get_opportunity_id() diff --git a/simulation/opportunities/FoodShortageOpportunitySystem.gd.uid b/simulation/opportunities/FoodShortageOpportunitySystem.gd.uid new file mode 100644 index 0000000..d908a99 --- /dev/null +++ b/simulation/opportunities/FoodShortageOpportunitySystem.gd.uid @@ -0,0 +1 @@ +uid://r6gpw0hx8oe2 diff --git a/simulation/state/OpportunityStateRecord.gd b/simulation/state/OpportunityStateRecord.gd new file mode 100644 index 0000000..3f686c2 --- /dev/null +++ b/simulation/state/OpportunityStateRecord.gd @@ -0,0 +1,167 @@ +class_name OpportunityStateRecord +extends RefCounted + +const 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 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + opportunity_id: int, + created_tick: int, + trigger_event_id: int, + interested_npc_id: int, + target_amount: float = DEFAULT_TARGET_AMOUNT +) -> OpportunityStateRecord: + return ( + OpportunityStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "opportunity_id": opportunity_id, + "opportunity_type": String(SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY), + "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_amount": target_amount, + "resolution_event_id": NO_EVENT_ID, + "resolved_tick": -1, + } + ) + ) + + +static func from_dictionary(record_data: Dictionary) -> OpportunityStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "opportunity_id", + "opportunity_type", + "status", + "created_tick", + "trigger_event_id", + "interested_npc_id", + "target_id", + "resource_id", + "target_amount", + "resolution_event_id", + "resolved_tick", + ] + ) + ): + 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"]) + 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 + 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: + return null + STATUS_RESOLVED: + if resolution_event_id <= trigger_event_id or resolved_tick < created_tick: + return null + _: + return null + var record := create( + opportunity_id, created_tick, trigger_event_id, interested_npc_id, target_amount + ) + if status == STATUS_RESOLVED: + record.resolve(resolution_event_id, resolved_tick) + return record + + +func resolve(resolution_event_id: int, resolved_tick: int) -> bool: + if ( + not is_open() + or resolution_event_id <= get_trigger_event_id() + or resolved_tick < get_created_tick() + ): + return false + data["status"] = String(STATUS_RESOLVED) + data["resolution_event_id"] = resolution_event_id + data["resolved_tick"] = resolved_tick + return true + + +func get_opportunity_id() -> int: + return int(data["opportunity_id"]) + + +func get_opportunity_type() -> StringName: + return StringName(data["opportunity_type"]) + + +func get_status() -> StringName: + return StringName(data["status"]) + + +func get_created_tick() -> int: + return int(data["created_tick"]) + + +func get_trigger_event_id() -> int: + return int(data["trigger_event_id"]) + + +func get_interested_npc_id() -> int: + return int(data["interested_npc_id"]) + + +func get_target_id() -> StringName: + return StringName(data["target_id"]) + + +func get_resource_id() -> StringName: + return StringName(data["resource_id"]) + + +func get_target_amount() -> float: + return float(data["target_amount"]) + + +func get_resolution_event_id() -> int: + return int(data["resolution_event_id"]) + + +func get_resolved_tick() -> int: + return int(data["resolved_tick"]) + + +func is_open() -> bool: + return get_status() == STATUS_OPEN + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/OpportunityStateRecord.gd.uid b/simulation/state/OpportunityStateRecord.gd.uid new file mode 100644 index 0000000..a948fae --- /dev/null +++ b/simulation/state/OpportunityStateRecord.gd.uid @@ -0,0 +1 @@ +uid://ce2xl34unwkef diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 5a01e63..a5142ea 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 := 7 +const SCHEMA_VERSION := 8 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 PREVIOUS_SCHEMA_VERSION := 6 +const RETENTION_LEGACY_SCHEMA_VERSION := 6 +const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary var village: VillageStateRecord @@ -18,6 +19,7 @@ var storages: Array[StorageStateRecord] = [] var economic_events: Array[EconomicEventRecord] = [] var relationships: Array[RelationshipStateRecord] = [] var event_knowledge: Array[KnownEventStateRecord] = [] +var opportunities: Array[OpportunityStateRecord] = [] func to_dictionary() -> Dictionary: @@ -40,6 +42,9 @@ func to_dictionary() -> Dictionary: var knowledge_data: Array[Dictionary] = [] for known_event_record in event_knowledge: knowledge_data.append(known_event_record.to_dictionary()) + var opportunity_data: Array[Dictionary] = [] + for opportunity_record in opportunities: + opportunity_data.append(opportunity_record.to_dictionary()) return { "schema": SCHEMA_NAME, @@ -51,7 +56,8 @@ func to_dictionary() -> Dictionary: "storages": storage_data, "economic_events": event_data, "relationships": relationship_data, - "event_knowledge": knowledge_data + "event_knowledge": knowledge_data, + "opportunities": opportunity_data } @@ -78,6 +84,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: RELATIONSHIP_LEGACY_SCHEMA_VERSION, KNOWLEDGE_LEGACY_SCHEMA_VERSION, PROVENANCE_LEGACY_SCHEMA_VERSION, + RETENTION_LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, ] ): @@ -96,6 +103,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "economic_events", "relationships", "event_knowledge", + "opportunities", ] ) ): @@ -104,22 +112,27 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var simulation_data = record_data["simulation"] if not simulation_data is Dictionary: return null - if not simulation_data.has_all( - [ - "seed", - "tick_interval", - "tick_count", - "clock_accumulator", - "clock_elapsed_ticks", - "wander_random_streams", - "next_event_id" - ] + if not ( + simulation_data + . has_all( + [ + "seed", + "tick_interval", + "tick_count", + "clock_accumulator", + "clock_elapsed_ticks", + "wander_random_streams", + "next_event_id", + "next_opportunity_id", + ] + ) ): return null var saved_tick_interval := float(simulation_data["tick_interval"]) var saved_tick_count := int(simulation_data["tick_count"]) var saved_clock_accumulator := float(simulation_data["clock_accumulator"]) var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"]) + var saved_next_opportunity_id := int(simulation_data["next_opportunity_id"]) if ( not is_finite(saved_tick_interval) or saved_tick_interval <= 0.0 @@ -127,6 +140,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: or not is_finite(saved_clock_accumulator) or saved_clock_accumulator < 0.0 or saved_clock_elapsed_ticks < 0 + or saved_next_opportunity_id < 0 ): return null if simulation_data.has("cycle_duration_seconds"): @@ -155,6 +169,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var event_data = record_data["economic_events"] var relationship_data = record_data["relationships"] var knowledge_data = record_data["event_knowledge"] + var opportunity_data = record_data["opportunities"] if ( not npc_data is Array or not resource_data is Array @@ -162,6 +177,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: or not event_data is Array or not relationship_data is Array or not knowledge_data is Array + or not opportunity_data is Array ): return null @@ -183,6 +199,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: record.npcs.append(npc_record) var resource_ids := {} + var resource_records_by_id := {} for item in resource_data: if not item is Dictionary: return null @@ -193,9 +210,11 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if resource_ids.has(resource_id): return null resource_ids[resource_id] = true + resource_records_by_id[resource_id] = resource_record record.resources.append(resource_record) var storage_ids := {} + var storage_records_by_id := {} for item in storage_data: if not item is Dictionary: return null @@ -206,6 +225,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if storage_ids.has(storage_id): return null storage_ids[storage_id] = true + storage_records_by_id[storage_id] = storage_record record.storages.append(storage_record) var event_ids := {} @@ -228,6 +248,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null var knowledge_keys := {} + var knowledge_records_by_key := {} for item in knowledge_data: if not item is Dictionary: return null @@ -242,6 +263,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if knowledge_keys.has(knowledge_key): return null knowledge_keys[knowledge_key] = true + knowledge_records_by_key[knowledge_key] = known_event_record record.event_knowledge.append(known_event_record) for known_event_record in record.event_knowledge: if not _is_valid_knowledge_provenance( @@ -289,6 +311,46 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: relationship_keys[relationship_key] = true record.relationships.append(relationship_record) + var opportunity_ids := {} + var trigger_event_ids := {} + var resolution_event_ids := {} + var has_open_opportunity := false + var highest_opportunity_id := -1 + for item in opportunity_data: + if not item is Dictionary: + return null + var opportunity_record := OpportunityStateRecord.from_dictionary(item) + if opportunity_record == null: + return null + var opportunity_id := opportunity_record.get_opportunity_id() + var trigger_event_id := opportunity_record.get_trigger_event_id() + if opportunity_ids.has(opportunity_id) or trigger_event_ids.has(trigger_event_id): + return null + if opportunity_record.get_status() == OpportunityStateRecord.STATUS_OPEN: + if has_open_opportunity: + return null + has_open_opportunity = true + elif opportunity_record.get_resolution_event_id() != OpportunityStateRecord.NO_EVENT_ID: + if resolution_event_ids.has(opportunity_record.get_resolution_event_id()): + return null + resolution_event_ids[opportunity_record.get_resolution_event_id()] = true + if not _is_valid_opportunity( + opportunity_record, + npc_ids, + storage_records_by_id, + event_records_by_id, + knowledge_records_by_key, + resource_records_by_id, + int(record.simulation["tick_count"]) + ): + return null + opportunity_ids[opportunity_id] = true + trigger_event_ids[trigger_event_id] = true + highest_opportunity_id = maxi(highest_opportunity_id, opportunity_id) + record.opportunities.append(opportunity_record) + if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id: + return null + return record @@ -310,11 +372,7 @@ static func _is_valid_knowledge_provenance( return false if method == SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY: return true - if ( - StringName(event.data["event_type"]) != SimulationIds.EVENT_STORAGE_DEPOSITED - or StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD - or float(event.data["amount"]) <= 0.0 - ): + if not EventKnowledgeSystem.is_knowable_event(event): return false match method: SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED: @@ -337,6 +395,117 @@ static func _is_valid_knowledge_provenance( return false +static func _is_valid_opportunity( + opportunity: OpportunityStateRecord, + npc_ids: 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 + ): + return false + 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): + 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: + return false + + var status := opportunity.get_status() + var resolution_event_id := opportunity.get_resolution_event_id() + var resolved_tick := opportunity.get_resolved_tick() + if status == OpportunityStateRecord.STATUS_OPEN: + var target_storage := ( + storage_records_by_id.get(opportunity.get_target_id()) as StorageStateRecord + ) + var evidence_key := ( + "%d:%d" % [opportunity.get_interested_npc_id(), opportunity.get_trigger_event_id()] + ) + var evidence := knowledge_records_by_key.get(evidence_key) as KnownEventStateRecord + return ( + resolution_event_id == OpportunityStateRecord.NO_EVENT_ID + and resolved_tick == -1 + and target_storage != null + and ( + target_storage.get_amount(opportunity.get_resource_id()) + < opportunity.get_target_amount() + ) + and evidence != null + and evidence.get_acquired_tick() <= created_tick + ) + if status != OpportunityStateRecord.STATUS_RESOLVED: + return false + if resolution_event_id == OpportunityStateRecord.NO_EVENT_ID: + return false + 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) + ): + return false + var resolution_tick := int(resolution_event.data["tick"]) + return ( + resolution_event_id > opportunity.get_trigger_event_id() + and resolution_tick >= created_tick + and resolved_tick == resolution_tick + and resolved_tick <= current_tick + ) + + +static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids: Dictionary) -> bool: + var actor_id := int(event.data["actor_id"]) + return ( + npc_ids.has(actor_id) + and StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_WITHDRAWN + and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY + and StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id) + and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and float(event.data["amount"]) > 0.0 + ) + + +static func _is_valid_pantry_supply_resolution( + event: EconomicEventRecord, 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 + or float(event.data["amount"]) <= 0.0 + ): + return false + if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: + return ( + npc_ids.has(actor_id) + and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) + ) + if event_type == SimulationIds.EVENT_RESOURCE_EXTRACTED and actor_id == -1: + var resource_state := ( + resource_records_by_id.get(StringName(event.data["source_id"])) as ResourceStateRecord + ) + return ( + resource_state != null + and resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD + and resource_state.can_player_use_resource() + ) + return false + + static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary: var migrated := legacy_data.duplicate(true) migrated["schema_version"] = SCHEMA_VERSION @@ -390,6 +559,10 @@ 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 return migrated diff --git a/tests/food_shortage_opportunity_test.gd b/tests/food_shortage_opportunity_test.gd new file mode 100644 index 0000000..0134cab --- /dev/null +++ b/tests/food_shortage_opportunity_test.gd @@ -0,0 +1,369 @@ +extends SceneTree + +var failures: Array[String] = [] +var opened_ids: Array[int] = [] +var resolved_ids: Array[int] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var manager := _create_manager() + manager.opportunity_opened.connect(_on_opportunity_opened) + manager.opportunity_resolved.connect(_on_opportunity_resolved) + var actor: SimNPC = manager.npcs[0] + var witness: SimNPC = manager.npcs[1] + var interested: SimNPC = manager.npcs[2] + var supplier: SimNPC = manager.npcs[3] + var pantry: StorageStateRecord = manager.get_pantry() + _set_pantry_amount(manager, 1.0) + actor.position = Vector3.ZERO + witness.position = Vector3(4.0, 0.0, 0.0) + interested.position = Vector3(20.0, 0.0, 0.0) + supplier.position = Vector3(40.0, 0.0, 0.0) + actor.hunger = 60.0 + witness.hunger = 20.0 + interested.hunger = 85.0 + + _check( + manager.get_active_opportunity() == null, + "An empty-or-low pantry alone must not invent an opportunity" + ) + _check( + is_equal_approx( + manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0), 1.0 + ), + "The opportunity trigger should be a real last-food withdrawal" + ) + var trigger_event := _latest_event_of_type( + manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, actor.id + ) + _check(trigger_event != null, "The last-food withdrawal should exist in objective history") + if trigger_event == null: + manager.free() + _finish() + return + var trigger_event_id := int(trigger_event.data["event_id"]) + _check( + ( + is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0) + and manager.npc_knows_event(actor.id, trigger_event_id) + and manager.npc_knows_event(witness.id, trigger_event_id) + and not manager.npc_knows_event(interested.id, trigger_event_id) + and manager.get_active_opportunity() == null + ), + "Knowledge without critical hunger and critical hunger without knowledge should not open" + ) + + _prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0)) + var event_count_before_open: int = manager.economic_events.size() + _check( + manager.try_communicate_at_shared_activity(actor.id, interested.id), + "A direct knower should be able to report the empty pantry at shared work" + ) + var opportunity: OpportunityStateRecord = manager.get_active_opportunity() + _check(opportunity != null, "A critically hungry knower should open the village need") + if opportunity == null: + manager.free() + _finish() + return + _check( + ( + opportunity.get_opportunity_id() == 0 + and opportunity.get_trigger_event_id() == trigger_event_id + and opportunity.get_interested_npc_id() == interested.id + and opportunity.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY + and opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD + and is_equal_approx(opportunity.get_target_amount(), 1.0) + and opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN + ), + "The open opportunity should reference the real knower, trigger, storage, and goal" + ) + _check( + ( + opened_ids == [opportunity.get_opportunity_id()] + and manager.economic_events.size() == event_count_before_open + and manager.opportunity_system.next_opportunity_id == 1 + ), + "Opening should emit once without adding quest-only objective events" + ) + _check( + manager.is_known_event_lasting(interested.id, trigger_event_id), + "An unresolved village need should protect its interested villager's causal memory" + ) + _check( + ( + not manager.try_communicate_at_shared_activity(actor.id, interested.id) + and manager.opportunity_system.get_all_sorted().size() == 1 + ), + "Repeated reports should neither replace provenance nor duplicate an open need" + ) + + var active_json: String = manager.serialize_state() + 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" + ) + _check( + ( + active_restored.get_state_checksum() == manager.get_state_checksum() + and active_restored.get_active_opportunity() != null + and active_restored.is_known_event_lasting(interested.id, trigger_event_id) + ), + "Active restore should preserve checksum, stable identity, and protected evidence" + ) + active_restored.tick_count = active_restored.get_knowledge_review_interval() + active_restored.call("_maintain_event_knowledge", true) + _check( + active_restored.npc_knows_event(interested.id, trigger_event_id), + "Daily memory review must retain the evidence for an unresolved opportunity" + ) + var restored_supplier: SimNPC = active_restored.npcs[3] + restored_supplier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + _check( + is_equal_approx( + active_restored.economy.deposit_inventory( + restored_supplier, SimulationIds.RESOURCE_FOOD + ), + 1.0 + ), + "A restored opportunity should still accept a real pantry supply event" + ) + var restored_resolution: OpportunityStateRecord = ( + active_restored.get_latest_opportunity_for_npc(interested.id) + ) + _check( + ( + restored_resolution.get_status() == OpportunityStateRecord.STATUS_RESOLVED + and active_restored.get_active_opportunity() == null + and ( + ( + active_restored + . get_opportunity_resolution_event(restored_resolution) + . data["event_id"] + ) + == restored_resolution.get_resolution_event_id() + ) + ), + "Restored wiring should close exactly once against the new persisted event ID" + ) + active_restored.free() + + interested.position = Vector3(21.0, 0.0, 0.0) + supplier.position = Vector3(22.0, 0.0, 0.0) + supplier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + var event_count_before_resolution: int = manager.economic_events.size() + _check( + is_equal_approx( + manager.economy.deposit_inventory(supplier, SimulationIds.RESOURCE_FOOD), 1.0 + ), + "An NPC should resolve the need through the real inventory-to-pantry transaction" + ) + var resolved: OpportunityStateRecord = manager.get_latest_opportunity_for_npc(interested.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 resolved.get_resolution_event_id() == int(resolution_event.data["event_id"]) + and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 1.0) + ), + "The opportunity should close on the exact later NPC supply event" + ) + _check( + ( + resolved_ids == [opportunity.get_opportunity_id()] + and manager.economic_events.size() == event_count_before_resolution + 1 + ), + "Resolution should emit once and reuse the supply event instead of creating quest history" + ) + _check( + not manager.is_known_event_lasting(interested.id, trigger_event_id), + "Resolved needs should release their trigger evidence back to normal memory retention" + ) + var supplier_relationship: RelationshipStateRecord = ( + manager.relationship_system.get_relationship(interested.id, supplier.id) + ) + _check( + ( + supplier_relationship != null + and is_equal_approx(supplier_relationship.get_trust(), 0.65) + and ( + supplier_relationship.get_last_trust_cause_event_id() + == resolved.get_resolution_event_id() + ) + ), + "The real resolving deposit should retain its existing witnessed trust consequence" + ) + + var resolved_json: String = manager.serialize_state() + var resolved_restored := _create_manager(903) + _check( + resolved_restored.restore_state_from_json(resolved_json), + "Resolved opportunity history should restore" + ) + _check( + ( + resolved_restored.get_state_checksum() == manager.get_state_checksum() + and resolved_restored.get_active_opportunity() == null + and ( + resolved_restored.get_latest_opportunity_for_npc(interested.id).get_status() + == OpportunityStateRecord.STATUS_RESOLVED + ) + ), + "Resolved restore should preserve checksum without reopening the old shortage" + ) + resolved_restored.free() + manager.free() + + opened_ids.clear() + resolved_ids.clear() + var player_manager := _create_manager(904) + var player_setup := _open_via_communication(player_manager) + var player_interested: SimNPC = player_setup["interested"] + var berry := ResourceNode.new() + berry.name = "OpportunityBerryBush" + berry.node_id = &"opportunity_berry_bush" + berry.action_id = SimulationIds.ACTION_GATHER_FOOD + berry.resource_id = SimulationIds.RESOURCE_FOOD + berry.initial_amount = 1.0 + berry.yield_per_action = 1.0 + berry.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + berry.add_child(interaction_point) + root.add_child(berry) + await process_frame + _check( + player_manager.register_resource_node(berry), + "The player resolution branch should bind a real finite ResourceNode" + ) + _check( + is_equal_approx(player_manager.harvest_resource_node(berry), 1.0), + "Player harvesting should supply the real pantry through the existing command" + ) + var player_resolved: OpportunityStateRecord = player_manager.get_latest_opportunity_for_npc( + player_interested.id + ) + var player_resolution: EconomicEventRecord = player_manager.get_opportunity_resolution_event( + player_resolved + ) + _check( + ( + player_resolved != null + and player_resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED + and player_resolution != null + and ( + StringName(player_resolution.data["event_type"]) + == SimulationIds.EVENT_RESOURCE_EXTRACTED + ) + and int(player_resolution.data["actor_id"]) == -1 + and ( + StringName(player_resolution.data["destination_id"]) + == SimulationIds.STORAGE_VILLAGE_PANTRY + ) + ), + "Player harvesting should be the second real resolution path with exact history" + ) + + berry.queue_free() + player_manager.free() + _finish() + + +func _open_via_communication(manager: Node) -> Dictionary: + var actor: SimNPC = manager.npcs[0] + var interested: SimNPC = manager.npcs[2] + _set_pantry_amount(manager, 1.0) + for npc in manager.npcs: + npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0) + actor.position = Vector3.ZERO + actor.hunger = 60.0 + interested.hunger = 90.0 + manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0) + _prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0)) + _check( + manager.try_communicate_at_shared_activity(actor.id, interested.id), + "Player branch should open from the same one-hop known shortage" + ) + _check(manager.get_active_opportunity() != null, "Player branch needs an active opportunity") + return {"actor": actor, "interested": interested} + + +func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void: + for npc in [first, second]: + npc.set_task(SimulationIds.ACTION_PATROL) + npc.target_id = &"guard_post" + npc.start_working() + first.position = position + second.position = position + Vector3(1.0, 0.0, 0.0) + + +func _set_pantry_amount(manager: Node, amount: float) -> void: + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + pantry.deposit(SimulationIds.RESOURCE_FOOD, amount) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + + +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_manager(seed_value: int = 901) -> 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(10.0, 0.0, 0.0), + Vector3(12.0, 0.0, 0.0), + Vector3(30.0, 0.0, 30.0), + Vector3(40.0, 0.0, 40.0), + ] + manager.home_positions = home_positions + root.add_child(manager) + manager.set_process(false) + return manager + + +func _on_opportunity_opened(opportunity: OpportunityStateRecord) -> void: + opened_ids.append(opportunity.get_opportunity_id()) + + +func _on_opportunity_resolved( + opportunity: OpportunityStateRecord, _cause_event: EconomicEventRecord +) -> void: + resolved_ids.append(opportunity.get_opportunity_id()) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Food shortage opportunity passed: known need -> real supply") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/food_shortage_opportunity_test.gd.uid b/tests/food_shortage_opportunity_test.gd.uid new file mode 100644 index 0000000..44467d8 --- /dev/null +++ b/tests/food_shortage_opportunity_test.gd.uid @@ -0,0 +1 @@ +uid://c42mc37eiqjrw diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index a10d49e..939e04f 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -119,6 +119,105 @@ func _run() -> void: not listener_visual.get_node("TrustReactionRoot").visible, "Learning a fact without a relationship consequence should not show a trust blossom" ) + + var village_stats_label := ( + main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label + ) + var pantry_state: StorageStateRecord = simulation_manager.get_pantry() + contributor.hunger = 85.0 + var withdrawn_for_need: float = simulation_manager.economy.withdraw_to_inventory( + contributor, + SimulationIds.RESOURCE_FOOD, + pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) + ) + _check(withdrawn_for_need > 0.0, "Runtime opportunity setup should empty the real pantry") + var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() + _check(opportunity != null, "Emptying the pantry should open one known shortage need") + _check( + ( + "Village need" in village_stats_label.text + and "◆ Restock the empty pantry" in village_stats_label.text + and ( + "%s is worried about the shortage" % contributor.npc_name + in village_stats_label.text + ) + and "Food 0 / 1" in village_stats_label.text + ), + "An open opportunity should add one compact state-derived village need" + ) + village_ui.selected_npc_index = contributor.id + village_ui.call("_refresh_npc_inspector") + _check( + ( + "Open village need" in inspector_label.text + and "◆ Restock the empty pantry" in inspector_label.text + and "Food 0 / 1" in inspector_label.text + ), + "The interested villager should expose the open pantry need in their inspector" + ) + village_ui.selected_npc_index = listener.id + village_ui.call("_refresh_npc_inspector") + _check( + "Open village need" not in inspector_label.text, + "An unrelated selected villager should not inherit someone else's open need" + ) + + _check( + ( + simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD) + > 0.0 + ), + "Opportunity presentation should resolve from a real pantry deposit" + ) + _check( + "Village need" not in village_stats_label.text, + "Resolved opportunities should leave the compact village summary" + ) + village_ui.selected_npc_index = contributor.id + village_ui.call("_refresh_npc_inspector") + _check( + ( + "Resolved village need" in inspector_label.text + and "◆ %s restocked the pantry" % contributor.npc_name in inspector_label.text + and "Food target 1" in inspector_label.text + ), + "The interested villager should retain a resolved detail naming the real actor" + ) + village_ui.selected_npc_index = listener.id + village_ui.call("_refresh_npc_inspector") + _check( + "Resolved village need" not in inspector_label.text, + "Resolved opportunity detail should remain scoped to its interested villager" + ) + + var player_need_withdrawal: float = simulation_manager.economy.withdraw_to_inventory( + contributor, + SimulationIds.RESOURCE_FOOD, + pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) + ) + _check(player_need_withdrawal > 0.0, "Player resolution setup should reopen a real need") + var player_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() + _check(player_opportunity != null, "A later shortage should open a new opportunity record") + var player := main_scene.get_node("Player") as Node3D + var opportunity_bush := ( + main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode + ) + var opportunity_bush_state: ResourceStateRecord = simulation_manager.get_resource_state( + opportunity_bush.node_id + ) + opportunity_bush_state.set_amount_remaining(1.0) + player.global_position = opportunity_bush.interaction_point.global_position + player.call("try_interact") + village_ui.selected_npc_index = contributor.id + village_ui.call("_refresh_npc_inspector") + _check( + ( + player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED + and "Resolved village need" in inspector_label.text + and "◆ Player restocked the pantry" in inspector_label.text + ), + "Player gathering should resolve the same need and be named from its real event" + ) _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 a08633c..51320e2 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -18,7 +18,9 @@ func _run() -> void: _test_previous_world_knowledge_migration() _test_previous_world_provenance_migration() _test_previous_world_retention_migration() + _test_previous_world_opportunity_migration() _test_relationship_schema_rejection() + _test_opportunity_schema_rejection() _test_schema_rejection() if failures.is_empty(): @@ -371,7 +373,7 @@ func _test_previous_world_provenance_migration() -> void: func _test_previous_world_retention_migration() -> void: var manager := _create_manager(61) var previous_data: Dictionary = manager.create_state_record().to_dictionary() - previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION + previous_data["schema_version"] = SimulationStateRecord.RETENTION_LEGACY_SCHEMA_VERSION previous_data["simulation"]["tick_count"] = 50 var deposit_event := EconomicEventRecord.create( 0, @@ -439,6 +441,25 @@ func _test_previous_world_retention_migration() -> void: manager.free() +func _test_previous_world_opportunity_migration() -> void: + var manager := _create_manager(62) + var previous_data: Dictionary = manager.create_state_record().to_dictionary() + previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION + previous_data.erase("opportunities") + previous_data["simulation"].erase("next_opportunity_id") + var migrated := SimulationStateRecord.from_dictionary(previous_data) + _check(migrated != null, "World schema v7 should add an empty opportunity stream") + if migrated != null: + _check( + ( + migrated.opportunities.is_empty() + and int(migrated.simulation["next_opportunity_id"]) == 0 + ), + "World v7 migration should initialize deterministic opportunity identity" + ) + manager.free() + + func _test_relationship_schema_rejection() -> void: var manager := _create_manager(58) var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary() @@ -685,6 +706,249 @@ func _test_relationship_schema_rejection() -> void: manager.free() +func _test_opportunity_schema_rejection() -> void: + var manager := _create_manager(63) + var valid_open := _build_opportunity_world(manager, false) + _check( + SimulationStateRecord.from_dictionary(valid_open) != null, + "A known empty-pantry trigger should accept one open restock opportunity" + ) + + var missing_knowledge := valid_open.duplicate(true) + missing_knowledge["event_knowledge"] = [] + _check( + SimulationStateRecord.from_dictionary(missing_knowledge) == null, + "An open opportunity should require its interested NPC to know the trigger" + ) + var knowledge_acquired_too_late := valid_open.duplicate(true) + knowledge_acquired_too_late["event_knowledge"][0]["acquisition_method"] = String( + SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY + ) + knowledge_acquired_too_late["event_knowledge"][0]["acquired_tick"] = 3 + _check( + SimulationStateRecord.from_dictionary(knowledge_acquired_too_late) == null, + "An open opportunity cannot predate the interested NPC's knowledge" + ) + var already_supplied := valid_open.duplicate(true) + for storage_data in already_supplied["storages"]: + if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY: + storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 1.0 + _check( + SimulationStateRecord.from_dictionary(already_supplied) == null, + "An open pantry need should reject a save whose target is already supplied" + ) + + var duplicate_id := valid_open.duplicate(true) + var second_opportunity: Dictionary = duplicate_id["opportunities"][0].duplicate(true) + second_opportunity["trigger_event_id"] = 1 + duplicate_id["economic_events"].append(_create_pantry_withdrawal_event(1, 3, 1).to_dictionary()) + duplicate_id["event_knowledge"].append( + ( + KnownEventStateRecord + . create( + 1, + 1, + SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, + KnownEventStateRecord.NO_SOURCE_NPC_ID, + 3 + ) + . to_dictionary() + ) + ) + duplicate_id["opportunities"].append(second_opportunity) + duplicate_id["simulation"]["next_event_id"] = 2 + duplicate_id["simulation"]["next_opportunity_id"] = 2 + _check( + SimulationStateRecord.from_dictionary(duplicate_id) == null, + "Opportunity IDs should be unique" + ) + var multiple_open := duplicate_id.duplicate(true) + multiple_open["opportunities"][1]["opportunity_id"] = 1 + _check( + SimulationStateRecord.from_dictionary(multiple_open) == null, + "The bounded pantry slice should restore at most one open opportunity" + ) + + var duplicate_trigger := valid_open.duplicate(true) + var repeated_trigger: Dictionary = duplicate_trigger["opportunities"][0].duplicate(true) + repeated_trigger["opportunity_id"] = 1 + duplicate_trigger["opportunities"].append(repeated_trigger) + duplicate_trigger["simulation"]["next_opportunity_id"] = 2 + _check( + SimulationStateRecord.from_dictionary(duplicate_trigger) == null, + "One objective trigger should not create duplicate opportunity records" + ) + + var colliding_next_id := valid_open.duplicate(true) + colliding_next_id["simulation"]["next_opportunity_id"] = 0 + _check( + SimulationStateRecord.from_dictionary(colliding_next_id) == null, + "The next opportunity ID should remain above restored history" + ) + + var unknown_interested_npc := valid_open.duplicate(true) + unknown_interested_npc["opportunities"][0]["interested_npc_id"] = 999 + _check( + SimulationStateRecord.from_dictionary(unknown_interested_npc) == null, + "Opportunities should reference an existing interested NPC" + ) + + var future_creation := valid_open.duplicate(true) + future_creation["opportunities"][0]["created_tick"] = 11 + _check( + SimulationStateRecord.from_dictionary(future_creation) == null, + "Opportunity creation cannot occur after the saved simulation tick" + ) + + var before_trigger := valid_open.duplicate(true) + before_trigger["opportunities"][0]["created_tick"] = 1 + _check( + SimulationStateRecord.from_dictionary(before_trigger) == null, + "Opportunity creation cannot predate its trigger event" + ) + + var invalid_trigger := valid_open.duplicate(true) + invalid_trigger["economic_events"][0]["destination_id"] = "unrelated_inventory" + _check( + SimulationStateRecord.from_dictionary(invalid_trigger) == null, + "A restock opportunity trigger should be a real NPC pantry withdrawal" + ) + + var player_trigger := valid_open.duplicate(true) + player_trigger["economic_events"][0]["actor_id"] = -1 + player_trigger["economic_events"][0]["destination_id"] = "player_inventory" + _check( + SimulationStateRecord.from_dictionary(player_trigger) == null, + "Player extraction or consumption should not masquerade as an informed NPC trigger" + ) + + var valid_resolved := _build_opportunity_world(manager, true) + _check( + SimulationStateRecord.from_dictionary(valid_resolved) != null, + "A later NPC pantry deposit should resolve an opportunity without retained knowledge" + ) + + var player_resolved := _build_opportunity_world(manager, true) + player_resolved["economic_events"][1] = ( + EconomicEventRecord + . create( + 1, + SimulationIds.EVENT_RESOURCE_EXTRACTED, + 3, + -1, + &"schema_test_berry", + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 1.0 + ) + . to_dictionary() + ) + player_resolved["resources"].append( + _create_player_food_resource_state(&"schema_test_berry").to_dictionary() + ) + _check( + SimulationStateRecord.from_dictionary(player_resolved) != null, + "A later player resource extraction into the pantry should be a valid resolution" + ) + var unknown_player_source := player_resolved.duplicate(true) + unknown_player_source["economic_events"][1]["source_id"] = "missing_berry" + _check( + SimulationStateRecord.from_dictionary(unknown_player_source) == null, + "A player resolution should reference a real player-usable food resource" + ) + + var invalid_resolution := valid_resolved.duplicate(true) + invalid_resolution["economic_events"][1]["destination_id"] = "somewhere_else" + _check( + SimulationStateRecord.from_dictionary(invalid_resolution) == null, + "Resolution events should actually supply food to the opportunity target" + ) + + var mismatched_resolution_tick := valid_resolved.duplicate(true) + mismatched_resolution_tick["opportunities"][0]["resolved_tick"] = 4 + _check( + SimulationStateRecord.from_dictionary(mismatched_resolution_tick) == null, + "Resolved opportunity ticks should match the referenced resolution event" + ) + manager.free() + + +func _build_opportunity_world(manager: Node, resolved: bool) -> Dictionary: + var world: Dictionary = manager.create_state_record().to_dictionary() + world["simulation"]["tick_count"] = 10 + for storage_data in world["storages"]: + if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY: + storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = ( + 1.0 if resolved else 0.0 + ) + world["economic_events"] = [_create_pantry_withdrawal_event(0, 2, 0).to_dictionary()] + world["simulation"]["next_event_id"] = 1 + var opportunity := OpportunityStateRecord.create(0, 2, 0, 0) + if resolved: + world["economic_events"].append( + ( + EconomicEventRecord + . create( + 1, + SimulationIds.EVENT_STORAGE_DEPOSITED, + 3, + 1, + SimulationIds.npc_inventory_id(1), + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 1.0 + ) + . to_dictionary() + ) + ) + world["simulation"]["next_event_id"] = 2 + opportunity.resolve(1, 3) + world["event_knowledge"] = [] + else: + world["event_knowledge"] = [ + ( + KnownEventStateRecord + . create( + 0, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, + KnownEventStateRecord.NO_SOURCE_NPC_ID, + 2 + ) + . to_dictionary() + ) + ] + world["opportunities"] = [opportunity.to_dictionary()] + world["simulation"]["next_opportunity_id"] = 1 + return world + + +func _create_pantry_withdrawal_event( + event_id: int, tick: int, actor_id: int +) -> EconomicEventRecord: + return EconomicEventRecord.create( + event_id, + SimulationIds.EVENT_STORAGE_WITHDRAWN, + tick, + actor_id, + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.npc_inventory_id(actor_id), + SimulationIds.RESOURCE_FOOD, + 1.0 + ) + + +func _create_player_food_resource_state(node_id: StringName) -> ResourceStateRecord: + var resource_node := ResourceNode.new() + resource_node.node_id = node_id + resource_node.action_id = SimulationIds.ACTION_GATHER_FOOD + resource_node.resource_id = SimulationIds.RESOURCE_FOOD + resource_node.can_player_use = true + var resource_state := ResourceStateRecord.create_from_node(resource_node) + resource_node.free() + return resource_state + + func _create_manager(seed_value: int) -> Node: var manager: Node = load("res://simulation/SimulationManager.gd").new() manager.simulation_seed = seed_value diff --git a/tests/unit/test_food_shortage_opportunity_system.gd b/tests/unit/test_food_shortage_opportunity_system.gd new file mode 100644 index 0000000..af17f69 --- /dev/null +++ b/tests/unit/test_food_shortage_opportunity_system.gd @@ -0,0 +1,316 @@ +extends GutTest + +const FoodShortageOpportunitySystemScript := preload( + "res://simulation/opportunities/FoodShortageOpportunitySystem.gd" +) + + +func test_opening_requires_current_knowledge_and_care() -> void: + var system := FoodShortageOpportunitySystemScript.new() + var pantry := _empty_pantry() + var npc := _npc(0, 90.0) + var npcs: Array[SimNPC] = [npc] + var knowledge := EventKnowledgeSystem.new() + var trigger := _withdrawal_event(4, 8, npc.id) + + assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {})) + _remember(knowledge, npc.id, 4, 8) + npc.hunger = FoodShortageOpportunitySystem.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 pantry := _empty_pantry() + var lower_hunger := _npc(1, 85.0) + var high_id := _npc(5, 92.0) + var low_id := _npc(2, 92.0) + var npcs: Array[SimNPC] = [high_id, lower_hunger, low_id] + var knowledge := EventKnowledgeSystem.new() + var trigger := _withdrawal_event(7, 10, lower_hunger.id) + for npc in npcs: + _remember(knowledge, npc.id, 7, 10) + var pantry_before := pantry.to_dictionary() + var tasks_before: Array[StringName] = [] + for npc in npcs: + tasks_before.append(npc.current_task) + + var opened: OpportunityStateRecord = system.consider_event( + trigger, 10, pantry, npcs, knowledge, {} + ) + + assert_not_null(opened) + assert_eq(opened.get_opportunity_id(), 0) + assert_eq(opened.get_interested_npc_id(), low_id.id) + assert_eq(opened.get_trigger_event_id(), 7) + assert_eq(opened.get_created_tick(), 10) + assert_eq(opened.get_opportunity_type(), SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY) + assert_eq(opened.get_status(), OpportunityStateRecord.STATUS_OPEN) + assert_eq(opened.get_target_id(), SimulationIds.STORAGE_VILLAGE_PANTRY) + assert_eq(opened.get_resource_id(), SimulationIds.RESOURCE_FOOD) + assert_eq(opened.get_target_amount(), 1.0) + assert_eq(pantry.to_dictionary(), pantry_before) + for index in npcs.size(): + assert_eq(npcs[index].current_task, tasks_before[index]) + assert_eq(system.next_opportunity_id, 1) + + +func test_near_equal_hunger_still_uses_the_exact_highest_value() -> void: + var system := FoodShortageOpportunitySystemScript.new() + var pantry := _empty_pantry() + var lower_id := _npc(1, 92.0) + var slightly_hungrier := _npc(9, 92.000001) + var npcs: Array[SimNPC] = [lower_id, slightly_hungrier] + var knowledge := EventKnowledgeSystem.new() + var trigger := _withdrawal_event(8, 10, lower_id.id) + for npc in npcs: + _remember(knowledge, npc.id, 8, 10) + + var opened: OpportunityStateRecord = system.consider_event( + trigger, 10, pantry, npcs, knowledge, {} + ) + + assert_not_null(opened) + assert_eq(opened.get_interested_npc_id(), slightly_hungrier.id) + + +func test_late_knowledge_can_open_old_event_and_open_opportunity_dedupes() -> void: + var system := FoodShortageOpportunitySystemScript.new() + var pantry := _empty_pantry() + var npc := _npc(3, 88.0) + var npcs: Array[SimNPC] = [npc] + var knowledge := EventKnowledgeSystem.new() + var original_trigger := _withdrawal_event(10, 4, npc.id) + + assert_null(system.consider_event(original_trigger, 4, pantry, npcs, knowledge, {})) + _remember(knowledge, npc.id, 10, 12) + var opened: OpportunityStateRecord = system.consider_event( + original_trigger, 12, pantry, npcs, knowledge, {} + ) + assert_not_null(opened) + assert_eq(opened.get_created_tick(), 12) + + var duplicate_trigger := _withdrawal_event(11, 13, npc.id) + _remember(knowledge, npc.id, 11, 13) + assert_null(system.consider_event(duplicate_trigger, 13, pantry, npcs, knowledge, {})) + assert_eq(system.get_all_sorted().size(), 1) + assert_eq(system.next_opportunity_id, 1) + + +func test_only_strict_later_npc_deposit_resolves_without_mutating_supply() -> void: + var fixture := _open_fixture() + var system: FoodShortageOpportunitySystem = fixture["system"] + var pantry: StorageStateRecord = fixture["pantry"] + var npcs: Array[SimNPC] = fixture["npcs"] + var knowledge: EventKnowledgeSystem = fixture["knowledge"] + pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0) + var food_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD) + + var invalid_events: Array[EconomicEventRecord] = [ + _supply_event(20, 11, SimulationIds.EVENT_RESOURCE_EXTRACTED, 0), + _supply_event(21, 11, SimulationIds.EVENT_STORAGE_DEPOSITED, -1), + EconomicEventRecord.create( + 25, + SimulationIds.EVENT_STORAGE_DEPOSITED, + 11, + 0, + &"not_the_actor_inventory", + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 1.0 + ), + EconomicEventRecord.create( + 22, + SimulationIds.EVENT_STORAGE_DEPOSITED, + 11, + 0, + SimulationIds.npc_inventory_id(0), + SimulationIds.STORAGE_VILLAGE_WOODPILE, + SimulationIds.RESOURCE_FOOD, + 1.0 + ), + EconomicEventRecord.create( + 23, + SimulationIds.EVENT_STORAGE_DEPOSITED, + 11, + 0, + SimulationIds.npc_inventory_id(0), + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 0.0 + ), + ] + for event in invalid_events: + assert_null(system.consider_event(event, 11, pantry, npcs, knowledge, {})) + assert_eq(pantry.get_amount(SimulationIds.RESOURCE_FOOD), food_before) + assert_eq(system.get_open_opportunity().get_status(), OpportunityStateRecord.STATUS_OPEN) + + var valid_supply := _supply_event(24, 12, SimulationIds.EVENT_STORAGE_DEPOSITED, 0) + var resolved: OpportunityStateRecord = system.consider_event( + valid_supply, 12, pantry, npcs, knowledge, {} + ) + assert_not_null(resolved) + assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED) + assert_eq(resolved.get_resolution_event_id(), 24) + assert_eq(resolved.get_resolved_tick(), 12) + assert_null(system.get_open_opportunity()) + assert_eq(pantry.get_amount(SimulationIds.RESOURCE_FOOD), food_before) + + +func test_player_extraction_into_pantry_is_the_other_valid_resolution() -> void: + var fixture := _open_fixture() + var system: FoodShortageOpportunitySystem = fixture["system"] + var pantry: StorageStateRecord = fixture["pantry"] + var npcs: Array[SimNPC] = fixture["npcs"] + var knowledge: EventKnowledgeSystem = fixture["knowledge"] + pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0) + var player_supply := _supply_event(30, 14, SimulationIds.EVENT_RESOURCE_EXTRACTED, -1) + assert_null(system.consider_event(player_supply, 14, pantry, npcs, knowledge, {})) + assert_not_null(system.get_open_opportunity()) + + var resolved: OpportunityStateRecord = system.consider_event( + player_supply, 14, pantry, npcs, knowledge, _player_resource_states() + ) + + assert_not_null(resolved) + assert_eq(resolved.get_resolution_event_id(), 30) + assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED) + + +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() + system.restore(restored_records, 6) + + assert_eq(system.next_opportunity_id, 6) + assert_eq(system.get_all_sorted().map(_opportunity_id), [2, 5]) + assert_eq(system.get_open_opportunity(), older) + assert_eq(system.get_latest_for_npc(9), newer) + assert_null(system.get_latest_for_npc(100)) + + var pantry := _empty_pantry() + pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0) + var npcs: Array[SimNPC] = [_npc(0, 10.0)] + var supply := _supply_event(40, 10, SimulationIds.EVENT_STORAGE_DEPOSITED, 0) + var resolved: OpportunityStateRecord = system.consider_event( + supply, 10, pantry, npcs, EventKnowledgeSystem.new(), {} + ) + assert_eq(resolved, older) + assert_eq(older.get_status(), OpportunityStateRecord.STATUS_RESOLVED) + assert_eq(newer.get_status(), OpportunityStateRecord.STATUS_OPEN) + assert_eq(system.get_open_opportunity(), newer) + + +func test_record_parser_rejects_malformed_state_combinations() -> void: + var valid := OpportunityStateRecord.create(3, 10, 8, 2) + assert_not_null(OpportunityStateRecord.from_dictionary(valid.to_dictionary())) + + var invalid_open := valid.to_dictionary() + invalid_open["resolution_event_id"] = 9 + assert_null(OpportunityStateRecord.from_dictionary(invalid_open)) + var invalid_type := valid.to_dictionary() + invalid_type["opportunity_type"] = "generic_quest" + assert_null(OpportunityStateRecord.from_dictionary(invalid_type)) + var invalid_target := valid.to_dictionary() + invalid_target["target_amount"] = 2.0 + assert_null(OpportunityStateRecord.from_dictionary(invalid_target)) + var invalid_resolved := valid.to_dictionary() + invalid_resolved["status"] = String(OpportunityStateRecord.STATUS_RESOLVED) + invalid_resolved["resolution_event_id"] = 12 + invalid_resolved["resolved_tick"] = 9 + assert_null(OpportunityStateRecord.from_dictionary(invalid_resolved)) + invalid_resolved["resolved_tick"] = 10 + invalid_resolved["resolution_event_id"] = valid.get_trigger_event_id() + assert_null(OpportunityStateRecord.from_dictionary(invalid_resolved)) + + +func _open_fixture() -> Dictionary: + var system := FoodShortageOpportunitySystemScript.new() + var pantry := _empty_pantry() + var npc := _npc(0, 90.0) + var npcs: Array[SimNPC] = [npc] + var knowledge := EventKnowledgeSystem.new() + var trigger := _withdrawal_event(1, 10, npc.id) + _remember(knowledge, npc.id, 1, 10) + assert_not_null(system.consider_event(trigger, 10, pantry, npcs, knowledge, {})) + return {"system": system, "pantry": pantry, "npcs": npcs, "knowledge": knowledge} + + +func _empty_pantry() -> StorageStateRecord: + return StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_PANTRY, {String(SimulationIds.RESOURCE_FOOD): 0.0} + ) + + +func _npc(npc_id: int, hunger: float) -> SimNPC: + var npc := SimNPC.new(npc_id, "NPC %d" % npc_id, SimulationIds.PROFESSION_FARMER, 5.0, 5.0) + npc.hunger = hunger + npc.is_dead = false + return npc + + +func _withdrawal_event(event_id: int, tick: int, actor_id: int) -> EconomicEventRecord: + return EconomicEventRecord.create( + event_id, + SimulationIds.EVENT_STORAGE_WITHDRAWN, + tick, + actor_id, + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.npc_inventory_id(actor_id), + SimulationIds.RESOURCE_FOOD, + 1.0 + ) + + +func _supply_event( + event_id: int, tick: int, event_type: StringName, actor_id: int +) -> EconomicEventRecord: + var source_id := &"player_resource" + if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED and actor_id >= 0: + source_id = SimulationIds.npc_inventory_id(actor_id) + return EconomicEventRecord.create( + event_id, + event_type, + tick, + actor_id, + source_id, + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 1.0 + ) + + +func _player_resource_states() -> Dictionary: + var resource_node := ResourceNode.new() + resource_node.node_id = &"player_resource" + resource_node.action_id = SimulationIds.ACTION_GATHER_FOOD + resource_node.resource_id = SimulationIds.RESOURCE_FOOD + resource_node.can_player_use = true + var resource_state := ResourceStateRecord.create_from_node(resource_node) + var result := {resource_node.node_id: resource_state} + resource_node.free() + return result + + +func _remember( + knowledge: EventKnowledgeSystem, knower_id: int, event_id: int, acquired_tick: int +) -> void: + var records := knowledge.get_all_sorted() + records.append( + KnownEventStateRecord.create( + knower_id, + event_id, + SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, + KnownEventStateRecord.NO_SOURCE_NPC_ID, + acquired_tick + ) + ) + knowledge.restore(records) + + +func _opportunity_id(opportunity: OpportunityStateRecord) -> int: + return opportunity.get_opportunity_id() diff --git a/tests/unit/test_food_shortage_opportunity_system.gd.uid b/tests/unit/test_food_shortage_opportunity_system.gd.uid new file mode 100644 index 0000000..cbb8995 --- /dev/null +++ b/tests/unit/test_food_shortage_opportunity_system.gd.uid @@ -0,0 +1 @@ +uid://cnodtf7lh6ona diff --git a/tools/capture_simulation_garden.gd b/tools/capture_simulation_garden.gd index 8cfc7ca..f119848 100644 --- a/tools/capture_simulation_garden.gd +++ b/tools/capture_simulation_garden.gd @@ -129,9 +129,63 @@ func _stage_social_history(main_scene: Node) -> bool: ): push_error("Simulation Garden staging did not produce the retained trust consequence") return false + if not _stage_pantry_need(main_scene, contributor, observer): + return false var village_ui := main_scene.get_node("UI") village_ui.selected_npc_index = simulation_manager.npcs.find(observer) village_ui.call("_refresh_npc_inspector") + var inspector_label := ( + main_scene.get_node("UI/NpcInspectorPanel/MarginContainer/NpcInspectorLabel") as Label + ) + if "Open village need" not in inspector_label.text or "Food 0 / 1" not in inspector_label.text: + push_error("Simulation Garden staging did not expose the selected person's need") + return false + return true + + +func _stage_pantry_need(main_scene: Node, actor: SimNPC, interested: SimNPC) -> bool: + var simulation_manager := main_scene.get_node("SimulationManager") + for npc in simulation_manager.npcs: + npc.hunger = minf(npc.hunger, 70.0) + actor.hunger = 60.0 + interested.hunger = 85.0 + var pantry: StorageStateRecord = simulation_manager.get_pantry() + pantry.withdraw( + SimulationIds.RESOURCE_FOOD, maxf(pantry.get_amount(SimulationIds.RESOURCE_FOOD) - 1.0, 0.0) + ) + simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + var withdrawn: float = simulation_manager.economy.withdraw_to_inventory( + actor, SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD) + ) + var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() + var village_need_label := ( + main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label + ) + if ( + withdrawn <= 0.0 + or opportunity == null + or opportunity.get_interested_npc_id() != interested.id + or "Village need" not in village_need_label.text + or "Food 0 / 1" not in village_need_label.text + ): + push_error("Simulation Garden staging did not expose the real empty-pantry need") + return false + var previous_task := interested.current_task + if not interested.target_id.is_empty(): + simulation_manager.release_npc_reservation(interested.id) + var selection: ActionSelectionResult = simulation_manager.action_selector.select_action( + interested, + simulation_manager.village, + simulation_manager.clock.time_of_day(), + simulation_manager.npcs + ) + if selection == null or selection.action_id != SimulationIds.ACTION_GATHER_FOOD: + push_error("Simulation Garden staging could not replan the hungry villager") + return false + simulation_manager.latest_decisions[interested.id] = selection + interested.set_task(selection.action_id, selection.duration_override) + simulation_manager.npc_task_changed.emit(interested, previous_task, selection.action_id) + simulation_manager.npc_target_requested.emit(interested) return true diff --git a/world/ui/ui.gd b/world/ui/ui.gd index f349573..55ad51d 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -33,6 +33,10 @@ func _ready() -> void: simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed) if simulation_manager.has_signal("event_knowledge_forgotten"): simulation_manager.event_knowledge_forgotten.connect(_on_event_knowledge_forgotten) + if simulation_manager.has_signal("opportunity_opened"): + simulation_manager.opportunity_opened.connect(_on_opportunity_opened) + if simulation_manager.has_signal("opportunity_resolved"): + simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved) if "village" in simulation_manager: _on_village_changed(simulation_manager.village) @@ -79,6 +83,7 @@ func _on_village_changed(village: SimVillage) -> void: var wood_rate := "" if rates["wood_per_day"] > 0.01: wood_rate = " (−%.0f/d)" % rates["wood_per_day"] + var opportunity_text := _build_active_opportunity_display() village_stats_label.text = ( """ Village [%s] @@ -92,7 +97,7 @@ func _on_village_changed(village: SimVillage) -> void: Food Mod: %.2f Safety Mod: %.2f Knowledge Mod: %.2f - %s + %s%s """ % [ speed_text, @@ -106,7 +111,8 @@ func _on_village_changed(village: SimVillage) -> void: village.food_modifier, village.safety_modifier, village.knowledge_modifier, - event_text + opportunity_text, + event_text, ] ) @@ -117,6 +123,7 @@ func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) - func _on_state_restored() -> void: selected_npc_index = 0 + _refresh_village_display() _refresh_npc_inspector() @@ -139,6 +146,23 @@ func _on_event_knowledge_forgotten(_knower_id: int, _event: EconomicEventRecord) _refresh_npc_inspector() +func _on_opportunity_opened(_opportunity: RefCounted) -> void: + _refresh_village_display() + _refresh_npc_inspector() + + +func _on_opportunity_resolved( + _opportunity: RefCounted, _resolution_event: EconomicEventRecord +) -> 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) + + func _refresh_npc_inspector() -> void: if not debug_overlay_visible: return @@ -189,6 +213,7 @@ func _refresh_npc_inspector() -> void: var event_text := _build_event_history(npc) var relationship_text := _build_relationship_display(npc) var memory_history_text := _build_memory_history_display(npc) + var opportunity_text := _build_npc_opportunity_display(npc) npc_inspector_label.text = ( ( "%s · %s\n" @@ -198,6 +223,7 @@ func _refresh_npc_inspector() -> void: + "Carrying food: %.0f wood: %.0f\n" + "%s\n" + "%s\n" + + "%s" + "Why\n%s%s\n\n" + "Own history\n%s\n\n" + "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" @@ -214,6 +240,7 @@ func _refresh_npc_inspector() -> void: npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD), relationship_text, memory_history_text, + opportunity_text, reason_text, score_text, event_text @@ -221,6 +248,69 @@ func _refresh_npc_inspector() -> void: ) +func _build_active_opportunity_display() -> String: + if not simulation_manager.has_method("get_active_opportunity"): + return "" + var opportunity = simulation_manager.get_active_opportunity() + if opportunity == null: + 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()) + return ( + "\n\nVillage need\n" + + "◆ Restock the empty pantry\n" + + "%s is worried about the shortage\n" % interested_name + + "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()] + ) + + +func _build_npc_opportunity_display(npc: SimNPC) -> String: + if not simulation_manager.has_method("get_latest_opportunity_for_npc"): + return "" + var opportunity = simulation_manager.get_latest_opportunity_for_npc(npc.id) + if opportunity == null: + 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()) + return ( + "Open village need\n" + + "◆ Restock the empty pantry\n" + + ( + "%s %.0f / %.0f\n\n" + % [resource_name, current_amount, opportunity.get_target_amount()] + ) + ) + if opportunity.get_status() != OpportunityStateRecord.STATUS_RESOLVED: + return "" + var resolution_event: EconomicEventRecord = simulation_manager.get_opportunity_resolution_event( + opportunity + ) + var actor_name := "Someone" + 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) + return ( + "Resolved village need\n" + + "◆ %s restocked the pantry\n" % actor_name + + "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()] + ) + + +func _get_npc_name(npc_id: int) -> String: + for npc in simulation_manager.npcs: + if npc.id == npc_id: + return npc.npc_name + return "Someone" + + func _get_action_name(action_id: StringName) -> String: if action_id == SimulationIds.ACTION_IDLE: return "Idle" @@ -327,12 +417,20 @@ func _build_memory_history_display(npc: SimNPC) -> String: func _build_compact_memory_summary(event: EconomicEventRecord, name_map: Dictionary) -> String: + var event_type := StringName(event.data["event_type"]) + var item_id := StringName(event.data["item_id"]) if ( - StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED - and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + event_type == SimulationIds.EVENT_STORAGE_DEPOSITED + and item_id == SimulationIds.RESOURCE_FOOD ): var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone") return "%s stocked %.0f food" % [actor_name, float(event.data["amount"])] + if ( + event_type == SimulationIds.EVENT_STORAGE_WITHDRAWN + and item_id == SimulationIds.RESOURCE_FOOD + ): + var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone") + return "%s took %.0f food from the pantry" % [actor_name, float(event.data["amount"])] return event.description(name_map)