From 6b5058059836929ab2ab253415da2f78b0d1cb67 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 21:15:58 +0200 Subject: [PATCH] feat: deliver emergent Jajce commitments --- docs/ARCHITECTURE_OVERVIEW.md | 52 ++- docs/SIMULATION_DEFINITIONS.md | 249 +++++----- docs/SIMULATION_STATE_SCHEMA.md | 102 ++++- simulation/SimulationManager.gd | 426 +++++++++++++++++- .../benchmark/SimulationScalingBenchmark.gd | 19 +- simulation/definitions/SimulationIds.gd | 5 + simulation/events/SimulationEventLog.gd | 57 +++ simulation/knowledge/EventKnowledgeSystem.gd | 17 + .../situations/CommitmentLifecycleService.gd | 250 ++++++++++ .../CommitmentLifecycleService.gd.uid | 1 + simulation/situations/QuestJournalSystem.gd | 26 +- simulation/situations/SituationSystem.gd | 4 + .../situations/SocialCommitmentSystem.gd | 28 +- .../state/ConversationActStateRecord.gd | 111 +++++ .../state/ConversationActStateRecord.gd.uid | 1 + .../state/QuestJournalEntryStateRecord.gd | 10 + simulation/state/SimulationStateRecord.gd | 147 ++++-- simulation/state/SituationStateRecord.gd | 6 +- .../validation/EmergentStateValidator.gd | 255 +++++++++++ .../validation/EmergentStateValidator.gd.uid | 1 + tests/emergent_jajce_slice_test.gd | 217 +++++++++ tests/emergent_jajce_slice_test.gd.uid | 1 + tests/simulation_scaling_benchmark_test.gd | 13 +- .../unit/test_commitment_lifecycle_service.gd | 252 +++++++++++ .../test_commitment_lifecycle_service.gd.uid | 1 + tests/unit/test_emergent_state_validation.gd | 86 ++++ .../test_emergent_state_validation.gd.uid | 1 + tests/unit/test_situation_system.gd | 13 +- 28 files changed, 2145 insertions(+), 206 deletions(-) create mode 100644 simulation/situations/CommitmentLifecycleService.gd create mode 100644 simulation/situations/CommitmentLifecycleService.gd.uid create mode 100644 simulation/state/ConversationActStateRecord.gd create mode 100644 simulation/state/ConversationActStateRecord.gd.uid create mode 100644 simulation/state/validation/EmergentStateValidator.gd create mode 100644 simulation/state/validation/EmergentStateValidator.gd.uid create mode 100644 tests/emergent_jajce_slice_test.gd create mode 100644 tests/emergent_jajce_slice_test.gd.uid create mode 100644 tests/unit/test_commitment_lifecycle_service.gd create mode 100644 tests/unit/test_commitment_lifecycle_service.gd.uid create mode 100644 tests/unit/test_emergent_state_validation.gd create mode 100644 tests/unit/test_emergent_state_validation.gd.uid diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 9eb71b6..d385fe9 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -41,10 +41,15 @@ SimulationClock -> ActionTargetResolver resolves a stable target ID -> VillageEconomy performs inventory/storage transactions -> AnimalCareSystem advances animal needs, routines, and feeding - -> SimulationEventLog records completed facts + -> SimulationEventLog records and indexes completed facts + -> WorldEventStore exposes typed participants, locations, and causal queries -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge - -> RelationshipSystem applies evidence-gated social consequences - -> VillageOpportunitySystem projects one known unresolved need + -> RelationshipSystem applies seven-dimensional evidence-backed consequences + -> SituationSystem projects real unresolved conditions from facts and events + -> QuestJournalSystem records only player discovery/tracking preference + -> SocialCommitmentSystem resolves promises from ordinary outcome events + -> ConversationService plans deterministic semantic turns from current context + -> VillageOpportunitySystem preserves the legacy bounded compatibility slice -> ConflictSystem advances indexed combatants and authoritative combat outcomes -> WorldViewManager presents travel, NPC state, and world-state cues -> ActiveWorldAdapter supplies loaded-world positions/capacity @@ -81,14 +86,29 @@ would otherwise obscure that lifecycle: saves. It emits combat and war narrative facts and spawn/death signals that presentation binds to `HostileCombatant` visuals; - `simulation/events/SimulationEventLog.gd` owns ordered event identity, - history queries, and rate calculations; -- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to + O(1) ID lookup, participant/type indexes, history queries, and rate + calculations. `WorldEventStore` supplies the richer typed entity/location + boundary without making presentation assets part of headless facts; +- `simulation/knowledge/EventKnowledgeSystem.gd` owns player/NPC references to known objective events, immutable acquisition provenance, proximity witnesses - at record time, one-hop direct fact transfer, and deterministic recent-fact - retention/importance ranking. It can place the active opportunity trigger - first without weakening its direct-source or one-hop checks; + at record time, one-hop direct fact transfer, salience/confidence/pinning, + and deterministic recent-fact retention/importance ranking; - `simulation/relationships/RelationshipSystem.gd` owns directed relationship - queries, event-driven trust changes, and deterministic social tie-breaking; + queries across familiarity, trust, affection, respect, fear, obligation, and + hostility, exact event-backed changes, and deterministic social tie-breaking; +- `simulation/situations/` owns typed predicates, bounded/deduplicated world + situations, live progress projection, the player journal, and social + commitments. None of these systems creates quest-only stock, enemies, or + actions; +- `simulation/dialogue/` owns semantic intent definitions, deterministic + conversation planning, safe template substitution, and the narrow Dialogue + Manager presentation adapter. Only semantic acts/topics enter persistence; +- `simulation/commands/` owns copy-only commands/offers/results and reusable + typed action effects. Concrete command services must revalidate authority at + execution time for both player and NPC callers; +- `simulation/regional/` owns deterministic scheduled jobs, keyed random + decisions, and analytical elapsed-tick helpers. Presentation frame budgets + may defer due work but cannot reorder it; - `simulation/population/SimulationPopulationView.gd` owns a transient derived all/living/starving stable-ID index. The manager rebuilds it once at the tick boundary and refreshes it after each interleaved NPC update; relationship and @@ -153,6 +173,7 @@ hard to read. | --- | --- | | `simulation/` | Headless-capable orchestration and core models | | `simulation/actions/` | Action decisions, execution, and target queries | +| `simulation/commands/` | Shared player/NPC command, offer, result, and effect contracts | | `simulation/animals/` | Animal lifecycle, target claims, and feeding transactions | | `simulation/economy/` | Authoritative inventory and storage transactions | | `simulation/events/` | Immutable event history and derived event queries | @@ -160,11 +181,15 @@ hard to read. | `simulation/relationships/` | Directed social consequences and relationship queries | | `simulation/population/` | Transient per-tick population query indexes | | `simulation/opportunities/` | Knowledge-gated unresolved-condition projections | +| `simulation/situations/` | World situation, journal, and commitment lifecycles | +| `simulation/dialogue/` | Semantic conversation planning and presenter boundary | +| `simulation/regional/` | Regional records, due-job scheduling, keyed randomness, analytical updates | | `simulation/state/` | Versioned, serializable mutable records | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions | | `simulation/persistence/` | Validated local save-file storage | | `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads | | `world/` | Loaded-world interaction geometry and presentation adapters | +| `world/targets/` | Context-scoped target capabilities and transient presentation handles | | `world/animals/` | Animal presentation binding, routine sites, and navigation reporting | | `world/animals/goat/` | Self-contained goat scene, visual script, habitat, and lookdev scene | | `world/resource_nodes/` | Finite resource presentation and disposable loaded-anchor index | @@ -217,6 +242,15 @@ guidance without turning established-path cleanup into unrelated churn. - New mutable features define serialization and deterministic continuation at the same time as their first gameplay use. Cross-record causes use stable event IDs rather than object references or prose. +- A situation or journal entry is a projection of ordinary facts. Accepting it + creates a social commitment only; fulfillment, supersession, failure, and + consequences always cite exact ordinary outcome events. +- Dialogue authority is the selected semantic intent/action at an expected + revision. Dialogue Manager renders one already-planned turn and never + evaluates gameplay conditions or mutates simulation state. +- Loaded navigation and visuals never decide whether distant work or travel + completes. Regional completion belongs to serialized due jobs; presentation + may interpolate and report local feasibility only. - Benchmark fixtures use ordinary simulation records and must round-trip through the current state schema. They may control workload setup, but must not add benchmark-only fields or branches to production saves and ticks. diff --git a/docs/SIMULATION_DEFINITIONS.md b/docs/SIMULATION_DEFINITIONS.md index ad0d3ad..cee325b 100644 --- a/docs/SIMULATION_DEFINITIONS.md +++ b/docs/SIMULATION_DEFINITIONS.md @@ -2,154 +2,163 @@ ## Purpose -Stable IDs and immutable definitions now form the vocabulary shared by -simulation state, systems, world adapters, scenes, tests, and future save -migrations. +Stable IDs and immutable definitions form the vocabulary shared by simulation +state, systems, world adapters, scenes, tests, and save migrations. -`SimulationIds` is the canonical source for code-facing `StringName` IDs. -`ActionDefinition` and `ProfessionDefinition` are editor-readable custom -resources. `SimulationDefinitions` loads, resolves, and validates the complete -prototype set. +`SimulationIds` remains the append-only source for code-facing compatibility +IDs. `SimulationContentPack` is the no-code authoring manifest and +`ContentCatalog` transactionally validates and publishes typed definitions in +stable-ID order. The current core pack contains actions, professions, +capability tags, items, resources, storage policies, and enemies. A failed pack +publishes nothing: duplicate IDs, unknown references, unsupported handlers, or +missing presentation cues are authoring errors rather than silent overrides. -## Current action contract +Simulation code resolves definitions through the catalog. Presentation cue IDs +remain headless strings and are mapped separately to scenes, materials, icons, +or animation hooks. Runtime quantities and ownership stay in state records; +saves do not contain `.tres` paths or copied definition balance. -Each executable action definition contains: +## Content-pack contract -- stable `action_id`; -- display name; -- default duration; -- optional preferred profession ID; -- target type: resource, activity, animal, or free movement; -- resource action ID when the action targets a ResourceNode; -- optional completion-cost resource ID and amount. +Each pack has a stable `pack_id`, display name, explicit pack dependencies, +required handler IDs, and typed definition arrays. Pack and definition IDs are +unique across the whole catalog. Packs cannot override each other in the first +version, and enumeration is sorted independently of resource load order. + +Adding data-only content is: + +1. create a typed `.tres` definition; +2. add it to an explicit pack; +3. optionally register its stable presentation cue in the presentation layer; +4. place or spawn an instance with its own canonical runtime ID. + +Adding a fundamentally new mechanic still requires one bounded reusable +handler. The pack declares that dependency so missing runtime support fails +validation rather than becoming a partially functioning object. + +## Action contract + +Each executable `ActionDefinition` contains: + +- stable action ID and display name; +- duration and optional preferred profession; +- target family and resource-action metadata; +- optional completion cost; +- actor and target capability tags; +- selection, target, and completion handler IDs; +- reusable typed effects; +- event and presentation cue IDs. + +Reusable `ActionEffect` definitions cover metric/need change, exact inventory +transfer, damage/healing, relationship-dimension change, scheduling an ordinary +action, and site-condition change. `ActionCommand` carries stable actor/action/ +target/item IDs, amount, and expected revision; `ActionOffer` and `ActionResult` +are copy-only values. `ActionCommandService` is the authority boundary and must +revalidate range, capabilities, reservation, permission, cost, and target state +at execution time for either a player or NPC caller. The current actions are gather food, gather wood, feed animal, deposit food, deposit wood, withdraw food, patrol, study, eat, rest, sleep, wander, and -defend. Idle and dead are stable state sentinels, not executable action -definitions. +defend. Idle and dead are state sentinels, not executable definitions. The +two-leg animal-feeding transaction remains a custom completion handler because +it has a real compound pickup/delivery contract and no second shared consumer +yet. -`defend` is the raid-response action: eligible strong villagers and guards -prefer it during an active raid, travel to the guard post, and raise village -safety while they work. Patrol activity sites accept it as a rally point. +## Items, resources, and storage -SimNPC reads default duration and preferred-profession metadata from these -definitions. WorldViewManager reads target type and resource-action metadata -instead of maintaining a separate gather-action map. ActionSelectionSystem and -SimulationManager share the same completion-cost metadata, so patrol and study -both require one stored wood without duplicating that rule. +`ItemDefinition` validates fields by category. Weapons require damage, reach, +cooldown, and an equip slot; materials and consumables do not carry fake weapon +fields. The core pack contains food, wood, herb, sword, and claw. Herb proves a +new non-weapon item and route without a manager branch. -`feed_animal` is the first animal-targeted action. Its definition prefers the -farmer profession and declares an exact one-food completion cost. Selection, -animal target reservation, supply pickup, and completion remain separate. NPC -care pays that definition cost from carried inventory after a real pantry -pickup; player care pays it directly from the pantry while player inventory is -deferred. Both paths use `AnimalCareSystem` and mutate only the exact target. -`SimulationIds` names Dunja and Zora, their private shelter anchors, and the -shared pasture anchor. These IDs are saved routine vocabulary; their loaded -positions and private/shared ownership come from `AnimalRoutineSite` rather -than entering an action definition or resource registry. +`ResourceDefinition` owns yielded item, gather action, capability tags, base +capacity/yield/regrowth, access policy, and presentation cue. Placement/state +records retain unique contextual IDs and overrides such as safety risk, +comfort, and discovery priority. Berry patches, trees, and herb patches share +this contract. -## Current profession contract +`StorageDefinition` declares accepted item IDs/tags, policy priority, capacity, +settlement scope, deposit/withdraw actions, and presentation cue. +`StorageRoutingPolicy` ranks compatible destinations deterministically by +priority then stable target ID. Food-to-pantry and wood-to-woodpile remain +covered; herb-to-apothecary proves data-only routing beyond those legacy cases. -Each profession definition contains a stable `profession_id`, display name, -body color, and prop color. The current professions are farmer, woodcutter, -guard, scholar, and wanderer. +## Professions and capabilities -NPC generation chooses from the registry's stable IDs. NPC state stores and -restores those IDs as `StringName`, and rejects records that reference an -unknown profession or executable action. +Each `ProfessionDefinition` contains a stable ID, display name, body color, and +prop color. Farmer, woodcutter, guard, scholar, and wanderer are registered in +the core pack. NPC state saves only the ID and rejects unknown definitions. -## Opportunity vocabulary +`CapabilityTagDefinition` gives actions and targets a validated shared +vocabulary. Tags express composable affordances; they do not mutate state or +replace concrete authority checks. -`SimulationIds` defines the four proven opportunity types, -`restock_empty_pantry`, `supply_missing_wood`, `feed_weak_villager`, and -`repair_home_roof`, 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`. +## Enemies and combat -The shared record and lifecycle collaborator was extracted only after the -food and wood consumers proved those fields. Their evidence, care, resolution, -and invalidation rules remain explicit branches. `feed_weak_villager` opens on -a `villager_weak` fact when a starving low-energy villager cannot eat and -resolves only when that same villager actually consumes food; `repair_home_roof` -opens on a `home_damaged` fact when a villager sleeps through low safety and -resolves through the real wood-deposit path. Do not add a generic quest- -definition registry until real acceptance, assignment, reward, or dialogue -consumers establish a second shared contract. +`EnemyDefinition` carries a behavior profile, combatant ID prefix, faction, +hostility, weapon, health, movement, and visual parameters. +`CombatantFactory` creates/restores every hostile and rebuilds collision-free +per-prefix allocators. The core boar reuses the wolf-hunt behavior without a +new manager or combat switch; raider and wolf compatibility spawn APIs delegate +to the same factory. -## Player actor vocabulary +Combatant and faction state use stable IDs. NPC combatants point to their +person ID; hostile records persist `enemy_definition_id`. Weapons resolve +through the same item catalog used by inventory rather than a parallel combat +item table. -`SimulationIds.PLAYER_ACTOR_ID` (`-1`) is the stable event actor for -player-performed actions, and `SimulationIds.PLAYER_INVENTORY_ID` names the -carried-inventory holder in economic events. Player-performed pantry food -supply is witnessed by nearby villagers and can raise their directed trust -toward the player, with the player as a relationship subject. +## Animals -## Item and weapon vocabulary +`AnimalDefinition` + `AnimalCatalog` + `AnimalFactory` apply the same +definition-instance-state-presentation contract to domestic animals. Goat and +sheep reuse the `grazer_routine`, feeding capability, generic CozyGrazer scene, +state migration, and care system; definition and visual parameters are the only +type-specific pieces. The animal catalog remains isolated until the next +shared content-pack migration adds that record family without destabilizing +the already validated core pack. -`ItemDefinition` is the editor-readable resource contract for items, and -`SimulationItems` is the current registry. Weapons carry `damage`, `reach`, -and `attack_cooldown` and fill the `hand` equip slot, so the same contract can -later host non-weapon items (food sacks, tools, armour) in a full inventory. -The current weapons are `item_sword` (the player's equipped blade) and -`item_claw` (wolf bites and unarmed defenders). NPC guards are equipped with -swords; other villagers fight with claws, which feeds the village-defense -derivation. +## Situations and dialogue -## Conflict vocabulary +Legacy `OpportunityStateRecord` vocabulary remains for save compatibility. +New emergent content uses typed `SituationDefinition` resources: reusable +predicates, evidence requirements, dedupe fields, priority/severity, +alternative objectives, exact ordinary resolution patterns, expiry/cooldown, +and dialogue topic IDs. Situation state saves the definition ID and causal +facts; progress is re-derived from authoritative state and `WorldEventStore`. -`CombatantStateRecord` and `FactionStateRecord` use stable IDs. Combatant kinds -are `npc`, `raider`, `wolf`, and `player`; NPC combatants reference their -`SimNPC` by stable ID. Factions are `faction_village` and `faction_tribe`, with -`neutral`/`hostile` stances and `none`/`planned`/`raiding`/`aborted` war plans. -Combat, raid, and war narrative facts use the stable event types -`combatant_hurt`, `combatant_killed`, `raid_started`, `war_resolved`, -`war_aborted`, and `wolf_hunt`. +`DialogueIntentDefinition` owns semantic preconditions, score, response intent +IDs, template variants, and optional action metadata. Stable hashing selects a +variant from conversation/turn/intent/topics without consuming shared RNG. +Rendered prose and Dialogue Manager resources are presentation and never +authoritative state. -## Enemy vocabulary +## Validation and authoring report -`EnemyDefinition` is the data contract for hostile creatures and -`SimulationEnemies` is the current registry. Each enemy carries a `kind` -(raider/wolf), equipped weapon, health, move speed, body/accent colors, and a -visual scale, so a new bear, bandit, or boar is mostly one definition plus a -small `_build_*` visual hook on the shared `CreatureVisual` root. The current -enemies are `enemy_raider` (sword) and `enemy_wolf` (claw). +The catalog rejects: -## Validation - -The registry rejects: - -- missing or duplicate IDs; -- empty display names; -- non-positive action durations; -- invalid target types; -- resource actions without a resource-action ID; -- incomplete, non-positive, or unknown completion-cost resources; -- references to unknown professions or resource actions; +- missing, empty, or duplicate pack/definition IDs; +- silent cross-pack overrides; +- unknown pack, handler, capability, item, profession, action, storage, + behavior-profile, or presentation-cue references; +- category-invalid item fields; +- incomplete or non-positive costs/effects; +- resources or storage routes with inconsistent item/action contracts; - definition resources that fail to load. -`tests/simulation_definitions_test.gd` verifies registry integrity, -definition-backed behavior, unknown-ID rejection, and serialization -round-tripping. +`ContentCatalog.get_authoring_report()` exposes validity, sorted published IDs, +and exact errors for CLI/editor tooling. Tests prove transactional failure, +deterministic enumeration, core cross-references, boar behavior reuse, shared +goat/sheep behavior, and herb routing. ## Deliberate boundary -Definitions currently own identity and the static metadata already proven by -the prototype, including the patrol/study completion-cost contract. They do not -yet own: +Definitions own reusable static metadata, not mutable inventory, reservations, +relationships, situation progress, entity ownership, or presentation nodes. +Handler implementations remain bounded typed strategies. A universal +reflection DSL, arbitrary scripts embedded in saves, and a full ECS are outside +the contract. -- utility thresholds and consideration curves; -- general preconditions beyond stored-resource completion costs; -- completion effects; -- interruption and failure policy; -- reservation strategy; -- detailed target-selection policy beyond current resource/activity/free - metadata; -- richer presentation hints beyond the current profession palette. - -Selection, execution, target resolution, and visual travel are now separate -responsibilities. The remaining concerns should move into definitions only -when the action-system implementations prove a stable, reusable contract. +Selection, execution, target resolution, simulation authority, and visual +travel remain separate responsibilities. Extend definitions only after a real +consumer proves a stable shared field; extend a handler when the mechanic itself +is genuinely new. diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index b1e8710..613afbe 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -3,7 +3,7 @@ ## Current contract `SimulationStateRecord` is the versioned JSON boundary for the current -simulation. The current world schema is v14 and captures: +simulation. The current world schema is v15 and captures: - simulation seed, tick interval, tick count, clock remainder, and elapsed clock ticks; @@ -18,19 +18,25 @@ simulation. The current world schema is v14 and captures: and next routine tick; - pantry contents, carried NPC inventory, the ordered economic event stream, and its next stable event ID; -- directed relationship records with familiarity, trust, and the stable event - ID that last changed trust; -- per-NPC known-event records that reference objective event history without - copying it, including first-acquisition method, acquisition tick, and - historical communicator provenance; +- sparse directed relationship records for familiarity, trust, affection, + respect, fear, obligation, and hostility, with the exact causal event for + every non-neutral change; +- player and NPC known-event records that reference objective event history + without copying it, including acquisition source, hop count, confidence, + salience, pin state, acquisition tick, and historical communicator + provenance; - opportunity records with stable type/status, interested NPC, trigger event, target storage/resource/amount, exact later resolution event identity, or a deterministic invalidation reason and close tick; +- world-backed situations, player journal discovery/tracking state, social + commitments, and bounded semantic conversation acts. These records retain + definition IDs and exact causal event references, never rendered dialogue + prose or copied quest progress; - a player record holding the player-citizen's hunger, energy, health, downed state, and carried inventory. The player's world position remains presentation-owned and is intentionally not serialized; -- combatant records (health, weapon, faction, position, hostility) for NPC - defenders and standalone raiders/wolves; +- combatant records (health, weapon, faction, position, hostility, and enemy + definition ID) for NPC defenders and standalone hostile archetypes; - faction records (food, warriors, aggression, morale, stance, war plan) for the village and the hill tribe. @@ -39,7 +45,7 @@ The top-level identity is: ```json { "schema": "the_steward.simulation", - "schema_version": 14 + "schema_version": 15 } ``` @@ -74,7 +80,9 @@ round-tripping, divergent known-event state, communicated provenance, and deterministic retention boundaries, and rejection of unsupported schemas. Active, resolved, and invalidated opportunity round-tripping, contradictory target state, invalid resource sources, and cross-record event references are -covered too. +covered too. The emergent slice additionally verifies that situation evidence, +journal links, commitment participants/outcomes, and semantic conversation +history survive a save between accepting and completing real work. NPCStateRecord v2 adds the resolved travel destination and whether it is active. Nested v1 NPC records migrate explicitly with no invented active @@ -182,13 +190,11 @@ Communicated and ambiguous legacy facts receive a fresh retention lease at the saved simulation tick because their exact acquisition time was not previously stored. The v6 source record supplies the persisted direct-method snapshot. -Importance is intentionally derived rather than duplicated: a fact is -`lasting` while it is the current trust cause for one of that knower's directed -relationships; every other fact is `recent`. Current causal facts are protected -so relationship state remains explainable. Each NPC keeps at most three recent -facts, and recent facts at least one simulated day old are removed at the next -deterministic daily-sized review. The objective event stream is never pruned by -this rule. +Importance combines persisted salience/pinning with current causal use. A fact +is protected while it explains any relationship dimension, an open situation, +journal discovery, or commitment. Unpinned ordinary memories remain bounded and +age through deterministic reviews. The objective event stream is never pruned +by this rule. SimulationStateRecord v8 adds the top-level `opportunities` array and `simulation.next_opportunity_id`. The first nested `OpportunityStateRecord` @@ -255,6 +261,50 @@ records migrate to full health and standing. The player combatant used by the conflict system is derived state and is never serialized; hostile attacks reduce the authoritative player health through the ordered event stream. +SimulationStateRecord v15 adds top-level `situations`, `quest_journal`, +`commitments`, and `conversation_history` arrays plus monotonic next-ID counters +for each family. World schemas v1-v14 migrate to empty emergent collections and +zero counters. On manager restore, a compatible legacy pantry opportunity can +be imported once into the new situation system when no situation history is +present; the two records are not independently generated substitutes for world +state. + +`SituationStateRecord` stores the definition and dedupe IDs, trigger event, +stable primitive context, selected objective alternative, lifecycle, and exact +resolution event. Live progress remains derived from the definition, ordinary +event store, and current authoritative facts. `QuestJournalEntryStateRecord` +stores discovery, tracking preference, selected alternative, and archive/close +state only; it neither copies objectives nor owns progress. The player therefore +cannot discover a journal entry until knowledge reaches them through an actual +conversation or other ordinary knowledge path. + +`CommitmentStateRecord` links one situation to typed debtor and creditor entity +references, semantic terms, a deadline, lifecycle, and exact outcome event. +Acceptance does not reserve stock or prevent other actors from resolving the +situation. A matching outcome performed by the debtor fulfills it; another +actor's outcome supersedes it; a still-achievable missed deadline breaks it at +the due tick. Release remains an explicit social action. Consequence application +is keyed by commitment/outcome identity so replay or restore cannot award it +twice. + +`ConversationActStateRecord` stores only a bounded semantic history: stable act +ID, tick, typed speaker/listener references, supported intent ID, and at most +eight topic IDs. Conversation IDs, current turns, option revisions, rendered +templates, balloon state, and prose are transient. Reopening dialogue plans from +current facts plus the retained semantic acts, so wording can evolve without +changing authoritative history. + +Nested `KnownEventStateRecord` v4 adds the player as a valid knower/source and +records hop count, confidence, salience, and explicit pinning. Communicated +facts remain exactly one hop from a performed or witnessed source. Nested +`RelationshipStateRecord` v2 adds affection, respect, fear, obligation, and +hostility, makes either player or NPC a valid endpoint, and stores a causal +event ID for all seven dimensions. Nested `AnimalStateRecord` v3 persists an +`animal_definition_id`; older records migrate to the goat definition. Nested +`CombatantStateRecord` v2 persists `enemy_definition_id`; legacy hostile kinds +map to their current raider or wolf definitions, while NPC defenders retain an +empty enemy definition. + Current-schema parsing requires unique animal IDs that do not collide with resource or storage IDs. An animal reservation must belong to an existing NPC whose active feed task targets that exact animal. Every `animal_fed` event @@ -389,13 +439,17 @@ 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–v10 layouts; -- player inventory or player relationship records; -- broader relationship dimensions, line-of-sight/hearing evidence, - continuous or personalized memory decay, reinforcement, false beliefs, or - multi-hop rumours; -- generalized opportunity/quest definitions, acceptance, rewards, assignment, - dialogue, or player-facing quest-log state; + supported v1-v14 layouts; +- line-of-sight/hearing evidence, personalized confidence decay, + reinforcement, false beliefs, or multi-hop rumours; +- the full regional situation predicate library, regional quest bounds, + contracts beyond the first social commitment consumer, or generated + free-form prose; +- persistence of a currently open dialogue UI/turn. Semantic conversation + history and commitment authority persist, while a balloon is regenerated + after restore; +- chunked regional saves, lazy location loading, or aggregate population + persistence; - persistence for the player transform or presentation-only scene state. Those features should build on this boundary rather than inventing parallel diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index c3e03ec..d2cb66d 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -28,6 +28,13 @@ 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) +signal situation_opened(situation: SituationStateRecord) +signal situation_closed(situation: SituationStateRecord) +signal quest_journal_changed(entry: QuestJournalEntryStateRecord) +signal commitment_changed(commitment: CommitmentStateRecord) +signal conversation_started(conversation_id: StringName, turn: ConversationTurn) +signal conversation_turn_changed(conversation_id: StringName, turn: ConversationTurn) +signal conversation_ended(conversation_id: StringName) signal combatant_spawned(combatant_id: StringName) signal combatant_died(combatant_id: StringName) signal raid_started(raider_ids: Array[StringName]) @@ -57,6 +64,12 @@ var animal_care := AnimalCareSystemScript.new() var relationship_system := RelationshipSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new() var opportunity_system := VillageOpportunitySystem.new() +var situation_system := SituationSystem.create_default() +var quest_journal_system := QuestJournalSystem.new() +var commitment_system := SocialCommitmentSystem.new() +var commitment_lifecycle := CommitmentLifecycleService.new() +var conversation_service := ConversationService.new() +var conversation_history: Array[ConversationActStateRecord] = [] var conflict_system := ConflictSystemScript.new() var storage_states: Dictionary: get: @@ -76,8 +89,14 @@ var target_resolver := ActionTargetResolver.new() var last_player_resource_query_stats: Dictionary = {} var _population_view := SimulationPopulationView.new() var speed_index := 2 +var _dialogue_speed_index := -1 +var _active_player_conversation: StringName +var _conversation_context_by_id: Dictionary = {} +var _next_conversation_act_id := 0 const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0] const KNOWLEDGE_COMMUNICATION_RADIUS := 2.5 +const MAX_CONVERSATION_ACTS_PER_PAIR := 8 +const MAX_CONVERSATION_HISTORY := 1024 const SEASON_DAYS := 4 const COLD_DAYS := 3 const SEASON_COLD := &"cold" @@ -214,6 +233,7 @@ func simulate_tick() -> void: ) if invalidated != null: opportunity_invalidated.emit(invalidated) + _maintain_emergent_world() if tick_count % get_knowledge_review_interval() == 0: _maintain_event_knowledge(true) if debug_logs: @@ -754,6 +774,7 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void: for learned_record in learned_records: _apply_new_event_knowledge(learned_record, event) _update_opportunity_from_event(event) + _update_situations_from_event(event) economic_event_recorded.emit(event) @@ -844,6 +865,53 @@ func _update_opportunity_from_event(event: EconomicEventRecord) -> void: _maintain_event_knowledge(false) +func _update_situations_from_event(event: EconomicEventRecord) -> void: + var world_event := event_log.get_world_event(int(event.data["event_id"])) + if world_event == null: + return + var interested_npc_id := int(event.data["actor_id"]) + if interested_npc_id < 0 or _find_npc_by_id(interested_npc_id) == null: + _maintain_emergent_world() + return + var facts := _get_situation_facts(interested_npc_id) + for evaluation in situation_system.consider_event(world_event, tick_count, facts): + if evaluation.opened_situation_id < 0: + continue + var opened := situation_system.get_by_id(evaluation.opened_situation_id) + if opened != null: + situation_opened.emit(opened) + _maintain_emergent_world() + + +func _maintain_emergent_world() -> void: + var facts := _get_situation_facts() + var closed := situation_system.maintain(tick_count, event_log.world_events, facts) + var changed_commitments := commitment_lifecycle.maintain_and_record( + tick_count, commitment_system, situation_system, event_log, facts, relationship_system + ) + for situation in closed: + situation_closed.emit(situation) + var journal_entry := quest_journal_system.synchronize(situation, tick_count) + if journal_entry != null: + quest_journal_changed.emit(journal_entry) + for commitment in changed_commitments: + var outcome_fact := commitment_lifecycle.record_outcome( + commitment, event_log, relationship_system + ) + _share_commitment_fact_with_creditor(commitment, outcome_fact) + commitment_changed.emit(commitment) + + +func _get_situation_facts(interested_npc_id: int = -1) -> Dictionary: + var facts := { + "pantry.food": get_pantry().get_amount(SimulationIds.RESOURCE_FOOD), + "target_id": String(SimulationIds.STORAGE_VILLAGE_PANTRY), + } + if interested_npc_id >= 0: + facts["interested_npc_id"] = interested_npc_id + return facts + + func _maintain_event_knowledge(expire_by_age: bool) -> void: var forgotten := event_knowledge_system.maintain_retention( tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age @@ -858,17 +926,9 @@ func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]: var lasting: Array[KnownEventStateRecord] = [] var seen := {} for relationship in relationship_system.get_all_sorted(): - var event_id := relationship.get_last_trust_cause_event_id() - if event_id == RelationshipStateRecord.NO_CAUSE_EVENT: - continue - var record := event_knowledge_system.get_record(relationship.get_observer_id(), event_id) - if record == null: - continue - var key := "%d:%d" % [record.get_knower_id(), record.get_event_id()] - if seen.has(key): - continue - seen[key] = true - lasting.append(record) + for dimension in RelationshipStateRecord.DIMENSIONS: + var event_id := relationship.get_last_cause_event_id(dimension) + _append_lasting_knowledge(lasting, seen, relationship.get_observer_id(), event_id) var open_opportunity: OpportunityStateRecord = opportunity_system.get_open_opportunity() if open_opportunity != null: var opportunity_record := event_knowledge_system.get_record( @@ -881,9 +941,35 @@ func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]: if not seen.has(opportunity_key): seen[opportunity_key] = true lasting.append(opportunity_record) + for situation in situation_system.get_all_sorted(): + var context := situation.get_context() + var interested_text := String(context.get("interested_npc_id", "")) + if interested_text.is_valid_int(): + _append_lasting_knowledge( + lasting, seen, interested_text.to_int(), situation.get_trigger_event_id() + ) + if quest_journal_system.get_for_situation(situation.get_situation_id()) != null: + _append_lasting_knowledge( + lasting, seen, SimulationIds.PLAYER_ACTOR_ID, situation.get_trigger_event_id() + ) return lasting +func _append_lasting_knowledge( + lasting: Array[KnownEventStateRecord], seen: Dictionary, knower_id: int, event_id: int +) -> void: + if event_id < 0: + return + var record := event_knowledge_system.get_record(knower_id, event_id) + if record == null: + return + var key := "%d:%d" % [record.get_knower_id(), record.get_event_id()] + if seen.has(key): + return + seen[key] = true + lasting.append(record) + + func _get_lasting_event_ids(npc_id: int) -> Array[int]: var event_ids: Array[int] = [] for record in _get_lasting_knowledge_records(): @@ -960,6 +1046,285 @@ func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord: return opportunity_system.get_latest_for_npc(npc_id) +func get_active_situations() -> Array[SituationStateRecord]: + return situation_system.get_active_sorted() + + +func get_quest_journal_entries() -> Array[QuestJournalEntryStateRecord]: + return quest_journal_system.get_all_sorted() + + +func get_active_commitments() -> Array[CommitmentStateRecord]: + return commitment_system.get_active_sorted() + + +func begin_player_conversation(npc_id: int) -> StringName: + if not _active_player_conversation.is_empty(): + return &"" + var npc := _find_npc_by_id(npc_id) + if npc == null or npc.is_dead: + return &"" + var situation := _get_known_situation_for_npc(npc_id) + if situation != null: + _discover_situation_from_speaker(situation, npc_id) + var npc_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, StringName(str(npc_id))) + var player_ref := WorldEntityRef.create( + WorldEventRecord.ENTITY_TYPE_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID)) + ) + var context := _build_conversation_context(npc, situation) + var conversation_id := conversation_service.begin(npc_ref, player_ref, context) + if conversation_id.is_empty(): + return &"" + _active_player_conversation = conversation_id + _conversation_context_by_id[conversation_id] = { + "npc_id": npc_id, + "situation_id": situation.get_situation_id() if situation != null else -1, + } + _dialogue_speed_index = speed_index + speed_index = SPEED_LEVELS.find(1.0) + speed_changed.emit(SPEED_LEVELS[speed_index]) + var turn := conversation_service.get_turn(conversation_id) + _record_conversation_act(turn) + conversation_started.emit(conversation_id, turn) + return conversation_id + + +func select_player_conversation_option( + conversation_id: StringName, option_id: StringName, expected_revision: int +) -> ConversationSelectionResult: + if conversation_id != _active_player_conversation: + return ConversationSelectionResult.new() + var current_turn := conversation_service.get_turn(conversation_id) + var selected_option := current_turn.get_option(option_id) if current_turn != null else null + var result := conversation_service.select_option(conversation_id, option_id, expected_revision) + if not result.was_accepted(): + return result + if selected_option != null: + _apply_conversation_intent(conversation_id, selected_option.get_intent_id()) + var turn := result.get_turn() + _record_conversation_act(turn) + conversation_turn_changed.emit(conversation_id, turn) + if turn != null and turn.is_terminal(): + end_player_conversation(conversation_id, turn.get_revision()) + return result + + +func end_player_conversation(conversation_id: StringName, expected_revision: int = -1) -> bool: + if conversation_id != _active_player_conversation: + return false + if not conversation_service.end(conversation_id, expected_revision): + return false + _active_player_conversation = &"" + _conversation_context_by_id.erase(conversation_id) + if _dialogue_speed_index >= 0: + speed_index = clampi(_dialogue_speed_index, 0, SPEED_LEVELS.size() - 1) + _dialogue_speed_index = -1 + speed_changed.emit(SPEED_LEVELS[speed_index]) + conversation_ended.emit(conversation_id) + return true + + +func is_dialogue_active() -> bool: + return not _active_player_conversation.is_empty() + + +func _get_known_situation_for_npc(npc_id: int) -> SituationStateRecord: + var known: Array[SituationStateRecord] = [] + for situation in situation_system.get_all_sorted(): + var context := situation.get_context() + if String(context.get("interested_npc_id", "")) != str(npc_id): + continue + if event_knowledge_system.knows_event(npc_id, situation.get_trigger_event_id()): + known.append(situation) + if known.is_empty(): + return null + known.sort_custom( + func(first: SituationStateRecord, second: SituationStateRecord) -> bool: + if first.get_created_tick() != second.get_created_tick(): + return first.get_created_tick() > second.get_created_tick() + return first.get_situation_id() > second.get_situation_id() + ) + return known[0] + + +func _discover_situation_from_speaker(situation: SituationStateRecord, npc_id: int) -> void: + var event := event_log.get_by_id(situation.get_trigger_event_id()) + if event == null: + return + if not event_knowledge_system.knows_event( + SimulationIds.PLAYER_ACTOR_ID, event.data["event_id"] + ): + var learned := event_knowledge_system.communicate_event_to_player(event, npc_id, tick_count) + if learned != null: + _apply_new_event_knowledge(learned, event) + event_knowledge_transferred.emit(npc_id, SimulationIds.PLAYER_ACTOR_ID, event) + event_knowledge_system.pin_event(SimulationIds.PLAYER_ACTOR_ID, int(event.data["event_id"])) + var existing := quest_journal_system.get_for_situation(situation.get_situation_id()) + if existing == null and situation.is_active(): + var definition := situation_system.get_definition(situation.get_definition_id()) + var entry := quest_journal_system.add_situation(situation, definition, &"", tick_count) + if entry != null: + quest_journal_changed.emit(entry) + + +func _build_conversation_context(npc: SimNPC, situation: SituationStateRecord) -> Dictionary: + var topics: Array[StringName] = [] + var trace: Array[StringName] = [&"speaker_available"] + var options: Dictionary = {} + var initial_intent := ConversationIntentIds.GREET + if situation != null: + var definition := situation_system.get_definition(situation.get_definition_id()) + topics = definition.dialogue_topic_ids.duplicate() + trace.append(&"speaker_knows_trigger_fact") + trace.append(&"listener_learned_trigger_fact") + trace.append(StringName("evidence_event_%d" % situation.get_trigger_event_id())) + if npc.hunger >= 60.0: + trace.append(&"speaker_hungry") + var relationship := relationship_system.get_relationship( + npc.id, SimulationIds.PLAYER_ACTOR_ID + ) + if relationship == null: + trace.append(&"relationship_unfamiliar") + elif relationship.get_familiarity() >= 0.5: + trace.append(&"relationship_familiar") + else: + trace.append(&"relationship_known") + var outcome := _get_latest_commitment_for_npc(npc.id, situation.get_situation_id()) + if outcome != null and not outcome.is_active(): + trace.append(StringName("commitment_%s" % outcome.get_status())) + if outcome.get_cause_event_id() >= 0: + trace.append(StringName("outcome_event_%d" % outcome.get_cause_event_id())) + match outcome.get_status(): + CommitmentStateRecord.STATUS_FULFILLED: + initial_intent = ConversationIntentIds.THANK + CommitmentStateRecord.STATUS_BROKEN: + initial_intent = ConversationIntentIds.REPROACH + CommitmentStateRecord.STATUS_SUPERSEDED: + initial_intent = ConversationIntentIds.ACKNOWLEDGE_SUPERSESSION + options[ConversationIntentIds.GREET] = [ + ConversationIntentIds.ASK_WELLBEING, + ConversationIntentIds.ASK_WHAT_HAPPENED, + ConversationIntentIds.OFFER_HELP, + ConversationIntentIds.DECLINE, + ConversationIntentIds.ASK_WHO_ELSE, + ConversationIntentIds.GOODBYE, + ] + return { + "conversation_key": &"jajce_npc_%d" % npc.id, + "initial_intent_id": initial_intent, + "topic_ids": topics, + "option_intent_ids": options, + "reason_trace": trace, + } + + +func _get_latest_commitment_for_npc(npc_id: int, situation_id: int = -1) -> CommitmentStateRecord: + var latest: CommitmentStateRecord + for commitment in commitment_system.get_all_sorted(): + var creditor := commitment.get_creditor() + if ( + creditor.get_entity_type() != WorldEventRecord.ENTITY_TYPE_NPC + or String(creditor.get_entity_id()) != str(npc_id) + or (situation_id >= 0 and commitment.get_situation_id() != situation_id) + ): + continue + if latest == null or commitment.get_commitment_id() > latest.get_commitment_id(): + latest = commitment + return latest + + +func _apply_conversation_intent(conversation_id: StringName, intent_id: StringName) -> void: + if intent_id not in [ConversationIntentIds.OFFER_HELP, ConversationIntentIds.ACCEPT]: + return + var context: Dictionary = _conversation_context_by_id.get(conversation_id, {}) + var situation := situation_system.get_by_id(int(context.get("situation_id", -1))) + var npc_id := int(context.get("npc_id", -1)) + if situation == null or npc_id < 0: + return + var definition := situation_system.get_definition(situation.get_definition_id()) + var alternative_id := SituationSystem.ALTERNATIVE_RESTOCK + var entry := quest_journal_system.get_for_situation(situation.get_situation_id()) + if entry != null and entry.get_selected_alternative_id().is_empty(): + quest_journal_system.select_alternative(situation, definition, alternative_id) + quest_journal_changed.emit(entry) + var player_ref := WorldEntityRef.create( + WorldEventRecord.ENTITY_TYPE_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID)) + ) + var npc_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, StringName(str(npc_id))) + var commitment := commitment_system.create_for_alternative( + situation, definition, alternative_id, player_ref, npc_ref, tick_count, tick_count + 20 + ) + if commitment != null: + var acceptance_fact := commitment_lifecycle.record_acceptance( + commitment, event_log, relationship_system + ) + _share_commitment_fact_with_creditor(commitment, acceptance_fact) + commitment_changed.emit(commitment) + + +func _share_commitment_fact_with_creditor( + commitment: CommitmentStateRecord, fact: EconomicEventRecord +) -> void: + if commitment == null or fact == null: + return + var creditor := commitment.get_creditor() + if ( + creditor.get_entity_type() + not in [WorldEventRecord.ENTITY_TYPE_NPC, WorldEventRecord.ENTITY_TYPE_PLAYER] + ): + return + var creditor_text := String(creditor.get_entity_id()) + if not creditor_text.is_valid_int(): + return + var debtor := commitment.get_debtor() + var debtor_text := String(debtor.get_entity_id()) + if not debtor_text.is_valid_int(): + return + var shared := event_knowledge_system.communicate_event( + fact, debtor_text.to_int(), creditor_text.to_int(), tick_count + ) + if shared != null: + _apply_new_event_knowledge(shared, fact) + + +func _record_conversation_act(turn: ConversationTurn) -> void: + if turn == null or turn.get_act() == null: + return + var act := turn.get_act() + var record := ConversationActStateRecord.create( + _next_conversation_act_id, + tick_count, + act.get_speaker(), + act.get_listener(), + act.get_intent_id(), + act.get_causal_topic_ids() + ) + if record == null: + return + conversation_history.append(record) + _next_conversation_act_id += 1 + _prune_conversation_history(record) + + +func _prune_conversation_history(latest: ConversationActStateRecord) -> void: + var latest_pair := _conversation_pair_key(latest) + var pair_count := 0 + for index in range(conversation_history.size() - 1, -1, -1): + if _conversation_pair_key(conversation_history[index]) != latest_pair: + continue + pair_count += 1 + if pair_count > MAX_CONVERSATION_ACTS_PER_PAIR: + conversation_history.remove_at(index) + while conversation_history.size() > MAX_CONVERSATION_HISTORY: + conversation_history.pop_front() + + +func _conversation_pair_key(act: ConversationActStateRecord) -> String: + var keys := [act.get_speaker().index_key(), act.get_listener().index_key()] + keys.sort() + return "%s|%s" % keys + + func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: return event_log.get_by_id(opportunity.get_trigger_event_id() if opportunity != null else -1) @@ -1305,6 +1670,10 @@ func create_state_record() -> SimulationStateRecord: "wander_random_streams": wander_streams, "next_event_id": next_event_id, "next_opportunity_id": opportunity_system.next_opportunity_id, + "next_situation_id": situation_system.get_next_situation_id(), + "next_journal_entry_id": quest_journal_system.get_next_entry_id(), + "next_commitment_id": commitment_system.get_next_commitment_id(), + "next_conversation_act_id": _next_conversation_act_id, "cycle_duration_seconds": clock.cycle_duration_seconds } record.village = VillageStateRecord.capture(village) @@ -1329,6 +1698,14 @@ func create_state_record() -> SimulationStateRecord: record.event_knowledge.append(known_event) for opportunity in opportunity_system.get_all_sorted(): record.opportunities.append(opportunity) + for situation in situation_system.get_all_sorted(): + record.situations.append(situation) + for journal_entry in quest_journal_system.get_all_sorted(): + record.quest_journal.append(journal_entry) + for commitment in commitment_system.get_all_sorted(): + record.commitments.append(commitment) + for conversation_act in conversation_history: + record.conversation_history.append(conversation_act) record.player = player_system.player_state conflict_system.append_state_records(record) return record @@ -1372,6 +1749,33 @@ func restore_state(record: SimulationStateRecord) -> bool: relationship_system.restore(record.relationships) event_knowledge_system.restore(record.event_knowledge) opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"])) + situation_system = SituationSystem.create_default() + if not situation_system.restore(record.situations, int(record.simulation["next_situation_id"])): + return false + if record.situations.is_empty(): + var migratable_opportunities: Array[OpportunityStateRecord] = [] + for opportunity in record.opportunities: + if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: + migratable_opportunities.append(opportunity) + if not situation_system.import_opportunities(migratable_opportunities).is_empty(): + return false + quest_journal_system = QuestJournalSystem.new() + if not quest_journal_system.restore( + record.quest_journal, int(record.simulation["next_journal_entry_id"]) + ): + return false + commitment_system = SocialCommitmentSystem.new() + if not commitment_system.restore( + record.commitments, int(record.simulation["next_commitment_id"]) + ): + return false + commitment_lifecycle = CommitmentLifecycleService.new() + conversation_service = ConversationService.new() + conversation_history.assign(record.conversation_history) + _next_conversation_act_id = int(record.simulation["next_conversation_act_id"]) + _active_player_conversation = &"" + _conversation_context_by_id.clear() + _dialogue_speed_index = -1 _maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0) wander_random_sources.clear() var wander_streams: Array = record.simulation["wander_random_streams"] diff --git a/simulation/benchmark/SimulationScalingBenchmark.gd b/simulation/benchmark/SimulationScalingBenchmark.gd index 622bb72..9253492 100644 --- a/simulation/benchmark/SimulationScalingBenchmark.gd +++ b/simulation/benchmark/SimulationScalingBenchmark.gd @@ -1,8 +1,8 @@ class_name SimulationScalingBenchmark extends RefCounted -const SCHEMA_VERSION := 2 -const WORKLOAD_ID := &"full_fidelity_combatant_headless_arrival_v2" +const SCHEMA_VERSION := 3 +const WORKLOAD_ID := &"full_fidelity_emergent_headless_arrival_v3" const RECENT_FACTS_PER_NPC := 3 const STARTING_CLOCK_TICK := 50 @@ -25,6 +25,14 @@ func prepare_manager( var no_opportunities: Array[OpportunityStateRecord] = [] manager.relationship_system.restore(no_relationships) manager.opportunity_system.restore(no_opportunities, 0) + var no_situations: Array[SituationStateRecord] = [] + var no_journal_entries: Array[QuestJournalEntryStateRecord] = [] + var no_commitments: Array[CommitmentStateRecord] = [] + manager.situation_system.restore(no_situations, 0) + manager.quest_journal_system.restore(no_journal_entries, 0) + manager.commitment_system.restore(no_commitments, 0) + manager.conversation_history.clear() + manager._next_conversation_act_id = 0 for npc_id in population: var npc_random := _create_random_source(seed_value, npc_id, 0) @@ -83,6 +91,8 @@ func measure_manager( var start_json: String = manager.serialize_state() var start_event_count: int = manager.economic_events.size() var start_known_count: int = manager.event_knowledge_system.get_all_sorted().size() + var start_situation_count: int = manager.situation_system.get_all_sorted().size() + var start_commitment_count: int = manager.commitment_system.get_all_sorted().size() var start_tick: int = manager.tick_count var measured_arrivals := 0 var simulation_usec := 0 @@ -134,6 +144,11 @@ func measure_manager( "events_recorded": manager.economic_events.size() - start_event_count, "start_known_reference_count": start_known_count, "end_known_reference_count": manager.event_knowledge_system.get_all_sorted().size(), + "start_situation_count": start_situation_count, + "end_situation_count": manager.situation_system.get_all_sorted().size(), + "start_commitment_count": start_commitment_count, + "end_commitment_count": manager.commitment_system.get_all_sorted().size(), + "conversation_act_count": manager.conversation_history.size(), "final_checksum": end_json.sha256_text(), } diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index b6cd93d..7006bbe 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -67,6 +67,11 @@ const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const EVENT_ANIMAL_FED := &"animal_fed" const EVENT_VILLAGER_WEAK := &"villager_weak" const EVENT_HOME_DAMAGED := &"home_damaged" +const EVENT_COMMITMENT_ACCEPTED := &"commitment_accepted" +const EVENT_COMMITMENT_FULFILLED := &"commitment_fulfilled" +const EVENT_COMMITMENT_BROKEN := &"commitment_broken" +const EVENT_COMMITMENT_RELEASED := &"commitment_released" +const EVENT_COMMITMENT_SUPERSEDED := &"commitment_superseded" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" diff --git a/simulation/events/SimulationEventLog.gd b/simulation/events/SimulationEventLog.gd index f3d8b71..ec50a92 100644 --- a/simulation/events/SimulationEventLog.gd +++ b/simulation/events/SimulationEventLog.gd @@ -4,6 +4,21 @@ signal event_recorded(event: EconomicEventRecord) const DEFAULT_RATE_WINDOW_TICKS := 200 const DEFAULT_TICKS_PER_DAY := 200.0 +const FACT_RESERVED_FIELDS := [ + "schema_version", + "event_id", + "event_type", + "tick", + "actor_id", + "source_id", + "destination_id", + "item_id", + "amount", + "action_name", + "action_id", + "required_amount", + "world_position", +] var events: Array[EconomicEventRecord] = [] var next_event_id := 0 @@ -69,6 +84,48 @@ func record_narrative( return event +func record_fact( + tick: int, + event_type: StringName, + actor_id: int, + source_id: StringName = &"", + destination_id: StringName = &"", + fact_payload: Dictionary = {}, + world_position: Vector3 = Vector3.ZERO +) -> EconomicEventRecord: + if tick < 0 or event_type.is_empty(): + return null + var event := EconomicEventRecord.create_narrative( + next_event_id, event_type, tick, actor_id, source_id, "", world_position + ) + event.data["destination_id"] = String(destination_id) + var payload_keys: Array = fact_payload.keys() + payload_keys.sort_custom( + func(first: Variant, second: Variant) -> bool: return str(first) < str(second) + ) + var normalized_keys := {} + for raw_key in payload_keys: + if not raw_key is String and not raw_key is StringName: + return null + var key := String(raw_key) + if key.is_empty() or key in FACT_RESERVED_FIELDS or normalized_keys.has(key): + return null + normalized_keys[key] = true + event.data[key] = fact_payload[raw_key] + var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary()) + if normalized == null: + return null + if ( + WorldEventRecord.from_economic_event( + normalized, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE + ) + == null + ): + return null + _append(normalized) + return normalized + + func get_for_actor(actor_id: int, max_count: int = 8) -> Array[EconomicEventRecord]: var results: Array[EconomicEventRecord] = [] var event_ids: Array = _event_ids_by_actor.get(actor_id, []) diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index 5c17f97..668f7eb 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -317,6 +317,23 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool: return false var event_type := StringName(event.data["event_type"]) var item_id := StringName(event.data["item_id"]) + if ( + event_type + in [ + SimulationIds.EVENT_COMMITMENT_ACCEPTED, + SimulationIds.EVENT_COMMITMENT_FULFILLED, + SimulationIds.EVENT_COMMITMENT_BROKEN, + SimulationIds.EVENT_COMMITMENT_RELEASED, + SimulationIds.EVENT_COMMITMENT_SUPERSEDED, + ] + ): + return ( + KnownEventStateRecord.is_valid_actor_id(int(event.data["actor_id"])) + and int(event.data.get("commitment_id", -1)) >= 0 + and event.data.get("debtor") is Dictionary + and event.data.get("creditor") is Dictionary + and event.data.get("terms") is Dictionary + ) if event_type == SimulationIds.EVENT_TASK_BLOCKED: return ( int(event.data["actor_id"]) >= 0 diff --git a/simulation/situations/CommitmentLifecycleService.gd b/simulation/situations/CommitmentLifecycleService.gd new file mode 100644 index 0000000..f6980b8 --- /dev/null +++ b/simulation/situations/CommitmentLifecycleService.gd @@ -0,0 +1,250 @@ +class_name CommitmentLifecycleService +extends RefCounted + +const EVENT_ACCEPTED := SimulationIds.EVENT_COMMITMENT_ACCEPTED +const EVENT_FULFILLED := SimulationIds.EVENT_COMMITMENT_FULFILLED +const EVENT_BROKEN := SimulationIds.EVENT_COMMITMENT_BROKEN +const EVENT_RELEASED := SimulationIds.EVENT_COMMITMENT_RELEASED +const EVENT_SUPERSEDED := SimulationIds.EVENT_COMMITMENT_SUPERSEDED + +const ACCEPTED_OBLIGATION := 0.25 +const FULFILLED_TRUST := 0.10 +const BROKEN_TRUST := -0.20 +const BROKEN_HOSTILITY := 0.15 + + +func record_acceptance( + commitment: CommitmentStateRecord, event_log: RefCounted, relationships: RefCounted +) -> EconomicEventRecord: + if commitment == null or not commitment.is_active() or event_log == null: + return null + var event := _find_fact(event_log, EVENT_ACCEPTED, commitment.get_commitment_id()) + if event == null: + var relationship := _get_consequence_relationship(commitment, relationships) + var obligation_delta := ( + minf(ACCEPTED_OBLIGATION, 1.0 - relationship.get_obligation()) + if relationship != null + else 0.0 + ) + event = _record_fact( + commitment, + event_log, + EVENT_ACCEPTED, + commitment.get_created_tick(), + CommitmentStateRecord.STATUS_ACTIVE, + &"accepted", + CommitmentStateRecord.NO_EVENT_ID, + {"obligation_delta": obligation_delta} + ) + if event != null: + _apply_acceptance(commitment, event, relationships) + return event + + +func maintain_and_record( + current_tick: int, + commitments: SocialCommitmentSystem, + situations: SituationSystem, + event_log: RefCounted, + state_facts: Dictionary, + relationships: RefCounted +) -> Array[CommitmentStateRecord]: + if commitments == null or situations == null or event_log == null: + return [] + var closed := commitments.maintain( + current_tick, situations, event_log.world_events, state_facts + ) + for commitment in closed: + record_outcome(commitment, event_log, relationships) + return closed + + +func record_outcome( + commitment: CommitmentStateRecord, event_log: RefCounted, relationships: RefCounted +) -> EconomicEventRecord: + if commitment == null or commitment.is_active() or event_log == null: + return null + var event_type := _event_type_for_status(commitment.get_status()) + if event_type.is_empty(): + return null + var event := _find_fact(event_log, event_type, commitment.get_commitment_id()) + if event == null: + var acceptance := _find_fact(event_log, EVENT_ACCEPTED, commitment.get_commitment_id()) + var obligation_delta := ( + float(acceptance.data.get("obligation_delta", ACCEPTED_OBLIGATION)) + if acceptance != null + else 0.0 + ) + event = _record_fact( + commitment, + event_log, + event_type, + commitment.get_closed_tick(), + commitment.get_status(), + commitment.get_close_reason(), + commitment.get_cause_event_id(), + {"obligation_delta": obligation_delta} + ) + if event != null: + _apply_outcome(commitment, event, relationships) + return event + + +func synchronize_outcomes( + commitments: SocialCommitmentSystem, event_log: RefCounted, relationships: RefCounted +) -> Array[EconomicEventRecord]: + var events: Array[EconomicEventRecord] = [] + if commitments == null: + return events + for commitment in commitments.get_all_sorted(): + if commitment.is_active(): + continue + var event := record_outcome(commitment, event_log, relationships) + if event != null: + events.append(event) + return events + + +func get_fact( + event_log: RefCounted, event_type: StringName, commitment_id: int +) -> EconomicEventRecord: + return _find_fact(event_log, event_type, commitment_id) + + +func _record_fact( + commitment: CommitmentStateRecord, + event_log: RefCounted, + event_type: StringName, + tick: int, + status: StringName, + reason: StringName, + cause_event_id: int, + additional_payload: Dictionary = {} +) -> EconomicEventRecord: + var debtor := commitment.get_debtor() + var creditor := commitment.get_creditor() + var debtor_actor_id := _relationship_actor_id(debtor) + if not RelationshipStateRecord.is_valid_actor_id(debtor_actor_id): + return null + var payload := { + "cause_event_id": cause_event_id, + "close_reason": String(reason), + "commitment_id": commitment.get_commitment_id(), + "creditor": creditor.to_dictionary(), + "debtor": debtor.to_dictionary(), + "situation_id": commitment.get_situation_id(), + "status": String(status), + "terms": commitment.get_terms(), + } + for key in additional_payload: + if payload.has(key): + return null + payload[key] = additional_payload[key] + return event_log.record_fact( + tick, + event_type, + debtor_actor_id, + StringName("commitment_%d" % commitment.get_commitment_id()), + StringName(creditor.index_key()), + payload + ) + + +func _apply_acceptance( + commitment: CommitmentStateRecord, event: EconomicEventRecord, relationships: RefCounted +) -> void: + var relationship := _get_consequence_relationship(commitment, relationships) + if relationship == null: + return + _apply_dimension_once( + relationship, + RelationshipStateRecord.DIMENSION_OBLIGATION, + float(event.data.get("obligation_delta", ACCEPTED_OBLIGATION)), + int(event.data["event_id"]) + ) + + +func _apply_outcome( + commitment: CommitmentStateRecord, event: EconomicEventRecord, relationships: RefCounted +) -> void: + var relationship := _get_consequence_relationship(commitment, relationships) + if relationship == null: + return + var event_id := int(event.data["event_id"]) + _apply_dimension_once( + relationship, + RelationshipStateRecord.DIMENSION_OBLIGATION, + -float(event.data.get("obligation_delta", ACCEPTED_OBLIGATION)), + event_id + ) + match commitment.get_status(): + CommitmentStateRecord.STATUS_FULFILLED: + _apply_dimension_once( + relationship, RelationshipStateRecord.DIMENSION_TRUST, FULFILLED_TRUST, event_id + ) + CommitmentStateRecord.STATUS_BROKEN: + _apply_dimension_once( + relationship, RelationshipStateRecord.DIMENSION_TRUST, BROKEN_TRUST, event_id + ) + _apply_dimension_once( + relationship, + RelationshipStateRecord.DIMENSION_HOSTILITY, + BROKEN_HOSTILITY, + event_id + ) + + +static func _apply_dimension_once( + relationship: RelationshipStateRecord, dimension: StringName, amount: float, cause_event_id: int +) -> void: + if relationship.get_last_cause_event_id(dimension) >= cause_event_id: + return + relationship.adjust_dimension(dimension, amount, cause_event_id) + + +static func _get_consequence_relationship( + commitment: CommitmentStateRecord, relationships: RefCounted +) -> RelationshipStateRecord: + if relationships == null: + return null + var observer_id := _relationship_actor_id(commitment.get_creditor()) + var subject_id := _relationship_actor_id(commitment.get_debtor()) + return relationships.get_or_create_relationship(observer_id, subject_id) + + +static func _relationship_actor_id(entity: WorldEntityRef) -> int: + if entity == null: + return -2147483648 + var entity_id := String(entity.get_entity_id()) + if not entity_id.is_valid_int(): + return -2147483648 + var actor_id := entity_id.to_int() + if entity.get_entity_type() == WorldEventRecord.ENTITY_TYPE_PLAYER: + return actor_id if actor_id == SimulationIds.PLAYER_ACTOR_ID else -2147483648 + if entity.get_entity_type() != WorldEventRecord.ENTITY_TYPE_NPC or actor_id < 0: + return -2147483648 + return actor_id + + +static func _find_fact( + event_log: RefCounted, event_type: StringName, commitment_id: int +) -> EconomicEventRecord: + if event_log == null or commitment_id < 0: + return null + for event in event_log.get_for_type(event_type): + if int(event.data.get("commitment_id", -1)) == commitment_id: + return event + return null + + +static func _event_type_for_status(status: StringName) -> StringName: + match status: + CommitmentStateRecord.STATUS_FULFILLED: + return EVENT_FULFILLED + CommitmentStateRecord.STATUS_BROKEN: + return EVENT_BROKEN + CommitmentStateRecord.STATUS_RELEASED: + return EVENT_RELEASED + CommitmentStateRecord.STATUS_SUPERSEDED: + return EVENT_SUPERSEDED + return &"" diff --git a/simulation/situations/CommitmentLifecycleService.gd.uid b/simulation/situations/CommitmentLifecycleService.gd.uid new file mode 100644 index 0000000..e9f930d --- /dev/null +++ b/simulation/situations/CommitmentLifecycleService.gd.uid @@ -0,0 +1 @@ +uid://rhqx2yahw4rj diff --git a/simulation/situations/QuestJournalSystem.gd b/simulation/situations/QuestJournalSystem.gd index a2cc373..bd5b53c 100644 --- a/simulation/situations/QuestJournalSystem.gd +++ b/simulation/situations/QuestJournalSystem.gd @@ -17,12 +17,13 @@ func add_situation( or definition == null or not situation.is_active() or situation.get_definition_id() != definition.situation_definition_id - or definition.get_alternative(alternative_id) == null + or (not alternative_id.is_empty() and definition.get_alternative(alternative_id) == null) or current_tick < situation.get_created_tick() or _entry_id_by_situation.has(situation.get_situation_id()) - or not situation.select_alternative(alternative_id) ): return null + if not alternative_id.is_empty() and not situation.select_alternative(alternative_id): + return null var entry := QuestJournalEntryStateRecord.create( _next_entry_id, situation.get_situation_id(), @@ -36,6 +37,23 @@ func add_situation( return entry +func select_alternative( + situation: SituationStateRecord, definition: SituationDefinition, alternative_id: StringName +) -> bool: + if ( + situation == null + or definition == null + or alternative_id.is_empty() + or situation.get_definition_id() != definition.situation_definition_id + or definition.get_alternative(alternative_id) == null + ): + return false + var entry := get_for_situation(situation.get_situation_id()) + if entry == null or not entry.is_active(): + return false + return situation.select_alternative(alternative_id) and entry.select_alternative(alternative_id) + + func synchronize( situation: SituationStateRecord, current_tick: int ) -> QuestJournalEntryStateRecord: @@ -115,3 +133,7 @@ func get_active_sorted() -> Array[QuestJournalEntryStateRecord]: if record.is_active(): records.append(record) return records + + +func get_next_entry_id() -> int: + return _next_entry_id diff --git a/simulation/situations/SituationSystem.gd b/simulation/situations/SituationSystem.gd index e306c26..5ab4b90 100644 --- a/simulation/situations/SituationSystem.gd +++ b/simulation/situations/SituationSystem.gd @@ -314,6 +314,10 @@ func get_active_sorted() -> Array[SituationStateRecord]: return active +func get_next_situation_id() -> int: + return _next_situation_id + + func _get_definitions_sorted() -> Array[SituationDefinition]: var definitions: Array[SituationDefinition] = [] for definition in _definitions_by_id.values(): diff --git a/simulation/situations/SocialCommitmentSystem.gd b/simulation/situations/SocialCommitmentSystem.gd index 0074a1f..717bceb 100644 --- a/simulation/situations/SocialCommitmentSystem.gd +++ b/simulation/situations/SocialCommitmentSystem.gd @@ -2,6 +2,7 @@ class_name SocialCommitmentSystem extends RefCounted const REASON_FACT_FULFILLED := &"world_fact_fulfilled" +const REASON_FACT_RESOLVED_BY_OTHER := &"world_fact_resolved_by_other" const REASON_DEADLINE_MISSED := &"deadline_missed" const REASON_SITUATION_EXPIRED := &"situation_expired" const REASON_SITUATION_SUPERSEDED := &"situation_superseded" @@ -70,11 +71,28 @@ func maintain( situation, world_events, state_facts, situation.get_selected_alternative_id() ) if progress.resolved: + var resolution_event := ( + world_events.get_by_id(progress.matched_event_id) if world_events != null else null + ) + var resolver := ( + resolution_event.get_participant(WorldEventRecord.ROLE_ACTOR) + if resolution_event != null + else null + ) + var debtor_resolved := resolver != null and resolver.equals(commitment.get_debtor()) + var resolution_status := ( + CommitmentStateRecord.STATUS_FULFILLED + if debtor_resolved + else CommitmentStateRecord.STATUS_SUPERSEDED + ) + var resolution_reason := ( + REASON_FACT_FULFILLED if debtor_resolved else REASON_FACT_RESOLVED_BY_OTHER + ) if _close( commitment, - CommitmentStateRecord.STATUS_FULFILLED, + resolution_status, current_tick, - REASON_FACT_FULFILLED, + resolution_reason, progress.matched_event_id ): closed.append(commitment) @@ -98,7 +116,7 @@ func maintain( closed.append(commitment) continue var deadline := commitment.get_deadline_tick() - if deadline >= 0 and current_tick > deadline: + if deadline >= 0 and current_tick >= deadline: if _close( commitment, CommitmentStateRecord.STATUS_BROKEN, @@ -164,6 +182,10 @@ func get_active_sorted() -> Array[CommitmentStateRecord]: return records +func get_next_commitment_id() -> int: + return _next_commitment_id + + func _close( commitment: CommitmentStateRecord, status: StringName, diff --git a/simulation/state/ConversationActStateRecord.gd b/simulation/state/ConversationActStateRecord.gd new file mode 100644 index 0000000..7ccdc65 --- /dev/null +++ b/simulation/state/ConversationActStateRecord.gd @@ -0,0 +1,111 @@ +class_name ConversationActStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 +const MAX_TOPICS := 8 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + act_id: int, + tick: int, + speaker: WorldEntityRef, + listener: WorldEntityRef, + intent_id: StringName, + topic_ids: Array[StringName] = [] +) -> ConversationActStateRecord: + if ( + act_id < 0 + or tick < 0 + or speaker == null + or listener == null + or not speaker.is_valid() + or not listener.is_valid() + or speaker.equals(listener) + or not ConversationIntentIds.is_supported(intent_id) + or topic_ids.size() > MAX_TOPICS + ): + return null + var normalized_topics: Array[String] = [] + var seen: Dictionary = {} + for topic_id in topic_ids: + if topic_id.is_empty() or seen.has(topic_id): + return null + seen[topic_id] = true + normalized_topics.append(String(topic_id)) + return ( + ConversationActStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "act_id": act_id, + "tick": tick, + "speaker": speaker.to_dictionary(), + "listener": listener.to_dictionary(), + "intent_id": String(intent_id), + "topic_ids": normalized_topics, + } + ) + ) + + +static func from_dictionary(record_data: Dictionary) -> ConversationActStateRecord: + if ( + int(record_data.get("schema_version", -1)) != SCHEMA_VERSION + or not record_data.has_all( + ["act_id", "tick", "speaker", "listener", "intent_id", "topic_ids"] + ) + or not record_data["speaker"] is Dictionary + or not record_data["listener"] is Dictionary + or not record_data["topic_ids"] is Array + ): + return null + var topics: Array[StringName] = [] + for topic in record_data["topic_ids"]: + if not topic is String and not topic is StringName: + return null + topics.append(StringName(topic)) + return create( + int(record_data["act_id"]), + int(record_data["tick"]), + WorldEntityRef.from_dictionary(record_data["speaker"]), + WorldEntityRef.from_dictionary(record_data["listener"]), + StringName(record_data["intent_id"]), + topics + ) + + +func get_act_id() -> int: + return int(data["act_id"]) + + +func get_tick() -> int: + return int(data["tick"]) + + +func get_speaker() -> WorldEntityRef: + return WorldEntityRef.from_dictionary(data["speaker"]) + + +func get_listener() -> WorldEntityRef: + return WorldEntityRef.from_dictionary(data["listener"]) + + +func get_intent_id() -> StringName: + return StringName(data["intent_id"]) + + +func get_topic_ids() -> Array[StringName]: + var result: Array[StringName] = [] + for topic in data["topic_ids"]: + result.append(StringName(topic)) + return result + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/ConversationActStateRecord.gd.uid b/simulation/state/ConversationActStateRecord.gd.uid new file mode 100644 index 0000000..aa70eee --- /dev/null +++ b/simulation/state/ConversationActStateRecord.gd.uid @@ -0,0 +1 @@ +uid://b4iruxxxc2g2j diff --git a/simulation/state/QuestJournalEntryStateRecord.gd b/simulation/state/QuestJournalEntryStateRecord.gd index d269bad..bb0953d 100644 --- a/simulation/state/QuestJournalEntryStateRecord.gd +++ b/simulation/state/QuestJournalEntryStateRecord.gd @@ -154,6 +154,16 @@ func set_tracking_preference(preference: StringName) -> bool: return true +func select_alternative(alternative_id: StringName) -> bool: + if not is_active() or alternative_id.is_empty(): + return false + var selected := get_selected_alternative_id() + if not selected.is_empty() and selected != alternative_id: + return false + data["selected_alternative_id"] = String(alternative_id) + return true + + func archive(closed_tick: int) -> bool: return close(STATUS_ARCHIVED, closed_tick) diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 4ab7ec4..7f9e6a1 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,7 +2,7 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 14 +const SCHEMA_VERSION := 15 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 @@ -15,6 +15,7 @@ const ROUTINE_LEGACY_SCHEMA_VERSION := 10 const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11 const PLAYER_NEEDS_SCHEMA_VERSION := 12 const CONFLICT_SCHEMA_VERSION := 13 +const PRE_EMERGENT_SCHEMA_VERSION := 14 const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary @@ -27,6 +28,10 @@ var economic_events: Array[EconomicEventRecord] = [] var relationships: Array[RelationshipStateRecord] = [] var event_knowledge: Array[KnownEventStateRecord] = [] var opportunities: Array[OpportunityStateRecord] = [] +var situations: Array[SituationStateRecord] = [] +var quest_journal: Array[QuestJournalEntryStateRecord] = [] +var commitments: Array[CommitmentStateRecord] = [] +var conversation_history: Array[ConversationActStateRecord] = [] var player: PlayerStateRecord var combatants: Array[CombatantStateRecord] = [] var factions: Array[FactionStateRecord] = [] @@ -58,6 +63,18 @@ func to_dictionary() -> Dictionary: var opportunity_data: Array[Dictionary] = [] for opportunity_record in opportunities: opportunity_data.append(opportunity_record.to_dictionary()) + var situation_data: Array[Dictionary] = [] + for situation_record in situations: + situation_data.append(situation_record.to_dictionary()) + var journal_data: Array[Dictionary] = [] + for journal_record in quest_journal: + journal_data.append(journal_record.to_dictionary()) + var commitment_data: Array[Dictionary] = [] + for commitment_record in commitments: + commitment_data.append(commitment_record.to_dictionary()) + var conversation_history_data: Array[Dictionary] = [] + for conversation_act in conversation_history: + conversation_history_data.append(conversation_act.to_dictionary()) var combatant_data: Array[Dictionary] = [] for combatant_record in combatants: combatant_data.append(combatant_record.to_dictionary()) @@ -78,6 +95,10 @@ func to_dictionary() -> Dictionary: "relationships": relationship_data, "event_knowledge": knowledge_data, "opportunities": opportunity_data, + "situations": situation_data, + "quest_journal": journal_data, + "commitments": commitment_data, + "conversation_history": conversation_history_data, "player": player.to_dictionary(), "combatants": combatant_data, "factions": faction_data @@ -115,6 +136,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: PLAYER_RELATIONSHIP_SCHEMA_VERSION, PLAYER_NEEDS_SCHEMA_VERSION, CONFLICT_SCHEMA_VERSION, + PRE_EMERGENT_SCHEMA_VERSION, ] ): record_data = _migrate_legacy(record_data, version) @@ -134,6 +156,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "relationships", "event_knowledge", "opportunities", + "situations", + "quest_journal", + "commitments", + "conversation_history", "player", "combatants", "factions", @@ -157,6 +183,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "wander_random_streams", "next_event_id", "next_opportunity_id", + "next_situation_id", + "next_journal_entry_id", + "next_commitment_id", + "next_conversation_act_id", ] ) ): @@ -166,6 +196,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var saved_clock_accumulator := float(simulation_data["clock_accumulator"]) var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"]) var saved_next_opportunity_id := int(simulation_data["next_opportunity_id"]) + var saved_next_situation_id := int(simulation_data["next_situation_id"]) + var saved_next_journal_entry_id := int(simulation_data["next_journal_entry_id"]) + var saved_next_commitment_id := int(simulation_data["next_commitment_id"]) + var saved_next_conversation_act_id := int(simulation_data["next_conversation_act_id"]) if ( not is_finite(saved_tick_interval) or saved_tick_interval <= 0.0 @@ -174,6 +208,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: or saved_clock_accumulator < 0.0 or saved_clock_elapsed_ticks < 0 or saved_next_opportunity_id < 0 + or saved_next_situation_id < 0 + or saved_next_journal_entry_id < 0 + or saved_next_commitment_id < 0 + or saved_next_conversation_act_id < 0 ): return null if simulation_data.has("cycle_duration_seconds"): @@ -204,6 +242,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: var relationship_data = record_data["relationships"] var knowledge_data = record_data["event_knowledge"] var opportunity_data = record_data["opportunities"] + var situation_data = record_data["situations"] + var journal_data = record_data["quest_journal"] + var commitment_data = record_data["commitments"] + var conversation_history_data = record_data["conversation_history"] if ( not npc_data is Array or not animal_data is Array @@ -213,6 +255,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: or not relationship_data is Array or not knowledge_data is Array or not opportunity_data is Array + or not situation_data is Array + or not journal_data is Array + or not commitment_data is Array + or not conversation_history_data is Array ): return null @@ -425,7 +471,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null var knower_id := known_event_record.get_knower_id() var known_event_id := known_event_record.get_event_id() - if not npc_ids.has(knower_id) or not event_ids.has(known_event_id): + if ( + (knower_id != SimulationIds.PLAYER_ACTOR_ID and not npc_ids.has(knower_id)) + or not event_ids.has(known_event_id) + ): return null var knowledge_key := "%d:%d" % [knower_id, known_event_id] if knowledge_keys.has(knowledge_key): @@ -448,29 +497,28 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null var observer_id := relationship_record.get_observer_id() var subject_id := relationship_record.get_subject_id() + var is_player_observer := observer_id == SimulationIds.PLAYER_ACTOR_ID var is_player_subject := subject_id == SimulationIds.PLAYER_ACTOR_ID - if not npc_ids.has(observer_id): + if not is_player_observer and not npc_ids.has(observer_id): return null if not is_player_subject and not npc_ids.has(subject_id): return null var relationship_key := "%d:%d" % [observer_id, subject_id] if relationship_keys.has(relationship_key): return null - var cause_event_id := relationship_record.get_last_trust_cause_event_id() - if ( - cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT - and not event_ids.has(cause_event_id) - ): - return null - if ( - cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT - and not knowledge_keys.has("%d:%d" % [observer_id, cause_event_id]) - ): - return null - if cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT: - var cause_event := event_records_by_id[cause_event_id] as EconomicEventRecord - if not _is_valid_relationship_cause(cause_event, subject_id, is_player_subject): + for dimension in RelationshipStateRecord.DIMENSIONS: + var cause_event_id := relationship_record.get_last_cause_event_id(dimension) + if cause_event_id == RelationshipStateRecord.NO_CAUSE_EVENT: + continue + if ( + not event_ids.has(cause_event_id) + or not knowledge_keys.has("%d:%d" % [observer_id, cause_event_id]) + ): return null + if dimension == RelationshipStateRecord.DIMENSION_TRUST: + var cause_event := event_records_by_id[cause_event_id] as EconomicEventRecord + if not _is_valid_relationship_cause(cause_event, subject_id, is_player_subject): + return null relationship_keys[relationship_key] = true record.relationships.append(relationship_record) @@ -515,6 +563,36 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id: return null + var emergent_records: Variant = EmergentStateValidator.parse( + situation_data, journal_data, commitment_data, simulation_data, npc_ids, event_ids + ) + if emergent_records == null: + return null + record.situations.assign(emergent_records["situations"]) + record.quest_journal.assign(emergent_records["quest_journal"]) + record.commitments.assign(emergent_records["commitments"]) + var conversation_act_ids: Dictionary = {} + var highest_conversation_act_id := -1 + for item in conversation_history_data: + if not item is Dictionary: + return null + var conversation_act := ConversationActStateRecord.from_dictionary(item) + if ( + conversation_act == null + or conversation_act_ids.has(conversation_act.get_act_id()) + or conversation_act.get_tick() > saved_tick_count + or not EmergentStateValidator.is_valid_actor(conversation_act.get_speaker(), npc_ids) + or not EmergentStateValidator.is_valid_actor(conversation_act.get_listener(), npc_ids) + ): + return null + conversation_act_ids[conversation_act.get_act_id()] = true + highest_conversation_act_id = maxi( + highest_conversation_act_id, conversation_act.get_act_id() + ) + record.conversation_history.append(conversation_act) + if saved_next_conversation_act_id <= highest_conversation_act_id: + return null + return record @@ -579,16 +657,21 @@ static func _is_valid_knowledge_provenance( SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED: if knower_id == actor_id: return false - var source_npc_id := known_event.get_source_npc_id() - if not npc_ids.has(source_npc_id): + var source_actor_id := known_event.get_source_actor_id() + if ( + source_actor_id != SimulationIds.PLAYER_ACTOR_ID + and not npc_ids.has(source_actor_id) + ): return false var source_method := known_event.get_source_acquisition_method() if ( (source_method == SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED) - != (source_npc_id == actor_id) + != (source_actor_id == actor_id) ): return false - return true + if source_method not in KnownEventStateRecord.DIRECT_ACQUISITION_METHODS: + return false + return known_event.get_hop_count() == 1 return false @@ -774,12 +857,13 @@ static func _is_valid_home_damaged_trigger( static func _is_valid_relationship_cause( cause_event: EconomicEventRecord, subject_id: int, is_player_subject: bool ) -> bool: + var event_type := StringName(cause_event.data["event_type"]) + if EmergentStateValidator.is_valid_commitment_relationship_cause(cause_event, subject_id): + return true if not is_player_subject: return ( int(cause_event.data["actor_id"]) == subject_id - and ( - StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED - ) + and (event_type == SimulationIds.EVENT_STORAGE_DEPOSITED) and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and float(cause_event.data["amount"]) > 0.0 ) @@ -789,7 +873,7 @@ static func _is_valid_relationship_cause( return false if float(cause_event.data["amount"]) <= 0.0: return false - if StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED: + if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: return ( StringName(cause_event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID and ( @@ -798,7 +882,7 @@ static func _is_valid_relationship_cause( ) ) return ( - StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED + event_type == SimulationIds.EVENT_RESOURCE_EXTRACTED and StringName(cause_event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY ) @@ -848,6 +932,16 @@ static func _is_valid_supply_resolution( static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary: var migrated := legacy_data.duplicate(true) migrated["schema_version"] = SCHEMA_VERSION + migrated["situations"] = [] + migrated["quest_journal"] = [] + migrated["commitments"] = [] + migrated["conversation_history"] = [] + var emergent_simulation_data: Dictionary = migrated.get("simulation", {}) + emergent_simulation_data["next_situation_id"] = 0 + emergent_simulation_data["next_journal_entry_id"] = 0 + emergent_simulation_data["next_commitment_id"] = 0 + emergent_simulation_data["next_conversation_act_id"] = 0 + migrated["simulation"] = emergent_simulation_data if not migrated.has("player"): migrated["player"] = PlayerStateRecord.create_default().to_dictionary() if version < CONFLICT_SCHEMA_VERSION: @@ -927,6 +1021,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary PLAYER_RELATIONSHIP_SCHEMA_VERSION, PLAYER_NEEDS_SCHEMA_VERSION, CONFLICT_SCHEMA_VERSION, + PRE_EMERGENT_SCHEMA_VERSION, ] ): migrated["opportunities"] = [] diff --git a/simulation/state/SituationStateRecord.gd b/simulation/state/SituationStateRecord.gd index e75eb9d..6c791b5 100644 --- a/simulation/state/SituationStateRecord.gd +++ b/simulation/state/SituationStateRecord.gd @@ -28,6 +28,10 @@ static func create( return null if trigger_event_id < NO_EVENT_ID: return null + var normalized_context := context.duplicate(true) + for integer_key in ["event_id", "opportunity_id"]: + if normalized_context.has(integer_key): + normalized_context[integer_key] = int(normalized_context[integer_key]) return ( SituationStateRecord . new( @@ -43,7 +47,7 @@ static func create( "resolution_event_id": NO_EVENT_ID, "closed_tick": -1, "close_reason": "", - "context": context.duplicate(true), + "context": normalized_context, } ) ) diff --git a/simulation/state/validation/EmergentStateValidator.gd b/simulation/state/validation/EmergentStateValidator.gd new file mode 100644 index 0000000..f1b1a8e --- /dev/null +++ b/simulation/state/validation/EmergentStateValidator.gd @@ -0,0 +1,255 @@ +class_name EmergentStateValidator +extends RefCounted + + +static func parse( + situation_data: Array, + journal_data: Array, + commitment_data: Array, + simulation_data: Dictionary, + npc_ids: Dictionary, + event_ids: Dictionary +) -> Variant: + var current_tick := int(simulation_data["tick_count"]) + var next_situation_id := int(simulation_data["next_situation_id"]) + var next_journal_entry_id := int(simulation_data["next_journal_entry_id"]) + var next_commitment_id := int(simulation_data["next_commitment_id"]) + var situation_system := SituationSystem.create_default() + var situations_by_id: Dictionary = {} + var situations: Array[SituationStateRecord] = [] + if not _parse_situations( + situation_data, + current_tick, + next_situation_id, + npc_ids, + event_ids, + situation_system, + situations_by_id, + situations + ): + return null + var journal: Array[QuestJournalEntryStateRecord] = [] + if not _parse_journal( + journal_data, + current_tick, + next_journal_entry_id, + situation_system, + situations_by_id, + journal + ): + return null + var commitments: Array[CommitmentStateRecord] = [] + if not _parse_commitments( + commitment_data, + current_tick, + next_commitment_id, + npc_ids, + event_ids, + situation_system, + situations_by_id, + commitments + ): + return null + return { + "situations": situations, + "quest_journal": journal, + "commitments": commitments, + } + + +static func _parse_situations( + serialized: Array, + current_tick: int, + next_id: int, + npc_ids: Dictionary, + event_ids: Dictionary, + system: SituationSystem, + by_id: Dictionary, + result: Array[SituationStateRecord] +) -> bool: + var highest_id := -1 + for item in serialized: + if not item is Dictionary: + return false + var record := SituationStateRecord.from_dictionary(item) + if record == null: + return false + var situation_id := record.get_situation_id() + var definition := system.get_definition(record.get_definition_id()) + if by_id.has(situation_id) or definition == null: + return false + if ( + record.get_created_tick() > current_tick + or (not record.is_active() and record.get_closed_tick() > current_tick) + ): + return false + var trigger_event_id := record.get_trigger_event_id() + if ( + trigger_event_id != SituationStateRecord.NO_EVENT_ID + and not event_ids.has(trigger_event_id) + ): + return false + var resolution_event_id := record.get_resolution_event_id() + if ( + resolution_event_id != SituationStateRecord.NO_EVENT_ID + and (not event_ids.has(resolution_event_id) or resolution_event_id <= trigger_event_id) + ): + return false + var alternative_id := record.get_selected_alternative_id() + if not alternative_id.is_empty() and definition.get_alternative(alternative_id) == null: + return false + var context := record.get_context() + if context.has("interested_npc_id"): + var interested_id_text := String(context["interested_npc_id"]) + if ( + not interested_id_text.is_valid_int() + or not npc_ids.has(interested_id_text.to_int()) + ): + return false + by_id[situation_id] = record + highest_id = maxi(highest_id, situation_id) + result.append(record) + if next_id <= highest_id: + return false + return system.restore(result, next_id) + + +static func _parse_journal( + serialized: Array, + current_tick: int, + next_id: int, + situation_system: SituationSystem, + situations_by_id: Dictionary, + result: Array[QuestJournalEntryStateRecord] +) -> bool: + var ids: Dictionary = {} + var situation_ids: Dictionary = {} + var highest_id := -1 + for item in serialized: + if not item is Dictionary: + return false + var record := QuestJournalEntryStateRecord.from_dictionary(item) + if record == null: + return false + var entry_id := record.get_entry_id() + var situation := situations_by_id.get(record.get_situation_id()) as SituationStateRecord + if ( + ids.has(entry_id) + or situation_ids.has(record.get_situation_id()) + or situation == null + or record.get_definition_id() != situation.get_definition_id() + or record.get_discovered_tick() < situation.get_created_tick() + or record.get_discovered_tick() > current_tick + or (not record.is_active() and record.get_closed_tick() > current_tick) + ): + return false + var definition := situation_system.get_definition(record.get_definition_id()) + var alternative_id := record.get_selected_alternative_id() + if ( + not alternative_id.is_empty() + and ( + definition == null + or definition.get_alternative(alternative_id) == null + or situation.get_selected_alternative_id() != alternative_id + ) + ): + return false + ids[entry_id] = true + situation_ids[record.get_situation_id()] = true + highest_id = maxi(highest_id, entry_id) + result.append(record) + if next_id <= highest_id: + return false + return QuestJournalSystem.new().restore(result, next_id) + + +static func _parse_commitments( + serialized: Array, + current_tick: int, + next_id: int, + npc_ids: Dictionary, + event_ids: Dictionary, + situation_system: SituationSystem, + situations_by_id: Dictionary, + result: Array[CommitmentStateRecord] +) -> bool: + var ids: Dictionary = {} + var highest_id := -1 + for item in serialized: + if not item is Dictionary: + return false + var record := CommitmentStateRecord.from_dictionary(item) + if record == null: + return false + var commitment_id := record.get_commitment_id() + var situation := situations_by_id.get(record.get_situation_id()) as SituationStateRecord + if ( + ids.has(commitment_id) + or situation == null + or record.get_created_tick() < situation.get_created_tick() + or record.get_created_tick() > current_tick + or (not record.is_active() and record.get_closed_tick() > current_tick) + or not is_valid_actor(record.get_debtor(), npc_ids) + or not is_valid_actor(record.get_creditor(), npc_ids) + ): + return false + var definition := situation_system.get_definition(situation.get_definition_id()) + var alternative: SituationAlternativeDefinition + if definition != null: + alternative = definition.get_alternative(situation.get_selected_alternative_id()) + if alternative == null or alternative.commitment_terms != record.get_terms(): + return false + var cause_event_id := record.get_cause_event_id() + if ( + cause_event_id != CommitmentStateRecord.NO_EVENT_ID + and not event_ids.has(cause_event_id) + ): + return false + ids[commitment_id] = true + highest_id = maxi(highest_id, commitment_id) + result.append(record) + if next_id <= highest_id: + return false + return SocialCommitmentSystem.new().restore(result, next_id) + + +static func is_valid_actor(entity: WorldEntityRef, npc_ids: Dictionary) -> bool: + if entity == null or not entity.is_valid(): + return false + if entity.get_entity_type() == WorldEventRecord.ENTITY_TYPE_PLAYER: + return String(entity.get_entity_id()) == str(SimulationIds.PLAYER_ACTOR_ID) + if entity.get_entity_type() != WorldEventRecord.ENTITY_TYPE_NPC: + return false + var entity_id_text := String(entity.get_entity_id()) + return entity_id_text.is_valid_int() and npc_ids.has(entity_id_text.to_int()) + + +static func is_valid_commitment_relationship_cause( + cause_event: EconomicEventRecord, subject_id: int +) -> bool: + if cause_event == null: + return false + if ( + StringName(cause_event.data["event_type"]) + not in [ + SimulationIds.EVENT_COMMITMENT_FULFILLED, + SimulationIds.EVENT_COMMITMENT_BROKEN, + ] + ): + return false + var debtor_data = cause_event.data.get("debtor") + if not debtor_data is Dictionary: + return false + var debtor := WorldEntityRef.from_dictionary(debtor_data) + if debtor == null: + return false + var expected_type := ( + WorldEventRecord.ENTITY_TYPE_PLAYER + if subject_id == SimulationIds.PLAYER_ACTOR_ID + else WorldEventRecord.ENTITY_TYPE_NPC + ) + return ( + debtor.get_entity_type() == expected_type + and String(debtor.get_entity_id()) == str(subject_id) + and int(cause_event.data["actor_id"]) == subject_id + ) diff --git a/simulation/state/validation/EmergentStateValidator.gd.uid b/simulation/state/validation/EmergentStateValidator.gd.uid new file mode 100644 index 0000000..e1eca7a --- /dev/null +++ b/simulation/state/validation/EmergentStateValidator.gd.uid @@ -0,0 +1 @@ +uid://bolivdac21erj diff --git a/tests/emergent_jajce_slice_test.gd b/tests/emergent_jajce_slice_test.gd new file mode 100644 index 0000000..bba4bea --- /dev/null +++ b/tests/emergent_jajce_slice_test.gd @@ -0,0 +1,217 @@ +extends SceneTree + +var failures: Array[String] = [] +var manager: Node + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + manager = load("res://simulation/SimulationManager.gd").new() + root.add_child(manager) + var speaker: SimNPC = manager.npcs[0] + speaker.position = Vector3.ZERO + speaker.hunger = 85.0 + for npc in manager.npcs: + if npc != speaker: + npc.position = Vector3(100.0 + npc.id, 0.0, 0.0) + _set_pantry_amount(1.0) + _check(manager.get_quest_journal_entries().is_empty(), "Journal starts empty") + _check( + is_equal_approx( + manager.economy.withdraw_to_inventory(speaker, SimulationIds.RESOURCE_FOOD, 1.0), 1.0 + ), + "A real last-food withdrawal creates the evidence" + ) + var situations: Array[SituationStateRecord] = manager.get_active_situations() + _check(situations.size() == 1, "The exact shortage evidence opens one world situation") + if situations.is_empty(): + _finish() + return + var situation := situations[0] + _check( + manager.event_knowledge_system.knows_event(speaker.id, situation.get_trigger_event_id()), + "The performing NPC knows the trigger fact" + ) + _check( + not manager.event_knowledge_system.knows_event( + SimulationIds.PLAYER_ACTOR_ID, situation.get_trigger_event_id() + ), + "The player does not know the situation before conversation" + ) + _check(manager.get_quest_journal_entries().is_empty(), "Unknown situations stay out of journal") + + manager.speed_index = 5 + var conversation_id: StringName = manager.begin_player_conversation(speaker.id) + _check(not conversation_id.is_empty(), "The informed NPC can begin generated dialogue") + _check( + manager.speed_index == 2 and manager.is_dialogue_active(), "Dialogue runs simulation at 1x" + ) + _check( + manager.event_knowledge_system.knows_event( + SimulationIds.PLAYER_ACTOR_ID, situation.get_trigger_event_id() + ), + "Speaking communicates the exact trigger fact to the player" + ) + _check(manager.get_quest_journal_entries().size() == 1, "Discovery projects into the journal") + var turn: ConversationTurn = manager.conversation_service.get_turn(conversation_id) + _check(turn != null and _has_intent(turn, ConversationIntentIds.OFFER_HELP), "Help is semantic") + _check(_has_intent(turn, ConversationIntentIds.DECLINE), "Declining remains available") + _check( + _has_intent(turn, ConversationIntentIds.ASK_WHO_ELSE), "Alternative helpers can be asked" + ) + var offer := _option_for_intent(turn, ConversationIntentIds.OFFER_HELP) + var offered: ConversationSelectionResult = manager.select_player_conversation_option( + conversation_id, offer.get_option_id(), turn.get_revision() + ) + _check(offered.was_accepted(), "Offering help advances the deterministic conversation") + _check(manager.get_active_commitments().size() == 1, "Offering help creates one commitment") + turn = offered.get_turn() + var accept := _option_for_intent(turn, ConversationIntentIds.ACCEPT) + var accepted: ConversationSelectionResult = manager.select_player_conversation_option( + conversation_id, accept.get_option_id(), turn.get_revision() + ) + _check(accepted.was_accepted(), "Accepting the offer records a semantic act") + _check(manager.get_active_commitments().size() == 1, "NPC acknowledgement cannot duplicate it") + _check( + ( + manager.get_quest_journal_entries()[0].get_selected_alternative_id() + == SituationSystem.ALTERNATIVE_RESTOCK + ), + "Acceptance selects a real resolution alternative without copying progress" + ) + var saved: String = manager.serialize_state() + var canonical_checksum: String = JSON.stringify(JSON.parse_string(saved)).sha256_text() + manager.end_player_conversation(conversation_id) + _check(manager.speed_index == 5, "Closing dialogue restores the prior speed") + + var restored: Node = load("res://simulation/SimulationManager.gd").new() + root.add_child(restored) + _check(restored.restore_state_from_json(saved), "Mid-commitment save reloads") + var restored_serialized: String = restored.serialize_state() + var restored_checksum: String = ( + JSON.stringify(JSON.parse_string(restored_serialized)).sha256_text() + ) + if restored_checksum != canonical_checksum: + var saved_data: Dictionary = JSON.parse_string(saved) + var restored_data: Dictionary = JSON.parse_string(restored_serialized) + print("[TEST] checksum mismatch sections: ", _different_keys(saved_data, restored_data)) + _check(restored_checksum == canonical_checksum, "Mid-commitment continuation is deterministic") + _check(restored.get_active_commitments().size() == 1, "The social commitment persists") + restored.player_system.add_carried(SimulationIds.RESOURCE_FOOD, 1.0) + _check( + is_equal_approx(restored.deposit_player_inventory(SimulationIds.RESOURCE_FOOD), 1.0), + "The player resolves the situation with an ordinary real deposit" + ) + var restored_situation: SituationStateRecord = restored.situation_system.get_by_id( + situation.get_situation_id() + ) + var restored_commitment: CommitmentStateRecord = restored.commitment_system.get_all_sorted()[0] + _check( + restored_situation.get_status() == SituationStateRecord.STATUS_RESOLVED, + "The exact deposit event resolves the world situation" + ) + _check( + ( + restored_commitment.get_status() == CommitmentStateRecord.STATUS_FULFILLED + and ( + restored_commitment.get_cause_event_id() + == restored_situation.get_resolution_event_id() + ) + ), + "That same event fulfills the commitment exactly once" + ) + _check( + ( + restored.get_quest_journal_entries()[0].get_status() + == QuestJournalEntryStateRecord.STATUS_COMPLETED + ), + "The journal projects the closed world state" + ) + _check( + restored.conversation_history.size() >= 3, + "Semantic conversation acts and topics persist, never rendered prose" + ) + var fulfilled_fact: EconomicEventRecord = restored.commitment_lifecycle.get_fact( + restored.event_log, + CommitmentLifecycleService.EVENT_FULFILLED, + restored_commitment.get_commitment_id() + ) + _check(fulfilled_fact != null, "Fulfillment records a durable social outcome fact") + var player_relationship: RelationshipStateRecord = ( + restored.relationship_system.get_relationship(speaker.id, SimulationIds.PLAYER_ACTOR_ID) + ) + _check( + ( + player_relationship != null + and ( + player_relationship.get_last_trust_cause_event_id() + == int(fulfilled_fact.data["event_id"]) + ) + ), + "The exact outcome fact causes the relationship consequence" + ) + _check( + SimulationStateRecord.from_json(restored.serialize_state()) != null, + "Fulfilled commitment facts and relationship causes remain schema-valid" + ) + var later_conversation: StringName = restored.begin_player_conversation(speaker.id) + var later_turn: ConversationTurn = restored.conversation_service.get_turn(later_conversation) + _check( + later_turn != null and later_turn.get_act().get_intent_id() == ConversationIntentIds.THANK, + "Later dialogue reflects the fulfilled commitment" + ) + _check( + ( + StringName("outcome_event_%d" % restored_situation.get_resolution_event_id()) + in later_turn.get_reason_trace() + ), + "Later dialogue traces the exact real deposit event" + ) + restored.end_player_conversation(later_conversation) + restored.free() + manager.free() + _finish() + + +func _set_pantry_amount(amount: float) -> void: + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + pantry.deposit(SimulationIds.RESOURCE_FOOD, amount) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + + +func _option_for_intent(turn: ConversationTurn, intent_id: StringName) -> ConversationOption: + for option in turn.get_options(): + if option.get_intent_id() == intent_id: + return option + return null + + +func _has_intent(turn: ConversationTurn, intent_id: StringName) -> bool: + return _option_for_intent(turn, intent_id) != null + + +func _check(condition: bool, message: String) -> void: + if condition: + return + failures.append(message) + push_error("[TEST] " + message) + + +func _different_keys(left: Dictionary, right: Dictionary) -> Array[String]: + var differences: Array[String] = [] + for key in left: + if not right.has(key) or JSON.stringify(left[key]) != JSON.stringify(right[key]): + differences.append(String(key)) + return differences + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Emergent Jajce slice passed") + quit(0) + else: + quit(1) diff --git a/tests/emergent_jajce_slice_test.gd.uid b/tests/emergent_jajce_slice_test.gd.uid new file mode 100644 index 0000000..ca082e1 --- /dev/null +++ b/tests/emergent_jajce_slice_test.gd.uid @@ -0,0 +1 @@ +uid://dca87o6k2xfww diff --git a/tests/simulation_scaling_benchmark_test.gd b/tests/simulation_scaling_benchmark_test.gd index 89b168c..a00ea49 100644 --- a/tests/simulation_scaling_benchmark_test.gd +++ b/tests/simulation_scaling_benchmark_test.gd @@ -38,16 +38,16 @@ func _run() -> void: _check(first["fixture_valid"], "The prepared benchmark state should pass schema validation") _check( ( - int(first["schema_version"]) == 2 - and SimulationScalingBenchmark.SCHEMA_VERSION == 2 + int(first["schema_version"]) == 3 + and SimulationScalingBenchmark.SCHEMA_VERSION == 3 and ( int(first["simulation_state_schema_version"]) == SimulationStateRecord.SCHEMA_VERSION ) - and StringName(first["workload_id"]) == &"full_fidelity_combatant_headless_arrival_v2" + and (StringName(first["workload_id"]) == &"full_fidelity_emergent_headless_arrival_v3") and ( SimulationScalingBenchmark.WORKLOAD_ID - == &"full_fidelity_combatant_headless_arrival_v2" + == &"full_fidelity_emergent_headless_arrival_v3" ) and int(first["population"]) == 12 and int(first["npc_combatant_count"]) == 12 @@ -86,6 +86,11 @@ func _run() -> void: "events_recorded", "start_known_reference_count", "end_known_reference_count", + "start_situation_count", + "end_situation_count", + "start_commitment_count", + "end_commitment_count", + "conversation_act_count", "arrivals_processed", "warmup_arrivals", "tick_interval", diff --git a/tests/unit/test_commitment_lifecycle_service.gd b/tests/unit/test_commitment_lifecycle_service.gd new file mode 100644 index 0000000..a8272ec --- /dev/null +++ b/tests/unit/test_commitment_lifecycle_service.gd @@ -0,0 +1,252 @@ +extends GutTest + +const CommitmentLifecycleService := preload( + "res://simulation/situations/CommitmentLifecycleService.gd" +) +const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") +const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") + + +func test_debtor_fulfillment_records_exact_facts_and_idempotent_consequences() -> void: + var context := _open_commitment(SimulationIds.PLAYER_ACTOR_ID, 4, 20) + var lifecycle := CommitmentLifecycleService.new() + var acceptance := lifecycle.record_acceptance( + context.commitment, context.event_log, context.relationships + ) + assert_not_null(acceptance) + assert_eq(StringName(acceptance.data["event_type"]), CommitmentLifecycleService.EVENT_ACCEPTED) + assert_eq(int(acceptance.data["commitment_id"]), context.commitment.get_commitment_id()) + var relationship: RelationshipStateRecord = context.relationships.get_relationship( + 4, SimulationIds.PLAYER_ACTOR_ID + ) + assert_not_null(relationship) + assert_almost_eq( + relationship.get_obligation(), CommitmentLifecycleService.ACCEPTED_OBLIGATION, 0.0001 + ) + assert_eq(relationship.get_last_obligation_cause_event_id(), int(acceptance.data["event_id"])) + + var resolution := _record_supply(context.event_log, 12, SimulationIds.PLAYER_ACTOR_ID) + var closed := lifecycle.maintain_and_record( + 12, + context.commitments, + context.situations, + context.event_log, + _pantry_facts(1.0), + context.relationships + ) + assert_eq(closed, [context.commitment]) + assert_eq(context.commitment.get_status(), CommitmentStateRecord.STATUS_FULFILLED) + assert_eq(context.commitment.get_cause_event_id(), int(resolution.data["event_id"])) + var fulfilled := lifecycle.get_fact( + context.event_log, + CommitmentLifecycleService.EVENT_FULFILLED, + context.commitment.get_commitment_id() + ) + assert_not_null(fulfilled) + assert_eq(int(fulfilled.data["cause_event_id"]), int(resolution.data["event_id"])) + assert_almost_eq(relationship.get_obligation(), 0.0, 0.0001) + assert_almost_eq( + relationship.get_trust(), + RelationshipStateRecord.NEUTRAL_TRUST + CommitmentLifecycleService.FULFILLED_TRUST, + 0.0001 + ) + assert_eq(relationship.get_last_trust_cause_event_id(), int(fulfilled.data["event_id"])) + assert_eq(relationship.get_last_obligation_cause_event_id(), int(fulfilled.data["event_id"])) + + var event_count: int = context.event_log.events.size() + var relationship_before := relationship.to_dictionary() + assert_same( + lifecycle.record_outcome(context.commitment, context.event_log, context.relationships), + fulfilled + ) + assert_true( + ( + lifecycle + . maintain_and_record( + 13, + context.commitments, + context.situations, + context.event_log, + _pantry_facts(1.0), + context.relationships + ) + . is_empty() + ) + ) + assert_eq(context.event_log.events.size(), event_count) + assert_eq(relationship.to_dictionary(), relationship_before) + _assert_fact_round_trip(context.event_log, fulfilled) + + +func test_deadline_breaks_at_due_tick_and_applies_one_exact_penalty() -> void: + var context := _open_commitment(2, 3, 12) + var lifecycle := CommitmentLifecycleService.new() + lifecycle.record_acceptance(context.commitment, context.event_log, context.relationships) + var relationship: RelationshipStateRecord = context.relationships.get_relationship(3, 2) + var closed := lifecycle.maintain_and_record( + 12, + context.commitments, + context.situations, + context.event_log, + _pantry_facts(0.0), + context.relationships + ) + + assert_eq(closed, [context.commitment]) + assert_eq(context.commitment.get_status(), CommitmentStateRecord.STATUS_BROKEN) + assert_eq(context.commitment.get_close_reason(), SocialCommitmentSystem.REASON_DEADLINE_MISSED) + var broken := lifecycle.get_fact( + context.event_log, + CommitmentLifecycleService.EVENT_BROKEN, + context.commitment.get_commitment_id() + ) + assert_not_null(broken) + assert_almost_eq(relationship.get_obligation(), 0.0, 0.0001) + assert_almost_eq( + relationship.get_trust(), + RelationshipStateRecord.NEUTRAL_TRUST + CommitmentLifecycleService.BROKEN_TRUST, + 0.0001 + ) + assert_almost_eq( + relationship.get_hostility(), CommitmentLifecycleService.BROKEN_HOSTILITY, 0.0001 + ) + for dimension in [ + RelationshipStateRecord.DIMENSION_OBLIGATION, + RelationshipStateRecord.DIMENSION_TRUST, + RelationshipStateRecord.DIMENSION_HOSTILITY, + ]: + assert_eq(relationship.get_last_cause_event_id(dimension), int(broken.data["event_id"])) + var before := relationship.to_dictionary() + assert_same( + lifecycle.record_outcome(context.commitment, context.event_log, context.relationships), + broken + ) + assert_eq(relationship.to_dictionary(), before) + + +func test_other_npc_resolution_supersedes_without_trust_or_hostility_penalty() -> void: + var context := _open_commitment(2, 4, 20) + var lifecycle := CommitmentLifecycleService.new() + lifecycle.record_acceptance(context.commitment, context.event_log, context.relationships) + var relationship: RelationshipStateRecord = context.relationships.get_relationship(4, 2) + var resolution := _record_supply(context.event_log, 12, 3) + var closed := lifecycle.maintain_and_record( + 12, + context.commitments, + context.situations, + context.event_log, + _pantry_facts(1.0), + context.relationships + ) + + assert_eq(closed, [context.commitment]) + assert_eq(context.commitment.get_status(), CommitmentStateRecord.STATUS_SUPERSEDED) + assert_eq( + context.commitment.get_close_reason(), SocialCommitmentSystem.REASON_FACT_RESOLVED_BY_OTHER + ) + assert_eq(context.commitment.get_cause_event_id(), int(resolution.data["event_id"])) + var superseded := lifecycle.get_fact( + context.event_log, + CommitmentLifecycleService.EVENT_SUPERSEDED, + context.commitment.get_commitment_id() + ) + assert_not_null(superseded) + assert_almost_eq(relationship.get_obligation(), 0.0, 0.0001) + assert_almost_eq(relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST, 0.0001) + assert_almost_eq(relationship.get_hostility(), 0.0, 0.0001) + assert_eq(relationship.get_last_obligation_cause_event_id(), int(superseded.data["event_id"])) + assert_eq(relationship.get_last_trust_cause_event_id(), RelationshipStateRecord.NO_CAUSE_EVENT) + assert_eq( + relationship.get_last_hostility_cause_event_id(), RelationshipStateRecord.NO_CAUSE_EVENT + ) + + +func test_released_commitment_records_fact_and_clears_obligation_without_penalty() -> void: + var context := _open_commitment(2, 4, 20) + var lifecycle := CommitmentLifecycleService.new() + var relationship: RelationshipStateRecord = context.relationships.get_or_create_relationship( + 4, 2 + ) + assert_almost_eq(relationship.adjust_obligation(0.9, -1), 0.9, 0.0001) + var acceptance := lifecycle.record_acceptance( + context.commitment, context.event_log, context.relationships + ) + assert_almost_eq(float(acceptance.data["obligation_delta"]), 0.1, 0.0001) + assert_almost_eq(relationship.get_obligation(), 1.0, 0.0001) + assert_true(context.commitments.release(0, 11, &"mutual_release")) + var released := lifecycle.record_outcome( + context.commitment, context.event_log, context.relationships + ) + + assert_not_null(released) + assert_eq(StringName(released.data["event_type"]), CommitmentLifecycleService.EVENT_RELEASED) + assert_almost_eq(relationship.get_obligation(), 0.9, 0.0001) + assert_almost_eq(relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST, 0.0001) + assert_almost_eq(relationship.get_hostility(), 0.0, 0.0001) + + +func _open_commitment(debtor_id: int, creditor_id: int, deadline_tick: int) -> Dictionary: + var event_log := SimulationEventLogScript.new() + var trigger := event_log.record_economic( + 10, + &"storage_withdrawn", + creditor_id, + &"village_pantry", + StringName("npc_inventory_%d" % creditor_id), + &"food", + 1.0 + ) + var situations := SituationSystem.create_default() + situations.consider_event( + event_log.get_world_event(int(trigger.data["event_id"])), 10, _pantry_facts(0.0) + ) + var situation := situations.get_by_id(0) + var commitments := SocialCommitmentSystem.new() + var debtor_type := ( + WorldEventRecord.ENTITY_TYPE_PLAYER + if debtor_id == SimulationIds.PLAYER_ACTOR_ID + else WorldEventRecord.ENTITY_TYPE_NPC + ) + var commitment := commitments.create_for_alternative( + situation, + situations.get_definition(situation.get_definition_id()), + SituationSystem.ALTERNATIVE_RESTOCK, + WorldEntityRef.create(debtor_type, StringName(str(debtor_id))), + WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, StringName(str(creditor_id))), + 10, + deadline_tick + ) + return { + "commitment": commitment, + "commitments": commitments, + "event_log": event_log, + "relationships": RelationshipSystemScript.new(), + "situations": situations, + } + + +func _record_supply(event_log: RefCounted, tick: int, actor_id: int) -> EconomicEventRecord: + var source_id := ( + &"player_inventory" + if actor_id == SimulationIds.PLAYER_ACTOR_ID + else StringName("npc_inventory_%d" % actor_id) + ) + return event_log.record_economic( + tick, &"storage_deposited", actor_id, source_id, &"village_pantry", &"food", 1.0 + ) + + +func _pantry_facts(amount: float) -> Dictionary: + return {"pantry.food": amount, "target_id": "village_pantry"} + + +func _assert_fact_round_trip(event_log: RefCounted, expected: EconomicEventRecord) -> void: + var records: Array[EconomicEventRecord] = [] + for event in event_log.events: + records.append(EconomicEventRecord.from_dictionary(event.to_dictionary())) + var restored := SimulationEventLogScript.new() + restored.restore(records, event_log.next_event_id) + var actual := restored.get_by_id(int(expected.data["event_id"])) + assert_not_null(actual) + assert_eq(actual.to_dictionary(), expected.to_dictionary()) + assert_not_null(restored.get_world_event(int(expected.data["event_id"]))) diff --git a/tests/unit/test_commitment_lifecycle_service.gd.uid b/tests/unit/test_commitment_lifecycle_service.gd.uid new file mode 100644 index 0000000..5a4afba --- /dev/null +++ b/tests/unit/test_commitment_lifecycle_service.gd.uid @@ -0,0 +1 @@ +uid://1walx8xflan0 diff --git a/tests/unit/test_emergent_state_validation.gd b/tests/unit/test_emergent_state_validation.gd new file mode 100644 index 0000000..ae822f7 --- /dev/null +++ b/tests/unit/test_emergent_state_validation.gd @@ -0,0 +1,86 @@ +extends GutTest + +const MANAGER_SCRIPT := preload("res://simulation/SimulationManager.gd") + +var manager: Node + + +func before_each() -> void: + manager = MANAGER_SCRIPT.new() + add_child_autofree(manager) + + +func test_world_v14_migrates_to_empty_emergent_collections() -> void: + var previous: Dictionary = manager.create_state_record().to_dictionary() + previous["schema_version"] = SimulationStateRecord.PRE_EMERGENT_SCHEMA_VERSION + for field in ["situations", "quest_journal", "commitments", "conversation_history"]: + previous.erase(field) + for field in [ + "next_situation_id", + "next_journal_entry_id", + "next_commitment_id", + "next_conversation_act_id", + ]: + previous["simulation"].erase(field) + var migrated: SimulationStateRecord = SimulationStateRecord.from_dictionary(previous) + assert_not_null(migrated) + assert_true(migrated.situations.is_empty()) + assert_true(migrated.quest_journal.is_empty()) + assert_true(migrated.commitments.is_empty()) + assert_true(migrated.conversation_history.is_empty()) + assert_eq(int(migrated.simulation["next_situation_id"]), 0) + assert_eq(int(migrated.simulation["next_conversation_act_id"]), 0) + + +func test_orphan_journal_and_situation_evidence_are_rejected() -> void: + var orphan_journal: Dictionary = manager.create_state_record().to_dictionary() + orphan_journal["quest_journal"] = [ + ( + QuestJournalEntryStateRecord + . create(0, 42, SituationSystem.DEFINITION_PANTRY_SHORTAGE, 0) + . to_dictionary() + ) + ] + orphan_journal["simulation"]["next_journal_entry_id"] = 1 + assert_null(SimulationStateRecord.from_dictionary(orphan_journal)) + + var missing_evidence: Dictionary = manager.create_state_record().to_dictionary() + missing_evidence["situations"] = [ + ( + SituationStateRecord + . create( + 0, + SituationSystem.DEFINITION_PANTRY_SHORTAGE, + "missing-evidence", + 0, + 999, + {"interested_npc_id": "0"} + ) + . to_dictionary() + ) + ] + missing_evidence["simulation"]["next_situation_id"] = 1 + assert_null(SimulationStateRecord.from_dictionary(missing_evidence)) + + +func test_conversation_history_requires_valid_actors_and_monotonic_id() -> void: + var state: Dictionary = manager.create_state_record().to_dictionary() + var npc_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_NPC, &"0") + var player_ref := WorldEntityRef.create(WorldEventRecord.ENTITY_TYPE_PLAYER, &"-1") + state["conversation_history"] = [ + ( + ConversationActStateRecord + . create( + 0, 0, npc_ref, player_ref, ConversationIntentIds.GREET, [&"topic_pantry_shortage"] + ) + . to_dictionary() + ) + ] + assert_null( + SimulationStateRecord.from_dictionary(state), + "The next stable act ID cannot collide with retained history" + ) + state["simulation"]["next_conversation_act_id"] = 1 + assert_not_null(SimulationStateRecord.from_dictionary(state)) + state["conversation_history"][0]["speaker"]["entity_id"] = "missing_npc" + assert_null(SimulationStateRecord.from_dictionary(state)) diff --git a/tests/unit/test_emergent_state_validation.gd.uid b/tests/unit/test_emergent_state_validation.gd.uid new file mode 100644 index 0000000..f849075 --- /dev/null +++ b/tests/unit/test_emergent_state_validation.gd.uid @@ -0,0 +1 @@ +uid://hr1yu4alsrsx diff --git a/tests/unit/test_situation_system.gd b/tests/unit/test_situation_system.gd index 693372a..82c89bb 100644 --- a/tests/unit/test_situation_system.gd +++ b/tests/unit/test_situation_system.gd @@ -181,7 +181,7 @@ func test_commitments_fulfill_break_release_and_supersede() -> void: ) ) var supplied_events := WorldEventStore.new() - assert_true(supplied_events.append(_pantry_supply(9, 12))) + assert_true(supplied_events.append(_pantry_supply(9, 12, SimulationIds.PLAYER_ACTOR_ID))) assert_eq(commitments.maintain(12, system, supplied_events, _pantry_facts(1.0)), [fulfilled]) assert_eq(fulfilled.get_status(), CommitmentStateRecord.STATUS_FULFILLED) assert_eq(fulfilled.get_cause_event_id(), 9) @@ -267,13 +267,18 @@ func _pantry_facts(amount: float) -> Dictionary: } -func _pantry_supply(event_id: int, tick: int) -> WorldEventRecord: +func _pantry_supply(event_id: int, tick: int, actor_id: int = 3) -> WorldEventRecord: + var source_id := ( + &"player_inventory" + if actor_id == SimulationIds.PLAYER_ACTOR_ID + else StringName("npc_inventory_%d" % actor_id) + ) var economic := EconomicEventRecord.create( event_id, &"storage_deposited", tick, - 3, - &"npc_inventory_3", + actor_id, + source_id, &"village_pantry", &"food", 1.0,