feat: deliver emergent Jajce commitments
This commit is contained in:
@@ -41,10 +41,15 @@ SimulationClock
|
|||||||
-> ActionTargetResolver resolves a stable target ID
|
-> ActionTargetResolver resolves a stable target ID
|
||||||
-> VillageEconomy performs inventory/storage transactions
|
-> VillageEconomy performs inventory/storage transactions
|
||||||
-> AnimalCareSystem advances animal needs, routines, and feeding
|
-> 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
|
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
|
||||||
-> RelationshipSystem applies evidence-gated social consequences
|
-> RelationshipSystem applies seven-dimensional evidence-backed consequences
|
||||||
-> VillageOpportunitySystem projects one known unresolved need
|
-> 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
|
-> ConflictSystem advances indexed combatants and authoritative combat outcomes
|
||||||
-> WorldViewManager presents travel, NPC state, and world-state cues
|
-> WorldViewManager presents travel, NPC state, and world-state cues
|
||||||
-> ActiveWorldAdapter supplies loaded-world positions/capacity
|
-> ActiveWorldAdapter supplies loaded-world positions/capacity
|
||||||
@@ -81,14 +86,29 @@ would otherwise obscure that lifecycle:
|
|||||||
saves. It emits combat and war narrative facts
|
saves. It emits combat and war narrative facts
|
||||||
and spawn/death signals that presentation binds to `HostileCombatant` visuals;
|
and spawn/death signals that presentation binds to `HostileCombatant` visuals;
|
||||||
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
||||||
history queries, and rate calculations;
|
O(1) ID lookup, participant/type indexes, history queries, and rate
|
||||||
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
|
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
|
known objective events, immutable acquisition provenance, proximity witnesses
|
||||||
at record time, one-hop direct fact transfer, and deterministic recent-fact
|
at record time, one-hop direct fact transfer, salience/confidence/pinning,
|
||||||
retention/importance ranking. It can place the active opportunity trigger
|
and deterministic recent-fact retention/importance ranking;
|
||||||
first without weakening its direct-source or one-hop checks;
|
|
||||||
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship
|
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship
|
||||||
queries, event-driven trust changes, and deterministic social tie-breaking;
|
queries 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
|
- `simulation/population/SimulationPopulationView.gd` owns a transient derived
|
||||||
all/living/starving stable-ID index. The manager rebuilds it once at the tick
|
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
|
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/` | Headless-capable orchestration and core models |
|
||||||
| `simulation/actions/` | Action decisions, execution, and target queries |
|
| `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/animals/` | Animal lifecycle, target claims, and feeding transactions |
|
||||||
| `simulation/economy/` | Authoritative inventory and storage transactions |
|
| `simulation/economy/` | Authoritative inventory and storage transactions |
|
||||||
| `simulation/events/` | Immutable event history and derived event queries |
|
| `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/relationships/` | Directed social consequences and relationship queries |
|
||||||
| `simulation/population/` | Transient per-tick population query indexes |
|
| `simulation/population/` | Transient per-tick population query indexes |
|
||||||
| `simulation/opportunities/` | Knowledge-gated unresolved-condition projections |
|
| `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/state/` | Versioned, serializable mutable records |
|
||||||
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
|
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
|
||||||
| `simulation/persistence/` | Validated local save-file storage |
|
| `simulation/persistence/` | Validated local save-file storage |
|
||||||
| `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads |
|
| `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads |
|
||||||
| `world/` | Loaded-world interaction geometry and presentation adapters |
|
| `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/` | Animal presentation binding, routine sites, and navigation reporting |
|
||||||
| `world/animals/goat/` | Self-contained goat scene, visual script, habitat, and lookdev scene |
|
| `world/animals/goat/` | Self-contained goat scene, visual script, habitat, and lookdev scene |
|
||||||
| `world/resource_nodes/` | Finite resource presentation and disposable loaded-anchor index |
|
| `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
|
- New mutable features define serialization and deterministic continuation at
|
||||||
the same time as their first gameplay use. Cross-record causes use stable
|
the same time as their first gameplay use. Cross-record causes use stable
|
||||||
event IDs rather than object references or prose.
|
event IDs rather than object references or prose.
|
||||||
|
- 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
|
- Benchmark fixtures use ordinary simulation records and must round-trip
|
||||||
through the current state schema. They may control workload setup, but must
|
through the current state schema. They may control workload setup, but must
|
||||||
not add benchmark-only fields or branches to production saves and ticks.
|
not add benchmark-only fields or branches to production saves and ticks.
|
||||||
|
|||||||
+129
-120
@@ -2,154 +2,163 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Stable IDs and immutable definitions now form the vocabulary shared by
|
Stable IDs and immutable definitions form the vocabulary shared by simulation
|
||||||
simulation state, systems, world adapters, scenes, tests, and future save
|
state, systems, world adapters, scenes, tests, and save migrations.
|
||||||
migrations.
|
|
||||||
|
|
||||||
`SimulationIds` is the canonical source for code-facing `StringName` IDs.
|
`SimulationIds` remains the append-only source for code-facing compatibility
|
||||||
`ActionDefinition` and `ProfessionDefinition` are editor-readable custom
|
IDs. `SimulationContentPack` is the no-code authoring manifest and
|
||||||
resources. `SimulationDefinitions` loads, resolves, and validates the complete
|
`ContentCatalog` transactionally validates and publishes typed definitions in
|
||||||
prototype set.
|
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`;
|
Each pack has a stable `pack_id`, display name, explicit pack dependencies,
|
||||||
- display name;
|
required handler IDs, and typed definition arrays. Pack and definition IDs are
|
||||||
- default duration;
|
unique across the whole catalog. Packs cannot override each other in the first
|
||||||
- optional preferred profession ID;
|
version, and enumeration is sorted independently of resource load order.
|
||||||
- target type: resource, activity, animal, or free movement;
|
|
||||||
- resource action ID when the action targets a ResourceNode;
|
Adding data-only content is:
|
||||||
- optional completion-cost resource ID and amount.
|
|
||||||
|
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,
|
The current actions are gather food, gather wood, feed animal, deposit food,
|
||||||
deposit wood, withdraw food, patrol, study, eat, rest, sleep, wander, and
|
deposit wood, withdraw food, patrol, study, eat, rest, sleep, wander, and
|
||||||
defend. Idle and dead are stable state sentinels, not executable action
|
defend. Idle and dead are state sentinels, not executable definitions. The
|
||||||
definitions.
|
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
|
## Items, resources, and storage
|
||||||
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.
|
|
||||||
|
|
||||||
SimNPC reads default duration and preferred-profession metadata from these
|
`ItemDefinition` validates fields by category. Weapons require damage, reach,
|
||||||
definitions. WorldViewManager reads target type and resource-action metadata
|
cooldown, and an equip slot; materials and consumables do not carry fake weapon
|
||||||
instead of maintaining a separate gather-action map. ActionSelectionSystem and
|
fields. The core pack contains food, wood, herb, sword, and claw. Herb proves a
|
||||||
SimulationManager share the same completion-cost metadata, so patrol and study
|
new non-weapon item and route without a manager branch.
|
||||||
both require one stored wood without duplicating that rule.
|
|
||||||
|
|
||||||
`feed_animal` is the first animal-targeted action. Its definition prefers the
|
`ResourceDefinition` owns yielded item, gather action, capability tags, base
|
||||||
farmer profession and declares an exact one-food completion cost. Selection,
|
capacity/yield/regrowth, access policy, and presentation cue. Placement/state
|
||||||
animal target reservation, supply pickup, and completion remain separate. NPC
|
records retain unique contextual IDs and overrides such as safety risk,
|
||||||
care pays that definition cost from carried inventory after a real pantry
|
comfort, and discovery priority. Berry patches, trees, and herb patches share
|
||||||
pickup; player care pays it directly from the pantry while player inventory is
|
this contract.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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,
|
## Professions and capabilities
|
||||||
body color, and prop color. The current professions are farmer, woodcutter,
|
|
||||||
guard, scholar, and wanderer.
|
|
||||||
|
|
||||||
NPC generation chooses from the registry's stable IDs. NPC state stores and
|
Each `ProfessionDefinition` contains a stable ID, display name, body color, and
|
||||||
restores those IDs as `StringName`, and rejects records that reference an
|
prop color. Farmer, woodcutter, guard, scholar, and wanderer are registered in
|
||||||
unknown profession or executable action.
|
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,
|
## Enemies and combat
|
||||||
`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`.
|
|
||||||
|
|
||||||
The shared record and lifecycle collaborator was extracted only after the
|
`EnemyDefinition` carries a behavior profile, combatant ID prefix, faction,
|
||||||
food and wood consumers proved those fields. Their evidence, care, resolution,
|
hostility, weapon, health, movement, and visual parameters.
|
||||||
and invalidation rules remain explicit branches. `feed_weak_villager` opens on
|
`CombatantFactory` creates/restores every hostile and rebuilds collision-free
|
||||||
a `villager_weak` fact when a starving low-energy villager cannot eat and
|
per-prefix allocators. The core boar reuses the wolf-hunt behavior without a
|
||||||
resolves only when that same villager actually consumes food; `repair_home_roof`
|
new manager or combat switch; raider and wolf compatibility spawn APIs delegate
|
||||||
opens on a `home_damaged` fact when a villager sleeps through low safety and
|
to the same factory.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
## Animals
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
## Situations and dialogue
|
||||||
`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.
|
|
||||||
|
|
||||||
## 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
|
`DialogueIntentDefinition` owns semantic preconditions, score, response intent
|
||||||
are `npc`, `raider`, `wolf`, and `player`; NPC combatants reference their
|
IDs, template variants, and optional action metadata. Stable hashing selects a
|
||||||
`SimNPC` by stable ID. Factions are `faction_village` and `faction_tribe`, with
|
variant from conversation/turn/intent/topics without consuming shared RNG.
|
||||||
`neutral`/`hostile` stances and `none`/`planned`/`raiding`/`aborted` war plans.
|
Rendered prose and Dialogue Manager resources are presentation and never
|
||||||
Combat, raid, and war narrative facts use the stable event types
|
authoritative state.
|
||||||
`combatant_hurt`, `combatant_killed`, `raid_started`, `war_resolved`,
|
|
||||||
`war_aborted`, and `wolf_hunt`.
|
|
||||||
|
|
||||||
## Enemy vocabulary
|
## Validation and authoring report
|
||||||
|
|
||||||
`EnemyDefinition` is the data contract for hostile creatures and
|
The catalog rejects:
|
||||||
`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).
|
|
||||||
|
|
||||||
## Validation
|
- missing, empty, or duplicate pack/definition IDs;
|
||||||
|
- silent cross-pack overrides;
|
||||||
The registry rejects:
|
- unknown pack, handler, capability, item, profession, action, storage,
|
||||||
|
behavior-profile, or presentation-cue references;
|
||||||
- missing or duplicate IDs;
|
- category-invalid item fields;
|
||||||
- empty display names;
|
- incomplete or non-positive costs/effects;
|
||||||
- non-positive action durations;
|
- resources or storage routes with inconsistent item/action contracts;
|
||||||
- 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;
|
|
||||||
- definition resources that fail to load.
|
- definition resources that fail to load.
|
||||||
|
|
||||||
`tests/simulation_definitions_test.gd` verifies registry integrity,
|
`ContentCatalog.get_authoring_report()` exposes validity, sorted published IDs,
|
||||||
definition-backed behavior, unknown-ID rejection, and serialization
|
and exact errors for CLI/editor tooling. Tests prove transactional failure,
|
||||||
round-tripping.
|
deterministic enumeration, core cross-references, boar behavior reuse, shared
|
||||||
|
goat/sheep behavior, and herb routing.
|
||||||
|
|
||||||
## Deliberate boundary
|
## Deliberate boundary
|
||||||
|
|
||||||
Definitions currently own identity and the static metadata already proven by
|
Definitions own reusable static metadata, not mutable inventory, reservations,
|
||||||
the prototype, including the patrol/study completion-cost contract. They do not
|
relationships, situation progress, entity ownership, or presentation nodes.
|
||||||
yet own:
|
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;
|
Selection, execution, target resolution, simulation authority, and visual
|
||||||
- general preconditions beyond stored-resource completion costs;
|
travel remain separate responsibilities. Extend definitions only after a real
|
||||||
- completion effects;
|
consumer proves a stable shared field; extend a handler when the mechanic itself
|
||||||
- interruption and failure policy;
|
is genuinely new.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Current contract
|
## Current contract
|
||||||
|
|
||||||
`SimulationStateRecord` is the versioned JSON boundary for the current
|
`SimulationStateRecord` is the versioned JSON boundary for the current
|
||||||
simulation. The current world schema is v14 and captures:
|
simulation. The current world schema is v15 and captures:
|
||||||
|
|
||||||
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
||||||
clock ticks;
|
clock ticks;
|
||||||
@@ -18,19 +18,25 @@ simulation. The current world schema is v14 and captures:
|
|||||||
and next routine tick;
|
and next routine tick;
|
||||||
- pantry contents, carried NPC inventory, the ordered economic event stream,
|
- pantry contents, carried NPC inventory, the ordered economic event stream,
|
||||||
and its next stable event ID;
|
and its next stable event ID;
|
||||||
- directed relationship records with familiarity, trust, and the stable event
|
- sparse directed relationship records for familiarity, trust, affection,
|
||||||
ID that last changed trust;
|
respect, fear, obligation, and hostility, with the exact causal event for
|
||||||
- per-NPC known-event records that reference objective event history without
|
every non-neutral change;
|
||||||
copying it, including first-acquisition method, acquisition tick, and
|
- player and NPC known-event records that reference objective event history
|
||||||
historical communicator provenance;
|
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,
|
- opportunity records with stable type/status, interested NPC, trigger event,
|
||||||
target storage/resource/amount, exact later resolution event identity, or a
|
target storage/resource/amount, exact later resolution event identity, or a
|
||||||
deterministic invalidation reason and close tick;
|
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,
|
- a player record holding the player-citizen's hunger, energy, health,
|
||||||
downed state, and carried inventory. The player's world position remains
|
downed state, and carried inventory. The player's world position remains
|
||||||
presentation-owned and is intentionally not serialized;
|
presentation-owned and is intentionally not serialized;
|
||||||
- combatant records (health, weapon, faction, position, hostility) for NPC
|
- combatant records (health, weapon, faction, position, hostility, and enemy
|
||||||
defenders and standalone raiders/wolves;
|
definition ID) for NPC defenders and standalone hostile archetypes;
|
||||||
- faction records (food, warriors, aggression, morale, stance, war plan) for
|
- faction records (food, warriors, aggression, morale, stance, war plan) for
|
||||||
the village and the hill tribe.
|
the village and the hill tribe.
|
||||||
|
|
||||||
@@ -39,7 +45,7 @@ The top-level identity is:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema": "the_steward.simulation",
|
"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.
|
deterministic retention boundaries, and rejection of unsupported schemas.
|
||||||
Active, resolved, and invalidated opportunity round-tripping, contradictory
|
Active, resolved, and invalidated opportunity round-tripping, contradictory
|
||||||
target state, invalid resource sources, and cross-record event references are
|
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
|
NPCStateRecord v2 adds the resolved travel destination and whether it is
|
||||||
active. Nested v1 NPC records migrate explicitly with no invented active
|
active. Nested v1 NPC records migrate explicitly with no invented active
|
||||||
@@ -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
|
saved simulation tick because their exact acquisition time was not previously
|
||||||
stored. The v6 source record supplies the persisted direct-method snapshot.
|
stored. The v6 source record supplies the persisted direct-method snapshot.
|
||||||
|
|
||||||
Importance is intentionally derived rather than duplicated: a fact is
|
Importance combines persisted salience/pinning with current causal use. A fact
|
||||||
`lasting` while it is the current trust cause for one of that knower's directed
|
is protected while it explains any relationship dimension, an open situation,
|
||||||
relationships; every other fact is `recent`. Current causal facts are protected
|
journal discovery, or commitment. Unpinned ordinary memories remain bounded and
|
||||||
so relationship state remains explainable. Each NPC keeps at most three recent
|
age through deterministic reviews. The objective event stream is never pruned
|
||||||
facts, and recent facts at least one simulated day old are removed at the next
|
by this rule.
|
||||||
deterministic daily-sized review. The objective event stream is never pruned by
|
|
||||||
this rule.
|
|
||||||
|
|
||||||
SimulationStateRecord v8 adds the top-level `opportunities` array and
|
SimulationStateRecord v8 adds the top-level `opportunities` array and
|
||||||
`simulation.next_opportunity_id`. The first nested `OpportunityStateRecord`
|
`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
|
conflict system is derived state and is never serialized; hostile attacks
|
||||||
reduce the authoritative player health through the ordered event stream.
|
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
|
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
|
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
|
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;
|
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
|
||||||
- migrations from any historical world schema other than the explicitly
|
- migrations from any historical world schema other than the explicitly
|
||||||
supported v1–v10 layouts;
|
supported v1-v14 layouts;
|
||||||
- player inventory or player relationship records;
|
- line-of-sight/hearing evidence, personalized confidence decay,
|
||||||
- broader relationship dimensions, line-of-sight/hearing evidence,
|
reinforcement, false beliefs, or multi-hop rumours;
|
||||||
continuous or personalized memory decay, reinforcement, false beliefs, or
|
- the full regional situation predicate library, regional quest bounds,
|
||||||
multi-hop rumours;
|
contracts beyond the first social commitment consumer, or generated
|
||||||
- generalized opportunity/quest definitions, acceptance, rewards, assignment,
|
free-form prose;
|
||||||
dialogue, or player-facing quest-log state;
|
- 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.
|
- persistence for the player transform or presentation-only scene state.
|
||||||
|
|
||||||
Those features should build on this boundary rather than inventing parallel
|
Those features should build on this boundary rather than inventing parallel
|
||||||
|
|||||||
+415
-11
@@ -28,6 +28,13 @@ signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord)
|
|||||||
signal opportunity_opened(opportunity: OpportunityStateRecord)
|
signal opportunity_opened(opportunity: OpportunityStateRecord)
|
||||||
signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord)
|
signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord)
|
||||||
signal opportunity_invalidated(opportunity: OpportunityStateRecord)
|
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_spawned(combatant_id: StringName)
|
||||||
signal combatant_died(combatant_id: StringName)
|
signal combatant_died(combatant_id: StringName)
|
||||||
signal raid_started(raider_ids: Array[StringName])
|
signal raid_started(raider_ids: Array[StringName])
|
||||||
@@ -57,6 +64,12 @@ var animal_care := AnimalCareSystemScript.new()
|
|||||||
var relationship_system := RelationshipSystemScript.new()
|
var relationship_system := RelationshipSystemScript.new()
|
||||||
var event_knowledge_system := EventKnowledgeSystemScript.new()
|
var event_knowledge_system := EventKnowledgeSystemScript.new()
|
||||||
var opportunity_system := VillageOpportunitySystem.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 conflict_system := ConflictSystemScript.new()
|
||||||
var storage_states: Dictionary:
|
var storage_states: Dictionary:
|
||||||
get:
|
get:
|
||||||
@@ -76,8 +89,14 @@ var target_resolver := ActionTargetResolver.new()
|
|||||||
var last_player_resource_query_stats: Dictionary = {}
|
var last_player_resource_query_stats: Dictionary = {}
|
||||||
var _population_view := SimulationPopulationView.new()
|
var _population_view := SimulationPopulationView.new()
|
||||||
var speed_index := 2
|
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 SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
|
||||||
const KNOWLEDGE_COMMUNICATION_RADIUS := 2.5
|
const KNOWLEDGE_COMMUNICATION_RADIUS := 2.5
|
||||||
|
const MAX_CONVERSATION_ACTS_PER_PAIR := 8
|
||||||
|
const MAX_CONVERSATION_HISTORY := 1024
|
||||||
const SEASON_DAYS := 4
|
const SEASON_DAYS := 4
|
||||||
const COLD_DAYS := 3
|
const COLD_DAYS := 3
|
||||||
const SEASON_COLD := &"cold"
|
const SEASON_COLD := &"cold"
|
||||||
@@ -214,6 +233,7 @@ func simulate_tick() -> void:
|
|||||||
)
|
)
|
||||||
if invalidated != null:
|
if invalidated != null:
|
||||||
opportunity_invalidated.emit(invalidated)
|
opportunity_invalidated.emit(invalidated)
|
||||||
|
_maintain_emergent_world()
|
||||||
if tick_count % get_knowledge_review_interval() == 0:
|
if tick_count % get_knowledge_review_interval() == 0:
|
||||||
_maintain_event_knowledge(true)
|
_maintain_event_knowledge(true)
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
@@ -754,6 +774,7 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
|
|||||||
for learned_record in learned_records:
|
for learned_record in learned_records:
|
||||||
_apply_new_event_knowledge(learned_record, event)
|
_apply_new_event_knowledge(learned_record, event)
|
||||||
_update_opportunity_from_event(event)
|
_update_opportunity_from_event(event)
|
||||||
|
_update_situations_from_event(event)
|
||||||
economic_event_recorded.emit(event)
|
economic_event_recorded.emit(event)
|
||||||
|
|
||||||
|
|
||||||
@@ -844,6 +865,53 @@ func _update_opportunity_from_event(event: EconomicEventRecord) -> void:
|
|||||||
_maintain_event_knowledge(false)
|
_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:
|
func _maintain_event_knowledge(expire_by_age: bool) -> void:
|
||||||
var forgotten := event_knowledge_system.maintain_retention(
|
var forgotten := event_knowledge_system.maintain_retention(
|
||||||
tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age
|
tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age
|
||||||
@@ -858,17 +926,9 @@ func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]:
|
|||||||
var lasting: Array[KnownEventStateRecord] = []
|
var lasting: Array[KnownEventStateRecord] = []
|
||||||
var seen := {}
|
var seen := {}
|
||||||
for relationship in relationship_system.get_all_sorted():
|
for relationship in relationship_system.get_all_sorted():
|
||||||
var event_id := relationship.get_last_trust_cause_event_id()
|
for dimension in RelationshipStateRecord.DIMENSIONS:
|
||||||
if event_id == RelationshipStateRecord.NO_CAUSE_EVENT:
|
var event_id := relationship.get_last_cause_event_id(dimension)
|
||||||
continue
|
_append_lasting_knowledge(lasting, seen, relationship.get_observer_id(), event_id)
|
||||||
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)
|
|
||||||
var open_opportunity: OpportunityStateRecord = opportunity_system.get_open_opportunity()
|
var open_opportunity: OpportunityStateRecord = opportunity_system.get_open_opportunity()
|
||||||
if open_opportunity != null:
|
if open_opportunity != null:
|
||||||
var opportunity_record := event_knowledge_system.get_record(
|
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):
|
if not seen.has(opportunity_key):
|
||||||
seen[opportunity_key] = true
|
seen[opportunity_key] = true
|
||||||
lasting.append(opportunity_record)
|
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
|
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]:
|
func _get_lasting_event_ids(npc_id: int) -> Array[int]:
|
||||||
var event_ids: Array[int] = []
|
var event_ids: Array[int] = []
|
||||||
for record in _get_lasting_knowledge_records():
|
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)
|
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:
|
func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord:
|
||||||
return event_log.get_by_id(opportunity.get_trigger_event_id() if opportunity != null else -1)
|
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,
|
"wander_random_streams": wander_streams,
|
||||||
"next_event_id": next_event_id,
|
"next_event_id": next_event_id,
|
||||||
"next_opportunity_id": opportunity_system.next_opportunity_id,
|
"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
|
"cycle_duration_seconds": clock.cycle_duration_seconds
|
||||||
}
|
}
|
||||||
record.village = VillageStateRecord.capture(village)
|
record.village = VillageStateRecord.capture(village)
|
||||||
@@ -1329,6 +1698,14 @@ func create_state_record() -> SimulationStateRecord:
|
|||||||
record.event_knowledge.append(known_event)
|
record.event_knowledge.append(known_event)
|
||||||
for opportunity in opportunity_system.get_all_sorted():
|
for opportunity in opportunity_system.get_all_sorted():
|
||||||
record.opportunities.append(opportunity)
|
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
|
record.player = player_system.player_state
|
||||||
conflict_system.append_state_records(record)
|
conflict_system.append_state_records(record)
|
||||||
return record
|
return record
|
||||||
@@ -1372,6 +1749,33 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
relationship_system.restore(record.relationships)
|
relationship_system.restore(record.relationships)
|
||||||
event_knowledge_system.restore(record.event_knowledge)
|
event_knowledge_system.restore(record.event_knowledge)
|
||||||
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
|
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)
|
_maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0)
|
||||||
wander_random_sources.clear()
|
wander_random_sources.clear()
|
||||||
var wander_streams: Array = record.simulation["wander_random_streams"]
|
var wander_streams: Array = record.simulation["wander_random_streams"]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
class_name SimulationScalingBenchmark
|
class_name SimulationScalingBenchmark
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const SCHEMA_VERSION := 2
|
const SCHEMA_VERSION := 3
|
||||||
const WORKLOAD_ID := &"full_fidelity_combatant_headless_arrival_v2"
|
const WORKLOAD_ID := &"full_fidelity_emergent_headless_arrival_v3"
|
||||||
const RECENT_FACTS_PER_NPC := 3
|
const RECENT_FACTS_PER_NPC := 3
|
||||||
const STARTING_CLOCK_TICK := 50
|
const STARTING_CLOCK_TICK := 50
|
||||||
|
|
||||||
@@ -25,6 +25,14 @@ func prepare_manager(
|
|||||||
var no_opportunities: Array[OpportunityStateRecord] = []
|
var no_opportunities: Array[OpportunityStateRecord] = []
|
||||||
manager.relationship_system.restore(no_relationships)
|
manager.relationship_system.restore(no_relationships)
|
||||||
manager.opportunity_system.restore(no_opportunities, 0)
|
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:
|
for npc_id in population:
|
||||||
var npc_random := _create_random_source(seed_value, npc_id, 0)
|
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_json: String = manager.serialize_state()
|
||||||
var start_event_count: int = manager.economic_events.size()
|
var start_event_count: int = manager.economic_events.size()
|
||||||
var start_known_count: int = manager.event_knowledge_system.get_all_sorted().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 start_tick: int = manager.tick_count
|
||||||
var measured_arrivals := 0
|
var measured_arrivals := 0
|
||||||
var simulation_usec := 0
|
var simulation_usec := 0
|
||||||
@@ -134,6 +144,11 @@ func measure_manager(
|
|||||||
"events_recorded": manager.economic_events.size() - start_event_count,
|
"events_recorded": manager.economic_events.size() - start_event_count,
|
||||||
"start_known_reference_count": start_known_count,
|
"start_known_reference_count": start_known_count,
|
||||||
"end_known_reference_count": manager.event_knowledge_system.get_all_sorted().size(),
|
"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(),
|
"final_checksum": end_json.sha256_text(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
|
|||||||
const EVENT_ANIMAL_FED := &"animal_fed"
|
const EVENT_ANIMAL_FED := &"animal_fed"
|
||||||
const EVENT_VILLAGER_WEAK := &"villager_weak"
|
const EVENT_VILLAGER_WEAK := &"villager_weak"
|
||||||
const EVENT_HOME_DAMAGED := &"home_damaged"
|
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_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
|
||||||
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
|
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ signal event_recorded(event: EconomicEventRecord)
|
|||||||
|
|
||||||
const DEFAULT_RATE_WINDOW_TICKS := 200
|
const DEFAULT_RATE_WINDOW_TICKS := 200
|
||||||
const DEFAULT_TICKS_PER_DAY := 200.0
|
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 events: Array[EconomicEventRecord] = []
|
||||||
var next_event_id := 0
|
var next_event_id := 0
|
||||||
@@ -69,6 +84,48 @@ func record_narrative(
|
|||||||
return event
|
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]:
|
func get_for_actor(actor_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
|
||||||
var results: Array[EconomicEventRecord] = []
|
var results: Array[EconomicEventRecord] = []
|
||||||
var event_ids: Array = _event_ids_by_actor.get(actor_id, [])
|
var event_ids: Array = _event_ids_by_actor.get(actor_id, [])
|
||||||
|
|||||||
@@ -317,6 +317,23 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool:
|
|||||||
return false
|
return false
|
||||||
var event_type := StringName(event.data["event_type"])
|
var event_type := StringName(event.data["event_type"])
|
||||||
var item_id := StringName(event.data["item_id"])
|
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:
|
if event_type == SimulationIds.EVENT_TASK_BLOCKED:
|
||||||
return (
|
return (
|
||||||
int(event.data["actor_id"]) >= 0
|
int(event.data["actor_id"]) >= 0
|
||||||
|
|||||||
@@ -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 &""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://rhqx2yahw4rj
|
||||||
@@ -17,12 +17,13 @@ func add_situation(
|
|||||||
or definition == null
|
or definition == null
|
||||||
or not situation.is_active()
|
or not situation.is_active()
|
||||||
or situation.get_definition_id() != definition.situation_definition_id
|
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 current_tick < situation.get_created_tick()
|
||||||
or _entry_id_by_situation.has(situation.get_situation_id())
|
or _entry_id_by_situation.has(situation.get_situation_id())
|
||||||
or not situation.select_alternative(alternative_id)
|
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
|
if not alternative_id.is_empty() and not situation.select_alternative(alternative_id):
|
||||||
|
return null
|
||||||
var entry := QuestJournalEntryStateRecord.create(
|
var entry := QuestJournalEntryStateRecord.create(
|
||||||
_next_entry_id,
|
_next_entry_id,
|
||||||
situation.get_situation_id(),
|
situation.get_situation_id(),
|
||||||
@@ -36,6 +37,23 @@ func add_situation(
|
|||||||
return entry
|
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(
|
func synchronize(
|
||||||
situation: SituationStateRecord, current_tick: int
|
situation: SituationStateRecord, current_tick: int
|
||||||
) -> QuestJournalEntryStateRecord:
|
) -> QuestJournalEntryStateRecord:
|
||||||
@@ -115,3 +133,7 @@ func get_active_sorted() -> Array[QuestJournalEntryStateRecord]:
|
|||||||
if record.is_active():
|
if record.is_active():
|
||||||
records.append(record)
|
records.append(record)
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
func get_next_entry_id() -> int:
|
||||||
|
return _next_entry_id
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ func get_active_sorted() -> Array[SituationStateRecord]:
|
|||||||
return active
|
return active
|
||||||
|
|
||||||
|
|
||||||
|
func get_next_situation_id() -> int:
|
||||||
|
return _next_situation_id
|
||||||
|
|
||||||
|
|
||||||
func _get_definitions_sorted() -> Array[SituationDefinition]:
|
func _get_definitions_sorted() -> Array[SituationDefinition]:
|
||||||
var definitions: Array[SituationDefinition] = []
|
var definitions: Array[SituationDefinition] = []
|
||||||
for definition in _definitions_by_id.values():
|
for definition in _definitions_by_id.values():
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class_name SocialCommitmentSystem
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const REASON_FACT_FULFILLED := &"world_fact_fulfilled"
|
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_DEADLINE_MISSED := &"deadline_missed"
|
||||||
const REASON_SITUATION_EXPIRED := &"situation_expired"
|
const REASON_SITUATION_EXPIRED := &"situation_expired"
|
||||||
const REASON_SITUATION_SUPERSEDED := &"situation_superseded"
|
const REASON_SITUATION_SUPERSEDED := &"situation_superseded"
|
||||||
@@ -70,11 +71,28 @@ func maintain(
|
|||||||
situation, world_events, state_facts, situation.get_selected_alternative_id()
|
situation, world_events, state_facts, situation.get_selected_alternative_id()
|
||||||
)
|
)
|
||||||
if progress.resolved:
|
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(
|
if _close(
|
||||||
commitment,
|
commitment,
|
||||||
CommitmentStateRecord.STATUS_FULFILLED,
|
resolution_status,
|
||||||
current_tick,
|
current_tick,
|
||||||
REASON_FACT_FULFILLED,
|
resolution_reason,
|
||||||
progress.matched_event_id
|
progress.matched_event_id
|
||||||
):
|
):
|
||||||
closed.append(commitment)
|
closed.append(commitment)
|
||||||
@@ -98,7 +116,7 @@ func maintain(
|
|||||||
closed.append(commitment)
|
closed.append(commitment)
|
||||||
continue
|
continue
|
||||||
var deadline := commitment.get_deadline_tick()
|
var deadline := commitment.get_deadline_tick()
|
||||||
if deadline >= 0 and current_tick > deadline:
|
if deadline >= 0 and current_tick >= deadline:
|
||||||
if _close(
|
if _close(
|
||||||
commitment,
|
commitment,
|
||||||
CommitmentStateRecord.STATUS_BROKEN,
|
CommitmentStateRecord.STATUS_BROKEN,
|
||||||
@@ -164,6 +182,10 @@ func get_active_sorted() -> Array[CommitmentStateRecord]:
|
|||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
func get_next_commitment_id() -> int:
|
||||||
|
return _next_commitment_id
|
||||||
|
|
||||||
|
|
||||||
func _close(
|
func _close(
|
||||||
commitment: CommitmentStateRecord,
|
commitment: CommitmentStateRecord,
|
||||||
status: StringName,
|
status: StringName,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b4iruxxxc2g2j
|
||||||
@@ -154,6 +154,16 @@ func set_tracking_preference(preference: StringName) -> bool:
|
|||||||
return true
|
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:
|
func archive(closed_tick: int) -> bool:
|
||||||
return close(STATUS_ARCHIVED, closed_tick)
|
return close(STATUS_ARCHIVED, closed_tick)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class_name SimulationStateRecord
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const SCHEMA_NAME := "the_steward.simulation"
|
const SCHEMA_NAME := "the_steward.simulation"
|
||||||
const SCHEMA_VERSION := 14
|
const SCHEMA_VERSION := 15
|
||||||
const LEGACY_SCHEMA_VERSION := 1
|
const LEGACY_SCHEMA_VERSION := 1
|
||||||
const EVENT_LEGACY_SCHEMA_VERSION := 2
|
const EVENT_LEGACY_SCHEMA_VERSION := 2
|
||||||
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
||||||
@@ -15,6 +15,7 @@ const ROUTINE_LEGACY_SCHEMA_VERSION := 10
|
|||||||
const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11
|
const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11
|
||||||
const PLAYER_NEEDS_SCHEMA_VERSION := 12
|
const PLAYER_NEEDS_SCHEMA_VERSION := 12
|
||||||
const CONFLICT_SCHEMA_VERSION := 13
|
const CONFLICT_SCHEMA_VERSION := 13
|
||||||
|
const PRE_EMERGENT_SCHEMA_VERSION := 14
|
||||||
const PREVIOUS_SCHEMA_VERSION := 7
|
const PREVIOUS_SCHEMA_VERSION := 7
|
||||||
|
|
||||||
var simulation: Dictionary
|
var simulation: Dictionary
|
||||||
@@ -27,6 +28,10 @@ var economic_events: Array[EconomicEventRecord] = []
|
|||||||
var relationships: Array[RelationshipStateRecord] = []
|
var relationships: Array[RelationshipStateRecord] = []
|
||||||
var event_knowledge: Array[KnownEventStateRecord] = []
|
var event_knowledge: Array[KnownEventStateRecord] = []
|
||||||
var opportunities: Array[OpportunityStateRecord] = []
|
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 player: PlayerStateRecord
|
||||||
var combatants: Array[CombatantStateRecord] = []
|
var combatants: Array[CombatantStateRecord] = []
|
||||||
var factions: Array[FactionStateRecord] = []
|
var factions: Array[FactionStateRecord] = []
|
||||||
@@ -58,6 +63,18 @@ func to_dictionary() -> Dictionary:
|
|||||||
var opportunity_data: Array[Dictionary] = []
|
var opportunity_data: Array[Dictionary] = []
|
||||||
for opportunity_record in opportunities:
|
for opportunity_record in opportunities:
|
||||||
opportunity_data.append(opportunity_record.to_dictionary())
|
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] = []
|
var combatant_data: Array[Dictionary] = []
|
||||||
for combatant_record in combatants:
|
for combatant_record in combatants:
|
||||||
combatant_data.append(combatant_record.to_dictionary())
|
combatant_data.append(combatant_record.to_dictionary())
|
||||||
@@ -78,6 +95,10 @@ func to_dictionary() -> Dictionary:
|
|||||||
"relationships": relationship_data,
|
"relationships": relationship_data,
|
||||||
"event_knowledge": knowledge_data,
|
"event_knowledge": knowledge_data,
|
||||||
"opportunities": opportunity_data,
|
"opportunities": opportunity_data,
|
||||||
|
"situations": situation_data,
|
||||||
|
"quest_journal": journal_data,
|
||||||
|
"commitments": commitment_data,
|
||||||
|
"conversation_history": conversation_history_data,
|
||||||
"player": player.to_dictionary(),
|
"player": player.to_dictionary(),
|
||||||
"combatants": combatant_data,
|
"combatants": combatant_data,
|
||||||
"factions": faction_data
|
"factions": faction_data
|
||||||
@@ -115,6 +136,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
|
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
|
||||||
PLAYER_NEEDS_SCHEMA_VERSION,
|
PLAYER_NEEDS_SCHEMA_VERSION,
|
||||||
CONFLICT_SCHEMA_VERSION,
|
CONFLICT_SCHEMA_VERSION,
|
||||||
|
PRE_EMERGENT_SCHEMA_VERSION,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
record_data = _migrate_legacy(record_data, version)
|
record_data = _migrate_legacy(record_data, version)
|
||||||
@@ -134,6 +156,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
"relationships",
|
"relationships",
|
||||||
"event_knowledge",
|
"event_knowledge",
|
||||||
"opportunities",
|
"opportunities",
|
||||||
|
"situations",
|
||||||
|
"quest_journal",
|
||||||
|
"commitments",
|
||||||
|
"conversation_history",
|
||||||
"player",
|
"player",
|
||||||
"combatants",
|
"combatants",
|
||||||
"factions",
|
"factions",
|
||||||
@@ -157,6 +183,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
"wander_random_streams",
|
"wander_random_streams",
|
||||||
"next_event_id",
|
"next_event_id",
|
||||||
"next_opportunity_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_accumulator := float(simulation_data["clock_accumulator"])
|
||||||
var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"])
|
var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"])
|
||||||
var saved_next_opportunity_id := int(simulation_data["next_opportunity_id"])
|
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 (
|
if (
|
||||||
not is_finite(saved_tick_interval)
|
not is_finite(saved_tick_interval)
|
||||||
or saved_tick_interval <= 0.0
|
or saved_tick_interval <= 0.0
|
||||||
@@ -174,6 +208,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
or saved_clock_accumulator < 0.0
|
or saved_clock_accumulator < 0.0
|
||||||
or saved_clock_elapsed_ticks < 0
|
or saved_clock_elapsed_ticks < 0
|
||||||
or saved_next_opportunity_id < 0
|
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
|
return null
|
||||||
if simulation_data.has("cycle_duration_seconds"):
|
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 relationship_data = record_data["relationships"]
|
||||||
var knowledge_data = record_data["event_knowledge"]
|
var knowledge_data = record_data["event_knowledge"]
|
||||||
var opportunity_data = record_data["opportunities"]
|
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 (
|
if (
|
||||||
not npc_data is Array
|
not npc_data is Array
|
||||||
or not animal_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 relationship_data is Array
|
||||||
or not knowledge_data is Array
|
or not knowledge_data is Array
|
||||||
or not opportunity_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
|
return null
|
||||||
|
|
||||||
@@ -425,7 +471,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
return null
|
return null
|
||||||
var knower_id := known_event_record.get_knower_id()
|
var knower_id := known_event_record.get_knower_id()
|
||||||
var known_event_id := known_event_record.get_event_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
|
return null
|
||||||
var knowledge_key := "%d:%d" % [knower_id, known_event_id]
|
var knowledge_key := "%d:%d" % [knower_id, known_event_id]
|
||||||
if knowledge_keys.has(knowledge_key):
|
if knowledge_keys.has(knowledge_key):
|
||||||
@@ -448,26 +497,25 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
return null
|
return null
|
||||||
var observer_id := relationship_record.get_observer_id()
|
var observer_id := relationship_record.get_observer_id()
|
||||||
var subject_id := relationship_record.get_subject_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
|
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
|
return null
|
||||||
if not is_player_subject and not npc_ids.has(subject_id):
|
if not is_player_subject and not npc_ids.has(subject_id):
|
||||||
return null
|
return null
|
||||||
var relationship_key := "%d:%d" % [observer_id, subject_id]
|
var relationship_key := "%d:%d" % [observer_id, subject_id]
|
||||||
if relationship_keys.has(relationship_key):
|
if relationship_keys.has(relationship_key):
|
||||||
return null
|
return null
|
||||||
var cause_event_id := relationship_record.get_last_trust_cause_event_id()
|
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 (
|
if (
|
||||||
cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT
|
not event_ids.has(cause_event_id)
|
||||||
and not event_ids.has(cause_event_id)
|
or not knowledge_keys.has("%d:%d" % [observer_id, cause_event_id])
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
if (
|
if dimension == RelationshipStateRecord.DIMENSION_TRUST:
|
||||||
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
|
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):
|
if not _is_valid_relationship_cause(cause_event, subject_id, is_player_subject):
|
||||||
return null
|
return null
|
||||||
@@ -515,6 +563,36 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id:
|
if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id:
|
||||||
return null
|
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
|
return record
|
||||||
|
|
||||||
|
|
||||||
@@ -579,16 +657,21 @@ static func _is_valid_knowledge_provenance(
|
|||||||
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
|
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
|
||||||
if knower_id == actor_id:
|
if knower_id == actor_id:
|
||||||
return false
|
return false
|
||||||
var source_npc_id := known_event.get_source_npc_id()
|
var source_actor_id := known_event.get_source_actor_id()
|
||||||
if not npc_ids.has(source_npc_id):
|
if (
|
||||||
|
source_actor_id != SimulationIds.PLAYER_ACTOR_ID
|
||||||
|
and not npc_ids.has(source_actor_id)
|
||||||
|
):
|
||||||
return false
|
return false
|
||||||
var source_method := known_event.get_source_acquisition_method()
|
var source_method := known_event.get_source_acquisition_method()
|
||||||
if (
|
if (
|
||||||
(source_method == SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED)
|
(source_method == SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED)
|
||||||
!= (source_npc_id == actor_id)
|
!= (source_actor_id == actor_id)
|
||||||
):
|
):
|
||||||
return false
|
return false
|
||||||
return true
|
if source_method not in KnownEventStateRecord.DIRECT_ACQUISITION_METHODS:
|
||||||
|
return false
|
||||||
|
return known_event.get_hop_count() == 1
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
@@ -774,12 +857,13 @@ static func _is_valid_home_damaged_trigger(
|
|||||||
static func _is_valid_relationship_cause(
|
static func _is_valid_relationship_cause(
|
||||||
cause_event: EconomicEventRecord, subject_id: int, is_player_subject: bool
|
cause_event: EconomicEventRecord, subject_id: int, is_player_subject: bool
|
||||||
) -> 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:
|
if not is_player_subject:
|
||||||
return (
|
return (
|
||||||
int(cause_event.data["actor_id"]) == subject_id
|
int(cause_event.data["actor_id"]) == subject_id
|
||||||
and (
|
and (event_type == SimulationIds.EVENT_STORAGE_DEPOSITED)
|
||||||
StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
|
|
||||||
)
|
|
||||||
and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||||
and float(cause_event.data["amount"]) > 0.0
|
and float(cause_event.data["amount"]) > 0.0
|
||||||
)
|
)
|
||||||
@@ -789,7 +873,7 @@ static func _is_valid_relationship_cause(
|
|||||||
return false
|
return false
|
||||||
if float(cause_event.data["amount"]) <= 0.0:
|
if float(cause_event.data["amount"]) <= 0.0:
|
||||||
return false
|
return false
|
||||||
if StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED:
|
if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED:
|
||||||
return (
|
return (
|
||||||
StringName(cause_event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID
|
StringName(cause_event.data["source_id"]) == SimulationIds.PLAYER_INVENTORY_ID
|
||||||
and (
|
and (
|
||||||
@@ -798,7 +882,7 @@ static func _is_valid_relationship_cause(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return (
|
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
|
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:
|
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
|
||||||
var migrated := legacy_data.duplicate(true)
|
var migrated := legacy_data.duplicate(true)
|
||||||
migrated["schema_version"] = SCHEMA_VERSION
|
migrated["schema_version"] = SCHEMA_VERSION
|
||||||
|
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"):
|
if not migrated.has("player"):
|
||||||
migrated["player"] = PlayerStateRecord.create_default().to_dictionary()
|
migrated["player"] = PlayerStateRecord.create_default().to_dictionary()
|
||||||
if version < CONFLICT_SCHEMA_VERSION:
|
if version < CONFLICT_SCHEMA_VERSION:
|
||||||
@@ -927,6 +1021,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
|||||||
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
|
PLAYER_RELATIONSHIP_SCHEMA_VERSION,
|
||||||
PLAYER_NEEDS_SCHEMA_VERSION,
|
PLAYER_NEEDS_SCHEMA_VERSION,
|
||||||
CONFLICT_SCHEMA_VERSION,
|
CONFLICT_SCHEMA_VERSION,
|
||||||
|
PRE_EMERGENT_SCHEMA_VERSION,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
migrated["opportunities"] = []
|
migrated["opportunities"] = []
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ static func create(
|
|||||||
return null
|
return null
|
||||||
if trigger_event_id < NO_EVENT_ID:
|
if trigger_event_id < NO_EVENT_ID:
|
||||||
return null
|
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 (
|
return (
|
||||||
SituationStateRecord
|
SituationStateRecord
|
||||||
. new(
|
. new(
|
||||||
@@ -43,7 +47,7 @@ static func create(
|
|||||||
"resolution_event_id": NO_EVENT_ID,
|
"resolution_event_id": NO_EVENT_ID,
|
||||||
"closed_tick": -1,
|
"closed_tick": -1,
|
||||||
"close_reason": "",
|
"close_reason": "",
|
||||||
"context": context.duplicate(true),
|
"context": normalized_context,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bolivdac21erj
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dca87o6k2xfww
|
||||||
@@ -38,16 +38,16 @@ func _run() -> void:
|
|||||||
_check(first["fixture_valid"], "The prepared benchmark state should pass schema validation")
|
_check(first["fixture_valid"], "The prepared benchmark state should pass schema validation")
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
int(first["schema_version"]) == 2
|
int(first["schema_version"]) == 3
|
||||||
and SimulationScalingBenchmark.SCHEMA_VERSION == 2
|
and SimulationScalingBenchmark.SCHEMA_VERSION == 3
|
||||||
and (
|
and (
|
||||||
int(first["simulation_state_schema_version"])
|
int(first["simulation_state_schema_version"])
|
||||||
== SimulationStateRecord.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 (
|
and (
|
||||||
SimulationScalingBenchmark.WORKLOAD_ID
|
SimulationScalingBenchmark.WORKLOAD_ID
|
||||||
== &"full_fidelity_combatant_headless_arrival_v2"
|
== &"full_fidelity_emergent_headless_arrival_v3"
|
||||||
)
|
)
|
||||||
and int(first["population"]) == 12
|
and int(first["population"]) == 12
|
||||||
and int(first["npc_combatant_count"]) == 12
|
and int(first["npc_combatant_count"]) == 12
|
||||||
@@ -86,6 +86,11 @@ func _run() -> void:
|
|||||||
"events_recorded",
|
"events_recorded",
|
||||||
"start_known_reference_count",
|
"start_known_reference_count",
|
||||||
"end_known_reference_count",
|
"end_known_reference_count",
|
||||||
|
"start_situation_count",
|
||||||
|
"end_situation_count",
|
||||||
|
"start_commitment_count",
|
||||||
|
"end_commitment_count",
|
||||||
|
"conversation_act_count",
|
||||||
"arrivals_processed",
|
"arrivals_processed",
|
||||||
"warmup_arrivals",
|
"warmup_arrivals",
|
||||||
"tick_interval",
|
"tick_interval",
|
||||||
|
|||||||
@@ -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"])))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://1walx8xflan0
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://hr1yu4alsrsx
|
||||||
@@ -181,7 +181,7 @@ func test_commitments_fulfill_break_release_and_supersede() -> void:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
var supplied_events := WorldEventStore.new()
|
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(commitments.maintain(12, system, supplied_events, _pantry_facts(1.0)), [fulfilled])
|
||||||
assert_eq(fulfilled.get_status(), CommitmentStateRecord.STATUS_FULFILLED)
|
assert_eq(fulfilled.get_status(), CommitmentStateRecord.STATUS_FULFILLED)
|
||||||
assert_eq(fulfilled.get_cause_event_id(), 9)
|
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(
|
var economic := EconomicEventRecord.create(
|
||||||
event_id,
|
event_id,
|
||||||
&"storage_deposited",
|
&"storage_deposited",
|
||||||
tick,
|
tick,
|
||||||
3,
|
actor_id,
|
||||||
&"npc_inventory_3",
|
source_id,
|
||||||
&"village_pantry",
|
&"village_pantry",
|
||||||
&"food",
|
&"food",
|
||||||
1.0,
|
1.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user