feat: add bounded npc memory retention

This commit is contained in:
Rijad Zuzo
2026-07-12 01:10:10 +02:00
parent 9a479ba182
commit 99b8146f75
17 changed files with 945 additions and 126 deletions
+7 -1
View File
@@ -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
+3 -2
View File
@@ -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;
+7 -2
View File
@@ -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
+21 -10
View File
@@ -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.
+18 -3
View File
@@ -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:
+16 -10
View File
@@ -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:
+33 -11
View File
@@ -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 v1v5 layouts;
supported v1v6 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
+67 -5
View File
@@ -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"]
+105 -9
View File
@@ -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"])
+45 -6
View File
@@ -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)
+101 -37
View File
@@ -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)
+5 -2
View File
@@ -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"
)
@@ -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)
@@ -0,0 +1 @@
uid://31vf2sh14nwe
+157 -17
View File
@@ -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()
+41 -5
View File
@@ -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])
+31 -6
View File
@@ -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))]
)