From 9a479ba182e693d33c7f905c3b889bfce27fe1fc Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sat, 11 Jul 2026 19:58:47 +0200 Subject: [PATCH] feat: add provenance-aware npc communication --- docs/ACTION_SYSTEM_ARCHITECTURE.md | 11 +- docs/ARCHITECTURE_OVERVIEW.md | 5 +- docs/BUILD_IN_PUBLIC_PLAN.md | 11 +- docs/ECONOMIC_EVENTS.md | 32 +- docs/LEARNING_ROADMAP.md | 24 +- docs/PROJECT_CONTEXT.md | 34 +- docs/SIMULATION_STATE_SCHEMA.md | 36 ++- simulation/SimulationManager.gd | 97 +++++- simulation/definitions/SimulationIds.gd | 5 + simulation/knowledge/EventKnowledgeSystem.gd | 49 ++- simulation/state/KnownEventStateRecord.gd | 40 ++- simulation/state/SimulationStateRecord.gd | 115 ++++++- ...communicated_knowledge_consequence_test.gd | 298 ++++++++++++++++++ ...unicated_knowledge_consequence_test.gd.uid | 1 + tests/jajce_runtime_integration_test.gd | 24 ++ tests/simulation_state_serialization_test.gd | 153 ++++++++- tests/unit/test_simulation_services.gd | 53 ++++ world/ui/ui.gd | 20 +- 18 files changed, 938 insertions(+), 70 deletions(-) create mode 100644 tests/communicated_knowledge_consequence_test.gd create mode 100644 tests/communicated_knowledge_consequence_test.gd.uid diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index 035c634..372744f 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -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 late shortfall suppresses the effect and records a `task_blocked` fact. The manager also captures actor/nearby knowledge before forwarding newly recorded -events into `RelationshipSystem`. It exposes knowledge, relationship, and cause -queries without moving social authority into UI. +events into `RelationshipSystem`. When an NPC arrives beside a worker at the +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 @@ -94,6 +97,10 @@ queries without moving social authority into UI. history; - initially observes successful food-deposit events for the actor and living 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 during restore; - answers deterministic known-event and knower queries without formatting diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index d0b96fa..7f5e1d8 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -15,7 +15,7 @@ SimulationClock -> ActionTargetResolver resolves a stable target ID -> VillageEconomy performs inventory/storage transactions -> SimulationEventLog records completed facts - -> EventKnowledgeSystem records actor/nearby knowledge + -> EventKnowledgeSystem records actor/nearby knowledge and bounded transfers -> RelationshipSystem applies evidence-gated social consequences -> WorldViewManager presents travel and NPC state -> ActiveWorldAdapter supplies loaded-world positions/capacity @@ -33,7 +33,8 @@ would otherwise obscure that lifecycle: - `simulation/events/SimulationEventLog.gd` owns ordered event identity, history queries, and rate calculations; - `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 queries, event-driven trust changes, and deterministic social tie-breaking; - `simulation/persistence/` owns save-slot file safety; diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 06e5a74..cdc50b4 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -689,12 +689,17 @@ Completed: 21. Witnessed event knowledge: objective food-deposit events create separate known-event references for their actor and nearby living NPCs. Knowledge 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: -1. Add one provenance-aware NPC-to-NPC fact transfer, then bounded - importance/retention rules, as sequenced in `LEARNING_ROADMAP.md`. +1. Add bounded importance/retention rules for known facts, as sequenced in + `LEARNING_ROADMAP.md`. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index 1aaaa80..d188f58 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -37,12 +37,13 @@ transfer occurred. ## 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 -per-NPC known-event references. Schema v1 and v2 saves migrate to an empty -stream beginning at ID zero. Parsing rejects duplicate event IDs, invalid or -duplicate knowledge references, relationship causes the observer does not -know, and a next ID that could collide with restored history. +per-NPC known-event references with first-acquisition provenance. Schema v1 and +v2 saves migrate to an empty stream beginning at ID zero. Parsing rejects +duplicate event IDs, invalid or duplicate knowledge references, impossible +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 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 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: ```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 large populations or long-running worlds, add measured retention, -archival/summary rules, and query indexes. Proximity is the only current witness -rule; line of sight, hearing, acquisition provenance, communication, secrecy, -false beliefs, multi-event causal graphs, and memory retention belong in later -event/history slices. They should extend this record family without making -prose authoritative or recomputing old evidence from current positions. +archival/summary rules, and query indexes. Proximity is the only current direct +witness rule, and communication is intentionally one-hop and tied to shared +activity arrival. Line of sight, hearing, acquisition timestamps, multi-hop +rumours, secrecy, false beliefs, multi-event causal graphs, and memory +retention belong in later event/history slices. They should extend this record +family without making prose authoritative or recomputing old evidence from +current positions. diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 36430dd..fb2c163 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -668,16 +668,26 @@ The first witnessed-knowledge slice is also complete: - two equally familiar guards with different evidence now gain different trust and choose helping versus ordinary patrol; - 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. -This proves the first Milestone 6 evidence-to-choice exit path without claiming -full memory or belief simulation. The practical next sequence is: +The first communicated-knowledge slice is complete: -1. Add acquisition provenance and one bounded NPC-to-NPC fact transfer so - information can travel beyond direct witnesses without replaying events. -2. Add importance/retention rules for known facts before expanding into - distortion, secrecy, rumours, or opportunity generation. +- first acquisition is persisted as performed, witnessed, communicated, or + legacy, with the direct speaker retained for communicated facts; +- a villager arriving beside an already-working villager at a shared + 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: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 50f51bb..d766f7a 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -414,8 +414,9 @@ Focused `RefCounted` collaborators keep rule ownership visible: - `VillageEconomy` owns storage/inventory transactions and synchronized village resource views; - `SimulationEventLog` owns deterministic event history and queries; -- `EventKnowledgeSystem` owns per-NPC known-event references and captures - actor/nearby evidence when a witnessable event is recorded; +- `EventKnowledgeSystem` owns per-NPC known-event references, immutable + first-acquisition provenance, actor/nearby evidence capture, and bounded + one-hop communication; - `RelationshipSystem` owns directed relationship state, event-driven trust consequences, and deterministic social queries. @@ -476,6 +477,7 @@ NpcVisual navigates through the active world | | | v | SimulationManager marks NPC as working + | -> at a shared activity, one nearby worker may communicate one direct fact | | | v | Later ticks complete work @@ -521,12 +523,13 @@ NpcVisual navigates through the active world │ ├── definitions/ Stable IDs and custom definition resources │ ├── economy/ Inventory and storage transactions │ ├── 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 │ ├── relationships/ Directed social consequences and queries │ └── state/ Versioned simulation-state records ├── tests/ │ ├── action_system_boundaries_test.gd +│ ├── communicated_knowledge_consequence_test.gd │ ├── deterministic_simulation_test.gd │ ├── food_storage_loop_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 the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. - 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 persistence remain deferred. - 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 located items. - NPCs have home positions, schedule periods, carried food/wood, and directed - familiarity/trust. They can retain direct proximity-witness knowledge of a - food deposit, but do not yet have wider social dimensions, goals, line of - sight/hearing evidence, memory decay, false beliefs, or communication. -- The reason inspector exposes current decisions, utility rejections, and one - exact relationship cause plus the latest known fact, but deeper historical - decision traces are not yet retained. + familiarity/trust. They retain direct or one-hop communicated knowledge of a + food deposit with first-acquisition provenance, but do not yet have wider + social dimensions, goals, line-of-sight/hearing evidence, importance/decay, + false beliefs, or multi-hop rumours. +- The reason inspector exposes current decisions, utility rejections, one exact + 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. - Unloaded traveling NPCs preserve their state but do not yet advance through abstract travel time. @@ -827,10 +831,12 @@ simulation-garden runtime capture are complete. The first relationship and knowledge consequences are also complete: food-deposit events become separate known facts for their actor and nearby -living NPCs; only an informed hungry familiar NPC gains directed trust. The -exact event remains visible as both known fact and relationship cause, and that -trust can redirect ordinary work toward helping a starving acquaintance. One -provenance-aware NPC-to-NPC fact transfer is the next systems slice. +living NPCs; only an informed hungry familiar NPC gains directed trust. At a +shared non-storage activity, an already-working direct knower can tell one fact +to an arriving nearby villager. The same event remains visible as known fact +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: diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 5e6f983..d039341 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -3,7 +3,7 @@ ## Current contract `SimulationStateRecord` is the versioned JSON boundary for the current -simulation. The current world schema is v5 and captures: +simulation. The current world schema is v6 and captures: - simulation seed, tick interval, tick count, clock remainder, and elapsed 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 ID that last changed trust; - 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: ```json { "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 round-tripping, presentation unload/rebind, directed relationship/cause -round-tripping, divergent known-event state, and rejection of unsupported -schemas. +round-tripping, divergent known-event state, communicated provenance, and +rejection of unsupported schemas. NPCStateRecord v2 adds the resolved travel destination and whether it is 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 schema used village-wide awareness, so this preserves the implied known fact -without falsely inventing spatial witness provenance. New v5 facts are created -only at event-record time from the actor and nearby NPC positions; historical -events are never re-evaluated against current positions. +without falsely inventing spatial witness provenance. + +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 @@ -146,10 +161,11 @@ This phase does not yet provide: - a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles; - migrations from any historical world schema other than the explicitly - supported v1–v4 layouts; + supported v1–v5 layouts; - player inventory or player relationship records; - 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. Those features should build on this boundary rather than inventing parallel diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 83f7ca4..6ad1643 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -16,6 +16,7 @@ signal state_restored signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult) signal relationship_changed(relationship: RelationshipStateRecord, cause_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() @@ -52,6 +53,7 @@ var action_executor := ActionExecutionSystem.new() var target_resolver := ActionTargetResolver.new() var speed_index := 2 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) 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.start_working() + var working_speakers: Array[SimNPC] = [] if npc.target_id != &"": for other in npcs: if other.id == npc.id or other.is_dead: continue + if other.target_id != npc.target_id: + continue if ( - other.target_id == npc.target_id - and ( - other.task_state - 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) + 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: print( @@ -583,14 +591,81 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void: event, npcs ) for learned_record in learned_records: - event_knowledge_changed.emit(learned_record.get_knower_id(), event) - var knower_ids: Array[int] = event_knowledge_system.get_knowers(int(event.data["event_id"])) + _apply_new_event_knowledge(learned_record, event) + 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( - event, npcs, knower_ids + event, npcs, newly_informed_ids ) for relationship in changed_relationships: 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: @@ -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) +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: return "%.2fx" % SPEED_LEVELS[speed_index] diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 11622b6..782fe30 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -41,6 +41,11 @@ const EVENT_TASK_STARTED := &"task_started" const EVENT_TASK_BLOCKED := &"task_blocked" 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: return StringName("npc_%d_inventory" % npc_id) diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index 40ac766..36ca729 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -13,7 +13,8 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno var actor := _find_npc(int(event.data["actor_id"]), npcs) if actor == null: 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: learned.append(actor_record) var witness_radius_squared := WITNESS_RADIUS * WITNESS_RADIUS @@ -23,17 +24,50 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno continue if npc.position.distance_squared_to(event_position) > witness_radius_squared: 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: learned.append(witness_record) learned.sort_custom(_sort_records) 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: 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]: var knower_ids: Array[int] = [] 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 -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) if known_events.has(key): 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 return record diff --git a/simulation/state/KnownEventStateRecord.gd b/simulation/state/KnownEventStateRecord.gd index bf76a8d..5815149 100644 --- a/simulation/state/KnownEventStateRecord.gd +++ b/simulation/state/KnownEventStateRecord.gd @@ -1,7 +1,15 @@ class_name KnownEventStateRecord 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 @@ -10,7 +18,12 @@ func _init(record_data: Dictionary = {}) -> void: 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 ( KnownEventStateRecord . new( @@ -18,6 +31,8 @@ static func create(knower_id: int, event_id: int) -> KnownEventStateRecord: "schema_version": SCHEMA_VERSION, "knower_id": knower_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: if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: 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 var knower_id := int(record_data["knower_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: 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: @@ -43,5 +67,13 @@ func get_event_id() -> int: 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: return data.duplicate(true) diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 3237edd..90f22ab 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,11 +2,12 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 5 +const SCHEMA_VERSION := 6 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 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 village: VillageStateRecord @@ -74,6 +75,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION, + KNOWLEDGE_LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, ] ): @@ -207,6 +209,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null var knowledge_keys := {} + var knowledge_records_by_key := {} for item in knowledge_data: if not item is Dictionary: return null @@ -221,7 +224,13 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if knowledge_keys.has(knowledge_key): return null knowledge_keys[knowledge_key] = true + knowledge_records_by_key[knowledge_key] = 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 := {} for item in relationship_data: @@ -266,6 +275,54 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: 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: var migrated := legacy_data.duplicate(true) 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] ): 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 @@ -377,3 +448,41 @@ static func _knowledge_dictionary_sort(first: Dictionary, second: Dictionary) -> if 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"]) + + +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 diff --git a/tests/communicated_knowledge_consequence_test.gd b/tests/communicated_knowledge_consequence_test.gd new file mode 100644 index 0000000..450264c --- /dev/null +++ b/tests/communicated_knowledge_consequence_test.gd @@ -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) diff --git a/tests/communicated_knowledge_consequence_test.gd.uid b/tests/communicated_knowledge_consequence_test.gd.uid new file mode 100644 index 0000000..af8aa08 --- /dev/null +++ b/tests/communicated_knowledge_consequence_test.gd.uid @@ -0,0 +1 @@ +uid://x8jugu64gj2o diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 427d435..c242bc0 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -44,6 +44,9 @@ func _run() -> void: ) var contributor: SimNPC = simulation_manager.npcs[0] 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 witness.position = Vector3(4.0, 0.0, 0.0) witness.hunger = 85.0 @@ -61,11 +64,32 @@ func _run() -> void: _check( ( "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 "Because:" in inspector_label.text ), "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( main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"), "Playable runtime should instance the Jajce Terrain3D world" diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index aedcbaa..3d5a59b 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -16,6 +16,7 @@ func _run() -> void: _test_previous_world_event_migration() _test_previous_world_relationship_migration() _test_previous_world_knowledge_migration() + _test_previous_world_provenance_migration() _test_relationship_schema_rejection() _test_schema_rejection() @@ -266,7 +267,7 @@ func _test_previous_world_relationship_migration() -> void: func _test_previous_world_knowledge_migration() -> void: var manager := _create_manager(59) 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") var deposit_event := EconomicEventRecord.create( 0, @@ -291,12 +292,72 @@ func _test_previous_world_knowledge_migration() -> void: migrated.event_knowledge.size() == 1 and migrated.event_knowledge[0].get_knower_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" ) 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: var manager := _create_manager(58) 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, "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() diff --git a/tests/unit/test_simulation_services.gd b/tests/unit/test_simulation_services.gd index 49b352a..826a6a8 100644 --- a/tests/unit/test_simulation_services.gd +++ b/tests/unit/test_simulation_services.gd @@ -128,3 +128,56 @@ func test_event_knowledge_is_spatial_directed_and_idempotent() -> void: assert_true(knowledge_system.knows_event(nearby.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_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)) diff --git a/world/ui/ui.gd b/world/ui/ui.gd index fb90081..e996307 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -288,4 +288,22 @@ func _build_known_fact_display(npc: SimNPC) -> String: for other_npc in simulation_manager.npcs: name_map[other_npc.id] = other_npc.npc_name 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]