feat: add provenance-aware npc communication

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