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