From 99b8146f750500d8095d5f0f410c2cb88725beea Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 12 Jul 2026 01:10:10 +0200 Subject: [PATCH] feat: add bounded npc memory retention --- docs/ACTION_SYSTEM_ARCHITECTURE.md | 8 +- docs/ARCHITECTURE_OVERVIEW.md | 5 +- docs/BUILD_IN_PUBLIC_PLAN.md | 9 +- docs/ECONOMIC_EVENTS.md | 31 +- docs/LEARNING_ROADMAP.md | 21 +- docs/PROJECT_CONTEXT.md | 26 +- docs/SIMULATION_STATE_SCHEMA.md | 44 ++- simulation/SimulationManager.gd | 72 ++++- simulation/knowledge/EventKnowledgeSystem.gd | 114 ++++++- simulation/state/KnownEventStateRecord.gd | 51 +++- simulation/state/SimulationStateRecord.gd | 138 ++++++--- tests/jajce_runtime_integration_test.gd | 7 +- tests/knowledge_retention_consequence_test.gd | 287 ++++++++++++++++++ ...nowledge_retention_consequence_test.gd.uid | 1 + tests/simulation_state_serialization_test.gd | 174 +++++++++-- tests/unit/test_simulation_services.gd | 46 ++- world/ui/ui.gd | 37 ++- 17 files changed, 945 insertions(+), 126 deletions(-) create mode 100644 tests/knowledge_retention_consequence_test.gd create mode 100644 tests/knowledge_retention_consequence_test.gd.uid diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index 372744f..6b9d5dc 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -76,7 +76,9 @@ 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. +authority into UI. After consequences are known, it protects current causal +facts, enforces the recent-memory cap, and runs age review from authoritative +simulation ticks. ### VillageEconomy @@ -101,6 +103,10 @@ authority into UI. 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; +- orders communication by lasting relationship relevance, then acquisition + recency; +- retains current relationship causes and at most three other recent facts per + NPC, expiring day-old recent facts at deterministic review boundaries; - 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 7f5e1d8..32d79a9 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 and bounded transfers + -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> RelationshipSystem applies evidence-gated social consequences -> WorldViewManager presents travel and NPC state -> ActiveWorldAdapter supplies loaded-world positions/capacity @@ -34,7 +34,8 @@ would otherwise obscure that lifecycle: history queries, and rate calculations; - `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to known objective events, immutable acquisition provenance, proximity witnesses - at record time, and one-hop direct fact transfer; + at record time, one-hop direct fact transfer, and deterministic recent-fact + retention; - `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 cdc50b4..ea7b69d 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -695,11 +695,16 @@ Completed: 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. +23. Bounded memory retention: acquisition time now drives fact recency; current + relationship causes remain lasting while each NPC keeps only three other + recent facts. A deterministic daily-sized review fades old routine facts, + conversations prefer lasting evidence, and the inspector shows both memory + counts and the displayed fact's retention class. Next: -1. Add bounded importance/retention rules for known facts, as sequenced in - `LEARNING_ROADMAP.md`. +1. Add a compact retained-memory/person-history view and one visible social + reaction cue, 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 d188f58..a2ac636 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -37,7 +37,7 @@ transfer occurred. ## Persistence and determinism -`SimulationStateRecord` schema v6 stores the ordered event stream, +`SimulationStateRecord` schema v7 stores the ordered event stream, `next_event_id`, directed relationships that may reference an exact event, and 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 @@ -63,6 +63,15 @@ 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. +Known facts are now bounded without deleting objective history. A fact is +lasting while it remains that NPC's current trust cause; other facts are recent. +An NPC keeps at most three recent facts, and a deterministic review once per +simulated-day-sized interval removes recent facts at least one day old. Current +causes remain so `Relationship` and `Because` never point at forgotten +evidence. Conversations prefer lasting direct facts, then the most recently +acquired direct fact. Communicated provenance snapshots how the speaker knew +the event, so a listener's memory stays valid after the speaker forgets. + The food-loop regression verifies this chain: ```text @@ -78,15 +87,17 @@ save/restore continuity. `NpcVisual` shows a small food sack whenever its authoritative inventory contains food. A newly loaded visual derives the same state directly from the NPC record, so unloading presentation does not lose the fact. +The selected-NPC inspector shows lasting/recent counts and labels the displayed +fact with both acquisition provenance and retention class. ## Deliberate limits -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 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. +The objective stream is currently kept in full for the small simulation +garden; only per-NPC known references are bounded. Before large populations or +long-running worlds, add measured 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, personalized reinforcement/decay, multi-hop rumours, secrecy, false +beliefs, and multi-event causal graphs 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 fb2c163..42dbe49 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -684,10 +684,25 @@ The first communicated-knowledge slice is complete: malformed-provenance rejection, event-history invariance, save/checksum, and informed-choice regressions cover the complete slice. +The first importance/retention slice is complete: + +- acquisition ticks order facts by when an NPC learned them rather than by the + objective event ID; +- a current relationship cause is lasting, while each NPC keeps at most three + other recent facts; +- a deterministic daily-sized review forgets recent facts at least one day old + without deleting objective event history or rolling back trust; +- communication prefers lasting direct evidence, and a source-method snapshot + keeps listener provenance valid after the teller forgets; +- the inspector shows lasting/recent counts, while schema-v7 migration, + boundary, capacity, cause-protection, save/checksum, and informed-choice + regressions cover the 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. +full belief simulation. The practical next sequence is a compact retained +memory/person-history view and a visible social-reaction cue, then one bounded +Milestone 7 rumour/opportunity proof without generalized distortion or +free-form dialogue. Recently completed: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index d766f7a..d2271b4 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -415,8 +415,8 @@ Focused `RefCounted` collaborators keep rule ownership visible: village resource views; - `SimulationEventLog` owns deterministic event history and queries; - `EventKnowledgeSystem` owns per-NPC known-event references, immutable - first-acquisition provenance, actor/nearby evidence capture, and bounded - one-hop communication; + first-acquisition time/provenance, actor/nearby evidence capture, bounded + one-hop communication, and deterministic recent-memory retention; - `RelationshipSystem` owns directed relationship state, event-driven trust consequences, and deterministic social queries. @@ -523,7 +523,7 @@ 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 facts, provenance, and bounded transfer +│ ├── knowledge/ Per-NPC facts, provenance, transfer, and retention │ ├── persistence/ Validated local save-slot storage │ ├── relationships/ Directed social consequences and queries │ └── state/ Versioned simulation-state records @@ -533,6 +533,7 @@ NpcVisual navigates through the active world │ ├── deterministic_simulation_test.gd │ ├── food_storage_loop_test.gd │ ├── jajce_world_scaffold_test.gd +│ ├── knowledge_retention_consequence_test.gd │ ├── jajce_runtime_integration_test.gd │ ├── npc_visual_lifecycle_test.gd │ ├── relationship_consequence_test.gd @@ -571,7 +572,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 v6. F5/F9 provide one + clock, and RNG state serialize through world schema v7. 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 @@ -586,12 +587,14 @@ These are expected prototype constraints, not necessarily isolated bugs: located items. - NPCs have home positions, schedule periods, carried food/wood, and directed 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. + food deposit with first-acquisition provenance. Current trust causes are + lasting; other knowledge is capped and reviewed after one simulated day. + NPCs do not yet have wider social dimensions, goals, line-of-sight/hearing + evidence, personalized reinforcement/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. + relationship cause, memory counts, and the latest known fact with its speaker + and retention class, but a broader person-history view is not yet present. - 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. @@ -836,7 +839,10 @@ 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. +retention now keep current relationship causes lasting, cap other memories, +and forget routine facts at deterministic daily-sized reviews without deleting +the objective event. A compact retained-memory/person-history view and visible +social-reaction cue are 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 d039341..f985617 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 v6 and captures: +simulation. The current world schema is v7 and captures: - simulation seed, tick interval, tick count, clock remainder, and elapsed clock ticks; @@ -18,14 +18,15 @@ simulation. The current world schema is v6 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, including first-acquisition method and communicator provenance. + copying it, including first-acquisition method, acquisition tick, and + historical communicator provenance. The top-level identity is: ```json { "schema": "the_steward.simulation", - "schema_version": 6 + "schema_version": 7 } ``` @@ -57,7 +58,7 @@ 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, communicated provenance, and -rejection of unsupported schemas. +deterministic retention boundaries, 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 @@ -120,10 +121,31 @@ 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. +provenance. At runtime, communication requires a distinct source who currently +knows the event through `performed` or `witnessed` acquisition. Persisted +records validate that direct-method snapshot even after the source forgets. +This enforces the current one-hop boundary and makes validation independent of +array ordering. + +SimulationStateRecord v7 / KnownEventStateRecord v3 adds `acquired_tick` and a +snapshot of the direct speaker's acquisition method. Direct facts use the +objective event tick; newly communicated facts use the conversation tick. The +speaker snapshot keeps historical provenance valid even if the speaker later +forgets their own copy, while runtime communication still requires a currently +known performed/witnessed fact and remains one-hop. + +World schema v6 migrates direct acquisition to the objective event tick. +Communicated and ambiguous legacy facts receive a fresh retention lease at the +saved simulation tick because their exact acquisition time was not previously +stored. The v6 source record supplies the persisted direct-method snapshot. + +Importance is intentionally derived rather than duplicated: a fact is +`lasting` while it is the current trust cause for one of that knower's directed +relationships; every other fact is `recent`. Current causal facts are protected +so relationship state remains explainable. Each NPC keeps at most three recent +facts, and recent facts at least one simulated day old are removed at the next +deterministic daily-sized review. The objective event stream is never pruned by +this rule. ## Resource authority @@ -161,11 +183,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–v5 layouts; + supported v1–v6 layouts; - player inventory or player relationship records; - broader relationship dimensions, line-of-sight/hearing evidence, - acquisition timestamps, importance/retention, memory decay, or multi-hop - rumours; + continuous or personalized memory decay, reinforcement, false beliefs, 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 6ad1643..a250f58 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -17,6 +17,7 @@ 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) +signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord) var village := SimVillage.new() @@ -158,6 +159,8 @@ func simulate_tick() -> void: if village_was_changed: village_changed.emit(village) + if tick_count % get_knowledge_review_interval() == 0: + _maintain_event_knowledge(true) if debug_logs: print(village.get_summary()) @@ -600,13 +603,15 @@ func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bo 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]) + var known_event_ids := event_knowledge_system.get_communicable_event_ids( + speaker.id, _get_lasting_event_ids(speaker.id) + ) + for event_id in known_event_ids: + var event := event_log.get_by_id(event_id) if event == null: continue var learned_record := event_knowledge_system.communicate_event( - event, speaker.id, listener.id + event, speaker.id, listener.id, tick_count ) if learned_record == null: continue @@ -648,13 +653,51 @@ func _can_communicate_at_shared_activity(speaker: SimNPC, listener: SimNPC) -> b 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, newly_informed_ids ) + event_knowledge_changed.emit(known_event.get_knower_id(), event) for relationship in changed_relationships: relationship_changed.emit(relationship, event) + _maintain_event_knowledge(false) + + +func _maintain_event_knowledge(expire_by_age: bool) -> void: + var forgotten := event_knowledge_system.maintain_retention( + tick_count, get_knowledge_review_interval(), _get_lasting_knowledge_records(), expire_by_age + ) + for known_event in forgotten: + var event := event_log.get_by_id(known_event.get_event_id()) + if event != null: + event_knowledge_forgotten.emit(known_event.get_knower_id(), event) + + +func _get_lasting_knowledge_records() -> Array[KnownEventStateRecord]: + var lasting: Array[KnownEventStateRecord] = [] + var seen := {} + for relationship in relationship_system.get_all_sorted(): + var event_id := relationship.get_last_trust_cause_event_id() + if event_id == RelationshipStateRecord.NO_CAUSE_EVENT: + continue + var record := event_knowledge_system.get_record(relationship.get_observer_id(), event_id) + if record == null: + continue + var key := "%d:%d" % [record.get_knower_id(), record.get_event_id()] + if seen.has(key): + continue + seen[key] = true + lasting.append(record) + return lasting + + +func _get_lasting_event_ids(npc_id: int) -> Array[int]: + var event_ids: Array[int] = [] + for record in _get_lasting_knowledge_records(): + if record.get_knower_id() == npc_id: + event_ids.append(record.get_event_id()) + event_ids.sort() + return event_ids func _find_npc_by_id(npc_id: int) -> SimNPC: @@ -695,6 +738,24 @@ func get_known_event_record(npc_id: int, event_id: int) -> KnownEventStateRecord return event_knowledge_system.get_record(npc_id, event_id) +func is_known_event_lasting(npc_id: int, event_id: int) -> bool: + return event_id in _get_lasting_event_ids(npc_id) + + +func get_memory_summary(npc_id: int) -> Dictionary: + var total := event_knowledge_system.get_known_event_ids(npc_id, 0).size() + var lasting := _get_lasting_event_ids(npc_id).size() + return {"lasting": lasting, "recent": maxi(total - lasting, 0)} + + +func get_knowledge_review_interval() -> int: + var active_cycle_duration := ( + clock.cycle_duration_seconds if clock != null else cycle_duration_seconds + ) + var active_tick_interval := clock.tick_interval if clock != null else tick_interval + return maxi(roundi(active_cycle_duration / maxf(active_tick_interval, 0.0001)), 1) + + func get_current_speed() -> String: return "%.2fx" % SPEED_LEVELS[speed_index] @@ -1018,6 +1079,7 @@ func restore_state(record: SimulationStateRecord) -> bool: npcs.append(npc_record.restore(debug_logs)) relationship_system.restore(record.relationships) event_knowledge_system.restore(record.event_knowledge) + _maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0) wander_random_sources.clear() var wander_streams: Array = record.simulation["wander_random_streams"] diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index 36ca729..975e230 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -2,6 +2,7 @@ class_name EventKnowledgeSystem extends RefCounted const WITNESS_RADIUS := 10.0 +const MAX_RECENT_FACTS_PER_NPC := 3 var known_events: Dictionary = {} @@ -14,7 +15,10 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno if actor == null: return learned var event_id := int(event.data["event_id"]) - var actor_record := _remember(actor.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED) + var acquired_tick := int(event.data["tick"]) + var actor_record := _remember( + actor.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, acquired_tick + ) if actor_record != null: learned.append(actor_record) var witness_radius_squared := WITNESS_RADIUS * WITNESS_RADIUS @@ -25,7 +29,7 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno if npc.position.distance_squared_to(event_position) > witness_radius_squared: continue var witness_record := _remember( - npc.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + npc.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, acquired_tick ) if witness_record != null: learned.append(witness_record) @@ -34,7 +38,7 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno func communicate_event( - event: EconomicEventRecord, speaker_id: int, listener_id: int + event: EconomicEventRecord, speaker_id: int, listener_id: int, acquired_tick: int ) -> KnownEventStateRecord: if ( event == null @@ -56,7 +60,12 @@ func communicate_event( ): return null return _remember( - listener_id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, speaker_id + listener_id, + event_id, + SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, + acquired_tick, + speaker_id, + speaker_record.get_acquisition_method() ) @@ -78,11 +87,14 @@ func get_knowers(event_id: int) -> Array[int]: func get_known_event_ids(knower_id: int, max_count: int = 3) -> Array[int]: - var event_ids: Array[int] = [] + var records: Array[KnownEventStateRecord] = [] for record in known_events.values(): if record.get_knower_id() == knower_id: - event_ids.append(record.get_event_id()) - event_ids.sort() + records.append(record) + records.sort_custom(_sort_by_acquisition) + var event_ids: Array[int] = [] + for record in records: + event_ids.append(record.get_event_id()) if max_count <= 0 or event_ids.size() <= max_count: return event_ids var recent_ids: Array[int] = [] @@ -91,6 +103,67 @@ func get_known_event_ids(knower_id: int, max_count: int = 3) -> Array[int]: return recent_ids +func get_communicable_event_ids(knower_id: int, lasting_event_ids: Array[int]) -> Array[int]: + var ranked: Array[Dictionary] = [] + for record in known_events.values(): + if record.get_knower_id() != knower_id: + continue + if record.get_acquisition_method() not in KnownEventStateRecord.DIRECT_ACQUISITION_METHODS: + continue + ( + ranked + . append( + { + "event_id": record.get_event_id(), + "acquired_tick": record.get_acquired_tick(), + "lasting": record.get_event_id() in lasting_event_ids, + } + ) + ) + ranked.sort_custom(_sort_communicable) + var event_ids: Array[int] = [] + for item in ranked: + event_ids.append(int(item["event_id"])) + return event_ids + + +func maintain_retention( + current_tick: int, + max_recent_age: int, + lasting_records: Array[KnownEventStateRecord], + expire_by_age: bool +) -> Array[KnownEventStateRecord]: + var lasting_keys := {} + for record in lasting_records: + lasting_keys[_key(record.get_knower_id(), record.get_event_id())] = true + var removal_keys := {} + var recent_by_knower := {} + for record in known_events.values(): + var key := _key(record.get_knower_id(), record.get_event_id()) + if lasting_keys.has(key): + continue + if expire_by_age and current_tick - record.get_acquired_tick() >= max_recent_age: + removal_keys[key] = true + continue + var recent: Array = recent_by_knower.get(record.get_knower_id(), []) + recent.append(record) + recent_by_knower[record.get_knower_id()] = recent + for recent in recent_by_knower.values(): + recent.sort_custom(_sort_by_acquisition) + var excess: int = recent.size() - MAX_RECENT_FACTS_PER_NPC + for index in range(maxi(excess, 0)): + var record := recent[index] as KnownEventStateRecord + removal_keys[_key(record.get_knower_id(), record.get_event_id())] = true + var forgotten: Array[KnownEventStateRecord] = [] + for record in get_all_sorted(): + var key := _key(record.get_knower_id(), record.get_event_id()) + if not removal_keys.has(key): + continue + known_events.erase(key) + forgotten.append(record) + return forgotten + + func get_all_sorted() -> Array[KnownEventStateRecord]: var records: Array[KnownEventStateRecord] = [] for record in known_events.values(): @@ -109,13 +182,20 @@ func _remember( knower_id: int, event_id: int, acquisition_method: StringName, - source_npc_id: int = KnownEventStateRecord.NO_SOURCE_NPC_ID + acquired_tick: int, + source_npc_id: int = KnownEventStateRecord.NO_SOURCE_NPC_ID, + source_acquisition_method: StringName = &"" ) -> KnownEventStateRecord: var key := _key(knower_id, event_id) if known_events.has(key): return null var record := KnownEventStateRecord.create( - knower_id, event_id, acquisition_method, source_npc_id + knower_id, + event_id, + acquisition_method, + source_npc_id, + acquired_tick, + source_acquisition_method ) known_events[key] = record return record @@ -144,3 +224,19 @@ static func _sort_records(first: KnownEventStateRecord, second: KnownEventStateR if first.get_knower_id() != second.get_knower_id(): return first.get_knower_id() < second.get_knower_id() return first.get_event_id() < second.get_event_id() + + +static func _sort_by_acquisition( + first: KnownEventStateRecord, second: KnownEventStateRecord +) -> bool: + if first.get_acquired_tick() != second.get_acquired_tick(): + return first.get_acquired_tick() < second.get_acquired_tick() + return first.get_event_id() < second.get_event_id() + + +static func _sort_communicable(first: Dictionary, second: Dictionary) -> bool: + if bool(first["lasting"]) != bool(second["lasting"]): + return bool(first["lasting"]) + if int(first["acquired_tick"]) != int(second["acquired_tick"]): + return int(first["acquired_tick"]) > int(second["acquired_tick"]) + return int(first["event_id"]) > int(second["event_id"]) diff --git a/simulation/state/KnownEventStateRecord.gd b/simulation/state/KnownEventStateRecord.gd index 5815149..3f3c50b 100644 --- a/simulation/state/KnownEventStateRecord.gd +++ b/simulation/state/KnownEventStateRecord.gd @@ -1,7 +1,7 @@ class_name KnownEventStateRecord extends RefCounted -const SCHEMA_VERSION := 2 +const SCHEMA_VERSION := 3 const NO_SOURCE_NPC_ID := -1 const VALID_ACQUISITION_METHODS := [ @@ -10,6 +10,10 @@ const VALID_ACQUISITION_METHODS := [ SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY, ] +const DIRECT_ACQUISITION_METHODS := [ + SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, +] var data: Dictionary @@ -22,7 +26,9 @@ static func create( knower_id: int, event_id: int, acquisition_method: StringName = SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY, - source_npc_id: int = NO_SOURCE_NPC_ID + source_npc_id: int = NO_SOURCE_NPC_ID, + acquired_tick: int = 0, + source_acquisition_method: StringName = &"" ) -> KnownEventStateRecord: return ( KnownEventStateRecord @@ -33,6 +39,8 @@ static func create( "event_id": event_id, "acquisition_method": String(acquisition_method), "source_npc_id": source_npc_id, + "acquired_tick": acquired_tick, + "source_acquisition_method": String(source_acquisition_method), } ) ) @@ -41,22 +49,45 @@ static func create( 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", "acquisition_method", "source_npc_id"]): + if not ( + record_data + . has_all( + [ + "knower_id", + "event_id", + "acquisition_method", + "source_npc_id", + "acquired_tick", + "source_acquisition_method", + ] + ) + ): 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: + var acquired_tick := int(record_data["acquired_tick"]) + var source_acquisition_method := StringName(record_data["source_acquisition_method"]) + if knower_id < 0 or event_id < 0 or acquired_tick < 0: return null 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: + if source_acquisition_method not in DIRECT_ACQUISITION_METHODS: + return null + elif source_npc_id != NO_SOURCE_NPC_ID or not source_acquisition_method.is_empty(): return null - return create(knower_id, event_id, acquisition_method, source_npc_id) + return create( + knower_id, + event_id, + acquisition_method, + source_npc_id, + acquired_tick, + source_acquisition_method + ) func get_knower_id() -> int: @@ -75,5 +106,13 @@ func get_source_npc_id() -> int: return int(data["source_npc_id"]) +func get_acquired_tick() -> int: + return int(data["acquired_tick"]) + + +func get_source_acquisition_method() -> StringName: + return StringName(data["source_acquisition_method"]) + + func to_dictionary() -> Dictionary: return data.duplicate(true) diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 90f22ab..5a01e63 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,12 +2,13 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 6 +const SCHEMA_VERSION := 7 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4 -const PREVIOUS_SCHEMA_VERSION := 5 +const PROVENANCE_LEGACY_SCHEMA_VERSION := 5 +const PREVIOUS_SCHEMA_VERSION := 6 var simulation: Dictionary var village: VillageStateRecord @@ -76,6 +77,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: EVENT_LEGACY_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION, KNOWLEDGE_LEGACY_SCHEMA_VERSION, + PROVENANCE_LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, ] ): @@ -114,6 +116,23 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: ] ): return null + var saved_tick_interval := float(simulation_data["tick_interval"]) + var saved_tick_count := int(simulation_data["tick_count"]) + var saved_clock_accumulator := float(simulation_data["clock_accumulator"]) + var saved_clock_elapsed_ticks := int(simulation_data["clock_elapsed_ticks"]) + if ( + not is_finite(saved_tick_interval) + or saved_tick_interval <= 0.0 + or saved_tick_count < 0 + or not is_finite(saved_clock_accumulator) + or saved_clock_accumulator < 0.0 + or saved_clock_elapsed_ticks < 0 + ): + return null + if simulation_data.has("cycle_duration_seconds"): + var saved_cycle_duration := float(simulation_data["cycle_duration_seconds"]) + if not is_finite(saved_cycle_duration) or saved_cycle_duration <= 0.0: + return null var wander_streams = simulation_data["wander_random_streams"] if not wander_streams is Array: return null @@ -209,7 +228,6 @@ 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 @@ -224,11 +242,10 @@ 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 + known_event_record, npc_ids, event_records_by_id, int(record.simulation["tick_count"]) ): return null @@ -279,7 +296,7 @@ static func _is_valid_knowledge_provenance( known_event: KnownEventStateRecord, npc_ids: Dictionary, event_records_by_id: Dictionary, - knowledge_records_by_key: Dictionary + current_tick: int ) -> bool: var event := event_records_by_id.get(known_event.get_event_id()) as EconomicEventRecord if event == null: @@ -287,6 +304,10 @@ static func _is_valid_knowledge_provenance( var method := known_event.get_acquisition_method() var knower_id := known_event.get_knower_id() var actor_id := int(event.data["actor_id"]) + var event_tick := int(event.data["tick"]) + var acquired_tick := known_event.get_acquired_tick() + if acquired_tick < event_tick or acquired_tick > current_tick: + return false if method == SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY: return true if ( @@ -297,29 +318,22 @@ static func _is_valid_knowledge_provenance( return false match method: SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED: - return knower_id == actor_id + return knower_id == actor_id and acquired_tick == event_tick SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED: - return knower_id != actor_id + return knower_id != actor_id and acquired_tick == event_tick 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, - ] - ) - ) + var source_method := known_event.get_source_acquisition_method() + if ( + (source_method == SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED) + != (source_npc_id == actor_id) + ): + return false + return true return false @@ -368,10 +382,13 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary ] ): migrated["event_knowledge"] = _migrate_relationship_causes( - migrated.get("relationships", []) + migrated.get("relationships", []), + int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0)) ) - migrated["event_knowledge"] = _migrate_known_event_provenance( - migrated.get("event_knowledge", []), migrated.get("economic_events", []) + migrated["event_knowledge"] = _migrate_known_event_retention( + migrated.get("event_knowledge", []), + migrated.get("economic_events", []), + int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0)) ) return migrated @@ -417,7 +434,9 @@ static func _relationship_dictionary_sort(first: Dictionary, second: Dictionary) return int(first["subject_id"]) < int(second["subject_id"]) -static func _migrate_relationship_causes(relationship_data: Variant) -> Array[Dictionary]: +static func _migrate_relationship_causes( + relationship_data: Variant, migration_tick: int +) -> Array[Dictionary]: var migrated_knowledge: Array[Dictionary] = [] var known_keys := {} if not relationship_data is Array: @@ -438,7 +457,17 @@ static func _migrate_relationship_causes(relationship_data: Variant) -> Array[Di continue known_keys[knowledge_key] = true migrated_knowledge.append( - KnownEventStateRecord.create(observer_id, cause_event_id).to_dictionary() + ( + KnownEventStateRecord + . create( + observer_id, + cause_event_id, + SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY, + KnownEventStateRecord.NO_SOURCE_NPC_ID, + migration_tick + ) + . to_dictionary() + ) ) migrated_knowledge.sort_custom(_knowledge_dictionary_sort) return migrated_knowledge @@ -450,17 +479,21 @@ static func _knowledge_dictionary_sort(first: Dictionary, second: Dictionary) -> return int(first["event_id"]) < int(second["event_id"]) -static func _migrate_known_event_provenance(knowledge_data: Variant, event_data: Variant) -> Array: +static func _migrate_known_event_retention( + knowledge_data: Variant, event_data: Variant, migration_tick: int +) -> Array: if not knowledge_data is Array: return [] - var actor_by_event_id := {} + var event_by_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) - ) + event_by_id[int(event_item.get("event_id", -1))] = event_item + var old_knowledge_by_key := {} + for item in knowledge_data: + if item is Dictionary and item.has_all(["knower_id", "event_id"]): + old_knowledge_by_key["%d:%d" % [int(item["knower_id"]), int(item["event_id"])]] = item var migrated_knowledge: Array = [] var can_sort := true for item in knowledge_data: @@ -471,17 +504,48 @@ static func _migrate_known_event_provenance(knowledge_data: Variant, event_data: 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"]): + var nested_version := int(item.get("schema_version", -1)) + if nested_version not in [1, 2] 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 + var event_item := event_by_id.get(event_id, {}) as Dictionary + var event_tick := int(event_item.get("tick", migration_tick)) + var event_actor_id := int(event_item.get("actor_id", -2)) + var acquisition_method := StringName( + item.get("acquisition_method", SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY) + ) + var source_npc_id := int(item.get("source_npc_id", KnownEventStateRecord.NO_SOURCE_NPC_ID)) + if nested_version == 1: + acquisition_method = SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY + source_npc_id = KnownEventStateRecord.NO_SOURCE_NPC_ID + if event_actor_id == knower_id: + acquisition_method = SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED + var acquired_tick := migration_tick + if acquisition_method in KnownEventStateRecord.DIRECT_ACQUISITION_METHODS: + acquired_tick = event_tick + var source_acquisition_method := &"" + if acquisition_method == SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED: + var source_item: Variant = old_knowledge_by_key.get( + "%d:%d" % [source_npc_id, event_id], {} + ) + if source_item is Dictionary: + source_acquisition_method = StringName(source_item.get("acquisition_method", &"")) migrated_knowledge.append( - KnownEventStateRecord.create(knower_id, event_id, acquisition_method).to_dictionary() + ( + KnownEventStateRecord + . create( + knower_id, + event_id, + acquisition_method, + source_npc_id, + acquired_tick, + source_acquisition_method + ) + . to_dictionary() + ) ) if can_sort: migrated_knowledge.sort_custom(_knowledge_dictionary_sort) diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index c242bc0..fa2aaa7 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -50,6 +50,7 @@ func _run() -> void: contributor.position = Vector3.ZERO witness.position = Vector3(4.0, 0.0, 0.0) witness.hunger = 85.0 + listener.hunger = 20.0 contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) _check( is_equal_approx( @@ -64,7 +65,8 @@ func _run() -> void: _check( ( "Known fact: %s deposited" % contributor.npc_name in inspector_label.text - and "(witnessed)" in inspector_label.text + and "witnessed · lasting" in inspector_label.text + and "Memory: 1 lasting · 0 recent" in inspector_label.text and "Relationship: %s" % contributor.npc_name in inspector_label.text and "Because:" in inspector_label.text ), @@ -86,7 +88,8 @@ func _run() -> void: _check( ( "Known fact: %s deposited" % contributor.npc_name in inspector_label.text - and "(heard from %s)" % witness.npc_name in inspector_label.text + and "heard from %s · recent" % witness.npc_name in inspector_label.text + and "Memory: 0 lasting · 1 recent" in inspector_label.text ), "NPC inspector should identify who communicated a known fact" ) diff --git a/tests/knowledge_retention_consequence_test.gd b/tests/knowledge_retention_consequence_test.gd new file mode 100644 index 0000000..6d6328c --- /dev/null +++ b/tests/knowledge_retention_consequence_test.gd @@ -0,0 +1,287 @@ +extends SceneTree + +var failures: Array[String] = [] +var forgotten_pairs: 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 lasting_listener: SimNPC = manager.npcs[2] + var recent_listener: SimNPC = manager.npcs[3] + for npc in manager.npcs: + npc.position = Vector3(40.0 + npc.id * 2.0, 0.0, 0.0) + contributor.position = Vector3.ZERO + witness.position = Vector3(4.0, 0.0, 0.0) + lasting_listener.position = Vector3(20.0, 0.0, 0.0) + recent_listener.position = Vector3(22.0, 0.0, 0.0) + witness.hunger = 20.0 + lasting_listener.hunger = 85.0 + recent_listener.hunger = 20.0 + contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0) + _check( + is_equal_approx( + manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD), 2.0 + ), + "Retention should begin from a real completed food deposit" + ) + var deposit_event := _find_deposit_event(manager, contributor.id) + _check(deposit_event != null, "The objective deposit fact should exist") + if deposit_event == null: + manager.free() + _finish() + return + var event_id := int(deposit_event.data["event_id"]) + + _prepare_worker(witness, Vector3(20.0, 0.0, 0.0), true) + _prepare_worker(lasting_listener, Vector3(21.0, 0.0, 0.0), false) + manager.notify_npc_arrived(lasting_listener.id) + _prepare_worker(recent_listener, Vector3(22.0, 0.0, 0.0), false) + manager.notify_npc_arrived(recent_listener.id) + _check( + ( + manager.npc_knows_event(lasting_listener.id, event_id) + and manager.npc_knows_event(recent_listener.id, event_id) + ), + "The direct witness should communicate the same fact to both listeners" + ) + var lasting_record: KnownEventStateRecord = manager.get_known_event_record( + lasting_listener.id, event_id + ) + var recent_record: KnownEventStateRecord = manager.get_known_event_record( + recent_listener.id, event_id + ) + _check( + ( + lasting_record != null + and recent_record != null + and lasting_record.get_acquired_tick() == manager.tick_count + and recent_record.get_acquired_tick() == manager.tick_count + and ( + lasting_record.get_source_acquisition_method() + == SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + ) + ), + "Communicated memories should retain acquisition time and direct-source proof" + ) + _check( + manager.is_known_event_lasting(lasting_listener.id, event_id), + "A fact that changed trust should become a lasting relationship cause" + ) + _check( + not manager.is_known_event_lasting(recent_listener.id, event_id), + "A fact without a social consequence should remain recent" + ) + _check( + ( + manager.get_memory_summary(lasting_listener.id) == {"lasting": 1, "recent": 0} + and manager.get_memory_summary(recent_listener.id) == {"lasting": 0, "recent": 1} + ), + "Memory summaries should distinguish lasting and recent knowledge" + ) + + var objective_event_count: int = manager.economic_events.size() + var objective_next_event_id: int = manager.next_event_id + for npc in manager.npcs: + npc.set_task(SimulationIds.ACTION_REST, 1000.0) + npc.target_id = &"retention_hold" + npc.start_working() + var review_interval: int = manager.get_knowledge_review_interval() + for _step in range(review_interval - 1): + manager.simulate_tick() + _check( + manager.npc_knows_event(recent_listener.id, event_id), + "Recent knowledge should survive until the exact daily review boundary" + ) + + var saved_json: String = manager.serialize_state() + var boundary_data = JSON.parse_string(saved_json) + boundary_data["simulation"]["tick_count"] = review_interval + var boundary_restored := _create_manager(998) + _check( + boundary_restored.restore_state_from_json(JSON.stringify(boundary_data)), + "A save loaded on a review boundary should restore successfully" + ) + _check( + ( + boundary_restored.npc_knows_event(lasting_listener.id, event_id) + and not boundary_restored.npc_knows_event(recent_listener.id, event_id) + and not boundary_restored.npc_knows_event(witness.id, event_id) + and not boundary_restored.npc_knows_event(contributor.id, event_id) + ), + "Loading exactly on a review boundary should immediately expire stale recent facts" + ) + var restored := _create_manager(999) + _check( + restored.restore_state_from_json(saved_json), + "Retention metadata should restore immediately before review" + ) + manager.event_knowledge_forgotten.connect(_on_event_knowledge_forgotten) + manager.simulate_tick() + restored.simulate_tick() + _check( + restored.get_state_checksum() == manager.get_state_checksum(), + "Original and restored simulations should forget the same facts at the same tick" + ) + _check( + ( + manager.npc_knows_event(lasting_listener.id, event_id) + and not manager.npc_knows_event(recent_listener.id, event_id) + and not manager.npc_knows_event(witness.id, event_id) + and not manager.npc_knows_event(contributor.id, event_id) + ), + "Daily review should keep only the lasting causal memory" + ) + _check( + forgotten_pairs == ["0:%d" % event_id, "1:%d" % event_id, "3:%d" % event_id], + "Forgetting signals should be emitted once in stable knower/event order" + ) + _check( + ( + manager.economic_events.size() == objective_event_count + and manager.next_event_id == objective_next_event_id + ), + "Forgetting knowledge must not prune or replay objective event history" + ) + var lasting_relationship: RelationshipStateRecord = ( + manager.relationship_system.get_relationship(lasting_listener.id, contributor.id) + ) + _check( + ( + lasting_relationship != null + and is_equal_approx(lasting_relationship.get_trust(), 0.65) + and lasting_relationship.get_last_trust_cause_event_id() == event_id + ), + "Forgetting routine facts must preserve trust and its explainable current cause" + ) + var reload_probe := _create_manager(1001) + _check( + reload_probe.restore_state_from_json(manager.serialize_state()), + "A communicated memory should remain valid after its direct speaker forgets" + ) + + _prepare_worker(witness, Vector3(20.0, 0.0, 0.0), true) + _prepare_worker(recent_listener, Vector3(21.0, 0.0, 0.0), true) + var before_stale_share: String = manager.get_state_checksum() + _check( + not manager.try_communicate_at_shared_activity(witness.id, recent_listener.id), + "A direct witness who forgot a fact should no longer be able to share it" + ) + _check( + manager.get_state_checksum() == before_stale_share, + "Rejected stale communication should not mutate state" + ) + + _prepare_choice_divergence(manager, contributor, lasting_listener, recent_listener) + var lasting_choice: ActionSelectionResult = manager.action_selector.select_action( + lasting_listener, manager.village, 0.5, manager.npcs + ) + var recent_choice: ActionSelectionResult = manager.action_selector.select_action( + recent_listener, manager.village, 0.5, manager.npcs + ) + _check( + ( + lasting_choice.action_id == SimulationIds.ACTION_GATHER_FOOD + and contributor.npc_name in lasting_choice.reason + ), + "The lasting causal memory should preserve the informed helping choice" + ) + _check( + recent_choice.action_id == SimulationIds.ACTION_PATROL, + "The villager whose routine fact faded should continue ordinary guard work" + ) + + manager.free() + boundary_restored.free() + restored.free() + reload_probe.free() + _finish() + + +func _prepare_worker(npc: SimNPC, position: Vector3, start_working: bool) -> void: + npc.set_task(SimulationIds.ACTION_PATROL) + npc.target_id = &"guard_post" + npc.position = position + if start_working: + npc.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 + contributor.is_dead = false + for observer in [informed, control]: + observer.profession = SimulationIds.PROFESSION_GUARD + observer.hunger = 20.0 + observer.energy = 80.0 + observer.is_starving = false + observer.starvation_ticks = 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 = 831) -> 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 _on_event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord) -> void: + forgotten_pairs.append("%d:%d" % [knower_id, int(event.data["event_id"])]) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Knowledge retention passed: recent fades, lasting cause remains") + 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/knowledge_retention_consequence_test.gd.uid b/tests/knowledge_retention_consequence_test.gd.uid new file mode 100644 index 0000000..68d3621 --- /dev/null +++ b/tests/knowledge_retention_consequence_test.gd.uid @@ -0,0 +1 @@ +uid://31vf2sh14nwe diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index 3d5a59b..a08633c 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -17,6 +17,7 @@ func _run() -> void: _test_previous_world_relationship_migration() _test_previous_world_knowledge_migration() _test_previous_world_provenance_migration() + _test_previous_world_retention_migration() _test_relationship_schema_rejection() _test_schema_rejection() @@ -138,6 +139,15 @@ func _test_schema_rejection() -> void: ) _check(SimulationStateRecord.from_json("[]") == null, "Non-record JSON should be rejected") + var manager := _create_manager(41) + var invalid_clock_data: Dictionary = manager.create_state_record().to_dictionary() + invalid_clock_data["simulation"]["tick_interval"] = 0.0 + _check( + SimulationStateRecord.from_dictionary(invalid_clock_data) == null, + "Saved simulation clocks with a zero tick interval should be rejected" + ) + manager.free() + func _test_legacy_resource_migration() -> void: var migrated := ResourceStateRecord.from_dictionary( @@ -272,7 +282,7 @@ func _test_previous_world_knowledge_migration() -> void: var deposit_event := EconomicEventRecord.create( 0, SimulationIds.EVENT_STORAGE_DEPOSITED, - 12, + 0, 1, SimulationIds.npc_inventory_id(1), SimulationIds.STORAGE_VILLAGE_PANTRY, @@ -305,11 +315,11 @@ func _test_previous_world_knowledge_migration() -> void: 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 + previous_data["schema_version"] = SimulationStateRecord.PROVENANCE_LEGACY_SCHEMA_VERSION var deposit_event := EconomicEventRecord.create( 0, SimulationIds.EVENT_STORAGE_DEPOSITED, - 12, + 0, 1, SimulationIds.npc_inventory_id(1), SimulationIds.STORAGE_VILLAGE_PANTRY, @@ -358,6 +368,77 @@ func _test_previous_world_provenance_migration() -> void: manager.free() +func _test_previous_world_retention_migration() -> void: + var manager := _create_manager(61) + var previous_data: Dictionary = manager.create_state_record().to_dictionary() + previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION + previous_data["simulation"]["tick_count"] = 50 + var deposit_event := EconomicEventRecord.create( + 0, + SimulationIds.EVENT_STORAGE_DEPOSITED, + 0, + 0, + SimulationIds.npc_inventory_id(0), + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 2.0 + ) + previous_data["economic_events"] = [deposit_event.to_dictionary()] + previous_data["simulation"]["next_event_id"] = 1 + var actor_knowledge := ( + KnownEventStateRecord + . create(0, 0, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED) + . to_dictionary() + ) + var witness_knowledge := ( + KnownEventStateRecord + . create(2, 0, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED) + . to_dictionary() + ) + var communicated_knowledge := ( + KnownEventStateRecord + . create( + 1, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, + 2, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + ) + . to_dictionary() + ) + for old_record in [actor_knowledge, witness_knowledge, communicated_knowledge]: + old_record["schema_version"] = 2 + old_record.erase("acquired_tick") + old_record.erase("source_acquisition_method") + previous_data["event_knowledge"] = [communicated_knowledge, actor_knowledge, witness_knowledge] + previous_data["relationships"] = [] + var migrated := SimulationStateRecord.from_dictionary(previous_data) + _check(migrated != null, "World schema v6 should add deterministic retention metadata") + if migrated != null: + var records_by_knower := {} + for record in migrated.event_knowledge: + records_by_knower[record.get_knower_id()] = record + var migrated_actor := records_by_knower.get(0) as KnownEventStateRecord + var migrated_listener := records_by_knower.get(1) as KnownEventStateRecord + _check( + migrated_actor != null and migrated_actor.get_acquired_tick() == 0, + "Direct v6 knowledge should retain the objective event tick" + ) + _check( + ( + migrated_listener != null + and migrated_listener.get_acquired_tick() == 50 + and ( + migrated_listener.get_source_acquisition_method() + == SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + ) + ), + "Communicated v6 knowledge should receive a fresh lease and source snapshot" + ) + manager.free() + + func _test_relationship_schema_rejection() -> void: var manager := _create_manager(58) var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary() @@ -389,7 +470,7 @@ func _test_relationship_schema_rejection() -> void: var event := EconomicEventRecord.create( 0, SimulationIds.EVENT_STORAGE_DEPOSITED, - 1, + 0, 0, SimulationIds.npc_inventory_id(0), SimulationIds.STORAGE_VILLAGE_PANTRY, @@ -411,7 +492,7 @@ func _test_relationship_schema_rejection() -> void: var unrelated_deposit := EconomicEventRecord.create( 0, SimulationIds.EVENT_STORAGE_DEPOSITED, - 1, + 0, 2, SimulationIds.npc_inventory_id(2), SimulationIds.STORAGE_VILLAGE_PANTRY, @@ -435,7 +516,7 @@ func _test_relationship_schema_rejection() -> void: var nonpositive_deposit := EconomicEventRecord.create( 0, SimulationIds.EVENT_STORAGE_DEPOSITED, - 1, + 0, 1, SimulationIds.npc_inventory_id(1), SimulationIds.STORAGE_VILLAGE_PANTRY, @@ -456,26 +537,40 @@ func _test_relationship_schema_rejection() -> void: var provenance_event := EconomicEventRecord.create( 0, SimulationIds.EVENT_STORAGE_DEPOSITED, - 1, + 0, 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"] = [ + var historical_source_data: Dictionary = manager.create_state_record().to_dictionary() + historical_source_data["economic_events"] = [provenance_event.to_dictionary()] + historical_source_data["simulation"]["next_event_id"] = 1 + historical_source_data["event_knowledge"] = [ ( KnownEventStateRecord - . create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 2) + . create( + 1, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, + 2, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + ) . to_dictionary() ) ] _check( - SimulationStateRecord.from_dictionary(missing_source_data) == null, - "Communicated knowledge must reference a source who knows the same event" + SimulationStateRecord.from_dictionary(historical_source_data) != null, + "A listener's provenance should remain valid after the direct source forgets" + ) + + var unknown_source_data: Dictionary = historical_source_data.duplicate(true) + unknown_source_data["event_knowledge"][0]["source_npc_id"] = 999 + _check( + SimulationStateRecord.from_dictionary(unknown_source_data) == null, + "Communicated knowledge must reference an existing historical source" ) var wrong_actor_data: Dictionary = manager.create_state_record().to_dictionary() @@ -499,12 +594,26 @@ func _test_relationship_schema_rejection() -> void: relayed_source_data["event_knowledge"] = [ ( KnownEventStateRecord - . create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 2) + . create( + 1, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, + 2, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED + ) . to_dictionary() ), ( KnownEventStateRecord - . create(2, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 0) + . create( + 2, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, + 0, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED + ) . to_dictionary() ), ( @@ -517,6 +626,14 @@ func _test_relationship_schema_rejection() -> void: SimulationStateRecord.from_dictionary(relayed_source_data) == null, "Communicated facts must not form a second relay hop in this phase" ) + var reacquired_source_data: Dictionary = relayed_source_data.duplicate(true) + reacquired_source_data["event_knowledge"][0]["source_acquisition_method"] = String( + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + ) + _check( + SimulationStateRecord.from_dictionary(reacquired_source_data) != null, + "Historical direct provenance should survive a source reacquiring the fact differently" + ) var order_independent_data: Dictionary = manager.create_state_record().to_dictionary() order_independent_data["economic_events"] = [provenance_event.to_dictionary()] @@ -524,7 +641,14 @@ func _test_relationship_schema_rejection() -> void: order_independent_data["event_knowledge"] = [ ( KnownEventStateRecord - . create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, 2) + . create( + 1, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, + 2, + 0, + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + ) . to_dictionary() ), ( @@ -542,6 +666,22 @@ func _test_relationship_schema_rejection() -> void: SimulationStateRecord.from_dictionary(order_independent_data) != null, "Provenance validation should not depend on knowledge array ordering" ) + + var invalid_direct_time_data: Dictionary = manager.create_state_record().to_dictionary() + invalid_direct_time_data["simulation"]["tick_count"] = 2 + invalid_direct_time_data["economic_events"] = [provenance_event.to_dictionary()] + invalid_direct_time_data["simulation"]["next_event_id"] = 1 + invalid_direct_time_data["event_knowledge"] = [ + ( + KnownEventStateRecord + . create(1, 0, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 1) + . to_dictionary() + ) + ] + _check( + SimulationStateRecord.from_dictionary(invalid_direct_time_data) == null, + "Direct witness acquisition must use the immutable objective event tick" + ) manager.free() diff --git a/tests/unit/test_simulation_services.gd b/tests/unit/test_simulation_services.gd index 826a6a8..33f664f 100644 --- a/tests/unit/test_simulation_services.gd +++ b/tests/unit/test_simulation_services.gd @@ -162,16 +162,20 @@ func test_event_communication_preserves_first_provenance_and_stops_after_one_hop 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_null(knowledge_system.communicate_event(event, listener.id, relay_target.id, 50)) + var communicated := knowledge_system.communicate_event(event, witness.id, listener.id, 50) 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(communicated.get_acquired_tick(), 50) + assert_eq( + communicated.get_source_acquisition_method(), SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED + ) + assert_null(knowledge_system.communicate_event(event, actor.id, listener.id, 50)) 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_null(knowledge_system.communicate_event(event, listener.id, relay_target.id, 50)) 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] = [ @@ -180,4 +184,36 @@ func test_event_communication_preserves_first_provenance_and_stops_after_one_hop ) ] actor_gap_system.restore(direct_witness_records) - assert_null(actor_gap_system.communicate_event(event, witness.id, actor.id)) + assert_null(actor_gap_system.communicate_event(event, witness.id, actor.id, 50)) + + +func test_knowledge_retention_is_bounded_stable_and_importance_ranked() -> void: + var knowledge_system := EventKnowledgeSystemScript.new() + var records: Array[KnownEventStateRecord] = [ + KnownEventStateRecord.create(0, 30, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 1), + KnownEventStateRecord.create(0, 20, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 2), + KnownEventStateRecord.create(0, 10, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 3), + KnownEventStateRecord.create(0, 5, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 4), + ] + knowledge_system.restore(records) + var no_lasting: Array[KnownEventStateRecord] = [] + var capacity_forgotten := knowledge_system.maintain_retention(4, 100, no_lasting, false) + + assert_eq(capacity_forgotten.size(), 1) + assert_eq(capacity_forgotten[0].get_event_id(), 30) + assert_eq(knowledge_system.get_known_event_ids(0, 0), [20, 10, 5]) + var lasting: Array[KnownEventStateRecord] = [knowledge_system.get_record(0, 20)] + assert_eq(knowledge_system.maintain_retention(101, 100, lasting, true).size(), 0) + assert_true(knowledge_system.knows_event(0, 20)) + assert_eq(knowledge_system.maintain_retention(103, 100, lasting, true).size(), 1) + assert_false(knowledge_system.knows_event(0, 10)) + assert_true(knowledge_system.knows_event(0, 20)) + + var ranked_system := EventKnowledgeSystemScript.new() + var ranked_records: Array[KnownEventStateRecord] = [ + KnownEventStateRecord.create(1, 1, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 10), + KnownEventStateRecord.create(1, 2, SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, -1, 20), + ] + ranked_system.restore(ranked_records) + var lasting_event_ids: Array[int] = [1] + assert_eq(ranked_system.get_communicable_event_ids(1, lasting_event_ids), [1, 2]) diff --git a/world/ui/ui.gd b/world/ui/ui.gd index e996307..f3967f3 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -31,6 +31,8 @@ func _ready() -> void: simulation_manager.relationship_changed.connect(_on_relationship_changed) if simulation_manager.has_signal("event_knowledge_changed"): simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed) + if simulation_manager.has_signal("event_knowledge_forgotten"): + simulation_manager.event_knowledge_forgotten.connect(_on_event_knowledge_forgotten) if "village" in simulation_manager: _on_village_changed(simulation_manager.village) @@ -133,6 +135,10 @@ func _on_event_knowledge_changed(_knower_id: int, _event: EconomicEventRecord) - _refresh_npc_inspector() +func _on_event_knowledge_forgotten(_knower_id: int, _event: EconomicEventRecord) -> void: + _refresh_npc_inspector() + + func _refresh_npc_inspector() -> void: if not debug_overlay_visible: return @@ -182,6 +188,7 @@ func _refresh_npc_inspector() -> void: score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows) var event_text := _build_event_history(npc) var relationship_text := _build_relationship_display(npc) + var memory_text := _build_memory_display(npc) var known_fact_text := _build_known_fact_display(npc) npc_inspector_label.text = ( ( @@ -192,6 +199,7 @@ func _refresh_npc_inspector() -> void: + "Carrying food: %.0f wood: %.0f\n" + "%s\n" + "%s\n" + + "%s\n" + "Why\n%s%s\n\n" + "Recent\n%s\n\n" + "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" @@ -207,6 +215,7 @@ func _refresh_npc_inspector() -> void: npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD), relationship_text, + memory_text, known_fact_text, reason_text, score_text, @@ -288,7 +297,8 @@ 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 - var provenance := "" + var provenance := "source not recorded" + var retention := "recent" 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"]) @@ -296,14 +306,29 @@ func _build_known_fact_display(npc: SimNPC) -> String: if known_record != null: match known_record.get_acquisition_method(): SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED: - provenance = " (performed personally)" + provenance = "performed personally" SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED: - provenance = " (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 + 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] + provenance = "source not recorded" + if ( + simulation_manager.has_method("is_known_event_lasting") + and (simulation_manager.is_known_event_lasting(npc.id, int(latest_event.data["event_id"]))) + ): + retention = "lasting" + return "Known fact: %s (%s · %s)" % [latest_event.description(name_map), provenance, retention] + + +func _build_memory_display(npc: SimNPC) -> String: + if not simulation_manager.has_method("get_memory_summary"): + return "" + var summary: Dictionary = simulation_manager.get_memory_summary(npc.id) + return ( + "Memory: %d lasting · %d recent" + % [int(summary.get("lasting", 0)), int(summary.get("recent", 0))] + )