feat: add knowledge-gated pantry opportunities
This commit is contained in:
@@ -17,6 +17,7 @@ SimulationClock
|
||||
-> SimulationEventLog records completed facts
|
||||
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
|
||||
-> RelationshipSystem applies evidence-gated social consequences
|
||||
-> FoodShortageOpportunitySystem projects one known unresolved need
|
||||
-> WorldViewManager presents travel and NPC state
|
||||
-> ActiveWorldAdapter supplies loaded-world positions/capacity
|
||||
-> NpcVisual performs local navigation, animation, and transient reactions
|
||||
@@ -38,6 +39,10 @@ would otherwise obscure that lifecycle:
|
||||
retention/importance ranking;
|
||||
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship
|
||||
queries, event-driven trust changes, and deterministic social tie-breaking;
|
||||
- `simulation/opportunities/FoodShortageOpportunitySystem.gd` observes
|
||||
immutable event references plus current pantry/NPC state, then owns the
|
||||
bounded open/resolved pantry-restock lifecycle without changing resources or
|
||||
assigning tasks;
|
||||
- `simulation/persistence/` owns save-slot file safety;
|
||||
- `simulation/state/` owns versioned serialized record contracts;
|
||||
- `simulation/definitions/` owns stable IDs and immutable action/profession
|
||||
@@ -58,6 +63,7 @@ hard to read.
|
||||
| `simulation/events/` | Immutable event history and derived event queries |
|
||||
| `simulation/knowledge/` | Per-NPC knowledge of objective event IDs |
|
||||
| `simulation/relationships/` | Directed social consequences and relationship queries |
|
||||
| `simulation/opportunities/` | Knowledge-gated unresolved-condition projections |
|
||||
| `simulation/state/` | Versioned, serializable mutable records |
|
||||
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
|
||||
| `simulation/persistence/` | Validated local save-file storage |
|
||||
@@ -80,15 +86,19 @@ improving ownership.
|
||||
ownership.
|
||||
- Presentation may report facts and submit commands; it does not choose NPC
|
||||
actions or own resource, storage, inventory, event, knowledge, relationship,
|
||||
or reservation state.
|
||||
opportunity, or reservation state.
|
||||
- Inspector history is a read-only façade query over objective events and
|
||||
retained knowledge. Relationship cues consume state-change signals and are
|
||||
intentionally absent from saves and checksums.
|
||||
retained knowledge. Opportunity presentation is likewise query-only.
|
||||
Relationship cues consume state-change signals and are intentionally absent
|
||||
from saves and checksums.
|
||||
- Resource changes go through `ResourceStateRecord`, NPC inventory, and
|
||||
`VillageEconomy`; `village.food` and `village.wood` are synchronized views.
|
||||
- New mutable features define serialization and deterministic continuation at
|
||||
the same time as their first gameplay use. Cross-record causes use stable
|
||||
event IDs rather than object references or prose.
|
||||
- Opportunity records reference stable NPC, storage, resource, trigger-event,
|
||||
and resolution-event IDs. Their generator may observe authoritative state
|
||||
and history, but it does not mutate the economy or command NPC behavior.
|
||||
- Prefer one tested vertical behavior over a generic framework with no proven
|
||||
consumers.
|
||||
|
||||
|
||||
@@ -705,10 +705,17 @@ Completed:
|
||||
actions. A real evidence-caused trust gain produces one short amber blossom
|
||||
above its observer, including in cinematic mode, without persistent or
|
||||
simulation-owned animation state.
|
||||
25. Knowledge-gated village need: a compact, cardless `Village need` line now
|
||||
shows the real pantry target, food progress, and interested villager. That
|
||||
person's inspector retains the open/resolved lifecycle and names the exact
|
||||
NPC or player supply actor. The repeatable debug capture stages this from
|
||||
live simulation state; world framing and the elevated follow camera remain
|
||||
unchanged.
|
||||
|
||||
Next:
|
||||
|
||||
1. Implement one bounded Milestone 7 rumour/opportunity proof, as sequenced in
|
||||
1. Prove the same opportunity lifecycle with one missing-wood `task_blocked`
|
||||
condition before extracting shared generation rules, as sequenced in
|
||||
`LEARNING_ROADMAP.md`.
|
||||
|
||||
Do not start with GIS data, a full city, a large asset pack, or more NPC
|
||||
|
||||
+37
-14
@@ -37,22 +37,25 @@ transfer occurred.
|
||||
|
||||
## Persistence and determinism
|
||||
|
||||
`SimulationStateRecord` schema v7 stores the ordered event stream,
|
||||
`SimulationStateRecord` schema v8 stores the ordered event stream,
|
||||
`next_event_id`, directed relationships that may reference an exact event, and
|
||||
per-NPC known-event references with first-acquisition provenance. Schema v1 and
|
||||
v2 saves migrate to an empty stream beginning at ID zero. Parsing rejects
|
||||
duplicate event IDs, invalid or duplicate knowledge references, impossible
|
||||
communicator sources, relationship causes the observer does not know, and a
|
||||
next ID that could collide with restored history.
|
||||
per-NPC known-event references with first-acquisition provenance, plus
|
||||
opportunity records that reference exact trigger/resolution events. Schema v1
|
||||
and v2 saves migrate to an empty stream beginning at ID zero; world schemas
|
||||
v1–v7 migrate to an empty opportunity list. Parsing rejects duplicate event
|
||||
IDs, invalid or duplicate knowledge/opportunity references, impossible
|
||||
communicator sources, relationship causes the observer does not know, and next
|
||||
IDs that could collide with restored history.
|
||||
|
||||
A successful food deposit can currently raise a hungry familiar NPC's directed
|
||||
trust in its contributor only when that NPC knows the event. The actor and
|
||||
living NPCs within the bounded witness radius receive a `KnownEventStateRecord`
|
||||
at record time. Witness distance uses the event's captured position, never the
|
||||
actor's later location. The relationship stores the same deposit event ID
|
||||
rather than copied prose, so the inspector can resolve and display the real
|
||||
completed fact. This is a first evidence-gated causal consumer of the event
|
||||
stream, not general event sourcing.
|
||||
Positive food deposits and positive NPC food withdrawals from
|
||||
`village_pantry` into that actor's matching inventory are currently knowable.
|
||||
The actor and living NPCs within the bounded witness radius receive a
|
||||
`KnownEventStateRecord` at record time. Witness distance uses the event's
|
||||
captured position, never the actor's later location. A successful food deposit
|
||||
can raise a hungry familiar witness's directed trust; the relationship stores
|
||||
that same deposit event ID rather than copied prose, so the inspector can
|
||||
resolve and display the real completed fact. This is evidence-gated use of the
|
||||
event stream, not general event sourcing.
|
||||
|
||||
Knowledge now records whether an NPC performed, witnessed, heard, or inherited
|
||||
a fact from a legacy save. When an NPC arrives beside an already-working NPC at
|
||||
@@ -72,6 +75,21 @@ evidence. Conversations prefer lasting direct facts, then the most recently
|
||||
acquired direct fact. Communicated provenance snapshots how the speaker knew
|
||||
the event, so a listener's memory stays valid after the speaker forgets.
|
||||
|
||||
`FoodShortageOpportunitySystem` is another read-only consumer of immutable
|
||||
facts. A known positive NPC pantry withdrawal, combined with the pantry
|
||||
currently being empty and a living critically hungry knower, can open one
|
||||
`restock_empty_pantry` record. The opportunity does not perform a transaction,
|
||||
assign an NPC, or append a quest-only event. It resolves only after the pantry
|
||||
actually reaches its target through either:
|
||||
|
||||
- a later positive `storage_deposited` event from a real NPC inventory; or
|
||||
- a later player `resource_extracted` event from an existing player-usable food
|
||||
ResourceNode directly into `village_pantry`.
|
||||
|
||||
The exact trigger and resolution IDs remain in opportunity history. The active
|
||||
trigger memory is protected until resolution, then returns to normal bounded
|
||||
retention.
|
||||
|
||||
The food-loop regression verifies this chain:
|
||||
|
||||
```text
|
||||
@@ -92,6 +110,9 @@ acquisition provenance, separately from three objective events the NPC
|
||||
personally performed. A real trust-changing consequence emits one transient
|
||||
amber blossom above its observer. The cue is presentation-only, remains visible
|
||||
in cinematic mode, and is neither saved nor replayed after visual rebuild.
|
||||
The village summary also shows one compact active need with its real food
|
||||
progress and interested villager; only that villager's inspector retains its
|
||||
open/resolved detail and names the exact supplier.
|
||||
|
||||
## Deliberate limits
|
||||
|
||||
@@ -104,3 +125,5 @@ hearing, personalized reinforcement/decay, multi-hop rumours, secrecy, false
|
||||
beliefs, and multi-event causal graphs belong in later event/history slices.
|
||||
They should extend this record family without making prose authoritative or
|
||||
recomputing old evidence from current positions.
|
||||
The current opportunity is likewise a single bounded projection, not a generic
|
||||
quest, reward, acceptance, dialogue, or capable-helper framework.
|
||||
|
||||
@@ -711,9 +711,30 @@ The compact history/reaction slice is complete:
|
||||
slice.
|
||||
|
||||
This completes the first bounded Milestone 6 evidence-to-choice presentation
|
||||
path without claiming full belief simulation. The practical next slice is one
|
||||
bounded Milestone 7 rumour/opportunity proof without generalized distortion,
|
||||
free-form dialogue, or a generic quest framework.
|
||||
path without claiming full belief simulation.
|
||||
|
||||
The first bounded Milestone 7 opportunity proof is complete:
|
||||
|
||||
- a positive NPC food withdrawal from `village_pantry`, combined with the
|
||||
pantry currently being empty, is the exact shortage evidence;
|
||||
- the need opens only when a living critically hungry villager knows that
|
||||
event through performance, witnessing, or the existing one-hop
|
||||
communication contract;
|
||||
- highest hunger and then lowest stable NPC ID select one interested villager,
|
||||
with at most one open pantry need;
|
||||
- schema-v8 opportunity records retain stable NPC, event, storage, resource,
|
||||
and resolution IDs, and protect the trigger memory while the need is open;
|
||||
- an exact later NPC food deposit or player resource extraction into the
|
||||
pantry resolves the same record through the real economy/event history;
|
||||
- generation neither mutates the economy nor commands NPC work, and it adds no
|
||||
quest-only event, acceptance, reward, dialogue, multi-hop rumour, or generic
|
||||
quest framework.
|
||||
|
||||
Milestone 7 is not complete. The practical next slice is a second bounded
|
||||
unresolved-condition consumer: surface missing-wood `task_blocked` facts from
|
||||
patrol or study as a cared-about woodpile-restock need, resolve it through real
|
||||
NPC/player wood supply, define deterministic interested-party death/staleness
|
||||
handling, and only then extract the lifecycle shared by two proven consumers.
|
||||
|
||||
Recently completed:
|
||||
|
||||
|
||||
+46
-18
@@ -2,7 +2,7 @@
|
||||
|
||||
> Agent-facing context for understanding the project quickly.
|
||||
>
|
||||
> Snapshot basis: repository state on July 11, 2026. Treat the code as the
|
||||
> Snapshot basis: repository state on July 13, 2026. Treat the code as the
|
||||
> source of truth when this document and the implementation differ.
|
||||
|
||||
See the [documentation map](README.md) for the authority and scope of each plan.
|
||||
@@ -374,7 +374,10 @@ A small village panel displays:
|
||||
decision reason, utility scores, directed familiarity/trust, and the exact
|
||||
completed event that last changed trust;
|
||||
- a compact selected-person history that separates up to four importance-ranked
|
||||
retained memories from the NPC's three latest objective personal actions.
|
||||
retained memories from the NPC's three latest objective personal actions;
|
||||
- a cardless active `Village need` line sourced from the real pantry target,
|
||||
food progress, and interested villager, with open/resolved detail only in
|
||||
that person's inspector.
|
||||
|
||||
NPC name/profession labels, definition-driven colors and props, carried-food
|
||||
visuals, compact task glyphs, and a brief amber blossom on real trust gains make
|
||||
@@ -419,7 +422,10 @@ Focused `RefCounted` collaborators keep rule ownership visible:
|
||||
one-hop communication, deterministic recent-memory retention, and stable
|
||||
importance ranking for presentation/communication queries;
|
||||
- `RelationshipSystem` owns directed relationship state, event-driven trust
|
||||
consequences, and deterministic social queries.
|
||||
consequences, and deterministic social queries;
|
||||
- `FoodShortageOpportunitySystem` projects the first knowledge-gated unresolved
|
||||
condition into a persisted open/resolved record without changing resources
|
||||
or assigning work.
|
||||
|
||||
`SimulationManager` remains the scene-tree façade and signal boundary rather
|
||||
than duplicating these responsibilities across additional manager nodes.
|
||||
@@ -456,9 +462,10 @@ follow camera.
|
||||
|
||||
### `world/ui/ui.gd`
|
||||
|
||||
The UI subscribes to village, task, event, knowledge, and relationship changes.
|
||||
It formats aggregate state and resolves known facts/relationship causes through
|
||||
the event log without owning or recomputing simulation facts.
|
||||
The UI subscribes to village, task, event, knowledge, relationship, and
|
||||
opportunity changes. It formats aggregate state and resolves known facts,
|
||||
relationship causes, and opportunity lifecycle details through manager queries
|
||||
without owning or recomputing simulation facts.
|
||||
|
||||
## Current runtime flow
|
||||
|
||||
@@ -491,6 +498,7 @@ NpcVisual navigates through the active world
|
||||
| -> SimulationEventLog appends completed facts
|
||||
| -> EventKnowledgeSystem captures actor/nearby knowledge
|
||||
| -> RelationshipSystem applies evidence-gated social consequences
|
||||
| -> FoodShortageOpportunitySystem may open or resolve a real need
|
||||
| |
|
||||
| v
|
||||
| village_changed signal updates the UI
|
||||
@@ -527,6 +535,7 @@ NpcVisual navigates through the active world
|
||||
│ ├── economy/ Inventory and storage transactions
|
||||
│ ├── events/ Ordered event history and queries
|
||||
│ ├── knowledge/ Per-NPC facts, provenance, transfer, and retention
|
||||
│ ├── opportunities/ Knowledge-gated unresolved-condition projection
|
||||
│ ├── persistence/ Validated local save-slot storage
|
||||
│ ├── relationships/ Directed social consequences and queries
|
||||
│ └── state/ Versioned simulation-state records
|
||||
@@ -535,6 +544,7 @@ NpcVisual navigates through the active world
|
||||
│ ├── communicated_knowledge_consequence_test.gd
|
||||
│ ├── deterministic_simulation_test.gd
|
||||
│ ├── food_storage_loop_test.gd
|
||||
│ ├── food_shortage_opportunity_test.gd
|
||||
│ ├── jajce_world_scaffold_test.gd
|
||||
│ ├── knowledge_retention_consequence_test.gd
|
||||
│ ├── jajce_runtime_integration_test.gd
|
||||
@@ -543,6 +553,7 @@ NpcVisual navigates through the active world
|
||||
│ ├── resource_node_player_parity_test.gd
|
||||
│ ├── simulation_definitions_test.gd
|
||||
│ ├── simulation_state_serialization_test.gd
|
||||
│ ├── unit/test_food_shortage_opportunity_system.gd
|
||||
│ └── witnessed_knowledge_consequence_test.gd
|
||||
├── terrain/jajce/ Dedicated Terrain3D seed data and assets
|
||||
├── tools/
|
||||
@@ -575,9 +586,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
||||
gathering use `ResourceNode` instances with no fallback, food transfer uses
|
||||
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
|
||||
- Current NPC, village, resource, storage, event, knowledge, relationship,
|
||||
clock, and RNG state serialize through world schema v7. F5/F9 provide one
|
||||
validated local quicksave; a save menu, metadata, and player-transform
|
||||
persistence remain deferred.
|
||||
opportunity, clock, and RNG state serialize through world schema v8. F5/F9
|
||||
provide one validated local quicksave; a save menu, metadata, and
|
||||
player-transform persistence remain deferred.
|
||||
- Simulation-owned resource records retain live amounts, reservations, and
|
||||
usage definitions while ResourceNode scenes are unloaded.
|
||||
- An explicit fixed-step clock converts frame delta into simulation ticks, but
|
||||
@@ -590,11 +601,15 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
||||
located items.
|
||||
- NPCs have home positions, schedule periods, carried food/wood, and directed
|
||||
familiarity/trust. They retain direct or one-hop communicated knowledge of a
|
||||
food deposit with first-acquisition provenance. Current trust causes are
|
||||
lasting; other knowledge is capped and reviewed after one simulated day.
|
||||
NPCs do not yet have wider social dimensions, goals, line-of-sight/hearing
|
||||
evidence, personalized reinforcement/decay, false beliefs, or multi-hop
|
||||
rumours.
|
||||
food deposit or NPC pantry withdrawal with first-acquisition provenance.
|
||||
Current trust causes and the trigger of an open opportunity are lasting;
|
||||
other knowledge is capped and reviewed after one simulated day. NPCs do not
|
||||
yet have wider social dimensions, goals, line-of-sight/hearing evidence,
|
||||
personalized reinforcement/decay, false beliefs, or multi-hop rumours.
|
||||
- One persisted `restock_empty_pantry` opportunity can emerge from a known
|
||||
withdrawal plus current emptiness and critical hunger. It has exact NPC and
|
||||
player supply resolutions, but no generic opportunity definitions, capable-
|
||||
helper assignment, acceptance, rewards, free-form dialogue, or quest log.
|
||||
- The reason inspector exposes current decisions, utility rejections, one exact
|
||||
relationship cause, and a compact person-history view that distinguishes
|
||||
importance-ranked retained memories from objective personal actions.
|
||||
@@ -613,8 +628,8 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
||||
- Terrain3D and the bounded Jajce beauty baseline now run in the main game
|
||||
scene; `Jajce Lookdev 01` and the runtime `Simulation Garden 01` are captured
|
||||
as reproducible presentation baselines.
|
||||
- Combat, companions, factions, politics, trade, rumours, quests, persistence,
|
||||
and regional travel do not yet exist.
|
||||
- Combat, companions, factions, politics, trade, generalized/multi-hop rumours,
|
||||
a general quest framework, and regional travel do not yet exist.
|
||||
- Stylized player/NPC silhouettes, water, foliage, and VFX support the current
|
||||
build-in-public baseline, while blockout buildings and several work/resource
|
||||
props remain visibly prototype-grade.
|
||||
@@ -846,8 +861,21 @@ retention now keep current relationship causes lasting, cap other memories,
|
||||
and forget routine facts at deterministic daily-sized reviews without deleting
|
||||
the objective event. The selected-person inspector now shows lasting-first
|
||||
retained memories beside objective personal history, and real trust changes
|
||||
produce a short observer-only amber blossom without altering saved state. The
|
||||
next systems slice is one bounded rumour/opportunity proof.
|
||||
produce a short observer-only amber blossom without altering saved state.
|
||||
|
||||
The first bounded opportunity proof is complete. A positive NPC pantry
|
||||
withdrawal plus current pantry emptiness opens one need only for a critically
|
||||
hungry villager who knows that exact event. The schema-v8 record persists its
|
||||
stable trigger, interested villager, pantry/food target, status, and later
|
||||
resolution event; active evidence remains lasting. A real later NPC deposit or
|
||||
player harvest into the pantry resolves it without quest-only history, economy
|
||||
mutation by the generator, or AI assignment. The compact village/inspector UI
|
||||
shows this lifecycle without changing the elevated follow camera.
|
||||
|
||||
Milestone 7 remains in progress. The next systems slice should prove the same
|
||||
lifecycle with a missing-wood `task_blocked` condition and deterministic
|
||||
interested-party death/staleness handling before extracting common opportunity
|
||||
machinery.
|
||||
|
||||
The remaining simulation-garden target still aims for:
|
||||
|
||||
|
||||
@@ -43,6 +43,18 @@ NPC generation chooses from the registry's stable IDs. NPC state stores and
|
||||
restores those IDs as `StringName`, and rejects records that reference an
|
||||
unknown profession or executable action.
|
||||
|
||||
## Opportunity vocabulary
|
||||
|
||||
`SimulationIds` also defines the first proven opportunity type,
|
||||
`restock_empty_pantry`, and its `open`/`resolved` statuses. These are stable
|
||||
serialized vocabulary, not editor-authored quest definitions. Dynamic trigger,
|
||||
interested NPC, pantry/food goal, progress, and exact resolution-event identity
|
||||
belong to `OpportunityStateRecord` and the focused opportunity system.
|
||||
|
||||
Do not extract a generic quest-definition registry until another real
|
||||
unresolved-condition consumer proves which lifecycle fields and rules are
|
||||
actually shared.
|
||||
|
||||
## Validation
|
||||
|
||||
The registry rejects:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current contract
|
||||
|
||||
`SimulationStateRecord` is the versioned JSON boundary for the current
|
||||
simulation. The current world schema is v7 and captures:
|
||||
simulation. The current world schema is v8 and captures:
|
||||
|
||||
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
||||
clock ticks;
|
||||
@@ -19,14 +19,16 @@ simulation. The current world schema is v7 and captures:
|
||||
ID that last changed trust;
|
||||
- per-NPC known-event records that reference objective event history without
|
||||
copying it, including first-acquisition method, acquisition tick, and
|
||||
historical communicator provenance.
|
||||
historical communicator provenance;
|
||||
- opportunity records with stable type/status, interested NPC, trigger event,
|
||||
target storage/resource/amount, and exact later resolution event identity.
|
||||
|
||||
The top-level identity is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "the_steward.simulation",
|
||||
"schema_version": 7
|
||||
"schema_version": 8
|
||||
}
|
||||
```
|
||||
|
||||
@@ -59,6 +61,8 @@ It also verifies clock remainder, resource amount/reservation/enabled
|
||||
round-tripping, presentation unload/rebind, directed relationship/cause
|
||||
round-tripping, divergent known-event state, communicated provenance, and
|
||||
deterministic retention boundaries, and rejection of unsupported schemas.
|
||||
Active and resolved opportunity round-tripping, contradictory target state,
|
||||
invalid resource sources, and cross-record event references are covered too.
|
||||
|
||||
NPCStateRecord v2 adds the resolved travel destination and whether it is
|
||||
active. Nested v1 NPC records migrate explicitly with no invented active
|
||||
@@ -147,6 +151,30 @@ facts, and recent facts at least one simulated day old are removed at the next
|
||||
deterministic daily-sized review. The objective event stream is never pruned by
|
||||
this rule.
|
||||
|
||||
SimulationStateRecord v8 adds the top-level `opportunities` array and
|
||||
`simulation.next_opportunity_id`. The first nested `OpportunityStateRecord`
|
||||
schema stores `opportunity_id`, `opportunity_type`, `status`, `created_tick`,
|
||||
`trigger_event_id`, `interested_npc_id`, `target_id`, `resource_id`,
|
||||
`target_amount`, `resolution_event_id`, and `resolved_tick`. World schemas
|
||||
v1–v7 migrate explicitly to an empty opportunity list with next ID zero.
|
||||
|
||||
The bounded `restock_empty_pantry` contract accepts at most one open record.
|
||||
Opportunity, trigger-event, and resolution-event IDs are unique and the next
|
||||
ID must remain above restored history. The interested NPC, pantry, food
|
||||
resource, and referenced events must exist. Its trigger is a positive NPC food
|
||||
withdrawal from `village_pantry` to that actor's matching inventory; an open
|
||||
record additionally requires the pantry to remain below its one-food target
|
||||
and the interested NPC to have acquired that fact no later than creation.
|
||||
Resolution references an exact later NPC inventory-to-pantry food deposit or a
|
||||
player `resource_extracted` event from an existing player-usable food resource
|
||||
into the pantry, with a matching resolved tick. Resolved history does not
|
||||
require the pantry still to contain food because later consumption is valid.
|
||||
|
||||
While the need is open, its interested villager's trigger evidence is treated
|
||||
as lasting during deterministic memory maintenance. Resolution releases that
|
||||
fact back to the normal bounded-retention rules; it does not delete objective
|
||||
event or opportunity history.
|
||||
|
||||
## Resource authority
|
||||
|
||||
`SimulationManager` owns `ResourceStateRecord` instances independently of the
|
||||
@@ -183,11 +211,13 @@ This phase does not yet provide:
|
||||
|
||||
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
|
||||
- migrations from any historical world schema other than the explicitly
|
||||
supported v1–v6 layouts;
|
||||
supported v1–v7 layouts;
|
||||
- player inventory or player relationship records;
|
||||
- broader relationship dimensions, line-of-sight/hearing evidence,
|
||||
continuous or personalized memory decay, reinforcement, false beliefs, or
|
||||
multi-hop rumours;
|
||||
- generalized opportunity/quest definitions, acceptance, rewards, assignment,
|
||||
dialogue, or player-facing quest-log state;
|
||||
- persistence for the player transform or presentation-only scene state.
|
||||
|
||||
Those features should build on this boundary rather than inventing parallel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Simulation Garden 01
|
||||
|
||||
Captured: 2026-07-12
|
||||
Captured: 2026-07-13
|
||||
|
||||
## Files
|
||||
|
||||
@@ -29,10 +29,12 @@ $env:LOCALAPPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot
|
||||
```
|
||||
|
||||
The script warms up `main.tscn`, verifies that the simulation and active NPC
|
||||
visuals exist, stages a real food-deposit/trust consequence plus one wind gust,
|
||||
captures the F10 debug presentation, then captures the cinematic presentation
|
||||
with development labels and UI hidden. The trust blossom is restarted only for
|
||||
the second framing so both captures show the same real consequence clearly.
|
||||
visuals exist, stages a real food-deposit/trust consequence, a one-food pantry
|
||||
shortage, the hungry interested villager's ordinary gather-food replan, and one
|
||||
wind gust. It captures the F10 debug presentation, then the cinematic
|
||||
presentation with development labels and UI hidden. The trust blossom is
|
||||
restarted only for the second framing so both captures show the same real
|
||||
consequence clearly.
|
||||
|
||||
## Review
|
||||
|
||||
@@ -109,3 +111,18 @@ consequence produces one brief amber halo-and-mote blossom above Tarik; it
|
||||
remains visible when F10 removes the debug UI but does not become permanent
|
||||
world clutter. Both refreshed images preserve the established elevated,
|
||||
slightly staggered top-down camera.
|
||||
|
||||
## July 13 opportunity follow-up
|
||||
|
||||
The debug capture now leaves one real `restock_empty_pantry` need open. The
|
||||
left summary puts the cardless need above routine recent events and names Tarik
|
||||
as the critically hungry interested villager; his inspector shows the same
|
||||
zero-of-one pantry target next to the exact withdrawal memory. Capture staging
|
||||
then asks the existing utility selector to replan Tarik, producing `Gather
|
||||
Food`, a real finite food target, and the honest reason `Critically hungry; must
|
||||
find food` instead of a stale pre-shortage decision.
|
||||
|
||||
The inspector still fits the 1280×720 frame without adding another panel, and
|
||||
the cinematic capture remains free of UI while preserving the task glyph,
|
||||
trust blossom, and sparse calligraphic gust. Neither gameplay camera defaults
|
||||
nor the established capture framing changed.
|
||||
|
||||
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 |
@@ -4,6 +4,9 @@ const SimulationEventLogScript := preload("res://simulation/events/SimulationEve
|
||||
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
||||
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
||||
const FoodShortageOpportunitySystemScript := preload(
|
||||
"res://simulation/opportunities/FoodShortageOpportunitySystem.gd"
|
||||
)
|
||||
|
||||
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
||||
signal village_changed(village: SimVillage)
|
||||
@@ -18,6 +21,8 @@ signal relationship_changed(relationship: RelationshipStateRecord, cause_event:
|
||||
signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord)
|
||||
signal event_knowledge_transferred(speaker_id: int, listener_id: int, event: EconomicEventRecord)
|
||||
signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord)
|
||||
signal opportunity_opened(opportunity: OpportunityStateRecord)
|
||||
signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord)
|
||||
|
||||
var village := SimVillage.new()
|
||||
|
||||
@@ -37,6 +42,7 @@ var event_log := SimulationEventLogScript.new()
|
||||
var economy := VillageEconomyScript.new()
|
||||
var relationship_system := RelationshipSystemScript.new()
|
||||
var event_knowledge_system := EventKnowledgeSystemScript.new()
|
||||
var opportunity_system := FoodShortageOpportunitySystemScript.new()
|
||||
var storage_states: Dictionary:
|
||||
get:
|
||||
return economy.storage_states
|
||||
@@ -595,6 +601,7 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
|
||||
)
|
||||
for learned_record in learned_records:
|
||||
_apply_new_event_knowledge(learned_record, event)
|
||||
_update_opportunity_from_event(event)
|
||||
economic_event_recorded.emit(event)
|
||||
|
||||
|
||||
@@ -617,6 +624,7 @@ func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bo
|
||||
continue
|
||||
_apply_new_event_knowledge(learned_record, event)
|
||||
event_knowledge_transferred.emit(speaker.id, listener.id, event)
|
||||
_update_opportunity_from_event(event)
|
||||
return true
|
||||
return false
|
||||
|
||||
@@ -663,6 +671,19 @@ func _apply_new_event_knowledge(
|
||||
_maintain_event_knowledge(false)
|
||||
|
||||
|
||||
func _update_opportunity_from_event(event: EconomicEventRecord) -> void:
|
||||
var changed: OpportunityStateRecord = opportunity_system.consider_event(
|
||||
event, tick_count, get_pantry(), npcs, event_knowledge_system, resource_states
|
||||
)
|
||||
if changed == null:
|
||||
return
|
||||
if changed.get_status() == OpportunityStateRecord.STATUS_OPEN:
|
||||
opportunity_opened.emit(changed)
|
||||
return
|
||||
opportunity_resolved.emit(changed, event)
|
||||
_maintain_event_knowledge(false)
|
||||
|
||||
|
||||
func _maintain_event_knowledge(expire_by_age: bool) -> void:
|
||||
var forgotten := event_knowledge_system.maintain_retention(
|
||||
tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age
|
||||
@@ -688,6 +709,18 @@ func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]:
|
||||
continue
|
||||
seen[key] = true
|
||||
lasting.append(record)
|
||||
var open_opportunity: OpportunityStateRecord = opportunity_system.get_open_opportunity()
|
||||
if open_opportunity != null:
|
||||
var opportunity_record := event_knowledge_system.get_record(
|
||||
open_opportunity.get_interested_npc_id(), open_opportunity.get_trigger_event_id()
|
||||
)
|
||||
if opportunity_record != null:
|
||||
var opportunity_key := (
|
||||
"%d:%d" % [opportunity_record.get_knower_id(), opportunity_record.get_event_id()]
|
||||
)
|
||||
if not seen.has(opportunity_key):
|
||||
seen[opportunity_key] = true
|
||||
lasting.append(opportunity_record)
|
||||
return lasting
|
||||
|
||||
|
||||
@@ -742,6 +775,29 @@ func get_retained_memory_events(npc_id: int, max_count: int = 4) -> Array[Econom
|
||||
return retained
|
||||
|
||||
|
||||
func get_active_opportunity() -> OpportunityStateRecord:
|
||||
return opportunity_system.get_open_opportunity()
|
||||
|
||||
|
||||
func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord:
|
||||
return opportunity_system.get_latest_for_npc(npc_id)
|
||||
|
||||
|
||||
func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
|
||||
if opportunity == null:
|
||||
return null
|
||||
return event_log.get_by_id(opportunity.get_trigger_event_id())
|
||||
|
||||
|
||||
func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
|
||||
if (
|
||||
opportunity == null
|
||||
or opportunity.get_resolution_event_id() == OpportunityStateRecord.NO_EVENT_ID
|
||||
):
|
||||
return null
|
||||
return event_log.get_by_id(opportunity.get_resolution_event_id())
|
||||
|
||||
|
||||
func npc_knows_event(npc_id: int, event_id: int) -> bool:
|
||||
return event_knowledge_system.knows_event(npc_id, event_id)
|
||||
|
||||
@@ -1032,6 +1088,7 @@ func create_state_record() -> SimulationStateRecord:
|
||||
"clock_elapsed_ticks": clock.elapsed_ticks,
|
||||
"wander_random_streams": wander_streams,
|
||||
"next_event_id": next_event_id,
|
||||
"next_opportunity_id": opportunity_system.next_opportunity_id,
|
||||
"cycle_duration_seconds": clock.cycle_duration_seconds
|
||||
}
|
||||
record.village = VillageStateRecord.capture(village)
|
||||
@@ -1054,6 +1111,8 @@ func create_state_record() -> SimulationStateRecord:
|
||||
record.relationships.append(relationship)
|
||||
for known_event in event_knowledge_system.get_all_sorted():
|
||||
record.event_knowledge.append(known_event)
|
||||
for opportunity in opportunity_system.get_all_sorted():
|
||||
record.opportunities.append(opportunity)
|
||||
return record
|
||||
|
||||
|
||||
@@ -1091,6 +1150,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
||||
npcs.append(npc_record.restore(debug_logs))
|
||||
relationship_system.restore(record.relationships)
|
||||
event_knowledge_system.restore(record.event_knowledge)
|
||||
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
|
||||
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
|
||||
|
||||
wander_random_sources.clear()
|
||||
|
||||
@@ -41,6 +41,10 @@ const EVENT_TASK_STARTED := &"task_started"
|
||||
const EVENT_TASK_BLOCKED := &"task_blocked"
|
||||
const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
|
||||
|
||||
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
|
||||
const OPPORTUNITY_STATUS_OPEN := &"open"
|
||||
const OPPORTUNITY_STATUS_RESOLVED := &"resolved"
|
||||
|
||||
const KNOWLEDGE_ACQUISITION_PERFORMED := &"performed"
|
||||
const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed"
|
||||
const KNOWLEDGE_ACQUISITION_COMMUNICATED := &"communicated"
|
||||
|
||||
@@ -9,7 +9,7 @@ var known_events: Dictionary = {}
|
||||
|
||||
func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[KnownEventStateRecord]:
|
||||
var learned: Array[KnownEventStateRecord] = []
|
||||
if not _is_witnessable(event):
|
||||
if not is_knowable_event(event):
|
||||
return learned
|
||||
var actor := _find_npc(int(event.data["actor_id"]), npcs)
|
||||
if actor == null:
|
||||
@@ -44,7 +44,7 @@ func communicate_event(
|
||||
event == null
|
||||
or speaker_id == listener_id
|
||||
or listener_id == int(event.data["actor_id"])
|
||||
or not _is_witnessable(event)
|
||||
or not is_knowable_event(event)
|
||||
):
|
||||
return null
|
||||
var event_id := int(event.data["event_id"])
|
||||
@@ -221,11 +221,21 @@ func _remember(
|
||||
return record
|
||||
|
||||
|
||||
func _is_witnessable(event: EconomicEventRecord) -> bool:
|
||||
static func is_knowable_event(event: EconomicEventRecord) -> bool:
|
||||
if event == null or float(event.data["amount"]) <= 0.0:
|
||||
return false
|
||||
var event_type := StringName(event.data["event_type"])
|
||||
var item_id := StringName(event.data["item_id"])
|
||||
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
|
||||
return item_id == SimulationIds.RESOURCE_FOOD
|
||||
if event_type != SimulationIds.EVENT_STORAGE_WITHDRAWN:
|
||||
return false
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
return (
|
||||
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
|
||||
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||
and float(event.data["amount"]) > 0.0
|
||||
actor_id >= 0
|
||||
and item_id == SimulationIds.RESOURCE_FOOD
|
||||
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
and (StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,13 +2,14 @@ class_name SimulationStateRecord
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_NAME := "the_steward.simulation"
|
||||
const SCHEMA_VERSION := 7
|
||||
const SCHEMA_VERSION := 8
|
||||
const LEGACY_SCHEMA_VERSION := 1
|
||||
const EVENT_LEGACY_SCHEMA_VERSION := 2
|
||||
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
||||
const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4
|
||||
const PROVENANCE_LEGACY_SCHEMA_VERSION := 5
|
||||
const PREVIOUS_SCHEMA_VERSION := 6
|
||||
const RETENTION_LEGACY_SCHEMA_VERSION := 6
|
||||
const PREVIOUS_SCHEMA_VERSION := 7
|
||||
|
||||
var simulation: Dictionary
|
||||
var village: VillageStateRecord
|
||||
@@ -18,6 +19,7 @@ var storages: Array[StorageStateRecord] = []
|
||||
var economic_events: Array[EconomicEventRecord] = []
|
||||
var relationships: Array[RelationshipStateRecord] = []
|
||||
var event_knowledge: Array[KnownEventStateRecord] = []
|
||||
var opportunities: Array[OpportunityStateRecord] = []
|
||||
|
||||
|
||||
func to_dictionary() -> Dictionary:
|
||||
@@ -40,6 +42,9 @@ func to_dictionary() -> Dictionary:
|
||||
var knowledge_data: Array[Dictionary] = []
|
||||
for known_event_record in event_knowledge:
|
||||
knowledge_data.append(known_event_record.to_dictionary())
|
||||
var opportunity_data: Array[Dictionary] = []
|
||||
for opportunity_record in opportunities:
|
||||
opportunity_data.append(opportunity_record.to_dictionary())
|
||||
|
||||
return {
|
||||
"schema": SCHEMA_NAME,
|
||||
@@ -51,7 +56,8 @@ func to_dictionary() -> Dictionary:
|
||||
"storages": storage_data,
|
||||
"economic_events": event_data,
|
||||
"relationships": relationship_data,
|
||||
"event_knowledge": knowledge_data
|
||||
"event_knowledge": knowledge_data,
|
||||
"opportunities": opportunity_data
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +84,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
RELATIONSHIP_LEGACY_SCHEMA_VERSION,
|
||||
KNOWLEDGE_LEGACY_SCHEMA_VERSION,
|
||||
PROVENANCE_LEGACY_SCHEMA_VERSION,
|
||||
RETENTION_LEGACY_SCHEMA_VERSION,
|
||||
PREVIOUS_SCHEMA_VERSION,
|
||||
]
|
||||
):
|
||||
@@ -96,6 +103,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
"economic_events",
|
||||
"relationships",
|
||||
"event_knowledge",
|
||||
"opportunities",
|
||||
]
|
||||
)
|
||||
):
|
||||
@@ -104,22 +112,27 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
var simulation_data = record_data["simulation"]
|
||||
if not simulation_data is Dictionary:
|
||||
return null
|
||||
if not simulation_data.has_all(
|
||||
[
|
||||
"seed",
|
||||
"tick_interval",
|
||||
"tick_count",
|
||||
"clock_accumulator",
|
||||
"clock_elapsed_ticks",
|
||||
"wander_random_streams",
|
||||
"next_event_id"
|
||||
]
|
||||
if not (
|
||||
simulation_data
|
||||
. has_all(
|
||||
[
|
||||
"seed",
|
||||
"tick_interval",
|
||||
"tick_count",
|
||||
"clock_accumulator",
|
||||
"clock_elapsed_ticks",
|
||||
"wander_random_streams",
|
||||
"next_event_id",
|
||||
"next_opportunity_id",
|
||||
]
|
||||
)
|
||||
):
|
||||
return null
|
||||
var saved_tick_interval := float(simulation_data["tick_interval"])
|
||||
var saved_tick_count := int(simulation_data["tick_count"])
|
||||
var saved_clock_accumulator := float(simulation_data["clock_accumulator"])
|
||||
var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"])
|
||||
var saved_next_opportunity_id := int(simulation_data["next_opportunity_id"])
|
||||
if (
|
||||
not is_finite(saved_tick_interval)
|
||||
or saved_tick_interval <= 0.0
|
||||
@@ -127,6 +140,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
or not is_finite(saved_clock_accumulator)
|
||||
or saved_clock_accumulator < 0.0
|
||||
or saved_clock_elapsed_ticks < 0
|
||||
or saved_next_opportunity_id < 0
|
||||
):
|
||||
return null
|
||||
if simulation_data.has("cycle_duration_seconds"):
|
||||
@@ -155,6 +169,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
var event_data = record_data["economic_events"]
|
||||
var relationship_data = record_data["relationships"]
|
||||
var knowledge_data = record_data["event_knowledge"]
|
||||
var opportunity_data = record_data["opportunities"]
|
||||
if (
|
||||
not npc_data is Array
|
||||
or not resource_data is Array
|
||||
@@ -162,6 +177,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
or not event_data is Array
|
||||
or not relationship_data is Array
|
||||
or not knowledge_data is Array
|
||||
or not opportunity_data is Array
|
||||
):
|
||||
return null
|
||||
|
||||
@@ -183,6 +199,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
record.npcs.append(npc_record)
|
||||
|
||||
var resource_ids := {}
|
||||
var resource_records_by_id := {}
|
||||
for item in resource_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
@@ -193,9 +210,11 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
if resource_ids.has(resource_id):
|
||||
return null
|
||||
resource_ids[resource_id] = true
|
||||
resource_records_by_id[resource_id] = resource_record
|
||||
record.resources.append(resource_record)
|
||||
|
||||
var storage_ids := {}
|
||||
var storage_records_by_id := {}
|
||||
for item in storage_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
@@ -206,6 +225,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
if storage_ids.has(storage_id):
|
||||
return null
|
||||
storage_ids[storage_id] = true
|
||||
storage_records_by_id[storage_id] = storage_record
|
||||
record.storages.append(storage_record)
|
||||
|
||||
var event_ids := {}
|
||||
@@ -228,6 +248,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
return null
|
||||
|
||||
var knowledge_keys := {}
|
||||
var knowledge_records_by_key := {}
|
||||
for item in knowledge_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
@@ -242,6 +263,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
if knowledge_keys.has(knowledge_key):
|
||||
return null
|
||||
knowledge_keys[knowledge_key] = true
|
||||
knowledge_records_by_key[knowledge_key] = known_event_record
|
||||
record.event_knowledge.append(known_event_record)
|
||||
for known_event_record in record.event_knowledge:
|
||||
if not _is_valid_knowledge_provenance(
|
||||
@@ -289,6 +311,46 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
relationship_keys[relationship_key] = true
|
||||
record.relationships.append(relationship_record)
|
||||
|
||||
var opportunity_ids := {}
|
||||
var trigger_event_ids := {}
|
||||
var resolution_event_ids := {}
|
||||
var has_open_opportunity := false
|
||||
var highest_opportunity_id := -1
|
||||
for item in opportunity_data:
|
||||
if not item is Dictionary:
|
||||
return null
|
||||
var opportunity_record := OpportunityStateRecord.from_dictionary(item)
|
||||
if opportunity_record == null:
|
||||
return null
|
||||
var opportunity_id := opportunity_record.get_opportunity_id()
|
||||
var trigger_event_id := opportunity_record.get_trigger_event_id()
|
||||
if opportunity_ids.has(opportunity_id) or trigger_event_ids.has(trigger_event_id):
|
||||
return null
|
||||
if opportunity_record.get_status() == OpportunityStateRecord.STATUS_OPEN:
|
||||
if has_open_opportunity:
|
||||
return null
|
||||
has_open_opportunity = true
|
||||
elif opportunity_record.get_resolution_event_id() != OpportunityStateRecord.NO_EVENT_ID:
|
||||
if resolution_event_ids.has(opportunity_record.get_resolution_event_id()):
|
||||
return null
|
||||
resolution_event_ids[opportunity_record.get_resolution_event_id()] = true
|
||||
if not _is_valid_opportunity(
|
||||
opportunity_record,
|
||||
npc_ids,
|
||||
storage_records_by_id,
|
||||
event_records_by_id,
|
||||
knowledge_records_by_key,
|
||||
resource_records_by_id,
|
||||
int(record.simulation["tick_count"])
|
||||
):
|
||||
return null
|
||||
opportunity_ids[opportunity_id] = true
|
||||
trigger_event_ids[trigger_event_id] = true
|
||||
highest_opportunity_id = maxi(highest_opportunity_id, opportunity_id)
|
||||
record.opportunities.append(opportunity_record)
|
||||
if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id:
|
||||
return null
|
||||
|
||||
return record
|
||||
|
||||
|
||||
@@ -310,11 +372,7 @@ static func _is_valid_knowledge_provenance(
|
||||
return false
|
||||
if method == SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY:
|
||||
return true
|
||||
if (
|
||||
StringName(event.data["event_type"]) != SimulationIds.EVENT_STORAGE_DEPOSITED
|
||||
or StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD
|
||||
or float(event.data["amount"]) <= 0.0
|
||||
):
|
||||
if not EventKnowledgeSystem.is_knowable_event(event):
|
||||
return false
|
||||
match method:
|
||||
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED:
|
||||
@@ -337,6 +395,117 @@ static func _is_valid_knowledge_provenance(
|
||||
return false
|
||||
|
||||
|
||||
static func _is_valid_opportunity(
|
||||
opportunity: OpportunityStateRecord,
|
||||
npc_ids: Dictionary,
|
||||
storage_records_by_id: Dictionary,
|
||||
event_records_by_id: Dictionary,
|
||||
knowledge_records_by_key: Dictionary,
|
||||
resource_records_by_id: Dictionary,
|
||||
current_tick: int
|
||||
) -> bool:
|
||||
if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY:
|
||||
return false
|
||||
if (
|
||||
not npc_ids.has(opportunity.get_interested_npc_id())
|
||||
or opportunity.get_target_id() != SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
or not storage_records_by_id.has(opportunity.get_target_id())
|
||||
or opportunity.get_resource_id() != SimulationIds.RESOURCE_FOOD
|
||||
or not is_finite(opportunity.get_target_amount())
|
||||
or opportunity.get_target_amount() <= 0.0
|
||||
):
|
||||
return false
|
||||
var trigger_event := (
|
||||
event_records_by_id.get(opportunity.get_trigger_event_id()) as EconomicEventRecord
|
||||
)
|
||||
if trigger_event == null or not _is_valid_pantry_empty_trigger(trigger_event, npc_ids):
|
||||
return false
|
||||
var trigger_tick := int(trigger_event.data["tick"])
|
||||
var created_tick := opportunity.get_created_tick()
|
||||
if created_tick < trigger_tick or created_tick > current_tick:
|
||||
return false
|
||||
|
||||
var status := opportunity.get_status()
|
||||
var resolution_event_id := opportunity.get_resolution_event_id()
|
||||
var resolved_tick := opportunity.get_resolved_tick()
|
||||
if status == OpportunityStateRecord.STATUS_OPEN:
|
||||
var target_storage := (
|
||||
storage_records_by_id.get(opportunity.get_target_id()) as StorageStateRecord
|
||||
)
|
||||
var evidence_key := (
|
||||
"%d:%d" % [opportunity.get_interested_npc_id(), opportunity.get_trigger_event_id()]
|
||||
)
|
||||
var evidence := knowledge_records_by_key.get(evidence_key) as KnownEventStateRecord
|
||||
return (
|
||||
resolution_event_id == OpportunityStateRecord.NO_EVENT_ID
|
||||
and resolved_tick == -1
|
||||
and target_storage != null
|
||||
and (
|
||||
target_storage.get_amount(opportunity.get_resource_id())
|
||||
< opportunity.get_target_amount()
|
||||
)
|
||||
and evidence != null
|
||||
and evidence.get_acquired_tick() <= created_tick
|
||||
)
|
||||
if status != OpportunityStateRecord.STATUS_RESOLVED:
|
||||
return false
|
||||
if resolution_event_id == OpportunityStateRecord.NO_EVENT_ID:
|
||||
return false
|
||||
var resolution_event := event_records_by_id.get(resolution_event_id) as EconomicEventRecord
|
||||
if (
|
||||
resolution_event == null
|
||||
or not _is_valid_pantry_supply_resolution(resolution_event, npc_ids, resource_records_by_id)
|
||||
):
|
||||
return false
|
||||
var resolution_tick := int(resolution_event.data["tick"])
|
||||
return (
|
||||
resolution_event_id > opportunity.get_trigger_event_id()
|
||||
and resolution_tick >= created_tick
|
||||
and resolved_tick == resolution_tick
|
||||
and resolved_tick <= current_tick
|
||||
)
|
||||
|
||||
|
||||
static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids: Dictionary) -> bool:
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
return (
|
||||
npc_ids.has(actor_id)
|
||||
and StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_WITHDRAWN
|
||||
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
and StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id)
|
||||
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||
and float(event.data["amount"]) > 0.0
|
||||
)
|
||||
|
||||
|
||||
static func _is_valid_pantry_supply_resolution(
|
||||
event: EconomicEventRecord, npc_ids: Dictionary, resource_records_by_id: Dictionary
|
||||
) -> bool:
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
var event_type := StringName(event.data["event_type"])
|
||||
if (
|
||||
StringName(event.data["destination_id"]) != SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||
or StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD
|
||||
or float(event.data["amount"]) <= 0.0
|
||||
):
|
||||
return false
|
||||
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
|
||||
return (
|
||||
npc_ids.has(actor_id)
|
||||
and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id)
|
||||
)
|
||||
if event_type == SimulationIds.EVENT_RESOURCE_EXTRACTED and actor_id == -1:
|
||||
var resource_state := (
|
||||
resource_records_by_id.get(StringName(event.data["source_id"])) as ResourceStateRecord
|
||||
)
|
||||
return (
|
||||
resource_state != null
|
||||
and resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD
|
||||
and resource_state.can_player_use_resource()
|
||||
)
|
||||
return false
|
||||
|
||||
|
||||
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
|
||||
var migrated := legacy_data.duplicate(true)
|
||||
migrated["schema_version"] = SCHEMA_VERSION
|
||||
@@ -390,6 +559,10 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
||||
migrated.get("economic_events", []),
|
||||
int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0))
|
||||
)
|
||||
migrated["opportunities"] = []
|
||||
var opportunity_simulation_data: Dictionary = migrated.get("simulation", {})
|
||||
opportunity_simulation_data["next_opportunity_id"] = 0
|
||||
migrated["simulation"] = opportunity_simulation_data
|
||||
return migrated
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -119,6 +119,105 @@ func _run() -> void:
|
||||
not listener_visual.get_node("TrustReactionRoot").visible,
|
||||
"Learning a fact without a relationship consequence should not show a trust blossom"
|
||||
)
|
||||
|
||||
var village_stats_label := (
|
||||
main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label
|
||||
)
|
||||
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
|
||||
contributor.hunger = 85.0
|
||||
var withdrawn_for_need: float = simulation_manager.economy.withdraw_to_inventory(
|
||||
contributor,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||
)
|
||||
_check(withdrawn_for_need > 0.0, "Runtime opportunity setup should empty the real pantry")
|
||||
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
|
||||
_check(opportunity != null, "Emptying the pantry should open one known shortage need")
|
||||
_check(
|
||||
(
|
||||
"Village need" in village_stats_label.text
|
||||
and "◆ Restock the empty pantry" in village_stats_label.text
|
||||
and (
|
||||
"%s is worried about the shortage" % contributor.npc_name
|
||||
in village_stats_label.text
|
||||
)
|
||||
and "Food 0 / 1" in village_stats_label.text
|
||||
),
|
||||
"An open opportunity should add one compact state-derived village need"
|
||||
)
|
||||
village_ui.selected_npc_index = contributor.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
_check(
|
||||
(
|
||||
"Open village need" in inspector_label.text
|
||||
and "◆ Restock the empty pantry" in inspector_label.text
|
||||
and "Food 0 / 1" in inspector_label.text
|
||||
),
|
||||
"The interested villager should expose the open pantry need in their inspector"
|
||||
)
|
||||
village_ui.selected_npc_index = listener.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
_check(
|
||||
"Open village need" not in inspector_label.text,
|
||||
"An unrelated selected villager should not inherit someone else's open need"
|
||||
)
|
||||
|
||||
_check(
|
||||
(
|
||||
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD)
|
||||
> 0.0
|
||||
),
|
||||
"Opportunity presentation should resolve from a real pantry deposit"
|
||||
)
|
||||
_check(
|
||||
"Village need" not in village_stats_label.text,
|
||||
"Resolved opportunities should leave the compact village summary"
|
||||
)
|
||||
village_ui.selected_npc_index = contributor.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
_check(
|
||||
(
|
||||
"Resolved village need" in inspector_label.text
|
||||
and "◆ %s restocked the pantry" % contributor.npc_name in inspector_label.text
|
||||
and "Food target 1" in inspector_label.text
|
||||
),
|
||||
"The interested villager should retain a resolved detail naming the real actor"
|
||||
)
|
||||
village_ui.selected_npc_index = listener.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
_check(
|
||||
"Resolved village need" not in inspector_label.text,
|
||||
"Resolved opportunity detail should remain scoped to its interested villager"
|
||||
)
|
||||
|
||||
var player_need_withdrawal: float = simulation_manager.economy.withdraw_to_inventory(
|
||||
contributor,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||
)
|
||||
_check(player_need_withdrawal > 0.0, "Player resolution setup should reopen a real need")
|
||||
var player_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
|
||||
_check(player_opportunity != null, "A later shortage should open a new opportunity record")
|
||||
var player := main_scene.get_node("Player") as Node3D
|
||||
var opportunity_bush := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes/BerryBush_01") as ResourceNode
|
||||
)
|
||||
var opportunity_bush_state: ResourceStateRecord = simulation_manager.get_resource_state(
|
||||
opportunity_bush.node_id
|
||||
)
|
||||
opportunity_bush_state.set_amount_remaining(1.0)
|
||||
player.global_position = opportunity_bush.interaction_point.global_position
|
||||
player.call("try_interact")
|
||||
village_ui.selected_npc_index = contributor.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
_check(
|
||||
(
|
||||
player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED
|
||||
and "Resolved village need" in inspector_label.text
|
||||
and "◆ Player restocked the pantry" in inspector_label.text
|
||||
),
|
||||
"Player gathering should resolve the same need and be named from its real event"
|
||||
)
|
||||
_check(
|
||||
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
|
||||
"Playable runtime should instance the Jajce Terrain3D world"
|
||||
|
||||
@@ -18,7 +18,9 @@ func _run() -> void:
|
||||
_test_previous_world_knowledge_migration()
|
||||
_test_previous_world_provenance_migration()
|
||||
_test_previous_world_retention_migration()
|
||||
_test_previous_world_opportunity_migration()
|
||||
_test_relationship_schema_rejection()
|
||||
_test_opportunity_schema_rejection()
|
||||
_test_schema_rejection()
|
||||
|
||||
if failures.is_empty():
|
||||
@@ -371,7 +373,7 @@ func _test_previous_world_provenance_migration() -> void:
|
||||
func _test_previous_world_retention_migration() -> void:
|
||||
var manager := _create_manager(61)
|
||||
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
|
||||
previous_data["schema_version"] = SimulationStateRecord.RETENTION_LEGACY_SCHEMA_VERSION
|
||||
previous_data["simulation"]["tick_count"] = 50
|
||||
var deposit_event := EconomicEventRecord.create(
|
||||
0,
|
||||
@@ -439,6 +441,25 @@ func _test_previous_world_retention_migration() -> void:
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_previous_world_opportunity_migration() -> void:
|
||||
var manager := _create_manager(62)
|
||||
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
|
||||
previous_data.erase("opportunities")
|
||||
previous_data["simulation"].erase("next_opportunity_id")
|
||||
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
||||
_check(migrated != null, "World schema v7 should add an empty opportunity stream")
|
||||
if migrated != null:
|
||||
_check(
|
||||
(
|
||||
migrated.opportunities.is_empty()
|
||||
and int(migrated.simulation["next_opportunity_id"]) == 0
|
||||
),
|
||||
"World v7 migration should initialize deterministic opportunity identity"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_relationship_schema_rejection() -> void:
|
||||
var manager := _create_manager(58)
|
||||
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||
@@ -685,6 +706,249 @@ func _test_relationship_schema_rejection() -> void:
|
||||
manager.free()
|
||||
|
||||
|
||||
func _test_opportunity_schema_rejection() -> void:
|
||||
var manager := _create_manager(63)
|
||||
var valid_open := _build_opportunity_world(manager, false)
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(valid_open) != null,
|
||||
"A known empty-pantry trigger should accept one open restock opportunity"
|
||||
)
|
||||
|
||||
var missing_knowledge := valid_open.duplicate(true)
|
||||
missing_knowledge["event_knowledge"] = []
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(missing_knowledge) == null,
|
||||
"An open opportunity should require its interested NPC to know the trigger"
|
||||
)
|
||||
var knowledge_acquired_too_late := valid_open.duplicate(true)
|
||||
knowledge_acquired_too_late["event_knowledge"][0]["acquisition_method"] = String(
|
||||
SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY
|
||||
)
|
||||
knowledge_acquired_too_late["event_knowledge"][0]["acquired_tick"] = 3
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(knowledge_acquired_too_late) == null,
|
||||
"An open opportunity cannot predate the interested NPC's knowledge"
|
||||
)
|
||||
var already_supplied := valid_open.duplicate(true)
|
||||
for storage_data in already_supplied["storages"]:
|
||||
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
|
||||
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = 1.0
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(already_supplied) == null,
|
||||
"An open pantry need should reject a save whose target is already supplied"
|
||||
)
|
||||
|
||||
var duplicate_id := valid_open.duplicate(true)
|
||||
var second_opportunity: Dictionary = duplicate_id["opportunities"][0].duplicate(true)
|
||||
second_opportunity["trigger_event_id"] = 1
|
||||
duplicate_id["economic_events"].append(_create_pantry_withdrawal_event(1, 3, 1).to_dictionary())
|
||||
duplicate_id["event_knowledge"].append(
|
||||
(
|
||||
KnownEventStateRecord
|
||||
. create(
|
||||
1,
|
||||
1,
|
||||
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
|
||||
KnownEventStateRecord.NO_SOURCE_NPC_ID,
|
||||
3
|
||||
)
|
||||
. to_dictionary()
|
||||
)
|
||||
)
|
||||
duplicate_id["opportunities"].append(second_opportunity)
|
||||
duplicate_id["simulation"]["next_event_id"] = 2
|
||||
duplicate_id["simulation"]["next_opportunity_id"] = 2
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(duplicate_id) == null,
|
||||
"Opportunity IDs should be unique"
|
||||
)
|
||||
var multiple_open := duplicate_id.duplicate(true)
|
||||
multiple_open["opportunities"][1]["opportunity_id"] = 1
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(multiple_open) == null,
|
||||
"The bounded pantry slice should restore at most one open opportunity"
|
||||
)
|
||||
|
||||
var duplicate_trigger := valid_open.duplicate(true)
|
||||
var repeated_trigger: Dictionary = duplicate_trigger["opportunities"][0].duplicate(true)
|
||||
repeated_trigger["opportunity_id"] = 1
|
||||
duplicate_trigger["opportunities"].append(repeated_trigger)
|
||||
duplicate_trigger["simulation"]["next_opportunity_id"] = 2
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(duplicate_trigger) == null,
|
||||
"One objective trigger should not create duplicate opportunity records"
|
||||
)
|
||||
|
||||
var colliding_next_id := valid_open.duplicate(true)
|
||||
colliding_next_id["simulation"]["next_opportunity_id"] = 0
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(colliding_next_id) == null,
|
||||
"The next opportunity ID should remain above restored history"
|
||||
)
|
||||
|
||||
var unknown_interested_npc := valid_open.duplicate(true)
|
||||
unknown_interested_npc["opportunities"][0]["interested_npc_id"] = 999
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(unknown_interested_npc) == null,
|
||||
"Opportunities should reference an existing interested NPC"
|
||||
)
|
||||
|
||||
var future_creation := valid_open.duplicate(true)
|
||||
future_creation["opportunities"][0]["created_tick"] = 11
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(future_creation) == null,
|
||||
"Opportunity creation cannot occur after the saved simulation tick"
|
||||
)
|
||||
|
||||
var before_trigger := valid_open.duplicate(true)
|
||||
before_trigger["opportunities"][0]["created_tick"] = 1
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(before_trigger) == null,
|
||||
"Opportunity creation cannot predate its trigger event"
|
||||
)
|
||||
|
||||
var invalid_trigger := valid_open.duplicate(true)
|
||||
invalid_trigger["economic_events"][0]["destination_id"] = "unrelated_inventory"
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(invalid_trigger) == null,
|
||||
"A restock opportunity trigger should be a real NPC pantry withdrawal"
|
||||
)
|
||||
|
||||
var player_trigger := valid_open.duplicate(true)
|
||||
player_trigger["economic_events"][0]["actor_id"] = -1
|
||||
player_trigger["economic_events"][0]["destination_id"] = "player_inventory"
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(player_trigger) == null,
|
||||
"Player extraction or consumption should not masquerade as an informed NPC trigger"
|
||||
)
|
||||
|
||||
var valid_resolved := _build_opportunity_world(manager, true)
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(valid_resolved) != null,
|
||||
"A later NPC pantry deposit should resolve an opportunity without retained knowledge"
|
||||
)
|
||||
|
||||
var player_resolved := _build_opportunity_world(manager, true)
|
||||
player_resolved["economic_events"][1] = (
|
||||
EconomicEventRecord
|
||||
. create(
|
||||
1,
|
||||
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
||||
3,
|
||||
-1,
|
||||
&"schema_test_berry",
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
. to_dictionary()
|
||||
)
|
||||
player_resolved["resources"].append(
|
||||
_create_player_food_resource_state(&"schema_test_berry").to_dictionary()
|
||||
)
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(player_resolved) != null,
|
||||
"A later player resource extraction into the pantry should be a valid resolution"
|
||||
)
|
||||
var unknown_player_source := player_resolved.duplicate(true)
|
||||
unknown_player_source["economic_events"][1]["source_id"] = "missing_berry"
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(unknown_player_source) == null,
|
||||
"A player resolution should reference a real player-usable food resource"
|
||||
)
|
||||
|
||||
var invalid_resolution := valid_resolved.duplicate(true)
|
||||
invalid_resolution["economic_events"][1]["destination_id"] = "somewhere_else"
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(invalid_resolution) == null,
|
||||
"Resolution events should actually supply food to the opportunity target"
|
||||
)
|
||||
|
||||
var mismatched_resolution_tick := valid_resolved.duplicate(true)
|
||||
mismatched_resolution_tick["opportunities"][0]["resolved_tick"] = 4
|
||||
_check(
|
||||
SimulationStateRecord.from_dictionary(mismatched_resolution_tick) == null,
|
||||
"Resolved opportunity ticks should match the referenced resolution event"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
|
||||
func _build_opportunity_world(manager: Node, resolved: bool) -> Dictionary:
|
||||
var world: Dictionary = manager.create_state_record().to_dictionary()
|
||||
world["simulation"]["tick_count"] = 10
|
||||
for storage_data in world["storages"]:
|
||||
if StringName(storage_data["storage_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY:
|
||||
storage_data["amounts"][String(SimulationIds.RESOURCE_FOOD)] = (
|
||||
1.0 if resolved else 0.0
|
||||
)
|
||||
world["economic_events"] = [_create_pantry_withdrawal_event(0, 2, 0).to_dictionary()]
|
||||
world["simulation"]["next_event_id"] = 1
|
||||
var opportunity := OpportunityStateRecord.create(0, 2, 0, 0)
|
||||
if resolved:
|
||||
world["economic_events"].append(
|
||||
(
|
||||
EconomicEventRecord
|
||||
. create(
|
||||
1,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
3,
|
||||
1,
|
||||
SimulationIds.npc_inventory_id(1),
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
. to_dictionary()
|
||||
)
|
||||
)
|
||||
world["simulation"]["next_event_id"] = 2
|
||||
opportunity.resolve(1, 3)
|
||||
world["event_knowledge"] = []
|
||||
else:
|
||||
world["event_knowledge"] = [
|
||||
(
|
||||
KnownEventStateRecord
|
||||
. create(
|
||||
0,
|
||||
0,
|
||||
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
|
||||
KnownEventStateRecord.NO_SOURCE_NPC_ID,
|
||||
2
|
||||
)
|
||||
. to_dictionary()
|
||||
)
|
||||
]
|
||||
world["opportunities"] = [opportunity.to_dictionary()]
|
||||
world["simulation"]["next_opportunity_id"] = 1
|
||||
return world
|
||||
|
||||
|
||||
func _create_pantry_withdrawal_event(
|
||||
event_id: int, tick: int, actor_id: int
|
||||
) -> EconomicEventRecord:
|
||||
return EconomicEventRecord.create(
|
||||
event_id,
|
||||
SimulationIds.EVENT_STORAGE_WITHDRAWN,
|
||||
tick,
|
||||
actor_id,
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
SimulationIds.npc_inventory_id(actor_id),
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
|
||||
|
||||
func _create_player_food_resource_state(node_id: StringName) -> ResourceStateRecord:
|
||||
var resource_node := ResourceNode.new()
|
||||
resource_node.node_id = node_id
|
||||
resource_node.action_id = SimulationIds.ACTION_GATHER_FOOD
|
||||
resource_node.resource_id = SimulationIds.RESOURCE_FOOD
|
||||
resource_node.can_player_use = true
|
||||
var resource_state := ResourceStateRecord.create_from_node(resource_node)
|
||||
resource_node.free()
|
||||
return resource_state
|
||||
|
||||
|
||||
func _create_manager(seed_value: int) -> Node:
|
||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||
manager.simulation_seed = seed_value
|
||||
|
||||
@@ -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
|
||||
@@ -129,9 +129,63 @@ func _stage_social_history(main_scene: Node) -> bool:
|
||||
):
|
||||
push_error("Simulation Garden staging did not produce the retained trust consequence")
|
||||
return false
|
||||
if not _stage_pantry_need(main_scene, contributor, observer):
|
||||
return false
|
||||
var village_ui := main_scene.get_node("UI")
|
||||
village_ui.selected_npc_index = simulation_manager.npcs.find(observer)
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
var inspector_label := (
|
||||
main_scene.get_node("UI/NpcInspectorPanel/MarginContainer/NpcInspectorLabel") as Label
|
||||
)
|
||||
if "Open village need" not in inspector_label.text or "Food 0 / 1" not in inspector_label.text:
|
||||
push_error("Simulation Garden staging did not expose the selected person's need")
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _stage_pantry_need(main_scene: Node, actor: SimNPC, interested: SimNPC) -> bool:
|
||||
var simulation_manager := main_scene.get_node("SimulationManager")
|
||||
for npc in simulation_manager.npcs:
|
||||
npc.hunger = minf(npc.hunger, 70.0)
|
||||
actor.hunger = 60.0
|
||||
interested.hunger = 85.0
|
||||
var pantry: StorageStateRecord = simulation_manager.get_pantry()
|
||||
pantry.withdraw(
|
||||
SimulationIds.RESOURCE_FOOD, maxf(pantry.get_amount(SimulationIds.RESOURCE_FOOD) - 1.0, 0.0)
|
||||
)
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||
var withdrawn: float = simulation_manager.economy.withdraw_to_inventory(
|
||||
actor, SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||
)
|
||||
var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
|
||||
var village_need_label := (
|
||||
main_scene.get_node("UI/VillagePanel/MarginContainer/VillageStatsLabel") as Label
|
||||
)
|
||||
if (
|
||||
withdrawn <= 0.0
|
||||
or opportunity == null
|
||||
or opportunity.get_interested_npc_id() != interested.id
|
||||
or "Village need" not in village_need_label.text
|
||||
or "Food 0 / 1" not in village_need_label.text
|
||||
):
|
||||
push_error("Simulation Garden staging did not expose the real empty-pantry need")
|
||||
return false
|
||||
var previous_task := interested.current_task
|
||||
if not interested.target_id.is_empty():
|
||||
simulation_manager.release_npc_reservation(interested.id)
|
||||
var selection: ActionSelectionResult = simulation_manager.action_selector.select_action(
|
||||
interested,
|
||||
simulation_manager.village,
|
||||
simulation_manager.clock.time_of_day(),
|
||||
simulation_manager.npcs
|
||||
)
|
||||
if selection == null or selection.action_id != SimulationIds.ACTION_GATHER_FOOD:
|
||||
push_error("Simulation Garden staging could not replan the hungry villager")
|
||||
return false
|
||||
simulation_manager.latest_decisions[interested.id] = selection
|
||||
interested.set_task(selection.action_id, selection.duration_override)
|
||||
simulation_manager.npc_task_changed.emit(interested, previous_task, selection.action_id)
|
||||
simulation_manager.npc_target_requested.emit(interested)
|
||||
return true
|
||||
|
||||
|
||||
|
||||
+102
-4
@@ -33,6 +33,10 @@ func _ready() -> void:
|
||||
simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed)
|
||||
if simulation_manager.has_signal("event_knowledge_forgotten"):
|
||||
simulation_manager.event_knowledge_forgotten.connect(_on_event_knowledge_forgotten)
|
||||
if simulation_manager.has_signal("opportunity_opened"):
|
||||
simulation_manager.opportunity_opened.connect(_on_opportunity_opened)
|
||||
if simulation_manager.has_signal("opportunity_resolved"):
|
||||
simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved)
|
||||
|
||||
if "village" in simulation_manager:
|
||||
_on_village_changed(simulation_manager.village)
|
||||
@@ -79,6 +83,7 @@ func _on_village_changed(village: SimVillage) -> void:
|
||||
var wood_rate := ""
|
||||
if rates["wood_per_day"] > 0.01:
|
||||
wood_rate = " (−%.0f/d)" % rates["wood_per_day"]
|
||||
var opportunity_text := _build_active_opportunity_display()
|
||||
village_stats_label.text = (
|
||||
"""
|
||||
Village [%s]
|
||||
@@ -92,7 +97,7 @@ func _on_village_changed(village: SimVillage) -> void:
|
||||
Food Mod: %.2f
|
||||
Safety Mod: %.2f
|
||||
Knowledge Mod: %.2f
|
||||
%s
|
||||
%s%s
|
||||
"""
|
||||
% [
|
||||
speed_text,
|
||||
@@ -106,7 +111,8 @@ func _on_village_changed(village: SimVillage) -> void:
|
||||
village.food_modifier,
|
||||
village.safety_modifier,
|
||||
village.knowledge_modifier,
|
||||
event_text
|
||||
opportunity_text,
|
||||
event_text,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -117,6 +123,7 @@ func _on_npc_decision_recorded(_npc: SimNPC, _decision: ActionSelectionResult) -
|
||||
|
||||
func _on_state_restored() -> void:
|
||||
selected_npc_index = 0
|
||||
_refresh_village_display()
|
||||
_refresh_npc_inspector()
|
||||
|
||||
|
||||
@@ -139,6 +146,23 @@ func _on_event_knowledge_forgotten(_knower_id: int, _event: EconomicEventRecord)
|
||||
_refresh_npc_inspector()
|
||||
|
||||
|
||||
func _on_opportunity_opened(_opportunity: RefCounted) -> void:
|
||||
_refresh_village_display()
|
||||
_refresh_npc_inspector()
|
||||
|
||||
|
||||
func _on_opportunity_resolved(
|
||||
_opportunity: RefCounted, _resolution_event: EconomicEventRecord
|
||||
) -> void:
|
||||
_refresh_village_display()
|
||||
_refresh_npc_inspector()
|
||||
|
||||
|
||||
func _refresh_village_display() -> void:
|
||||
if simulation_manager != null and "village" in simulation_manager:
|
||||
_on_village_changed(simulation_manager.village)
|
||||
|
||||
|
||||
func _refresh_npc_inspector() -> void:
|
||||
if not debug_overlay_visible:
|
||||
return
|
||||
@@ -189,6 +213,7 @@ func _refresh_npc_inspector() -> void:
|
||||
var event_text := _build_event_history(npc)
|
||||
var relationship_text := _build_relationship_display(npc)
|
||||
var memory_history_text := _build_memory_history_display(npc)
|
||||
var opportunity_text := _build_npc_opportunity_display(npc)
|
||||
npc_inspector_label.text = (
|
||||
(
|
||||
"%s · %s\n"
|
||||
@@ -198,6 +223,7 @@ func _refresh_npc_inspector() -> void:
|
||||
+ "Carrying food: %.0f wood: %.0f\n"
|
||||
+ "%s\n"
|
||||
+ "%s\n"
|
||||
+ "%s"
|
||||
+ "Why\n%s%s\n\n"
|
||||
+ "Own history\n%s\n\n"
|
||||
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
|
||||
@@ -214,6 +240,7 @@ func _refresh_npc_inspector() -> void:
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
|
||||
relationship_text,
|
||||
memory_history_text,
|
||||
opportunity_text,
|
||||
reason_text,
|
||||
score_text,
|
||||
event_text
|
||||
@@ -221,6 +248,69 @@ func _refresh_npc_inspector() -> void:
|
||||
)
|
||||
|
||||
|
||||
func _build_active_opportunity_display() -> String:
|
||||
if not simulation_manager.has_method("get_active_opportunity"):
|
||||
return ""
|
||||
var opportunity = simulation_manager.get_active_opportunity()
|
||||
if opportunity == null:
|
||||
return ""
|
||||
var interested_name := _get_npc_name(opportunity.get_interested_npc_id())
|
||||
var resource_name := String(opportunity.get_resource_id()).capitalize()
|
||||
var current_amount := 0.0
|
||||
var pantry: StorageStateRecord = simulation_manager.get_pantry()
|
||||
if pantry != null:
|
||||
current_amount = pantry.get_amount(opportunity.get_resource_id())
|
||||
return (
|
||||
"\n\nVillage need\n"
|
||||
+ "◆ Restock the empty pantry\n"
|
||||
+ "%s is worried about the shortage\n" % interested_name
|
||||
+ "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()]
|
||||
)
|
||||
|
||||
|
||||
func _build_npc_opportunity_display(npc: SimNPC) -> String:
|
||||
if not simulation_manager.has_method("get_latest_opportunity_for_npc"):
|
||||
return ""
|
||||
var opportunity = simulation_manager.get_latest_opportunity_for_npc(npc.id)
|
||||
if opportunity == null:
|
||||
return ""
|
||||
var resource_name := String(opportunity.get_resource_id()).capitalize()
|
||||
if opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN:
|
||||
var pantry: StorageStateRecord = simulation_manager.get_pantry()
|
||||
var current_amount := 0.0
|
||||
if pantry != null:
|
||||
current_amount = pantry.get_amount(opportunity.get_resource_id())
|
||||
return (
|
||||
"Open village need\n"
|
||||
+ "◆ Restock the empty pantry\n"
|
||||
+ (
|
||||
"%s %.0f / %.0f\n\n"
|
||||
% [resource_name, current_amount, opportunity.get_target_amount()]
|
||||
)
|
||||
)
|
||||
if opportunity.get_status() != OpportunityStateRecord.STATUS_RESOLVED:
|
||||
return ""
|
||||
var resolution_event: EconomicEventRecord = simulation_manager.get_opportunity_resolution_event(
|
||||
opportunity
|
||||
)
|
||||
var actor_name := "Someone"
|
||||
if resolution_event != null:
|
||||
var actor_id := int(resolution_event.data["actor_id"])
|
||||
actor_name = "Player" if actor_id < 0 else _get_npc_name(actor_id)
|
||||
return (
|
||||
"Resolved village need\n"
|
||||
+ "◆ %s restocked the pantry\n" % actor_name
|
||||
+ "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()]
|
||||
)
|
||||
|
||||
|
||||
func _get_npc_name(npc_id: int) -> String:
|
||||
for npc in simulation_manager.npcs:
|
||||
if npc.id == npc_id:
|
||||
return npc.npc_name
|
||||
return "Someone"
|
||||
|
||||
|
||||
func _get_action_name(action_id: StringName) -> String:
|
||||
if action_id == SimulationIds.ACTION_IDLE:
|
||||
return "Idle"
|
||||
@@ -327,12 +417,20 @@ func _build_memory_history_display(npc: SimNPC) -> String:
|
||||
|
||||
|
||||
func _build_compact_memory_summary(event: EconomicEventRecord, name_map: Dictionary) -> String:
|
||||
var event_type := StringName(event.data["event_type"])
|
||||
var item_id := StringName(event.data["item_id"])
|
||||
if (
|
||||
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
|
||||
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||
event_type == SimulationIds.EVENT_STORAGE_DEPOSITED
|
||||
and item_id == SimulationIds.RESOURCE_FOOD
|
||||
):
|
||||
var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone")
|
||||
return "%s stocked %.0f food" % [actor_name, float(event.data["amount"])]
|
||||
if (
|
||||
event_type == SimulationIds.EVENT_STORAGE_WITHDRAWN
|
||||
and item_id == SimulationIds.RESOURCE_FOOD
|
||||
):
|
||||
var actor_name: String = name_map.get(int(event.data["actor_id"]), "Someone")
|
||||
return "%s took %.0f food from the pantry" % [actor_name, float(event.data["amount"])]
|
||||
return event.description(name_map)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user