feat: add provenance-aware npc communication

This commit is contained in:
Rijad Zuzo
2026-07-11 19:58:47 +02:00
parent 81409650df
commit 9a479ba182
18 changed files with 938 additions and 70 deletions
+9 -2
View File
@@ -72,8 +72,11 @@ does not recompute decisions. At completion it atomically pays any
definition-backed stored-resource cost before applying the action effect. A definition-backed stored-resource cost before applying the action effect. A
late shortfall suppresses the effect and records a `task_blocked` fact. The late shortfall suppresses the effect and records a `task_blocked` fact. The
manager also captures actor/nearby knowledge before forwarding newly recorded manager also captures actor/nearby knowledge before forwarding newly recorded
events into `RelationshipSystem`. It exposes knowledge, relationship, and cause events into `RelationshipSystem`. When an NPC arrives beside a worker at the
queries without moving social authority into UI. same non-storage activity site, the manager may transfer one direct known fact
and applies its consequence only to that newly informed listener. It exposes
knowledge, provenance, relationship, and cause queries without moving social
authority into UI.
### VillageEconomy ### VillageEconomy
@@ -94,6 +97,10 @@ queries without moving social authority into UI.
history; history;
- initially observes successful food-deposit events for the actor and living - initially observes successful food-deposit events for the actor and living
NPCs within a fixed proximity radius; NPCs within a fixed proximity radius;
- preserves first acquisition as performed, witnessed, communicated, or
legacy, with a stable source NPC ID for direct communication;
- permits one performed/witnessed fact to cross one co-located social hop, but
does not relay communicated or ambiguous legacy facts;
- captures evidence only when the event happens, never from current positions - captures evidence only when the event happens, never from current positions
during restore; during restore;
- answers deterministic known-event and knower queries without formatting - answers deterministic known-event and knower queries without formatting
+3 -2
View File
@@ -15,7 +15,7 @@ SimulationClock
-> ActionTargetResolver resolves a stable target ID -> ActionTargetResolver resolves a stable target ID
-> VillageEconomy performs inventory/storage transactions -> VillageEconomy performs inventory/storage transactions
-> SimulationEventLog records completed facts -> SimulationEventLog records completed facts
-> EventKnowledgeSystem records actor/nearby knowledge -> EventKnowledgeSystem records actor/nearby knowledge and bounded transfers
-> RelationshipSystem applies evidence-gated social consequences -> RelationshipSystem applies evidence-gated social consequences
-> WorldViewManager presents travel and NPC state -> WorldViewManager presents travel and NPC state
-> ActiveWorldAdapter supplies loaded-world positions/capacity -> ActiveWorldAdapter supplies loaded-world positions/capacity
@@ -33,7 +33,8 @@ would otherwise obscure that lifecycle:
- `simulation/events/SimulationEventLog.gd` owns ordered event identity, - `simulation/events/SimulationEventLog.gd` owns ordered event identity,
history queries, and rate calculations; history queries, and rate calculations;
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to - `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
known objective events and captures proximity witnesses at record time; known objective events, immutable acquisition provenance, proximity witnesses
at record time, and one-hop direct fact transfer;
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship - `simulation/relationships/RelationshipSystem.gd` owns directed relationship
queries, event-driven trust changes, and deterministic social tie-breaking; queries, event-driven trust changes, and deterministic social tie-breaking;
- `simulation/persistence/` owns save-slot file safety; - `simulation/persistence/` owns save-slot file safety;
+8 -3
View File
@@ -689,12 +689,17 @@ Completed:
21. Witnessed event knowledge: objective food-deposit events create separate 21. Witnessed event knowledge: objective food-deposit events create separate
known-event references for their actor and nearby living NPCs. Knowledge known-event references for their actor and nearby living NPCs. Knowledge
gates trust, produces a real informed-versus-uninformed choice divergence, gates trust, produces a real informed-versus-uninformed choice divergence,
survives schema-v5 save/load, and appears in the NPC inspector. survives versioned save/load, and appears in the NPC inspector.
22. Co-worker fact communication: known-event records retain performed,
witnessed, communicated, or legacy acquisition provenance. An arriving NPC
can hear one direct fact from a nearby worker at the same non-storage
activity; the inspector names the speaker, the original event is not
replayed, and heard facts do not cascade yet.
Next: Next:
1. Add one provenance-aware NPC-to-NPC fact transfer, then bounded 1. Add bounded importance/retention rules for known facts, as sequenced in
importance/retention rules, as sequenced in `LEARNING_ROADMAP.md`. `LEARNING_ROADMAP.md`.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+22 -10
View File
@@ -37,12 +37,13 @@ transfer occurred.
## Persistence and determinism ## Persistence and determinism
`SimulationStateRecord` schema v5 stores the ordered event stream, `SimulationStateRecord` schema v6 stores the ordered event stream,
`next_event_id`, directed relationships that may reference an exact event, and `next_event_id`, directed relationships that may reference an exact event, and
per-NPC known-event references. Schema v1 and v2 saves migrate to an empty per-NPC known-event references with first-acquisition provenance. Schema v1 and
stream beginning at ID zero. Parsing rejects duplicate event IDs, invalid or v2 saves migrate to an empty stream beginning at ID zero. Parsing rejects
duplicate knowledge references, relationship causes the observer does not duplicate event IDs, invalid or duplicate knowledge references, impossible
know, and a next ID that could collide with restored history. communicator sources, relationship causes the observer does not know, and a
next ID that could collide with restored history.
A successful food deposit can currently raise a hungry familiar NPC's directed A successful food deposit can currently raise a hungry familiar NPC's directed
trust in its contributor only when that NPC knows the event. The actor and trust in its contributor only when that NPC knows the event. The actor and
@@ -53,6 +54,15 @@ rather than copied prose, so the inspector can resolve and display the real
completed fact. This is a first evidence-gated causal consumer of the event completed fact. This is a first evidence-gated causal consumer of the event
stream, not general event sourcing. stream, not general event sourcing.
Knowledge now records whether an NPC performed, witnessed, heard, or inherited
a fact from a legacy save. When an NPC arrives beside an already-working NPC at
the same non-storage activity site, the worker may communicate one newest
direct fact within 2.5 metres. The listener references the same immutable event
and retains the speaker ID; no transaction or event is replayed. Only
performed/witnessed facts can cross this one social hop, so this is not yet a
rumour cascade. Relationship appraisal uses the listener's state when the fact
is acquired, not a reconstruction of their needs when the old event occurred.
The food-loop regression verifies this chain: The food-loop regression verifies this chain:
```text ```text
@@ -73,8 +83,10 @@ NPC record, so unloading presentation does not lose the fact.
The stream is currently kept in full for the small simulation garden. Before The stream is currently kept in full for the small simulation garden. Before
large populations or long-running worlds, add measured retention, large populations or long-running worlds, add measured retention,
archival/summary rules, and query indexes. Proximity is the only current witness archival/summary rules, and query indexes. Proximity is the only current direct
rule; line of sight, hearing, acquisition provenance, communication, secrecy, witness rule, and communication is intentionally one-hop and tied to shared
false beliefs, multi-event causal graphs, and memory retention belong in later activity arrival. Line of sight, hearing, acquisition timestamps, multi-hop
event/history slices. They should extend this record family without making rumours, secrecy, false beliefs, multi-event causal graphs, and memory
prose authoritative or recomputing old evidence from current positions. retention belong in later event/history slices. They should extend this record
family without making prose authoritative or recomputing old evidence from
current positions.
+17 -7
View File
@@ -668,16 +668,26 @@ The first witnessed-knowledge slice is also complete:
- two equally familiar guards with different evidence now gain different trust - two equally familiar guards with different evidence now gain different trust
and choose helping versus ordinary patrol; and choose helping versus ordinary patrol;
- the inspector resolves and displays the selected NPC's latest known fact; - the inspector resolves and displays the selected NPC's latest known fact;
- schema v5 migration, invalid-reference rejection, deterministic save/load, - schema v6 migration, invalid-reference rejection, deterministic save/load,
and proximity/idempotence tests cover the complete slice. and proximity/idempotence tests cover the complete slice.
This proves the first Milestone 6 evidence-to-choice exit path without claiming The first communicated-knowledge slice is complete:
full memory or belief simulation. The practical next sequence is:
1. Add acquisition provenance and one bounded NPC-to-NPC fact transfer so - first acquisition is persisted as performed, witnessed, communicated, or
information can travel beyond direct witnesses without replaying events. legacy, with the direct speaker retained for communicated facts;
2. Add importance/retention rules for known facts before expanding into - a villager arriving beside an already-working villager at a shared
distortion, secrecy, rumours, or opportunity generation. non-storage activity can receive one newest direct fact within 2.5 metres;
- communication references the original immutable event, changes only the
listener's knowledge/relationship consequence, and cannot relay a heard fact
again in this bounded phase;
- the inspector names who communicated the fact, and schema-v6 migration,
malformed-provenance rejection, event-history invariance, save/checksum, and
informed-choice regressions cover the complete slice.
This advances the first Milestone 6 evidence-to-choice path without claiming
full memory or belief simulation. The practical next slice is importance and
retention for known facts before expanding into distortion, secrecy, rumours,
or opportunity generation.
Recently completed: Recently completed:
+20 -14
View File
@@ -414,8 +414,9 @@ Focused `RefCounted` collaborators keep rule ownership visible:
- `VillageEconomy` owns storage/inventory transactions and synchronized - `VillageEconomy` owns storage/inventory transactions and synchronized
village resource views; village resource views;
- `SimulationEventLog` owns deterministic event history and queries; - `SimulationEventLog` owns deterministic event history and queries;
- `EventKnowledgeSystem` owns per-NPC known-event references and captures - `EventKnowledgeSystem` owns per-NPC known-event references, immutable
actor/nearby evidence when a witnessable event is recorded; first-acquisition provenance, actor/nearby evidence capture, and bounded
one-hop communication;
- `RelationshipSystem` owns directed relationship state, event-driven trust - `RelationshipSystem` owns directed relationship state, event-driven trust
consequences, and deterministic social queries. consequences, and deterministic social queries.
@@ -476,6 +477,7 @@ NpcVisual navigates through the active world
| | | |
| v | v
| SimulationManager marks NPC as working | SimulationManager marks NPC as working
| -> at a shared activity, one nearby worker may communicate one direct fact
| | | |
| v | v
| Later ticks complete work | Later ticks complete work
@@ -521,12 +523,13 @@ NpcVisual navigates through the active world
│ ├── definitions/ Stable IDs and custom definition resources │ ├── definitions/ Stable IDs and custom definition resources
│ ├── economy/ Inventory and storage transactions │ ├── economy/ Inventory and storage transactions
│ ├── events/ Ordered event history and queries │ ├── events/ Ordered event history and queries
│ ├── knowledge/ Per-NPC references to known objective events │ ├── knowledge/ Per-NPC facts, provenance, and bounded transfer
│ ├── persistence/ Validated local save-slot storage │ ├── persistence/ Validated local save-slot storage
│ ├── relationships/ Directed social consequences and queries │ ├── relationships/ Directed social consequences and queries
│ └── state/ Versioned simulation-state records │ └── state/ Versioned simulation-state records
├── tests/ ├── tests/
│ ├── action_system_boundaries_test.gd │ ├── action_system_boundaries_test.gd
│ ├── communicated_knowledge_consequence_test.gd
│ ├── deterministic_simulation_test.gd │ ├── deterministic_simulation_test.gd
│ ├── food_storage_loop_test.gd │ ├── food_storage_loop_test.gd
│ ├── jajce_world_scaffold_test.gd │ ├── jajce_world_scaffold_test.gd
@@ -568,7 +571,7 @@ These are expected prototype constraints, not necessarily isolated bugs:
gathering use `ResourceNode` instances with no fallback, food transfer uses gathering use `ResourceNode` instances with no fallback, food transfer uses
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
- Current NPC, village, resource, storage, event, knowledge, relationship, - Current NPC, village, resource, storage, event, knowledge, relationship,
clock, and RNG state serialize through world schema v5. F5/F9 provide one clock, and RNG state serialize through world schema v6. F5/F9 provide one
validated local quicksave; a save menu, metadata, and player-transform validated local quicksave; a save menu, metadata, and player-transform
persistence remain deferred. persistence remain deferred.
- Simulation-owned resource records retain live amounts, reservations, and - Simulation-owned resource records retain live amounts, reservations, and
@@ -582,12 +585,13 @@ These are expected prototype constraints, not necessarily isolated bugs:
village storage. Other village metrics remain aggregate values rather than village storage. Other village metrics remain aggregate values rather than
located items. located items.
- NPCs have home positions, schedule periods, carried food/wood, and directed - NPCs have home positions, schedule periods, carried food/wood, and directed
familiarity/trust. They can retain direct proximity-witness knowledge of a familiarity/trust. They retain direct or one-hop communicated knowledge of a
food deposit, but do not yet have wider social dimensions, goals, line of food deposit with first-acquisition provenance, but do not yet have wider
sight/hearing evidence, memory decay, false beliefs, or communication. social dimensions, goals, line-of-sight/hearing evidence, importance/decay,
- The reason inspector exposes current decisions, utility rejections, and one false beliefs, or multi-hop rumours.
exact relationship cause plus the latest known fact, but deeper historical - The reason inspector exposes current decisions, utility rejections, one exact
decision traces are not yet retained. relationship cause, and the latest known fact with its speaker when
communicated, but deeper historical decision traces are not yet retained.
- Active navigation is used as if all agents are local; no simulation LOD exists. - Active navigation is used as if all agents are local; no simulation LOD exists.
- Unloaded traveling NPCs preserve their state but do not yet advance through - Unloaded traveling NPCs preserve their state but do not yet advance through
abstract travel time. abstract travel time.
@@ -827,10 +831,12 @@ simulation-garden runtime capture are complete.
The first relationship and knowledge consequences are also complete: The first relationship and knowledge consequences are also complete:
food-deposit events become separate known facts for their actor and nearby food-deposit events become separate known facts for their actor and nearby
living NPCs; only an informed hungry familiar NPC gains directed trust. The living NPCs; only an informed hungry familiar NPC gains directed trust. At a
exact event remains visible as both known fact and relationship cause, and that shared non-storage activity, an already-working direct knower can tell one fact
trust can redirect ordinary work toward helping a starving acquaintance. One to an arriving nearby villager. The same event remains visible as known fact
provenance-aware NPC-to-NPC fact transfer is the next systems slice. and relationship cause, the inspector names the speaker, and the listener's
future work can change without duplicating event history. Importance and
retention for known facts is the next systems slice.
The remaining simulation-garden target still aims for: The remaining simulation-garden target still aims for:
+26 -10
View File
@@ -3,7 +3,7 @@
## Current contract ## Current contract
`SimulationStateRecord` is the versioned JSON boundary for the current `SimulationStateRecord` is the versioned JSON boundary for the current
simulation. The current world schema is v5 and captures: simulation. The current world schema is v6 and captures:
- simulation seed, tick interval, tick count, clock remainder, and elapsed - simulation seed, tick interval, tick count, clock remainder, and elapsed
clock ticks; clock ticks;
@@ -18,14 +18,14 @@ simulation. The current world schema is v5 and captures:
- directed relationship records with familiarity, trust, and the stable event - directed relationship records with familiarity, trust, and the stable event
ID that last changed trust; ID that last changed trust;
- per-NPC known-event records that reference objective event history without - per-NPC known-event records that reference objective event history without
copying it. copying it, including first-acquisition method and communicator provenance.
The top-level identity is: The top-level identity is:
```json ```json
{ {
"schema": "the_steward.simulation", "schema": "the_steward.simulation",
"schema_version": 5 "schema_version": 6
} }
``` ```
@@ -56,8 +56,8 @@ its future random sequence.
It also verifies clock remainder, resource amount/reservation/enabled It also verifies clock remainder, resource amount/reservation/enabled
round-tripping, presentation unload/rebind, directed relationship/cause round-tripping, presentation unload/rebind, directed relationship/cause
round-tripping, divergent known-event state, and rejection of unsupported round-tripping, divergent known-event state, communicated provenance, and
schemas. rejection of unsupported schemas.
NPCStateRecord v2 adds the resolved travel destination and whether it is NPCStateRecord v2 adds the resolved travel destination and whether it is
active. Nested v1 NPC records migrate explicitly with no invented active active. Nested v1 NPC records migrate explicitly with no invented active
@@ -106,9 +106,24 @@ a food deposit performed by the relationship subject.
World schema v4 migrates relationship causes into known-event records. The old World schema v4 migrates relationship causes into known-event records. The old
schema used village-wide awareness, so this preserves the implied known fact schema used village-wide awareness, so this preserves the implied known fact
without falsely inventing spatial witness provenance. New v5 facts are created without falsely inventing spatial witness provenance.
only at event-record time from the actor and nearby NPC positions; historical
events are never re-evaluated against current positions. SimulationStateRecord v6 / KnownEventStateRecord v2 adds immutable
first-acquisition provenance: `performed`, `witnessed`, `communicated`, or
`legacy`. Communicated records also retain the stable NPC ID of the direct
speaker. New direct facts are created only at event-record time from the actor
and nearby NPC positions; historical events are never re-evaluated against
current positions. A co-located shared-activity encounter can add one
communicated reference to the original fact without appending or replaying the
event.
World schema v5 migrates actor-owned knowledge to `performed`; non-actor
knowledge becomes `legacy` because v5 may contain either a proximity witness or
an older implied relationship fact. Parsing never invents that missing
provenance. Current communicated records require a distinct existing source
who knows the same event through `performed` or `witnessed` acquisition. This
enforces the current one-hop boundary and makes validation independent of array
ordering.
## Resource authority ## Resource authority
@@ -146,10 +161,11 @@ This phase does not yet provide:
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles; - a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
- migrations from any historical world schema other than the explicitly - migrations from any historical world schema other than the explicitly
supported v1v4 layouts; supported v1v5 layouts;
- player inventory or player relationship records; - player inventory or player relationship records;
- broader relationship dimensions, line-of-sight/hearing evidence, - broader relationship dimensions, line-of-sight/hearing evidence,
acquisition provenance, memory decay, or communication; acquisition timestamps, importance/retention, memory decay, or multi-hop
rumours;
- persistence for the player transform or presentation-only scene state. - persistence for the player transform or presentation-only scene state.
Those features should build on this boundary rather than inventing parallel Those features should build on this boundary rather than inventing parallel
+88 -9
View File
@@ -16,6 +16,7 @@ signal state_restored
signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult) signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult)
signal relationship_changed(relationship: RelationshipStateRecord, cause_event: EconomicEventRecord) signal relationship_changed(relationship: RelationshipStateRecord, cause_event: EconomicEventRecord)
signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord) signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord)
signal event_knowledge_transferred(speaker_id: int, listener_id: int, event: EconomicEventRecord)
var village := SimVillage.new() var village := SimVillage.new()
@@ -52,6 +53,7 @@ var action_executor := ActionExecutionSystem.new()
var target_resolver := ActionTargetResolver.new() var target_resolver := ActionTargetResolver.new()
var speed_index := 2 var speed_index := 2
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0] const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
const KNOWLEDGE_COMMUNICATION_RADIUS := 2.5
signal speed_changed(multiplier: float) signal speed_changed(multiplier: float)
const NPC_NAMES := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"] const NPC_NAMES := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
@@ -384,18 +386,24 @@ func notify_npc_arrived(npc_id: int) -> void:
npc.has_travel_target = false npc.has_travel_target = false
npc.start_working() npc.start_working()
var working_speakers: Array[SimNPC] = []
if npc.target_id != &"": if npc.target_id != &"":
for other in npcs: for other in npcs:
if other.id == npc.id or other.is_dead: if other.id == npc.id or other.is_dead:
continue continue
if other.target_id != npc.target_id:
continue
if ( if (
other.target_id == npc.target_id other.task_state
and ( in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
other.task_state
in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
)
): ):
relationship_system.increase_shared_work_familiarity(npc.id, other.id) relationship_system.increase_shared_work_familiarity(npc.id, other.id)
if other.task_state == SimNPC.TASK_STATE_WORKING:
working_speakers.append(other)
working_speakers.sort_custom(_sort_npcs_by_id)
for speaker in working_speakers:
if try_communicate_at_shared_activity(speaker.id, npc.id):
break
if debug_logs: if debug_logs:
print( print(
@@ -583,14 +591,81 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
event, npcs event, npcs
) )
for learned_record in learned_records: for learned_record in learned_records:
event_knowledge_changed.emit(learned_record.get_knower_id(), event) _apply_new_event_knowledge(learned_record, event)
var knower_ids: Array[int] = event_knowledge_system.get_knowers(int(event.data["event_id"])) economic_event_recorded.emit(event)
func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bool:
var speaker := _find_npc_by_id(speaker_id)
var listener := _find_npc_by_id(listener_id)
if not _can_communicate_at_shared_activity(speaker, listener):
return false
var known_event_ids := event_knowledge_system.get_known_event_ids(speaker.id, 0)
for index in range(known_event_ids.size() - 1, -1, -1):
var event := event_log.get_by_id(known_event_ids[index])
if event == null:
continue
var learned_record := event_knowledge_system.communicate_event(
event, speaker.id, listener.id
)
if learned_record == null:
continue
_apply_new_event_knowledge(learned_record, event)
event_knowledge_transferred.emit(speaker.id, listener.id, event)
return true
return false
func _can_communicate_at_shared_activity(speaker: SimNPC, listener: SimNPC) -> bool:
if speaker == null or listener == null or speaker.id == listener.id:
return false
if speaker.is_dead or listener.is_dead:
return false
if (
speaker.task_state != SimNPC.TASK_STATE_WORKING
or listener.task_state != SimNPC.TASK_STATE_WORKING
):
return false
if speaker.target_id.is_empty() or speaker.target_id != listener.target_id:
return false
if storage_states.has(speaker.target_id):
return false
var speaker_action := SimulationDefinitions.get_action(speaker.current_task)
var listener_action := SimulationDefinitions.get_action(listener.current_task)
if (
speaker_action == null
or listener_action == null
or speaker_action.target_type != SimulationIds.TARGET_ACTIVITY
or listener_action.target_type != SimulationIds.TARGET_ACTIVITY
):
return false
return (
speaker.position.distance_squared_to(listener.position)
<= KNOWLEDGE_COMMUNICATION_RADIUS * KNOWLEDGE_COMMUNICATION_RADIUS
)
func _apply_new_event_knowledge(
known_event: KnownEventStateRecord, event: EconomicEventRecord
) -> void:
event_knowledge_changed.emit(known_event.get_knower_id(), event)
var newly_informed_ids: Array[int] = [known_event.get_knower_id()]
var changed_relationships: Array[RelationshipStateRecord] = relationship_system.apply_event( var changed_relationships: Array[RelationshipStateRecord] = relationship_system.apply_event(
event, npcs, knower_ids event, npcs, newly_informed_ids
) )
for relationship in changed_relationships: for relationship in changed_relationships:
relationship_changed.emit(relationship, event) relationship_changed.emit(relationship, event)
economic_event_recorded.emit(event)
func _find_npc_by_id(npc_id: int) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
static func _sort_npcs_by_id(first: SimNPC, second: SimNPC) -> bool:
return first.id < second.id
func get_primary_relationship(npc_id: int) -> RelationshipStateRecord: func get_primary_relationship(npc_id: int) -> RelationshipStateRecord:
@@ -616,6 +691,10 @@ func npc_knows_event(npc_id: int, event_id: int) -> bool:
return event_knowledge_system.knows_event(npc_id, event_id) return event_knowledge_system.knows_event(npc_id, event_id)
func get_known_event_record(npc_id: int, event_id: int) -> KnownEventStateRecord:
return event_knowledge_system.get_record(npc_id, event_id)
func get_current_speed() -> String: func get_current_speed() -> String:
return "%.2fx" % SPEED_LEVELS[speed_index] return "%.2fx" % SPEED_LEVELS[speed_index]
+5
View File
@@ -41,6 +41,11 @@ const EVENT_TASK_STARTED := &"task_started"
const EVENT_TASK_BLOCKED := &"task_blocked" const EVENT_TASK_BLOCKED := &"task_blocked"
const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
const KNOWLEDGE_ACQUISITION_PERFORMED := &"performed"
const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed"
const KNOWLEDGE_ACQUISITION_COMMUNICATED := &"communicated"
const KNOWLEDGE_ACQUISITION_LEGACY := &"legacy"
static func npc_inventory_id(npc_id: int) -> StringName: static func npc_inventory_id(npc_id: int) -> StringName:
return StringName("npc_%d_inventory" % npc_id) return StringName("npc_%d_inventory" % npc_id)
+45 -4
View File
@@ -13,7 +13,8 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno
var actor := _find_npc(int(event.data["actor_id"]), npcs) var actor := _find_npc(int(event.data["actor_id"]), npcs)
if actor == null: if actor == null:
return learned return learned
var actor_record := _remember(actor.id, int(event.data["event_id"])) var event_id := int(event.data["event_id"])
var actor_record := _remember(actor.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED)
if actor_record != null: if actor_record != null:
learned.append(actor_record) learned.append(actor_record)
var witness_radius_squared := WITNESS_RADIUS * WITNESS_RADIUS var witness_radius_squared := WITNESS_RADIUS * WITNESS_RADIUS
@@ -23,17 +24,50 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno
continue continue
if npc.position.distance_squared_to(event_position) > witness_radius_squared: if npc.position.distance_squared_to(event_position) > witness_radius_squared:
continue continue
var witness_record := _remember(npc.id, int(event.data["event_id"])) var witness_record := _remember(
npc.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
)
if witness_record != null: if witness_record != null:
learned.append(witness_record) learned.append(witness_record)
learned.sort_custom(_sort_records) learned.sort_custom(_sort_records)
return learned return learned
func communicate_event(
event: EconomicEventRecord, speaker_id: int, listener_id: int
) -> KnownEventStateRecord:
if (
event == null
or speaker_id == listener_id
or listener_id == int(event.data["actor_id"])
or not _is_witnessable(event)
):
return null
var event_id := int(event.data["event_id"])
var speaker_record := get_record(speaker_id, event_id)
if speaker_record == null:
return null
if (
speaker_record.get_acquisition_method()
not in [
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED,
]
):
return null
return _remember(
listener_id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, speaker_id
)
func knows_event(knower_id: int, event_id: int) -> bool: func knows_event(knower_id: int, event_id: int) -> bool:
return known_events.has(_key(knower_id, event_id)) return known_events.has(_key(knower_id, event_id))
func get_record(knower_id: int, event_id: int) -> KnownEventStateRecord:
return known_events.get(_key(knower_id, event_id)) as KnownEventStateRecord
func get_knowers(event_id: int) -> Array[int]: func get_knowers(event_id: int) -> Array[int]:
var knower_ids: Array[int] = [] var knower_ids: Array[int] = []
for record in known_events.values(): for record in known_events.values():
@@ -71,11 +105,18 @@ func restore(records: Array[KnownEventStateRecord]) -> void:
known_events[_key(record.get_knower_id(), record.get_event_id())] = record known_events[_key(record.get_knower_id(), record.get_event_id())] = record
func _remember(knower_id: int, event_id: int) -> KnownEventStateRecord: func _remember(
knower_id: int,
event_id: int,
acquisition_method: StringName,
source_npc_id: int = KnownEventStateRecord.NO_SOURCE_NPC_ID
) -> KnownEventStateRecord:
var key := _key(knower_id, event_id) var key := _key(knower_id, event_id)
if known_events.has(key): if known_events.has(key):
return null return null
var record := KnownEventStateRecord.create(knower_id, event_id) var record := KnownEventStateRecord.create(
knower_id, event_id, acquisition_method, source_npc_id
)
known_events[key] = record known_events[key] = record
return record return record
+36 -4
View File
@@ -1,7 +1,15 @@
class_name KnownEventStateRecord class_name KnownEventStateRecord
extends RefCounted extends RefCounted
const SCHEMA_VERSION := 1 const SCHEMA_VERSION := 2
const NO_SOURCE_NPC_ID := -1
const VALID_ACQUISITION_METHODS := [
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED,
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED,
SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY,
]
var data: Dictionary var data: Dictionary
@@ -10,7 +18,12 @@ func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true) data = record_data.duplicate(true)
static func create(knower_id: int, event_id: int) -> KnownEventStateRecord: static func create(
knower_id: int,
event_id: int,
acquisition_method: StringName = SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY,
source_npc_id: int = NO_SOURCE_NPC_ID
) -> KnownEventStateRecord:
return ( return (
KnownEventStateRecord KnownEventStateRecord
. new( . new(
@@ -18,6 +31,8 @@ static func create(knower_id: int, event_id: int) -> KnownEventStateRecord:
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"knower_id": knower_id, "knower_id": knower_id,
"event_id": event_id, "event_id": event_id,
"acquisition_method": String(acquisition_method),
"source_npc_id": source_npc_id,
} }
) )
) )
@@ -26,13 +41,22 @@ static func create(knower_id: int, event_id: int) -> KnownEventStateRecord:
static func from_dictionary(record_data: Dictionary) -> KnownEventStateRecord: static func from_dictionary(record_data: Dictionary) -> KnownEventStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null return null
if not record_data.has_all(["knower_id", "event_id"]): if not record_data.has_all(["knower_id", "event_id", "acquisition_method", "source_npc_id"]):
return null return null
var knower_id := int(record_data["knower_id"]) var knower_id := int(record_data["knower_id"])
var event_id := int(record_data["event_id"]) var event_id := int(record_data["event_id"])
var acquisition_method := StringName(record_data["acquisition_method"])
var source_npc_id := int(record_data["source_npc_id"])
if knower_id < 0 or event_id < 0: if knower_id < 0 or event_id < 0:
return null return null
return create(knower_id, event_id) if acquisition_method not in VALID_ACQUISITION_METHODS:
return null
if acquisition_method == SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
if source_npc_id < 0 or source_npc_id == knower_id:
return null
elif source_npc_id != NO_SOURCE_NPC_ID:
return null
return create(knower_id, event_id, acquisition_method, source_npc_id)
func get_knower_id() -> int: func get_knower_id() -> int:
@@ -43,5 +67,13 @@ func get_event_id() -> int:
return int(data["event_id"]) return int(data["event_id"])
func get_acquisition_method() -> StringName:
return StringName(data["acquisition_method"])
func get_source_npc_id() -> int:
return int(data["source_npc_id"])
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
return data.duplicate(true) return data.duplicate(true)
+112 -3
View File
@@ -2,11 +2,12 @@ class_name SimulationStateRecord
extends RefCounted extends RefCounted
const SCHEMA_NAME := "the_steward.simulation" const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 5 const SCHEMA_VERSION := 6
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const EVENT_LEGACY_SCHEMA_VERSION := 2 const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
const PREVIOUS_SCHEMA_VERSION := 4 const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4
const PREVIOUS_SCHEMA_VERSION := 5
var simulation: Dictionary var simulation: Dictionary
var village: VillageStateRecord var village: VillageStateRecord
@@ -74,6 +75,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
LEGACY_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION,
EVENT_LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION,
RELATIONSHIP_LEGACY_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION,
KNOWLEDGE_LEGACY_SCHEMA_VERSION,
PREVIOUS_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION,
] ]
): ):
@@ -207,6 +209,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
return null return null
var knowledge_keys := {} var knowledge_keys := {}
var knowledge_records_by_key := {}
for item in knowledge_data: for item in knowledge_data:
if not item is Dictionary: if not item is Dictionary:
return null return null
@@ -221,7 +224,13 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if knowledge_keys.has(knowledge_key): if knowledge_keys.has(knowledge_key):
return null return null
knowledge_keys[knowledge_key] = true knowledge_keys[knowledge_key] = true
knowledge_records_by_key[knowledge_key] = known_event_record
record.event_knowledge.append(known_event_record) record.event_knowledge.append(known_event_record)
for known_event_record in record.event_knowledge:
if not _is_valid_knowledge_provenance(
known_event_record, npc_ids, event_records_by_id, knowledge_records_by_key
):
return null
var relationship_keys := {} var relationship_keys := {}
for item in relationship_data: for item in relationship_data:
@@ -266,6 +275,54 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
return record return record
static func _is_valid_knowledge_provenance(
known_event: KnownEventStateRecord,
npc_ids: Dictionary,
event_records_by_id: Dictionary,
knowledge_records_by_key: Dictionary
) -> bool:
var event := event_records_by_id.get(known_event.get_event_id()) as EconomicEventRecord
if event == null:
return false
var method := known_event.get_acquisition_method()
var knower_id := known_event.get_knower_id()
var actor_id := int(event.data["actor_id"])
if method == SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY:
return true
if (
StringName(event.data["event_type"]) != SimulationIds.EVENT_STORAGE_DEPOSITED
or StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD
or float(event.data["amount"]) <= 0.0
):
return false
match method:
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED:
return knower_id == actor_id
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED:
return knower_id != actor_id
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):
return false
var source_record := (
knowledge_records_by_key.get("%d:%d" % [source_npc_id, known_event.get_event_id()])
as KnownEventStateRecord
)
return (
source_record != null
and (
source_record.get_acquisition_method()
in [
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED,
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED,
]
)
)
return false
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary: static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
var migrated := legacy_data.duplicate(true) var migrated := legacy_data.duplicate(true)
migrated["schema_version"] = SCHEMA_VERSION migrated["schema_version"] = SCHEMA_VERSION
@@ -301,7 +358,21 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
in [LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION] in [LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION]
): ):
migrated["relationships"] = _migrate_npc_familiarity(legacy_data.get("npcs", [])) migrated["relationships"] = _migrate_npc_familiarity(legacy_data.get("npcs", []))
migrated["event_knowledge"] = _migrate_relationship_causes(migrated.get("relationships", [])) if (
version
in [
LEGACY_SCHEMA_VERSION,
EVENT_LEGACY_SCHEMA_VERSION,
RELATIONSHIP_LEGACY_SCHEMA_VERSION,
KNOWLEDGE_LEGACY_SCHEMA_VERSION,
]
):
migrated["event_knowledge"] = _migrate_relationship_causes(
migrated.get("relationships", [])
)
migrated["event_knowledge"] = _migrate_known_event_provenance(
migrated.get("event_knowledge", []), migrated.get("economic_events", [])
)
return migrated return migrated
@@ -377,3 +448,41 @@ static func _knowledge_dictionary_sort(first: Dictionary, second: Dictionary) ->
if int(first["knower_id"]) != int(second["knower_id"]): if int(first["knower_id"]) != int(second["knower_id"]):
return int(first["knower_id"]) < int(second["knower_id"]) return int(first["knower_id"]) < int(second["knower_id"])
return int(first["event_id"]) < int(second["event_id"]) return int(first["event_id"]) < int(second["event_id"])
static func _migrate_known_event_provenance(knowledge_data: Variant, event_data: Variant) -> Array:
if not knowledge_data is Array:
return []
var actor_by_event_id := {}
if event_data is Array:
for event_item in event_data:
if not event_item is Dictionary:
continue
actor_by_event_id[int(event_item.get("event_id", -1))] = int(
event_item.get("actor_id", -1)
)
var migrated_knowledge: Array = []
var can_sort := true
for item in knowledge_data:
if not item is Dictionary:
migrated_knowledge.append(item)
can_sort = false
continue
if int(item.get("schema_version", -1)) == KnownEventStateRecord.SCHEMA_VERSION:
migrated_knowledge.append(item.duplicate(true))
continue
if int(item.get("schema_version", -1)) != 1 or not item.has_all(["knower_id", "event_id"]):
migrated_knowledge.append(item.duplicate(true))
can_sort = false
continue
var knower_id := int(item["knower_id"])
var event_id := int(item["event_id"])
var acquisition_method := SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY
if int(actor_by_event_id.get(event_id, -2)) == knower_id:
acquisition_method = SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED
migrated_knowledge.append(
KnownEventStateRecord.create(knower_id, event_id, acquisition_method).to_dictionary()
)
if can_sort:
migrated_knowledge.sort_custom(_knowledge_dictionary_sort)
return migrated_knowledge
@@ -0,0 +1,298 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
var contributor: SimNPC = manager.npcs[0]
var witness: SimNPC = manager.npcs[1]
var listener: SimNPC = manager.npcs[2]
var control: SimNPC = manager.npcs[3]
contributor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0)
listener.position = Vector3(20.0, 0.0, 0.0)
control.position = Vector3(21.0, 0.0, 0.0)
for observer in [witness, listener, control]:
observer.hunger = 85.0
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
_check(
is_equal_approx(
manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD), 2.0
),
"Communication should begin from a real completed food-deposit fact"
)
var deposit_event := _find_deposit_event(manager, contributor.id)
_check(deposit_event != null, "The original deposit must remain in objective event history")
if deposit_event == null:
manager.free()
_finish()
return
var event_id := int(deposit_event.data["event_id"])
_check(
(
manager.npc_knows_event(contributor.id, event_id)
and manager.npc_knows_event(witness.id, event_id)
and not manager.npc_knows_event(listener.id, event_id)
and not manager.npc_knows_event(control.id, event_id)
),
"Only the contributor and nearby witness should know the fact initially"
)
_check(
(
manager.get_known_event_record(witness.id, event_id).get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
),
"The teller should retain direct witness provenance"
)
for npc in [witness, listener]:
npc.set_task(SimulationIds.ACTION_EAT)
npc.target_id = SimulationIds.STORAGE_VILLAGE_PANTRY
npc.position = Vector3(20.0 + npc.id, 0.0, 0.0)
npc.start_working()
_check(
not manager.try_communicate_at_shared_activity(witness.id, listener.id),
"Passing at storage should not trigger the bounded shared-work conversation"
)
_prepare_shared_patrol(witness, listener, Vector3(20.0, 0.0, 0.0), Vector3(30.0, 0.0, 0.0))
var before_failed_transfer: String = manager.get_state_checksum()
_check(
not manager.try_communicate_at_shared_activity(witness.id, listener.id),
"Matching target IDs must not teleport knowledge between distant workers"
)
_check(
manager.get_state_checksum() == before_failed_transfer,
"A rejected distant conversation must not mutate simulation state"
)
listener.set_task(SimulationIds.ACTION_PATROL)
listener.target_id = witness.target_id
listener.position = witness.position + Vector3(1.0, 0.0, 0.0)
var event_count_before: int = manager.economic_events.size()
var next_event_id_before: int = manager.next_event_id
manager.notify_npc_arrived(listener.id)
_check(
manager.npc_knows_event(listener.id, event_id),
"Arriving beside the witness at shared work should transfer one fact"
)
_check(
(
manager.economic_events.size() == event_count_before
and manager.next_event_id == next_event_id_before
),
"Communication must reference the original event without replaying or duplicating it"
)
var communicated_record: KnownEventStateRecord = manager.get_known_event_record(
listener.id, event_id
)
_check(
(
communicated_record != null
and (
communicated_record.get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED
)
and communicated_record.get_source_npc_id() == witness.id
),
"The listener should preserve who communicated the objective fact"
)
var listener_relationship: RelationshipStateRecord = (
manager.relationship_system.get_relationship(listener.id, contributor.id)
)
var control_relationship: RelationshipStateRecord = (
manager.relationship_system.get_relationship(control.id, contributor.id)
)
_check(
(
listener_relationship != null
and is_equal_approx(listener_relationship.get_trust(), 0.65)
and listener_relationship.get_last_trust_cause_event_id() == event_id
),
"Learning the fact should apply its trust consequence only to the listener"
)
_check(
(
control_relationship != null
and is_equal_approx(
control_relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST
)
and not manager.npc_knows_event(control.id, event_id)
),
"A nearby uninvolved villager should not receive a directed conversation"
)
var before_repeat: String = manager.get_state_checksum()
_check(
not manager.try_communicate_at_shared_activity(witness.id, listener.id),
"Repeating the same conversation should not replace first-acquisition provenance"
)
_check(
manager.get_state_checksum() == before_repeat,
"Repeated communication must not add knowledge or apply trust twice"
)
control.set_task(SimulationIds.ACTION_PATROL)
control.target_id = listener.target_id
control.position = listener.position + Vector3(1.0, 0.0, 0.0)
control.start_working()
_check(
not manager.try_communicate_at_shared_activity(listener.id, control.id),
"Communicated facts should not relay again before the rumour phase"
)
_prepare_choice_divergence(manager, contributor, listener, control)
var informed_choice: ActionSelectionResult = manager.action_selector.select_action(
listener, manager.village, 0.5, manager.npcs
)
var control_choice: ActionSelectionResult = manager.action_selector.select_action(
control, manager.village, 0.5, manager.npcs
)
_check(
(
informed_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
and contributor.npc_name in informed_choice.reason
),
"The informed listener should act on communicated evidence"
)
_check(
control_choice.action_id == SimulationIds.ACTION_PATROL,
"The uninformed control should continue ordinary guard work"
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(999)
_check(restored.restore_state_from_json(saved_json), "Communicated provenance should restore")
var restored_record: KnownEventStateRecord = restored.get_known_event_record(
listener.id, event_id
)
_check(
(
restored_record != null
and (
restored_record.get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED
)
and restored_record.get_source_npc_id() == witness.id
),
"Save/load should preserve the exact speaker provenance"
)
_check(
restored.get_state_checksum() == manager.get_state_checksum(),
"Restored communication state should retain the deterministic checksum"
)
var restored_listener: SimNPC = restored.npcs[listener.id]
var restored_control: SimNPC = restored.npcs[control.id]
var current_next_informed_choice: ActionSelectionResult = manager.action_selector.select_action(
listener, manager.village, 0.5, manager.npcs
)
var current_next_control_choice: ActionSelectionResult = manager.action_selector.select_action(
control, manager.village, 0.5, manager.npcs
)
var restored_informed_choice: ActionSelectionResult = restored.action_selector.select_action(
restored_listener, restored.village, 0.5, restored.npcs
)
var restored_control_choice: ActionSelectionResult = restored.action_selector.select_action(
restored_control, restored.village, 0.5, restored.npcs
)
_check(
(
restored_informed_choice.action_id == current_next_informed_choice.action_id
and restored_control_choice.action_id == current_next_control_choice.action_id
),
"Restored knowledge should preserve the next informed and control choices"
)
_check(
restored.get_state_checksum() == manager.get_state_checksum(),
"Equivalent future choices should advance restored RNG streams identically"
)
manager.free()
restored.free()
_finish()
func _prepare_shared_patrol(
witness: SimNPC, listener: SimNPC, witness_position: Vector3, listener_position: Vector3
) -> void:
for npc in [witness, listener]:
npc.set_task(SimulationIds.ACTION_PATROL)
npc.target_id = &"guard_post"
witness.position = witness_position
listener.position = listener_position
witness.start_working()
listener.start_working()
func _prepare_choice_divergence(
manager: Node, contributor: SimNPC, informed: SimNPC, control: SimNPC
) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(
SimulationIds.RESOURCE_FOOD,
maxf(
(
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
- (ActionSelectionSystem.RELATIONSHIP_AID_PANTRY_THRESHOLD - 5.0)
),
0.0
)
)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
manager.economy.deposit_resource(SimulationIds.RESOURCE_WOOD, 10.0)
manager.village.safety = 50.0
manager.village.knowledge = 20.0
manager.village.update_priorities()
contributor.hunger = 95.0
contributor.is_starving = true
for observer in [informed, control]:
observer.profession = SimulationIds.PROFESSION_GUARD
observer.hunger = 20.0
observer.energy = 80.0
observer.inventory.clear()
observer.last_task = &""
func _find_deposit_event(manager: Node, actor_id: int) -> EconomicEventRecord:
for event in manager.get_npc_events(actor_id, 4):
if StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED:
return event
return null
func _create_manager(seed_value: int = 821) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(1.0, 0.0, 0.0),
Vector3(2.0, 0.0, 0.0),
Vector3(3.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _finish() -> void:
if failures.is_empty():
print("[TEST] Communicated knowledge passed: witness -> coworker -> informed choice")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
@@ -0,0 +1 @@
uid://x8jugu64gj2o
+24
View File
@@ -44,6 +44,9 @@ func _run() -> void:
) )
var contributor: SimNPC = simulation_manager.npcs[0] var contributor: SimNPC = simulation_manager.npcs[0]
var witness: SimNPC = simulation_manager.npcs[1] var witness: SimNPC = simulation_manager.npcs[1]
var listener: SimNPC = simulation_manager.npcs[2]
for npc in simulation_manager.npcs:
npc.position = Vector3(20.0 + npc.id * 2.0, 0.0, 0.0)
contributor.position = Vector3.ZERO contributor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0) witness.position = Vector3(4.0, 0.0, 0.0)
witness.hunger = 85.0 witness.hunger = 85.0
@@ -61,11 +64,32 @@ func _run() -> void:
_check( _check(
( (
"Known fact: %s deposited" % contributor.npc_name in inspector_label.text "Known fact: %s deposited" % contributor.npc_name in inspector_label.text
and "(witnessed)" in inspector_label.text
and "Relationship: %s" % contributor.npc_name in inspector_label.text and "Relationship: %s" % contributor.npc_name in inspector_label.text
and "Because:" in inspector_label.text and "Because:" in inspector_label.text
), ),
"NPC inspector should show the witnessed fact and its relationship consequence" "NPC inspector should show the witnessed fact and its relationship consequence"
) )
var guard_site := (
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
)
witness.set_task(SimulationIds.ACTION_PATROL)
witness.target_id = guard_site.site_id
witness.position = guard_site.get_interaction_position()
witness.start_working()
listener.set_task(SimulationIds.ACTION_PATROL)
listener.target_id = guard_site.site_id
listener.position = witness.position + Vector3(1.0, 0.0, 0.0)
simulation_manager.notify_npc_arrived(listener.id)
village_ui.selected_npc_index = listener.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Known fact: %s deposited" % contributor.npc_name in inspector_label.text
and "(heard from %s)" % witness.npc_name in inspector_label.text
),
"NPC inspector should identify who communicated a known fact"
)
_check( _check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"), main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
"Playable runtime should instance the Jajce Terrain3D world" "Playable runtime should instance the Jajce Terrain3D world"
+152 -1
View File
@@ -16,6 +16,7 @@ func _run() -> void:
_test_previous_world_event_migration() _test_previous_world_event_migration()
_test_previous_world_relationship_migration() _test_previous_world_relationship_migration()
_test_previous_world_knowledge_migration() _test_previous_world_knowledge_migration()
_test_previous_world_provenance_migration()
_test_relationship_schema_rejection() _test_relationship_schema_rejection()
_test_schema_rejection() _test_schema_rejection()
@@ -266,7 +267,7 @@ func _test_previous_world_relationship_migration() -> void:
func _test_previous_world_knowledge_migration() -> void: func _test_previous_world_knowledge_migration() -> void:
var manager := _create_manager(59) var manager := _create_manager(59)
var previous_data: Dictionary = manager.create_state_record().to_dictionary() var previous_data: Dictionary = manager.create_state_record().to_dictionary()
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION previous_data["schema_version"] = SimulationStateRecord.KNOWLEDGE_LEGACY_SCHEMA_VERSION
previous_data.erase("event_knowledge") previous_data.erase("event_knowledge")
var deposit_event := EconomicEventRecord.create( var deposit_event := EconomicEventRecord.create(
0, 0,
@@ -291,12 +292,72 @@ func _test_previous_world_knowledge_migration() -> void:
migrated.event_knowledge.size() == 1 migrated.event_knowledge.size() == 1
and migrated.event_knowledge[0].get_knower_id() == 0 and migrated.event_knowledge[0].get_knower_id() == 0
and migrated.event_knowledge[0].get_event_id() == 0 and migrated.event_knowledge[0].get_event_id() == 0
and (
migrated.event_knowledge[0].get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY
)
), ),
"World v4 migration should preserve the fact implied by a relationship cause" "World v4 migration should preserve the fact implied by a relationship cause"
) )
manager.free() manager.free()
func _test_previous_world_provenance_migration() -> void:
var manager := _create_manager(60)
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
var deposit_event := EconomicEventRecord.create(
0,
SimulationIds.EVENT_STORAGE_DEPOSITED,
12,
1,
SimulationIds.npc_inventory_id(1),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
2.0
)
previous_data["economic_events"] = [deposit_event.to_dictionary()]
previous_data["simulation"]["next_event_id"] = 1
previous_data["event_knowledge"] = [
{"schema_version": 1, "knower_id": 0, "event_id": 0},
{"schema_version": 1, "knower_id": 1, "event_id": 0},
]
previous_data["relationships"] = [
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
]
var migrated := SimulationStateRecord.from_dictionary(previous_data)
_check(migrated != null, "World schema v5 should add honest acquisition provenance")
if migrated != null:
var observer_record: KnownEventStateRecord
var actor_record: KnownEventStateRecord
for known_event in migrated.event_knowledge:
if known_event.get_knower_id() == 0:
observer_record = known_event
elif known_event.get_knower_id() == 1:
actor_record = known_event
_check(
(
actor_record != null
and (
actor_record.get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED
)
),
"World v5 migration can safely identify an actor's own completed fact"
)
_check(
(
observer_record != null
and (
observer_record.get_acquisition_method()
== SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY
)
),
"World v5 migration must not invent witness or speaker provenance"
)
manager.free()
func _test_relationship_schema_rejection() -> void: func _test_relationship_schema_rejection() -> void:
var manager := _create_manager(58) var manager := _create_manager(58)
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary() var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
@@ -391,6 +452,96 @@ func _test_relationship_schema_rejection() -> void:
SimulationStateRecord.from_dictionary(nonpositive_cause_data) == null, SimulationStateRecord.from_dictionary(nonpositive_cause_data) == null,
"A trust cause must describe a successful positive food deposit" "A trust cause must describe a successful positive food deposit"
) )
var provenance_event := EconomicEventRecord.create(
0,
SimulationIds.EVENT_STORAGE_DEPOSITED,
1,
0,
SimulationIds.npc_inventory_id(0),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
var missing_source_data: Dictionary = manager.create_state_record().to_dictionary()
missing_source_data["economic_events"] = [provenance_event.to_dictionary()]
missing_source_data["simulation"]["next_event_id"] = 1
missing_source_data["event_knowledge"] = [
(
KnownEventStateRecord
. create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 2)
. to_dictionary()
)
]
_check(
SimulationStateRecord.from_dictionary(missing_source_data) == null,
"Communicated knowledge must reference a source who knows the same event"
)
var wrong_actor_data: Dictionary = manager.create_state_record().to_dictionary()
wrong_actor_data["economic_events"] = [provenance_event.to_dictionary()]
wrong_actor_data["simulation"]["next_event_id"] = 1
wrong_actor_data["event_knowledge"] = [
(
KnownEventStateRecord
. create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED)
. to_dictionary()
)
]
_check(
SimulationStateRecord.from_dictionary(wrong_actor_data) == null,
"Performed provenance must belong to the event actor"
)
var relayed_source_data: Dictionary = manager.create_state_record().to_dictionary()
relayed_source_data["economic_events"] = [provenance_event.to_dictionary()]
relayed_source_data["simulation"]["next_event_id"] = 1
relayed_source_data["event_knowledge"] = [
(
KnownEventStateRecord
. create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 2)
. to_dictionary()
),
(
KnownEventStateRecord
. create(2, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 0)
. to_dictionary()
),
(
KnownEventStateRecord
. create(0, 0, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED)
. to_dictionary()
),
]
_check(
SimulationStateRecord.from_dictionary(relayed_source_data) == null,
"Communicated facts must not form a second relay hop in this phase"
)
var order_independent_data: Dictionary = manager.create_state_record().to_dictionary()
order_independent_data["economic_events"] = [provenance_event.to_dictionary()]
order_independent_data["simulation"]["next_event_id"] = 1
order_independent_data["event_knowledge"] = [
(
KnownEventStateRecord
. create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 2)
. to_dictionary()
),
(
KnownEventStateRecord
. create(0, 0, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED)
. to_dictionary()
),
(
KnownEventStateRecord
. create(2, 0, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED)
. to_dictionary()
),
]
_check(
SimulationStateRecord.from_dictionary(order_independent_data) != null,
"Provenance validation should not depend on knowledge array ordering"
)
manager.free() manager.free()
+53
View File
@@ -128,3 +128,56 @@ func test_event_knowledge_is_spatial_directed_and_idempotent() -> void:
assert_true(knowledge_system.knows_event(nearby.id, 12)) assert_true(knowledge_system.knows_event(nearby.id, 12))
assert_false(knowledge_system.knows_event(distant.id, 12)) assert_false(knowledge_system.knows_event(distant.id, 12))
assert_eq(knowledge_system.get_knowers(12), [actor.id, nearby.id]) assert_eq(knowledge_system.get_knowers(12), [actor.id, nearby.id])
assert_eq(
knowledge_system.get_record(actor.id, 12).get_acquisition_method(),
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED
)
assert_eq(
knowledge_system.get_record(nearby.id, 12).get_acquisition_method(),
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
)
func test_event_communication_preserves_first_provenance_and_stops_after_one_hop() -> void:
var actor := SimNPC.new(0, "Amina", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
var witness := SimNPC.new(1, "Tarik", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
var listener := SimNPC.new(2, "Jasmin", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
var relay_target := SimNPC.new(3, "Elma", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
actor.position = Vector3.ZERO
witness.position = Vector3(2.0, 0.0, 0.0)
listener.position = Vector3(20.0, 0.0, 0.0)
relay_target.position = Vector3(20.0, 0.0, 1.0)
var villagers: Array[SimNPC] = [actor, witness, listener, relay_target]
var event := EconomicEventRecord.create(
21,
SimulationIds.EVENT_STORAGE_DEPOSITED,
40,
actor.id,
SimulationIds.npc_inventory_id(actor.id),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
2.0,
Vector3.ZERO
)
var knowledge_system := EventKnowledgeSystemScript.new()
knowledge_system.observe_event(event, villagers)
assert_null(knowledge_system.communicate_event(event, listener.id, relay_target.id))
var communicated := knowledge_system.communicate_event(event, witness.id, listener.id)
assert_not_null(communicated)
assert_eq(
communicated.get_acquisition_method(), SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED
)
assert_eq(communicated.get_source_npc_id(), witness.id)
assert_null(knowledge_system.communicate_event(event, actor.id, listener.id))
assert_eq(knowledge_system.get_record(listener.id, event.data["event_id"]), communicated)
assert_null(knowledge_system.communicate_event(event, listener.id, relay_target.id))
assert_false(knowledge_system.knows_event(relay_target.id, int(event.data["event_id"])))
var actor_gap_system := EventKnowledgeSystemScript.new()
var direct_witness_records: Array[KnownEventStateRecord] = [
KnownEventStateRecord.create(
witness.id, int(event.data["event_id"]), SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
)
]
actor_gap_system.restore(direct_witness_records)
assert_null(actor_gap_system.communicate_event(event, witness.id, actor.id))
+19 -1
View File
@@ -288,4 +288,22 @@ func _build_known_fact_display(npc: SimNPC) -> String:
for other_npc in simulation_manager.npcs: for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name name_map[other_npc.id] = other_npc.npc_name
var latest_event := known_events[known_events.size() - 1] as EconomicEventRecord var latest_event := known_events[known_events.size() - 1] as EconomicEventRecord
return "Known fact: %s" % latest_event.description(name_map) var provenance := ""
if simulation_manager.has_method("get_known_event_record"):
var known_record: KnownEventStateRecord = simulation_manager.get_known_event_record(
npc.id, int(latest_event.data["event_id"])
)
if known_record != null:
match known_record.get_acquisition_method():
SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED:
provenance = " (performed personally)"
SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED:
provenance = " (witnessed)"
SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED:
var source_name: String = name_map.get(
known_record.get_source_npc_id(), "someone"
)
provenance = " (heard from %s)" % source_name
SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY:
provenance = " (source not recorded)"
return "Known fact: %s%s" % [latest_event.description(name_map), provenance]