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
+88 -9
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]
)
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