feat: add provenance-aware npc communication
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current contract
|
||||
|
||||
`SimulationStateRecord` is the versioned JSON boundary for the current
|
||||
simulation. The current world schema is v5 and captures:
|
||||
simulation. The current world schema is v6 and captures:
|
||||
|
||||
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
||||
clock ticks;
|
||||
@@ -18,14 +18,14 @@ simulation. The current world schema is v5 and captures:
|
||||
- directed relationship records with familiarity, trust, and the stable event
|
||||
ID that last changed trust;
|
||||
- per-NPC known-event records that reference objective event history without
|
||||
copying it.
|
||||
copying it, including first-acquisition method and communicator provenance.
|
||||
|
||||
The top-level identity is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "the_steward.simulation",
|
||||
"schema_version": 5
|
||||
"schema_version": 6
|
||||
}
|
||||
```
|
||||
|
||||
@@ -56,8 +56,8 @@ its future random sequence.
|
||||
|
||||
It also verifies clock remainder, resource amount/reservation/enabled
|
||||
round-tripping, presentation unload/rebind, directed relationship/cause
|
||||
round-tripping, divergent known-event state, and rejection of unsupported
|
||||
schemas.
|
||||
round-tripping, divergent known-event state, communicated provenance, and
|
||||
rejection of unsupported schemas.
|
||||
|
||||
NPCStateRecord v2 adds the resolved travel destination and whether it is
|
||||
active. Nested v1 NPC records migrate explicitly with no invented active
|
||||
@@ -106,9 +106,24 @@ a food deposit performed by the relationship subject.
|
||||
|
||||
World schema v4 migrates relationship causes into known-event records. The old
|
||||
schema used village-wide awareness, so this preserves the implied known fact
|
||||
without falsely inventing spatial witness provenance. New v5 facts are created
|
||||
only at event-record time from the actor and nearby NPC positions; historical
|
||||
events are never re-evaluated against current positions.
|
||||
without falsely inventing spatial witness provenance.
|
||||
|
||||
SimulationStateRecord v6 / KnownEventStateRecord v2 adds immutable
|
||||
first-acquisition provenance: `performed`, `witnessed`, `communicated`, or
|
||||
`legacy`. Communicated records also retain the stable NPC ID of the direct
|
||||
speaker. New direct facts are created only at event-record time from the actor
|
||||
and nearby NPC positions; historical events are never re-evaluated against
|
||||
current positions. A co-located shared-activity encounter can add one
|
||||
communicated reference to the original fact without appending or replaying the
|
||||
event.
|
||||
|
||||
World schema v5 migrates actor-owned knowledge to `performed`; non-actor
|
||||
knowledge becomes `legacy` because v5 may contain either a proximity witness or
|
||||
an older implied relationship fact. Parsing never invents that missing
|
||||
provenance. Current communicated records require a distinct existing source
|
||||
who knows the same event through `performed` or `witnessed` acquisition. This
|
||||
enforces the current one-hop boundary and makes validation independent of array
|
||||
ordering.
|
||||
|
||||
## Resource authority
|
||||
|
||||
@@ -146,10 +161,11 @@ This phase does not yet provide:
|
||||
|
||||
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
|
||||
- migrations from any historical world schema other than the explicitly
|
||||
supported v1–v4 layouts;
|
||||
supported v1–v5 layouts;
|
||||
- player inventory or player relationship records;
|
||||
- broader relationship dimensions, line-of-sight/hearing evidence,
|
||||
acquisition provenance, memory decay, or communication;
|
||||
acquisition timestamps, importance/retention, memory decay, or multi-hop
|
||||
rumours;
|
||||
- persistence for the player transform or presentation-only scene state.
|
||||
|
||||
Those features should build on this boundary rather than inventing parallel
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user