feat: add knowledge-gated pantry opportunities

This commit is contained in:
Rijad Zuzo
2026-07-13 00:46:56 +02:00
parent 9fcf6a0a58
commit c722e32a88
26 changed files with 2027 additions and 77 deletions
+13 -3
View File
@@ -17,6 +17,7 @@ SimulationClock
-> SimulationEventLog records completed facts -> SimulationEventLog records completed facts
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
-> RelationshipSystem applies evidence-gated social consequences -> RelationshipSystem applies evidence-gated social consequences
-> FoodShortageOpportunitySystem projects one known unresolved need
-> WorldViewManager presents travel and NPC state -> WorldViewManager presents travel and NPC state
-> ActiveWorldAdapter supplies loaded-world positions/capacity -> ActiveWorldAdapter supplies loaded-world positions/capacity
-> NpcVisual performs local navigation, animation, and transient reactions -> NpcVisual performs local navigation, animation, and transient reactions
@@ -38,6 +39,10 @@ would otherwise obscure that lifecycle:
retention/importance ranking; retention/importance ranking;
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship - `simulation/relationships/RelationshipSystem.gd` owns directed relationship
queries, event-driven trust changes, and deterministic social tie-breaking; queries, event-driven trust changes, and deterministic social tie-breaking;
- `simulation/opportunities/FoodShortageOpportunitySystem.gd` observes
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/persistence/` owns save-slot file safety;
- `simulation/state/` owns versioned serialized record contracts; - `simulation/state/` owns versioned serialized record contracts;
- `simulation/definitions/` owns stable IDs and immutable action/profession - `simulation/definitions/` owns stable IDs and immutable action/profession
@@ -58,6 +63,7 @@ hard to read.
| `simulation/events/` | Immutable event history and derived event queries | | `simulation/events/` | Immutable event history and derived event queries |
| `simulation/knowledge/` | Per-NPC knowledge of objective event IDs | | `simulation/knowledge/` | Per-NPC knowledge of objective event IDs |
| `simulation/relationships/` | Directed social consequences and relationship queries | | `simulation/relationships/` | Directed social consequences and relationship queries |
| `simulation/opportunities/` | Knowledge-gated unresolved-condition projections |
| `simulation/state/` | Versioned, serializable mutable records | | `simulation/state/` | Versioned, serializable mutable records |
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
| `simulation/persistence/` | Validated local save-file storage | | `simulation/persistence/` | Validated local save-file storage |
@@ -80,15 +86,19 @@ improving ownership.
ownership. ownership.
- Presentation may report facts and submit commands; it does not choose NPC - Presentation may report facts and submit commands; it does not choose NPC
actions or own resource, storage, inventory, event, knowledge, relationship, 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 - Inspector history is a read-only façade query over objective events and
retained knowledge. Relationship cues consume state-change signals and are retained knowledge. Opportunity presentation is likewise query-only.
intentionally absent from saves and checksums. Relationship cues consume state-change signals and are intentionally absent
from saves and checksums.
- Resource changes go through `ResourceStateRecord`, NPC inventory, and - Resource changes go through `ResourceStateRecord`, NPC inventory, and
`VillageEconomy`; `village.food` and `village.wood` are synchronized views. `VillageEconomy`; `village.food` and `village.wood` are synchronized views.
- New mutable features define serialization and deterministic continuation at - New mutable features define serialization and deterministic continuation at
the same time as their first gameplay use. Cross-record causes use stable the same time as their first gameplay use. Cross-record causes use stable
event IDs rather than object references or prose. 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 - Prefer one tested vertical behavior over a generic framework with no proven
consumers. consumers.
+8 -1
View File
@@ -705,10 +705,17 @@ Completed:
actions. A real evidence-caused trust gain produces one short amber blossom actions. A real evidence-caused trust gain produces one short amber blossom
above its observer, including in cinematic mode, without persistent or above its observer, including in cinematic mode, without persistent or
simulation-owned animation state. 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: 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`. `LEARNING_ROADMAP.md`.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
+37 -14
View File
@@ -37,22 +37,25 @@ transfer occurred.
## Persistence and determinism ## 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 `next_event_id`, directed relationships that may reference an exact event, and
per-NPC known-event references with first-acquisition provenance. Schema v1 and per-NPC known-event references with first-acquisition provenance, plus
v2 saves migrate to an empty stream beginning at ID zero. Parsing rejects opportunity records that reference exact trigger/resolution events. Schema v1
duplicate event IDs, invalid or duplicate knowledge references, impossible and v2 saves migrate to an empty stream beginning at ID zero; world schemas
communicator sources, relationship causes the observer does not know, and a v1v7 migrate to an empty opportunity list. Parsing rejects duplicate event
next ID that could collide with restored history. 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 Positive food deposits and positive NPC food withdrawals from
trust in its contributor only when that NPC knows the event. The actor and `village_pantry` into that actor's matching inventory are currently knowable.
living NPCs within the bounded witness radius receive a `KnownEventStateRecord` The actor and living NPCs within the bounded witness radius receive a
at record time. Witness distance uses the event's captured position, never the `KnownEventStateRecord` at record time. Witness distance uses the event's
actor's later location. The relationship stores the same deposit event ID captured position, never the actor's later location. A successful food deposit
rather than copied prose, so the inspector can resolve and display the real can raise a hungry familiar witness's directed trust; the relationship stores
completed fact. This is a first evidence-gated causal consumer of the event that same deposit event ID rather than copied prose, so the inspector can
stream, not general event sourcing. 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 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 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 acquired direct fact. Communicated provenance snapshots how the speaker knew
the event, so a listener's memory stays valid after the speaker forgets. the event, so a listener's memory stays valid after the speaker forgets.
`FoodShortageOpportunitySystem` is another read-only consumer of immutable
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: The food-loop regression verifies this chain:
```text ```text
@@ -92,6 +110,9 @@ acquisition provenance, separately from three objective events the NPC
personally performed. A real trust-changing consequence emits one transient personally performed. A real trust-changing consequence emits one transient
amber blossom above its observer. The cue is presentation-only, remains visible amber blossom above its observer. The cue is presentation-only, remains visible
in cinematic mode, and is neither saved nor replayed after visual rebuild. in cinematic mode, and is neither saved nor replayed after visual rebuild.
The village summary also shows one compact active need with its real food
progress and interested villager; only that villager's inspector retains its
open/resolved detail and names the exact supplier.
## Deliberate limits ## 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. beliefs, and multi-event causal graphs belong in later event/history slices.
They should extend this record family without making prose authoritative or They should extend this record family without making prose authoritative or
recomputing old evidence from current positions. recomputing old evidence from current positions.
The current opportunity is likewise a single bounded projection, not a generic
quest, reward, acceptance, dialogue, or capable-helper framework.
+24 -3
View File
@@ -711,9 +711,30 @@ The compact history/reaction slice is complete:
slice. slice.
This completes the first bounded Milestone 6 evidence-to-choice presentation This completes the first bounded Milestone 6 evidence-to-choice presentation
path without claiming full belief simulation. The practical next slice is one path without claiming full belief simulation.
bounded Milestone 7 rumour/opportunity proof without generalized distortion,
free-form dialogue, or a generic quest framework. 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: Recently completed:
+46 -18
View File
@@ -2,7 +2,7 @@
> Agent-facing context for understanding the project quickly. > 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. > source of truth when this document and the implementation differ.
See the [documentation map](README.md) for the authority and scope of each plan. 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 decision reason, utility scores, directed familiarity/trust, and the exact
completed event that last changed trust; completed event that last changed trust;
- a compact selected-person history that separates up to four importance-ranked - 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 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 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 one-hop communication, deterministic recent-memory retention, and stable
importance ranking for presentation/communication queries; importance ranking for presentation/communication queries;
- `RelationshipSystem` owns directed relationship state, event-driven trust - `RelationshipSystem` owns directed relationship state, event-driven trust
consequences, and deterministic social queries. consequences, and deterministic social queries;
- `FoodShortageOpportunitySystem` projects the first knowledge-gated unresolved
condition into a persisted open/resolved record without changing resources
or assigning work.
`SimulationManager` remains the scene-tree façade and signal boundary rather `SimulationManager` remains the scene-tree façade and signal boundary rather
than duplicating these responsibilities across additional manager nodes. than duplicating these responsibilities across additional manager nodes.
@@ -456,9 +462,10 @@ follow camera.
### `world/ui/ui.gd` ### `world/ui/ui.gd`
The UI subscribes to village, task, event, knowledge, and relationship changes. The UI subscribes to village, task, event, knowledge, relationship, and
It formats aggregate state and resolves known facts/relationship causes through opportunity changes. It formats aggregate state and resolves known facts,
the event log without owning or recomputing simulation facts. relationship causes, and opportunity lifecycle details through manager queries
without owning or recomputing simulation facts.
## Current runtime flow ## Current runtime flow
@@ -491,6 +498,7 @@ NpcVisual navigates through the active world
| -> SimulationEventLog appends completed facts | -> SimulationEventLog appends completed facts
| -> EventKnowledgeSystem captures actor/nearby knowledge | -> EventKnowledgeSystem captures actor/nearby knowledge
| -> RelationshipSystem applies evidence-gated social consequences | -> RelationshipSystem applies evidence-gated social consequences
| -> FoodShortageOpportunitySystem may open or resolve a real need
| | | |
| v | v
| village_changed signal updates the UI | village_changed signal updates the UI
@@ -527,6 +535,7 @@ NpcVisual navigates through the active world
│ ├── economy/ Inventory and storage transactions │ ├── economy/ Inventory and storage transactions
│ ├── events/ Ordered event history and queries │ ├── events/ Ordered event history and queries
│ ├── knowledge/ Per-NPC facts, provenance, transfer, and retention │ ├── knowledge/ Per-NPC facts, provenance, transfer, and retention
│ ├── opportunities/ Knowledge-gated unresolved-condition projection
│ ├── persistence/ Validated local save-slot storage │ ├── persistence/ Validated local save-slot storage
│ ├── relationships/ Directed social consequences and queries │ ├── relationships/ Directed social consequences and queries
│ └── state/ Versioned simulation-state records │ └── state/ Versioned simulation-state records
@@ -535,6 +544,7 @@ NpcVisual navigates through the active world
│ ├── communicated_knowledge_consequence_test.gd │ ├── communicated_knowledge_consequence_test.gd
│ ├── deterministic_simulation_test.gd │ ├── deterministic_simulation_test.gd
│ ├── food_storage_loop_test.gd │ ├── food_storage_loop_test.gd
│ ├── food_shortage_opportunity_test.gd
│ ├── jajce_world_scaffold_test.gd │ ├── jajce_world_scaffold_test.gd
│ ├── knowledge_retention_consequence_test.gd │ ├── knowledge_retention_consequence_test.gd
│ ├── jajce_runtime_integration_test.gd │ ├── jajce_runtime_integration_test.gd
@@ -543,6 +553,7 @@ NpcVisual navigates through the active world
│ ├── resource_node_player_parity_test.gd │ ├── resource_node_player_parity_test.gd
│ ├── simulation_definitions_test.gd │ ├── simulation_definitions_test.gd
│ ├── simulation_state_serialization_test.gd │ ├── simulation_state_serialization_test.gd
│ ├── unit/test_food_shortage_opportunity_system.gd
│ └── witnessed_knowledge_consequence_test.gd │ └── witnessed_knowledge_consequence_test.gd
├── terrain/jajce/ Dedicated Terrain3D seed data and assets ├── terrain/jajce/ Dedicated Terrain3D seed data and assets
├── tools/ ├── tools/
@@ -575,9 +586,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
gathering use `ResourceNode` instances with no fallback, food transfer uses gathering use `ResourceNode` instances with no fallback, food transfer uses
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
- Current NPC, village, resource, storage, event, knowledge, relationship, - Current NPC, village, resource, storage, event, knowledge, relationship,
clock, and RNG state serialize through world schema v7. F5/F9 provide one opportunity, clock, and RNG state serialize through world schema v8. F5/F9
validated local quicksave; a save menu, metadata, and player-transform provide one validated local quicksave; a save menu, metadata, and
persistence remain deferred. player-transform persistence remain deferred.
- Simulation-owned resource records retain live amounts, reservations, and - Simulation-owned resource records retain live amounts, reservations, and
usage definitions while ResourceNode scenes are unloaded. usage definitions while ResourceNode scenes are unloaded.
- An explicit fixed-step clock converts frame delta into simulation ticks, but - 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. located items.
- NPCs have home positions, schedule periods, carried food/wood, and directed - NPCs have home positions, schedule periods, carried food/wood, and directed
familiarity/trust. They retain direct or one-hop communicated knowledge of a familiarity/trust. They retain direct or one-hop communicated knowledge of a
food deposit with first-acquisition provenance. Current trust causes are food deposit or NPC pantry withdrawal with first-acquisition provenance.
lasting; other knowledge is capped and reviewed after one simulated day. Current trust causes and the trigger of an open opportunity are lasting;
NPCs do not yet have wider social dimensions, goals, line-of-sight/hearing other knowledge is capped and reviewed after one simulated day. NPCs do not
evidence, personalized reinforcement/decay, false beliefs, or multi-hop yet have wider social dimensions, goals, line-of-sight/hearing evidence,
rumours. 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 - The reason inspector exposes current decisions, utility rejections, one exact
relationship cause, and a compact person-history view that distinguishes relationship cause, and a compact person-history view that distinguishes
importance-ranked retained memories from objective personal actions. importance-ranked retained memories from objective personal actions.
@@ -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 - 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 scene; `Jajce Lookdev 01` and the runtime `Simulation Garden 01` are captured
as reproducible presentation baselines. as reproducible presentation baselines.
- Combat, companions, factions, politics, trade, rumours, quests, persistence, - Combat, companions, factions, politics, trade, generalized/multi-hop rumours,
and regional travel do not yet exist. a general quest framework, and regional travel do not yet exist.
- Stylized player/NPC silhouettes, water, foliage, and VFX support the current - Stylized player/NPC silhouettes, water, foliage, and VFX support the current
build-in-public baseline, while blockout buildings and several work/resource build-in-public baseline, while blockout buildings and several work/resource
props remain visibly prototype-grade. 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 and forget routine facts at deterministic daily-sized reviews without deleting
the objective event. The selected-person inspector now shows lasting-first the objective event. The selected-person inspector now shows lasting-first
retained memories beside objective personal history, and real trust changes retained memories beside objective personal history, and real trust changes
produce a short observer-only amber blossom without altering saved state. The produce a short observer-only amber blossom without altering saved state.
next systems slice is one bounded rumour/opportunity proof.
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: The remaining simulation-garden target still aims for:
+12
View File
@@ -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 restores those IDs as `StringName`, and rejects records that reference an
unknown profession or executable action. 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 ## Validation
The registry rejects: The registry rejects:
+34 -4
View File
@@ -3,7 +3,7 @@
## Current contract ## Current contract
`SimulationStateRecord` is the versioned JSON boundary for the current `SimulationStateRecord` is the versioned JSON boundary for the current
simulation. The current world schema is v7 and captures: simulation. The current world schema is v8 and captures:
- simulation seed, tick interval, tick count, clock remainder, and elapsed - simulation seed, tick interval, tick count, clock remainder, and elapsed
clock ticks; clock ticks;
@@ -19,14 +19,16 @@ simulation. The current world schema is v7 and captures:
ID that last changed trust; ID that last changed trust;
- per-NPC known-event records that reference objective event history without - per-NPC known-event records that reference objective event history without
copying it, including first-acquisition method, acquisition tick, and copying it, including first-acquisition method, acquisition tick, and
historical communicator provenance. historical communicator provenance;
- opportunity records with stable type/status, interested NPC, trigger event,
target storage/resource/amount, and exact later resolution event identity.
The top-level identity is: The top-level identity is:
```json ```json
{ {
"schema": "the_steward.simulation", "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, presentation unload/rebind, directed relationship/cause
round-tripping, divergent known-event state, communicated provenance, and round-tripping, divergent known-event state, communicated provenance, and
deterministic retention boundaries, and rejection of unsupported schemas. deterministic retention boundaries, and rejection of unsupported schemas.
Active and resolved opportunity round-tripping, contradictory target state,
invalid resource sources, and cross-record event references are covered too.
NPCStateRecord v2 adds the resolved travel destination and whether it is NPCStateRecord v2 adds the resolved travel destination and whether it is
active. Nested v1 NPC records migrate explicitly with no invented active active. Nested v1 NPC records migrate explicitly with no invented active
@@ -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 deterministic daily-sized review. The objective event stream is never pruned by
this rule. 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
v1v7 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 ## Resource authority
`SimulationManager` owns `ResourceStateRecord` instances independently of the `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; - a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
- migrations from any historical world schema other than the explicitly - migrations from any historical world schema other than the explicitly
supported v1v6 layouts; supported v1v7 layouts;
- player inventory or player relationship records; - player inventory or player relationship records;
- broader relationship dimensions, line-of-sight/hearing evidence, - broader relationship dimensions, line-of-sight/hearing evidence,
continuous or personalized memory decay, reinforcement, false beliefs, or continuous or personalized memory decay, reinforcement, false beliefs, or
multi-hop rumours; 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. - persistence for the player transform or presentation-only scene state.
Those features should build on this boundary rather than inventing parallel Those features should build on this boundary rather than inventing parallel
+22 -5
View File
@@ -1,6 +1,6 @@
# Simulation Garden 01 # Simulation Garden 01
Captured: 2026-07-12 Captured: 2026-07-13
## Files ## 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 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, visuals exist, stages a real food-deposit/trust consequence, a one-food pantry
captures the F10 debug presentation, then captures the cinematic presentation shortage, the hungry interested villager's ordinary gather-food replan, and one
with development labels and UI hidden. The trust blossom is restarted only for wind gust. It captures the F10 debug presentation, then the cinematic
the second framing so both captures show the same real consequence clearly. 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 ## 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 remains visible when F10 removes the debug UI but does not become permanent
world clutter. Both refreshed images preserve the established elevated, world clutter. Both refreshed images preserve the established elevated,
slightly staggered top-down camera. 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 457 KiB

+60
View File
@@ -4,6 +4,9 @@ const SimulationEventLogScript := preload("res://simulation/events/SimulationEve
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd") const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
const FoodShortageOpportunitySystemScript := preload(
"res://simulation/opportunities/FoodShortageOpportunitySystem.gd"
)
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName) signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
signal village_changed(village: SimVillage) signal village_changed(village: SimVillage)
@@ -18,6 +21,8 @@ signal relationship_changed(relationship: RelationshipStateRecord, cause_event:
signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord) signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord)
signal event_knowledge_transferred(speaker_id: int, listener_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 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() var village := SimVillage.new()
@@ -37,6 +42,7 @@ var event_log := SimulationEventLogScript.new()
var economy := VillageEconomyScript.new() var economy := VillageEconomyScript.new()
var relationship_system := RelationshipSystemScript.new() var relationship_system := RelationshipSystemScript.new()
var event_knowledge_system := EventKnowledgeSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new()
var opportunity_system := FoodShortageOpportunitySystemScript.new()
var storage_states: Dictionary: var storage_states: Dictionary:
get: get:
return economy.storage_states return economy.storage_states
@@ -595,6 +601,7 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
) )
for learned_record in learned_records: for learned_record in learned_records:
_apply_new_event_knowledge(learned_record, event) _apply_new_event_knowledge(learned_record, event)
_update_opportunity_from_event(event)
economic_event_recorded.emit(event) economic_event_recorded.emit(event)
@@ -617,6 +624,7 @@ func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bo
continue continue
_apply_new_event_knowledge(learned_record, event) _apply_new_event_knowledge(learned_record, event)
event_knowledge_transferred.emit(speaker.id, listener.id, event) event_knowledge_transferred.emit(speaker.id, listener.id, event)
_update_opportunity_from_event(event)
return true return true
return false return false
@@ -663,6 +671,19 @@ func _apply_new_event_knowledge(
_maintain_event_knowledge(false) _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: func _maintain_event_knowledge(expire_by_age: bool) -> void:
var forgotten := event_knowledge_system.maintain_retention( var forgotten := event_knowledge_system.maintain_retention(
tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age 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 continue
seen[key] = true seen[key] = true
lasting.append(record) 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 return lasting
@@ -742,6 +775,29 @@ func get_retained_memory_events(npc_id: int, max_count: int = 4) -> Array[Econom
return retained 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: func npc_knows_event(npc_id: int, event_id: int) -> bool:
return event_knowledge_system.knows_event(npc_id, event_id) 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, "clock_elapsed_ticks": clock.elapsed_ticks,
"wander_random_streams": wander_streams, "wander_random_streams": wander_streams,
"next_event_id": next_event_id, "next_event_id": next_event_id,
"next_opportunity_id": opportunity_system.next_opportunity_id,
"cycle_duration_seconds": clock.cycle_duration_seconds "cycle_duration_seconds": clock.cycle_duration_seconds
} }
record.village = VillageStateRecord.capture(village) record.village = VillageStateRecord.capture(village)
@@ -1054,6 +1111,8 @@ func create_state_record() -> SimulationStateRecord:
record.relationships.append(relationship) record.relationships.append(relationship)
for known_event in event_knowledge_system.get_all_sorted(): for known_event in event_knowledge_system.get_all_sorted():
record.event_knowledge.append(known_event) record.event_knowledge.append(known_event)
for opportunity in opportunity_system.get_all_sorted():
record.opportunities.append(opportunity)
return record return record
@@ -1091,6 +1150,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
npcs.append(npc_record.restore(debug_logs)) npcs.append(npc_record.restore(debug_logs))
relationship_system.restore(record.relationships) relationship_system.restore(record.relationships)
event_knowledge_system.restore(record.event_knowledge) event_knowledge_system.restore(record.event_knowledge)
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0) _maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
wander_random_sources.clear() wander_random_sources.clear()
+4
View File
@@ -41,6 +41,10 @@ const EVENT_TASK_STARTED := &"task_started"
const EVENT_TASK_BLOCKED := &"task_blocked" const EVENT_TASK_BLOCKED := &"task_blocked"
const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
const OPPORTUNITY_STATUS_OPEN := &"open"
const OPPORTUNITY_STATUS_RESOLVED := &"resolved"
const KNOWLEDGE_ACQUISITION_PERFORMED := &"performed" const KNOWLEDGE_ACQUISITION_PERFORMED := &"performed"
const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed" const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed"
const KNOWLEDGE_ACQUISITION_COMMUNICATED := &"communicated" const KNOWLEDGE_ACQUISITION_COMMUNICATED := &"communicated"
+16 -6
View File
@@ -9,7 +9,7 @@ var known_events: Dictionary = {}
func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[KnownEventStateRecord]: func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[KnownEventStateRecord]:
var learned: Array[KnownEventStateRecord] = [] var learned: Array[KnownEventStateRecord] = []
if not _is_witnessable(event): if not is_knowable_event(event):
return learned return learned
var actor := _find_npc(int(event.data["actor_id"]), npcs) var actor := _find_npc(int(event.data["actor_id"]), npcs)
if actor == null: if actor == null:
@@ -44,7 +44,7 @@ func communicate_event(
event == null event == null
or speaker_id == listener_id or speaker_id == listener_id
or listener_id == int(event.data["actor_id"]) or listener_id == int(event.data["actor_id"])
or not _is_witnessable(event) or not is_knowable_event(event)
): ):
return null return null
var event_id := int(event.data["event_id"]) var event_id := int(event.data["event_id"])
@@ -221,11 +221,21 @@ func _remember(
return record 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 ( return (
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED actor_id >= 0
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and item_id == SimulationIds.RESOURCE_FOOD
and float(event.data["amount"]) > 0.0 and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
and (StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id))
) )
@@ -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()
@@ -0,0 +1 @@
uid://r6gpw0hx8oe2
+167
View File
@@ -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)
@@ -0,0 +1 @@
uid://ce2xl34unwkef
+191 -18
View File
@@ -2,13 +2,14 @@ class_name SimulationStateRecord
extends RefCounted extends RefCounted
const SCHEMA_NAME := "the_steward.simulation" const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 7 const SCHEMA_VERSION := 8
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const EVENT_LEGACY_SCHEMA_VERSION := 2 const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4 const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4
const PROVENANCE_LEGACY_SCHEMA_VERSION := 5 const PROVENANCE_LEGACY_SCHEMA_VERSION := 5
const PREVIOUS_SCHEMA_VERSION := 6 const RETENTION_LEGACY_SCHEMA_VERSION := 6
const PREVIOUS_SCHEMA_VERSION := 7
var simulation: Dictionary var simulation: Dictionary
var village: VillageStateRecord var village: VillageStateRecord
@@ -18,6 +19,7 @@ var storages: Array[StorageStateRecord] = []
var economic_events: Array[EconomicEventRecord] = [] var economic_events: Array[EconomicEventRecord] = []
var relationships: Array[RelationshipStateRecord] = [] var relationships: Array[RelationshipStateRecord] = []
var event_knowledge: Array[KnownEventStateRecord] = [] var event_knowledge: Array[KnownEventStateRecord] = []
var opportunities: Array[OpportunityStateRecord] = []
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
@@ -40,6 +42,9 @@ func to_dictionary() -> Dictionary:
var knowledge_data: Array[Dictionary] = [] var knowledge_data: Array[Dictionary] = []
for known_event_record in event_knowledge: for known_event_record in event_knowledge:
knowledge_data.append(known_event_record.to_dictionary()) 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 { return {
"schema": SCHEMA_NAME, "schema": SCHEMA_NAME,
@@ -51,7 +56,8 @@ func to_dictionary() -> Dictionary:
"storages": storage_data, "storages": storage_data,
"economic_events": event_data, "economic_events": event_data,
"relationships": relationship_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, RELATIONSHIP_LEGACY_SCHEMA_VERSION,
KNOWLEDGE_LEGACY_SCHEMA_VERSION, KNOWLEDGE_LEGACY_SCHEMA_VERSION,
PROVENANCE_LEGACY_SCHEMA_VERSION, PROVENANCE_LEGACY_SCHEMA_VERSION,
RETENTION_LEGACY_SCHEMA_VERSION,
PREVIOUS_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION,
] ]
): ):
@@ -96,6 +103,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
"economic_events", "economic_events",
"relationships", "relationships",
"event_knowledge", "event_knowledge",
"opportunities",
] ]
) )
): ):
@@ -104,22 +112,27 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
var simulation_data = record_data["simulation"] var simulation_data = record_data["simulation"]
if not simulation_data is Dictionary: if not simulation_data is Dictionary:
return null return null
if not simulation_data.has_all( if not (
[ simulation_data
"seed", . has_all(
"tick_interval", [
"tick_count", "seed",
"clock_accumulator", "tick_interval",
"clock_elapsed_ticks", "tick_count",
"wander_random_streams", "clock_accumulator",
"next_event_id" "clock_elapsed_ticks",
] "wander_random_streams",
"next_event_id",
"next_opportunity_id",
]
)
): ):
return null return null
var saved_tick_interval := float(simulation_data["tick_interval"]) var saved_tick_interval := float(simulation_data["tick_interval"])
var saved_tick_count := int(simulation_data["tick_count"]) var saved_tick_count := int(simulation_data["tick_count"])
var saved_clock_accumulator := float(simulation_data["clock_accumulator"]) var saved_clock_accumulator := float(simulation_data["clock_accumulator"])
var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"]) var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"])
var saved_next_opportunity_id := int(simulation_data["next_opportunity_id"])
if ( if (
not is_finite(saved_tick_interval) not is_finite(saved_tick_interval)
or saved_tick_interval <= 0.0 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 not is_finite(saved_clock_accumulator)
or saved_clock_accumulator < 0.0 or saved_clock_accumulator < 0.0
or saved_clock_elapsed_ticks < 0 or saved_clock_elapsed_ticks < 0
or saved_next_opportunity_id < 0
): ):
return null return null
if simulation_data.has("cycle_duration_seconds"): 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 event_data = record_data["economic_events"]
var relationship_data = record_data["relationships"] var relationship_data = record_data["relationships"]
var knowledge_data = record_data["event_knowledge"] var knowledge_data = record_data["event_knowledge"]
var opportunity_data = record_data["opportunities"]
if ( if (
not npc_data is Array not npc_data is Array
or not resource_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 event_data is Array
or not relationship_data is Array or not relationship_data is Array
or not knowledge_data is Array or not knowledge_data is Array
or not opportunity_data is Array
): ):
return null return null
@@ -183,6 +199,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
record.npcs.append(npc_record) record.npcs.append(npc_record)
var resource_ids := {} var resource_ids := {}
var resource_records_by_id := {}
for item in resource_data: for item in resource_data:
if not item is Dictionary: if not item is Dictionary:
return null return null
@@ -193,9 +210,11 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if resource_ids.has(resource_id): if resource_ids.has(resource_id):
return null return null
resource_ids[resource_id] = true resource_ids[resource_id] = true
resource_records_by_id[resource_id] = resource_record
record.resources.append(resource_record) record.resources.append(resource_record)
var storage_ids := {} var storage_ids := {}
var storage_records_by_id := {}
for item in storage_data: for item in storage_data:
if not item is Dictionary: if not item is Dictionary:
return null return null
@@ -206,6 +225,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if storage_ids.has(storage_id): if storage_ids.has(storage_id):
return null return null
storage_ids[storage_id] = true storage_ids[storage_id] = true
storage_records_by_id[storage_id] = storage_record
record.storages.append(storage_record) record.storages.append(storage_record)
var event_ids := {} var event_ids := {}
@@ -228,6 +248,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
return null return null
var knowledge_keys := {} var knowledge_keys := {}
var knowledge_records_by_key := {}
for item in knowledge_data: for item in knowledge_data:
if not item is Dictionary: if not item is Dictionary:
return null return null
@@ -242,6 +263,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if knowledge_keys.has(knowledge_key): if knowledge_keys.has(knowledge_key):
return null return null
knowledge_keys[knowledge_key] = true knowledge_keys[knowledge_key] = true
knowledge_records_by_key[knowledge_key] = known_event_record
record.event_knowledge.append(known_event_record) record.event_knowledge.append(known_event_record)
for known_event_record in record.event_knowledge: for known_event_record in record.event_knowledge:
if not _is_valid_knowledge_provenance( if not _is_valid_knowledge_provenance(
@@ -289,6 +311,46 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
relationship_keys[relationship_key] = true relationship_keys[relationship_key] = true
record.relationships.append(relationship_record) 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 return record
@@ -310,11 +372,7 @@ static func _is_valid_knowledge_provenance(
return false return false
if method == SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY: if method == SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY:
return true return true
if ( if not EventKnowledgeSystem.is_knowable_event(event):
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
):
return false return false
match method: match method:
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED: SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED:
@@ -337,6 +395,117 @@ static func _is_valid_knowledge_provenance(
return false 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: static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
var migrated := legacy_data.duplicate(true) var migrated := legacy_data.duplicate(true)
migrated["schema_version"] = SCHEMA_VERSION migrated["schema_version"] = SCHEMA_VERSION
@@ -390,6 +559,10 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
migrated.get("economic_events", []), migrated.get("economic_events", []),
int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0)) int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0))
) )
migrated["opportunities"] = []
var opportunity_simulation_data: Dictionary = migrated.get("simulation", {})
opportunity_simulation_data["next_opportunity_id"] = 0
migrated["simulation"] = opportunity_simulation_data
return migrated return migrated
+369
View File
@@ -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)
@@ -0,0 +1 @@
uid://c42mc37eiqjrw
+99
View File
@@ -119,6 +119,105 @@ func _run() -> void:
not listener_visual.get_node("TrustReactionRoot").visible, not listener_visual.get_node("TrustReactionRoot").visible,
"Learning a fact without a relationship consequence should not show a trust blossom" "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( _check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"), main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
"Playable runtime should instance the Jajce Terrain3D world" "Playable runtime should instance the Jajce Terrain3D world"
+265 -1
View File
@@ -18,7 +18,9 @@ func _run() -> void:
_test_previous_world_knowledge_migration() _test_previous_world_knowledge_migration()
_test_previous_world_provenance_migration() _test_previous_world_provenance_migration()
_test_previous_world_retention_migration() _test_previous_world_retention_migration()
_test_previous_world_opportunity_migration()
_test_relationship_schema_rejection() _test_relationship_schema_rejection()
_test_opportunity_schema_rejection()
_test_schema_rejection() _test_schema_rejection()
if failures.is_empty(): if failures.is_empty():
@@ -371,7 +373,7 @@ func _test_previous_world_provenance_migration() -> void:
func _test_previous_world_retention_migration() -> void: func _test_previous_world_retention_migration() -> void:
var manager := _create_manager(61) var manager := _create_manager(61)
var previous_data: Dictionary = manager.create_state_record().to_dictionary() 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 previous_data["simulation"]["tick_count"] = 50
var deposit_event := EconomicEventRecord.create( var deposit_event := EconomicEventRecord.create(
0, 0,
@@ -439,6 +441,25 @@ func _test_previous_world_retention_migration() -> void:
manager.free() 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: func _test_relationship_schema_rejection() -> void:
var manager := _create_manager(58) var manager := _create_manager(58)
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary() var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
@@ -685,6 +706,249 @@ func _test_relationship_schema_rejection() -> void:
manager.free() 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: func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new() var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value manager.simulation_seed = seed_value
@@ -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()
@@ -0,0 +1 @@
uid://cnodtf7lh6ona
+54
View File
@@ -129,9 +129,63 @@ func _stage_social_history(main_scene: Node) -> bool:
): ):
push_error("Simulation Garden staging did not produce the retained trust consequence") push_error("Simulation Garden staging did not produce the retained trust consequence")
return false return false
if not _stage_pantry_need(main_scene, contributor, observer):
return false
var village_ui := main_scene.get_node("UI") var village_ui := main_scene.get_node("UI")
village_ui.selected_npc_index = simulation_manager.npcs.find(observer) village_ui.selected_npc_index = simulation_manager.npcs.find(observer)
village_ui.call("_refresh_npc_inspector") 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 return true
+102 -4
View File
@@ -33,6 +33,10 @@ func _ready() -> void:
simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed) simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed)
if simulation_manager.has_signal("event_knowledge_forgotten"): if simulation_manager.has_signal("event_knowledge_forgotten"):
simulation_manager.event_knowledge_forgotten.connect(_on_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: if "village" in simulation_manager:
_on_village_changed(simulation_manager.village) _on_village_changed(simulation_manager.village)
@@ -79,6 +83,7 @@ func _on_village_changed(village: SimVillage) -> void:
var wood_rate := "" var wood_rate := ""
if rates["wood_per_day"] > 0.01: if rates["wood_per_day"] > 0.01:
wood_rate = " (%.0f/d)" % rates["wood_per_day"] wood_rate = " (%.0f/d)" % rates["wood_per_day"]
var opportunity_text := _build_active_opportunity_display()
village_stats_label.text = ( village_stats_label.text = (
""" """
Village [%s] Village [%s]
@@ -92,7 +97,7 @@ func _on_village_changed(village: SimVillage) -> void:
Food Mod: %.2f Food Mod: %.2f
Safety Mod: %.2f Safety Mod: %.2f
Knowledge Mod: %.2f Knowledge Mod: %.2f
%s %s%s
""" """
% [ % [
speed_text, speed_text,
@@ -106,7 +111,8 @@ func _on_village_changed(village: SimVillage) -> void:
village.food_modifier, village.food_modifier,
village.safety_modifier, village.safety_modifier,
village.knowledge_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: func _on_state_restored() -> void:
selected_npc_index = 0 selected_npc_index = 0
_refresh_village_display()
_refresh_npc_inspector() _refresh_npc_inspector()
@@ -139,6 +146,23 @@ func _on_event_knowledge_forgotten(_knower_id: int, _event: EconomicEventRecord)
_refresh_npc_inspector() _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: func _refresh_npc_inspector() -> void:
if not debug_overlay_visible: if not debug_overlay_visible:
return return
@@ -189,6 +213,7 @@ func _refresh_npc_inspector() -> void:
var event_text := _build_event_history(npc) var event_text := _build_event_history(npc)
var relationship_text := _build_relationship_display(npc) var relationship_text := _build_relationship_display(npc)
var memory_history_text := _build_memory_history_display(npc) var memory_history_text := _build_memory_history_display(npc)
var opportunity_text := _build_npc_opportunity_display(npc)
npc_inspector_label.text = ( npc_inspector_label.text = (
( (
"%s · %s\n" "%s · %s\n"
@@ -198,6 +223,7 @@ func _refresh_npc_inspector() -> void:
+ "Carrying food: %.0f wood: %.0f\n" + "Carrying food: %.0f wood: %.0f\n"
+ "%s\n" + "%s\n"
+ "%s\n" + "%s\n"
+ "%s"
+ "Why\n%s%s\n\n" + "Why\n%s%s\n\n"
+ "Own history\n%s\n\n" + "Own history\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" + "[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), npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
relationship_text, relationship_text,
memory_history_text, memory_history_text,
opportunity_text,
reason_text, reason_text,
score_text, score_text,
event_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: func _get_action_name(action_id: StringName) -> String:
if action_id == SimulationIds.ACTION_IDLE: if action_id == SimulationIds.ACTION_IDLE:
return "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: 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 ( if (
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED event_type == SimulationIds.EVENT_STORAGE_DEPOSITED
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and item_id == SimulationIds.RESOURCE_FOOD
): ):
var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone") var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone")
return "%s stocked %.0f food" % [actor_name, float(event.data["amount"])] 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) return event.description(name_map)