feat: add missing wood opportunities

This commit is contained in:
Rijad Zuzo
2026-07-16 01:50:19 +02:00
parent f82d9683de
commit 79dd503343
26 changed files with 1054 additions and 175 deletions
+5 -5
View File
@@ -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
+9 -4
View File
@@ -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
View File
@@ -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
v1v7 migrate to an empty opportunity list. Parsing rejects duplicate event
v1v7 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.
+25 -6
View File
@@ -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
View File
@@ -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:
+13 -8
View File
@@ -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
+39 -18
View File
@@ -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
v1v7 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 v1v7 layouts;
supported v1v8 layouts;
- player inventory or player relationship records;
- broader relationship dimensions, line-of-sight/hearing evidence,
continuous or personalized memory decay, reinforcement, false beliefs, or
+38 -11
View File
@@ -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
+4
View File
@@ -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"
+11 -2
View File
@@ -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)
+14 -2
View File
@@ -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
+14 -1
View File
@@ -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:
@@ -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
+22 -4
View File
@@ -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)
+111 -24
View File
@@ -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:
match opportunity_type:
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY:
if (
target_id != SimulationIds.STORAGE_VILLAGE_PANTRY
or resource_id != SimulationIds.RESOURCE_FOOD
):
return null
if target_id != SimulationIds.STORAGE_VILLAGE_PANTRY:
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD:
if (
target_id != SimulationIds.STORAGE_VILLAGE_WOODPILE
or resource_id != SimulationIds.RESOURCE_WOOD
):
return null
if resource_id != SimulationIds.RESOURCE_FOOD:
_:
return null
if not is_finite(target_amount) or not is_equal_approx(target_amount, DEFAULT_TARGET_AMOUNT):
return null
match status:
STATUS_OPEN:
if resolution_event_id != NO_EVENT_ID or resolved_tick != -1:
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
+75 -12
View File
@@ -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,7 +420,16 @@ 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()
@@ -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,6 +621,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
migrated.get("economic_events", []),
int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0))
)
if version != OPPORTUNITY_LEGACY_SCHEMA_VERSION:
migrated["opportunities"] = []
var opportunity_simulation_data: Dictionary = migrated.get("simulation", {})
opportunity_simulation_data["next_opportunity_id"] = 0
+1 -1
View File
@@ -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(
(
+38 -1
View File
@@ -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()
@@ -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]
+318
View File
@@ -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
View File
@@ -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:
+15 -2
View File
@@ -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
)
)