feat: deliver emergent Jajce commitments

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