diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index 92fee85..5c17f97 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -2,7 +2,10 @@ class_name EventKnowledgeSystem extends RefCounted const WITNESS_RADIUS := 10.0 -const MAX_RECENT_FACTS_PER_NPC := 3 +const MAX_RECENT_FACTS_PER_KNOWER := 3 +const MAX_RECENT_FACTS_PER_NPC := MAX_RECENT_FACTS_PER_KNOWER +const MAX_PINNED_FACTS_PER_KNOWER := 4 +const COMMUNICATED_CONFIDENCE_FACTOR := 0.9 var known_events: Dictionary = {} @@ -18,7 +21,16 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno return learned var event_id := int(event.data["event_id"]) var acquired_tick := int(event.data["tick"]) - if actor != null: + if is_player_actor: + var player_record := _remember( + SimulationIds.PLAYER_ACTOR_ID, + event_id, + SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, + acquired_tick + ) + if player_record != null: + learned.append(player_record) + elif actor != null: var actor_record := _remember( actor.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, acquired_tick ) @@ -47,8 +59,11 @@ func communicate_event( ) -> KnownEventStateRecord: if ( event == null + or not KnownEventStateRecord.is_valid_actor_id(speaker_id) + or not KnownEventStateRecord.is_valid_actor_id(listener_id) or speaker_id == listener_id or listener_id == int(event.data["actor_id"]) + or acquired_tick < 0 or not is_knowable_event(event) ): return null @@ -70,10 +85,19 @@ func communicate_event( SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED, acquired_tick, speaker_id, - speaker_record.get_acquisition_method() + speaker_record.get_acquisition_method(), + speaker_record.get_hop_count() + 1, + speaker_record.get_confidence() * COMMUNICATED_CONFIDENCE_FACTOR, + speaker_record.get_salience() ) +func communicate_event_to_player( + event: EconomicEventRecord, speaker_id: int, acquired_tick: int +) -> KnownEventStateRecord: + return communicate_event(event, speaker_id, SimulationIds.PLAYER_ACTOR_ID, acquired_tick) + + func knows_event(knower_id: int, event_id: int) -> bool: return known_events.has(_key(knower_id, event_id)) @@ -82,6 +106,37 @@ func get_record(knower_id: int, event_id: int) -> KnownEventStateRecord: return known_events.get(_key(knower_id, event_id)) as KnownEventStateRecord +func pin_event(knower_id: int, event_id: int, pinned: bool = true) -> bool: + var record := get_record(knower_id, event_id) + if record == null or record.is_pinned() == pinned: + return false + if pinned and _get_pinned_count(knower_id) >= MAX_PINNED_FACTS_PER_KNOWER: + return false + return record.set_pinned(pinned) + + +func set_event_confidence(knower_id: int, event_id: int, confidence: float) -> bool: + var record := get_record(knower_id, event_id) + return record != null and record.set_confidence(confidence) + + +func set_event_salience(knower_id: int, event_id: int, salience: float) -> bool: + var record := get_record(knower_id, event_id) + return record != null and record.set_salience(salience) + + +func get_pinned_event_ids(knower_id: int) -> Array[int]: + var records: Array[KnownEventStateRecord] = [] + for record in known_events.values(): + if record.get_knower_id() == knower_id and record.is_pinned(): + records.append(record) + records.sort_custom(_sort_by_acquisition) + var event_ids: Array[int] = [] + for record in records: + event_ids.append(record.get_event_id()) + return event_ids + + func get_knowers(event_id: int) -> Array[int]: var knower_ids: Array[int] = [] for record in known_events.values(): @@ -146,6 +201,9 @@ func _get_ranked_event_ids( "event_id": record.get_event_id(), "acquired_tick": record.get_acquired_tick(), "lasting": record.get_event_id() in lasting_event_ids, + "pinned": record.is_pinned(), + "confidence": record.get_confidence(), + "salience": record.get_salience(), } ) ) @@ -171,7 +229,7 @@ func maintain_retention( var recent_by_knower := {} for record in known_events.values(): var key := _key(record.get_knower_id(), record.get_event_id()) - if lasting_keys.has(key): + if lasting_keys.has(key) or record.is_pinned(): continue if expire_by_age and current_tick - record.get_acquired_tick() >= max_recent_age: removal_keys[key] = true @@ -181,7 +239,7 @@ func maintain_retention( recent_by_knower[record.get_knower_id()] = recent for recent in recent_by_knower.values(): recent.sort_custom(_sort_by_acquisition) - var excess: int = recent.size() - MAX_RECENT_FACTS_PER_NPC + var excess: int = recent.size() - MAX_RECENT_FACTS_PER_KNOWER for index in range(maxi(excess, 0)): var record := recent[index] as KnownEventStateRecord removal_keys[_key(record.get_knower_id(), record.get_event_id())] = true @@ -214,24 +272,46 @@ func _remember( event_id: int, acquisition_method: StringName, acquired_tick: int, - source_npc_id: int = KnownEventStateRecord.NO_SOURCE_NPC_ID, - source_acquisition_method: StringName = &"" + source_actor_id: int = KnownEventStateRecord.NO_SOURCE_ACTOR_ID, + source_acquisition_method: StringName = &"", + hop_count: int = KnownEventStateRecord.AUTO_HOP_COUNT, + confidence: float = KnownEventStateRecord.DEFAULT_CONFIDENCE, + salience: float = KnownEventStateRecord.DEFAULT_SALIENCE, + pinned: bool = false ) -> KnownEventStateRecord: + if not KnownEventStateRecord.is_valid_actor_id(knower_id) or event_id < 0 or acquired_tick < 0: + return null var key := _key(knower_id, event_id) if known_events.has(key): return null + var source_npc_id := ( + source_actor_id if source_actor_id >= 0 else KnownEventStateRecord.NO_SOURCE_NPC_ID + ) var record := KnownEventStateRecord.create( knower_id, event_id, acquisition_method, source_npc_id, acquired_tick, - source_acquisition_method + source_acquisition_method, + hop_count, + confidence, + salience, + source_actor_id, + pinned ) known_events[key] = record return record +func _get_pinned_count(knower_id: int) -> int: + var count := 0 + for record in known_events.values(): + if record.get_knower_id() == knower_id and record.is_pinned(): + count += 1 + return count + + static func is_knowable_event(event: EconomicEventRecord) -> bool: if event == null: return false @@ -308,8 +388,14 @@ static func _sort_by_acquisition( static func _sort_by_importance(first: Dictionary, second: Dictionary) -> bool: + if bool(first["pinned"]) != bool(second["pinned"]): + return bool(first["pinned"]) if bool(first["lasting"]) != bool(second["lasting"]): return bool(first["lasting"]) + if not is_equal_approx(float(first["salience"]), float(second["salience"])): + return float(first["salience"]) > float(second["salience"]) + if not is_equal_approx(float(first["confidence"]), float(second["confidence"])): + return float(first["confidence"]) > float(second["confidence"]) if int(first["acquired_tick"]) != int(second["acquired_tick"]): return int(first["acquired_tick"]) > int(second["acquired_tick"]) return int(first["event_id"]) > int(second["event_id"]) diff --git a/simulation/relationships/RelationshipSystem.gd b/simulation/relationships/RelationshipSystem.gd index f3569c4..bed2e5e 100644 --- a/simulation/relationships/RelationshipSystem.gd +++ b/simulation/relationships/RelationshipSystem.gd @@ -5,6 +5,8 @@ const TRUSTED_HELP_THRESHOLD := 0.6 const FOOD_AID_TRUST_GAIN := 0.15 const SHARED_WORK_FAMILIARITY_GAIN := 0.1 const PLAYER_FAMILIARITY_SEED := 0.5 +const DEFAULT_SALIENT_RELATIONSHIP_LIMIT := 4 +const MAX_BOUNDED_RELATIONSHIPS_PER_QUERY := 8 var relationships: Dictionary = {} @@ -39,6 +41,86 @@ func get_relationship(observer_id: int, subject_id: int) -> RelationshipStateRec return relationships.get(_key(observer_id, subject_id)) as RelationshipStateRecord +func get_or_create_relationship( + observer_id: int, subject_id: int, initial_familiarity: float = 0.0 +) -> RelationshipStateRecord: + if ( + not RelationshipStateRecord.is_valid_actor_id(observer_id) + or not RelationshipStateRecord.is_valid_actor_id(subject_id) + or observer_id == subject_id + ): + return null + var relationship := get_relationship(observer_id, subject_id) + if relationship != null: + return relationship + relationship = RelationshipStateRecord.create(observer_id, subject_id, initial_familiarity) + _add(relationship) + return relationship + + +func adjust_relationship_dimension( + observer_id: int, + subject_id: int, + dimension: StringName, + amount: float, + cause_event_id: int, + create_if_missing: bool = true +) -> float: + var relationship := get_relationship(observer_id, subject_id) + if relationship == null and create_if_missing: + relationship = get_or_create_relationship(observer_id, subject_id) + if relationship == null: + return 0.0 + return relationship.adjust_dimension(dimension, amount, cause_event_id) + + +func get_relationship_dimension(observer_id: int, subject_id: int, dimension: StringName) -> float: + var relationship := get_relationship(observer_id, subject_id) + return -1.0 if relationship == null else relationship.get_dimension(dimension) + + +func get_relationship_cause_event_id( + observer_id: int, subject_id: int, dimension: StringName +) -> int: + var relationship := get_relationship(observer_id, subject_id) + return ( + RelationshipStateRecord.NO_CAUSE_EVENT + if relationship == null + else relationship.get_last_cause_event_id(dimension) + ) + + +func get_relationships_for_observer(observer_id: int) -> Array[RelationshipStateRecord]: + var records: Array[RelationshipStateRecord] = [] + for relationship in relationships.values(): + if relationship.get_observer_id() == observer_id: + records.append(relationship) + records.sort_custom(_sort_relationships) + return records + + +func get_salient_relationships( + observer_id: int, + max_count: int = DEFAULT_SALIENT_RELATIONSHIP_LIMIT, + minimum_salience: float = 0.0 +) -> Array[RelationshipStateRecord]: + if max_count <= 0: + return [] + var records: Array[RelationshipStateRecord] = [] + var bounded_minimum := clampf(minimum_salience, 0.0, 1.0) + for relationship in relationships.values(): + if relationship.get_observer_id() != observer_id: + continue + if relationship.get_salience() < bounded_minimum: + continue + records.append(relationship) + records.sort_custom(_is_more_salient) + var bounded_count := mini(max_count, MAX_BOUNDED_RELATIONSHIPS_PER_QUERY) + if records.size() > bounded_count: + records.resize(bounded_count) + return records + + func is_trusted_for_help(observer_id: int, subject_id: int) -> bool: var relationship := get_relationship(observer_id, subject_id) return ( @@ -49,8 +131,10 @@ func is_trusted_for_help(observer_id: int, subject_id: int) -> bool: func increase_shared_work_familiarity(first_id: int, second_id: int) -> void: - var first_to_second := _get_or_create(first_id, second_id) - var second_to_first := _get_or_create(second_id, first_id) + var first_to_second := get_or_create_relationship(first_id, second_id) + var second_to_first := get_or_create_relationship(second_id, first_id) + if first_to_second == null or second_to_first == null: + return first_to_second.increase_familiarity(SHARED_WORK_FAMILIARITY_GAIN) second_to_first.increase_familiarity(SHARED_WORK_FAMILIARITY_GAIN) @@ -174,15 +258,6 @@ func get_trusted_starving_subject( return effective_view.get_starving(best_relationship.get_subject_id()) -func _get_or_create(observer_id: int, subject_id: int) -> RelationshipStateRecord: - var relationship := get_relationship(observer_id, subject_id) - if relationship != null: - return relationship - relationship = RelationshipStateRecord.create(observer_id, subject_id, 0.0) - _add(relationship) - return relationship - - func _add(relationship: RelationshipStateRecord) -> void: relationships[_key(relationship.get_observer_id(), relationship.get_subject_id())] = relationship @@ -207,3 +282,13 @@ static func _is_stronger( if not is_equal_approx(candidate.get_familiarity(), current.get_familiarity()): return candidate.get_familiarity() > current.get_familiarity() return candidate.get_subject_id() < current.get_subject_id() + + +static func _is_more_salient( + first: RelationshipStateRecord, second: RelationshipStateRecord +) -> bool: + if not is_equal_approx(first.get_salience(), second.get_salience()): + return first.get_salience() > second.get_salience() + if not is_equal_approx(first.get_trust(), second.get_trust()): + return first.get_trust() > second.get_trust() + return first.get_subject_id() < second.get_subject_id() diff --git a/simulation/state/KnownEventStateRecord.gd b/simulation/state/KnownEventStateRecord.gd index 3f3c50b..0b13100 100644 --- a/simulation/state/KnownEventStateRecord.gd +++ b/simulation/state/KnownEventStateRecord.gd @@ -1,8 +1,13 @@ class_name KnownEventStateRecord extends RefCounted -const SCHEMA_VERSION := 3 +const SCHEMA_VERSION := 4 +const LEGACY_SCHEMA_VERSION := 3 const NO_SOURCE_NPC_ID := -1 +const NO_SOURCE_ACTOR_ID := -2 +const DEFAULT_CONFIDENCE := 1.0 +const DEFAULT_SALIENCE := 0.5 +const AUTO_HOP_COUNT := -1 const VALID_ACQUISITION_METHODS := [ SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, @@ -28,8 +33,28 @@ static func create( acquisition_method: StringName = SimulationIds.KNOWLEDGE_ACQUISITION_LEGACY, source_npc_id: int = NO_SOURCE_NPC_ID, acquired_tick: int = 0, - source_acquisition_method: StringName = &"" + source_acquisition_method: StringName = &"", + hop_count: int = AUTO_HOP_COUNT, + confidence: float = DEFAULT_CONFIDENCE, + salience: float = DEFAULT_SALIENCE, + source_actor_id: int = NO_SOURCE_ACTOR_ID, + pinned: bool = false ) -> KnownEventStateRecord: + var is_communicated := acquisition_method == SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED + var canonical_source_actor_id := source_actor_id + var canonical_source_npc_id := source_npc_id + if is_communicated: + if canonical_source_actor_id == NO_SOURCE_ACTOR_ID: + canonical_source_actor_id = source_npc_id + canonical_source_npc_id = ( + canonical_source_actor_id if canonical_source_actor_id >= 0 else NO_SOURCE_NPC_ID + ) + else: + canonical_source_actor_id = NO_SOURCE_ACTOR_ID + canonical_source_npc_id = NO_SOURCE_NPC_ID + var canonical_hop_count := hop_count + if canonical_hop_count == AUTO_HOP_COUNT: + canonical_hop_count = 1 if is_communicated else 0 return ( KnownEventStateRecord . new( @@ -38,56 +63,82 @@ static func create( "knower_id": knower_id, "event_id": event_id, "acquisition_method": String(acquisition_method), - "source_npc_id": source_npc_id, + "source_npc_id": canonical_source_npc_id, + "source_actor_id": canonical_source_actor_id, "acquired_tick": acquired_tick, "source_acquisition_method": String(source_acquisition_method), + "hop_count": canonical_hop_count, + "confidence": clampf(confidence, 0.0, 1.0), + "salience": clampf(salience, 0.0, 1.0), + "pinned": pinned, } ) ) static func from_dictionary(record_data: Dictionary) -> KnownEventStateRecord: - if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + var version := int(record_data.get("schema_version", -1)) + var normalized := record_data.duplicate(true) + if version == LEGACY_SCHEMA_VERSION: + normalized = _migrate_v3(record_data) + elif version != SCHEMA_VERSION: return null if not ( - record_data + normalized . has_all( [ "knower_id", "event_id", "acquisition_method", "source_npc_id", + "source_actor_id", "acquired_tick", "source_acquisition_method", + "hop_count", + "confidence", + "salience", + "pinned", ] ) ): 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"]) - var acquired_tick := int(record_data["acquired_tick"]) - var source_acquisition_method := StringName(record_data["source_acquisition_method"]) - if knower_id < 0 or event_id < 0 or acquired_tick < 0: + var knower_id := int(normalized["knower_id"]) + var event_id := int(normalized["event_id"]) + var acquisition_method := StringName(normalized["acquisition_method"]) + var source_npc_id := int(normalized["source_npc_id"]) + var source_actor_id := int(normalized["source_actor_id"]) + var acquired_tick := int(normalized["acquired_tick"]) + var source_acquisition_method := StringName(normalized["source_acquisition_method"]) + var hop_count := int(normalized["hop_count"]) + var confidence := float(normalized["confidence"]) + var salience := float(normalized["salience"]) + if not is_valid_actor_id(knower_id) or event_id < 0 or acquired_tick < 0 or hop_count < 0: + return null + if not is_finite(confidence) or confidence < 0.0 or confidence > 1.0: + return null + if not is_finite(salience) or salience < 0.0 or salience > 1.0: + return null + if typeof(normalized["pinned"]) != TYPE_BOOL: return null if acquisition_method not in VALID_ACQUISITION_METHODS: return null if acquisition_method == SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED: - if source_npc_id < 0 or source_npc_id == knower_id: + if not is_valid_actor_id(source_actor_id) or source_actor_id == knower_id: return null - if source_acquisition_method not in DIRECT_ACQUISITION_METHODS: + if source_actor_id >= 0 and source_npc_id != source_actor_id: return null - elif source_npc_id != NO_SOURCE_NPC_ID or not source_acquisition_method.is_empty(): + if source_actor_id == SimulationIds.PLAYER_ACTOR_ID and source_npc_id != NO_SOURCE_NPC_ID: + return null + if source_acquisition_method not in DIRECT_ACQUISITION_METHODS or hop_count != 1: + return null + elif ( + source_npc_id != NO_SOURCE_NPC_ID + or source_actor_id != NO_SOURCE_ACTOR_ID + or not source_acquisition_method.is_empty() + or hop_count != 0 + ): return null - return create( - knower_id, - event_id, - acquisition_method, - source_npc_id, - acquired_tick, - source_acquisition_method - ) + return KnownEventStateRecord.new(normalized) func get_knower_id() -> int: @@ -106,6 +157,10 @@ func get_source_npc_id() -> int: return int(data["source_npc_id"]) +func get_source_actor_id() -> int: + return int(data["source_actor_id"]) + + func get_acquired_tick() -> int: return int(data["acquired_tick"]) @@ -114,5 +169,69 @@ func get_source_acquisition_method() -> StringName: return StringName(data["source_acquisition_method"]) +func get_hop_count() -> int: + return int(data["hop_count"]) + + +func get_confidence() -> float: + return float(data["confidence"]) + + +func get_salience() -> float: + return float(data["salience"]) + + +func is_pinned() -> bool: + return bool(data["pinned"]) + + +func set_pinned(pinned: bool) -> bool: + if is_pinned() == pinned: + return false + data["pinned"] = pinned + return true + + +func set_confidence(confidence: float) -> bool: + if not is_finite(confidence): + return false + var bounded := clampf(confidence, 0.0, 1.0) + if is_equal_approx(get_confidence(), bounded): + return false + data["confidence"] = bounded + return true + + +func set_salience(salience: float) -> bool: + if not is_finite(salience): + return false + var bounded := clampf(salience, 0.0, 1.0) + if is_equal_approx(get_salience(), bounded): + return false + data["salience"] = bounded + return true + + func to_dictionary() -> Dictionary: return data.duplicate(true) + + +static func is_valid_actor_id(actor_id: int) -> bool: + return actor_id >= 0 or actor_id == SimulationIds.PLAYER_ACTOR_ID + + +static func _migrate_v3(record_data: Dictionary) -> Dictionary: + var migrated := record_data.duplicate(true) + var acquisition_method := StringName(migrated.get("acquisition_method", &"")) + var is_communicated := acquisition_method == SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED + migrated["schema_version"] = SCHEMA_VERSION + migrated["source_actor_id"] = ( + int(migrated.get("source_npc_id", NO_SOURCE_NPC_ID)) + if is_communicated + else NO_SOURCE_ACTOR_ID + ) + migrated["hop_count"] = 1 if is_communicated else 0 + migrated["confidence"] = DEFAULT_CONFIDENCE + migrated["salience"] = DEFAULT_SALIENCE + migrated["pinned"] = false + return migrated diff --git a/simulation/state/RelationshipStateRecord.gd b/simulation/state/RelationshipStateRecord.gd index fbba9f9..ac424aa 100644 --- a/simulation/state/RelationshipStateRecord.gd +++ b/simulation/state/RelationshipStateRecord.gd @@ -1,10 +1,28 @@ class_name RelationshipStateRecord extends RefCounted -const SCHEMA_VERSION := 1 +const SCHEMA_VERSION := 2 +const LEGACY_SCHEMA_VERSION := 1 const NO_CAUSE_EVENT := -1 const NEUTRAL_TRUST := 0.5 +const DIMENSION_FAMILIARITY := &"familiarity" +const DIMENSION_TRUST := &"trust" +const DIMENSION_AFFECTION := &"affection" +const DIMENSION_RESPECT := &"respect" +const DIMENSION_FEAR := &"fear" +const DIMENSION_OBLIGATION := &"obligation" +const DIMENSION_HOSTILITY := &"hostility" +const DIMENSIONS := [ + DIMENSION_FAMILIARITY, + DIMENSION_TRUST, + DIMENSION_AFFECTION, + DIMENSION_RESPECT, + DIMENSION_FEAR, + DIMENSION_OBLIGATION, + DIMENSION_HOSTILITY, +] + var data: Dictionary @@ -28,34 +46,50 @@ static func create( "subject_id": subject_id, "familiarity": clampf(familiarity, 0.0, 1.0), "trust": clampf(trust, 0.0, 1.0), + "affection": 0.0, + "respect": 0.0, + "fear": 0.0, + "obligation": 0.0, + "hostility": 0.0, + "last_familiarity_cause_event_id": NO_CAUSE_EVENT, "last_trust_cause_event_id": last_trust_cause_event_id, + "last_affection_cause_event_id": NO_CAUSE_EVENT, + "last_respect_cause_event_id": NO_CAUSE_EVENT, + "last_fear_cause_event_id": NO_CAUSE_EVENT, + "last_obligation_cause_event_id": NO_CAUSE_EVENT, + "last_hostility_cause_event_id": NO_CAUSE_EVENT, } ) ) static func from_dictionary(record_data: Dictionary) -> RelationshipStateRecord: - if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + var version := int(record_data.get("schema_version", -1)) + var normalized := record_data.duplicate(true) + if version == LEGACY_SCHEMA_VERSION: + normalized = _migrate_v1(record_data) + elif version != SCHEMA_VERSION: return null - if not record_data.has_all( - ["observer_id", "subject_id", "familiarity", "trust", "last_trust_cause_event_id"] - ): + var required_fields := ["observer_id", "subject_id"] + for dimension in DIMENSIONS: + required_fields.append(String(dimension)) + required_fields.append(_cause_key(dimension)) + if not normalized.has_all(required_fields): return null - var observer_id := int(record_data["observer_id"]) - var subject_id := int(record_data["subject_id"]) - var familiarity := float(record_data["familiarity"]) - var trust := float(record_data["trust"]) - var cause_event_id := int(record_data["last_trust_cause_event_id"]) - var is_player_subject := subject_id == SimulationIds.PLAYER_ACTOR_ID - if observer_id < 0 or observer_id == subject_id: + var observer_id := int(normalized["observer_id"]) + var subject_id := int(normalized["subject_id"]) + if not is_valid_actor_id(observer_id) or not is_valid_actor_id(subject_id): return null - if not is_player_subject and subject_id < 0: + if observer_id == subject_id: return null - if familiarity < 0.0 or familiarity > 1.0 or trust < 0.0 or trust > 1.0: - return null - if cause_event_id < NO_CAUSE_EVENT: - return null - return create(observer_id, subject_id, familiarity, trust, cause_event_id) + for dimension in DIMENSIONS: + var value := float(normalized[String(dimension)]) + var cause_event_id := int(normalized[_cause_key(dimension)]) + if not is_finite(value) or value < 0.0 or value > 1.0: + return null + if cause_event_id < NO_CAUSE_EVENT: + return null + return RelationshipStateRecord.new(normalized) func get_observer_id() -> int: @@ -67,31 +101,162 @@ func get_subject_id() -> int: func get_familiarity() -> float: - return float(data["familiarity"]) + return get_dimension(DIMENSION_FAMILIARITY) func get_trust() -> float: - return float(data["trust"]) + return get_dimension(DIMENSION_TRUST) + + +func get_affection() -> float: + return get_dimension(DIMENSION_AFFECTION) + + +func get_respect() -> float: + return get_dimension(DIMENSION_RESPECT) + + +func get_fear() -> float: + return get_dimension(DIMENSION_FEAR) + + +func get_obligation() -> float: + return get_dimension(DIMENSION_OBLIGATION) + + +func get_hostility() -> float: + return get_dimension(DIMENSION_HOSTILITY) + + +func get_dimension(dimension: StringName) -> float: + if not has_dimension(dimension): + return -1.0 + return float(data[String(dimension)]) + + +func get_last_familiarity_cause_event_id() -> int: + return get_last_cause_event_id(DIMENSION_FAMILIARITY) func get_last_trust_cause_event_id() -> int: - return int(data["last_trust_cause_event_id"]) + return get_last_cause_event_id(DIMENSION_TRUST) + + +func get_last_affection_cause_event_id() -> int: + return get_last_cause_event_id(DIMENSION_AFFECTION) + + +func get_last_respect_cause_event_id() -> int: + return get_last_cause_event_id(DIMENSION_RESPECT) + + +func get_last_fear_cause_event_id() -> int: + return get_last_cause_event_id(DIMENSION_FEAR) + + +func get_last_obligation_cause_event_id() -> int: + return get_last_cause_event_id(DIMENSION_OBLIGATION) + + +func get_last_hostility_cause_event_id() -> int: + return get_last_cause_event_id(DIMENSION_HOSTILITY) + + +func get_last_cause_event_id(dimension: StringName) -> int: + if not has_dimension(dimension): + return NO_CAUSE_EVENT + return int(data[_cause_key(dimension)]) func increase_familiarity(amount: float) -> float: - var previous := get_familiarity() - data["familiarity"] = clampf(previous + maxf(amount, 0.0), 0.0, 1.0) - return get_familiarity() - previous + return adjust_dimension(DIMENSION_FAMILIARITY, maxf(amount, 0.0), NO_CAUSE_EVENT) + + +func increase_familiarity_with_cause(amount: float, cause_event_id: int) -> float: + return adjust_dimension(DIMENSION_FAMILIARITY, maxf(amount, 0.0), cause_event_id) func increase_trust(amount: float, cause_event_id: int) -> float: - var previous := get_trust() - data["trust"] = clampf(previous + maxf(amount, 0.0), 0.0, 1.0) - var applied := get_trust() - previous - if applied > 0.0: - data["last_trust_cause_event_id"] = cause_event_id + return adjust_dimension(DIMENSION_TRUST, maxf(amount, 0.0), cause_event_id) + + +func adjust_affection(amount: float, cause_event_id: int) -> float: + return adjust_dimension(DIMENSION_AFFECTION, amount, cause_event_id) + + +func adjust_respect(amount: float, cause_event_id: int) -> float: + return adjust_dimension(DIMENSION_RESPECT, amount, cause_event_id) + + +func adjust_fear(amount: float, cause_event_id: int) -> float: + return adjust_dimension(DIMENSION_FEAR, amount, cause_event_id) + + +func adjust_obligation(amount: float, cause_event_id: int) -> float: + return adjust_dimension(DIMENSION_OBLIGATION, amount, cause_event_id) + + +func adjust_hostility(amount: float, cause_event_id: int) -> float: + return adjust_dimension(DIMENSION_HOSTILITY, amount, cause_event_id) + + +func adjust_dimension(dimension: StringName, amount: float, cause_event_id: int) -> float: + if not has_dimension(dimension) or not is_finite(amount) or cause_event_id < NO_CAUSE_EVENT: + return 0.0 + var previous := get_dimension(dimension) + var current := clampf(previous + amount, 0.0, 1.0) + var applied := current - previous + if is_zero_approx(applied): + return 0.0 + data[String(dimension)] = current + if cause_event_id != NO_CAUSE_EVENT: + data[_cause_key(dimension)] = cause_event_id return applied +func get_salience() -> float: + var salience := get_familiarity() + salience = maxf(salience, absf(get_trust() - NEUTRAL_TRUST) * 2.0) + for dimension in [ + DIMENSION_AFFECTION, + DIMENSION_RESPECT, + DIMENSION_FEAR, + DIMENSION_OBLIGATION, + DIMENSION_HOSTILITY, + ]: + salience = maxf(salience, get_dimension(dimension)) + return clampf(salience, 0.0, 1.0) + + func to_dictionary() -> Dictionary: return data.duplicate(true) + + +static func has_dimension(dimension: StringName) -> bool: + return dimension in DIMENSIONS + + +static func is_valid_actor_id(actor_id: int) -> bool: + return actor_id >= 0 or actor_id == SimulationIds.PLAYER_ACTOR_ID + + +static func _cause_key(dimension: StringName) -> String: + return "last_%s_cause_event_id" % String(dimension) + + +static func _migrate_v1(record_data: Dictionary) -> Dictionary: + var migrated := record_data.duplicate(true) + migrated["schema_version"] = SCHEMA_VERSION + for dimension in [ + DIMENSION_AFFECTION, + DIMENSION_RESPECT, + DIMENSION_FEAR, + DIMENSION_OBLIGATION, + DIMENSION_HOSTILITY, + ]: + migrated[String(dimension)] = 0.0 + for dimension in DIMENSIONS: + var cause_key := _cause_key(dimension) + if not migrated.has(cause_key): + migrated[cause_key] = NO_CAUSE_EVENT + return migrated diff --git a/tests/unit/test_social_foundations.gd b/tests/unit/test_social_foundations.gd new file mode 100644 index 0000000..b72b257 --- /dev/null +++ b/tests/unit/test_social_foundations.gd @@ -0,0 +1,200 @@ +extends GutTest + +const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd") +const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") + + +func test_known_event_v3_migrates_metadata_and_player_is_a_valid_knower() -> void: + var legacy_data := { + "schema_version": KnownEventStateRecord.LEGACY_SCHEMA_VERSION, + "knower_id": 2, + "event_id": 12, + "acquisition_method": String(SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED), + "source_npc_id": 1, + "acquired_tick": 30, + "source_acquisition_method": String(SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED), + } + var migrated := KnownEventStateRecord.from_dictionary(legacy_data) + + assert_not_null(migrated) + assert_eq(int(migrated.to_dictionary()["schema_version"]), KnownEventStateRecord.SCHEMA_VERSION) + assert_eq(migrated.get_source_actor_id(), 1) + assert_eq(migrated.get_hop_count(), 1) + assert_eq(migrated.get_confidence(), KnownEventStateRecord.DEFAULT_CONFIDENCE) + assert_eq(migrated.get_salience(), KnownEventStateRecord.DEFAULT_SALIENCE) + assert_false(migrated.is_pinned()) + + var player_record := KnownEventStateRecord.create( + SimulationIds.PLAYER_ACTOR_ID, + 13, + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, + KnownEventStateRecord.NO_SOURCE_NPC_ID, + 31 + ) + assert_not_null(KnownEventStateRecord.from_dictionary(player_record.to_dictionary())) + var invalid_knower_data := player_record.to_dictionary() + invalid_knower_data["knower_id"] = KnownEventStateRecord.NO_SOURCE_ACTOR_ID + assert_null(KnownEventStateRecord.from_dictionary(invalid_knower_data)) + + +func test_direct_npc_knowledge_can_be_communicated_explicitly_to_player() -> void: + var event := EconomicEventRecord.create( + 21, + SimulationIds.EVENT_STORAGE_DEPOSITED, + 40, + 0, + SimulationIds.npc_inventory_id(0), + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 2.0, + Vector3.ZERO + ) + var speaker_record := KnownEventStateRecord.create( + 1, + int(event.data["event_id"]), + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, + KnownEventStateRecord.NO_SOURCE_NPC_ID, + 40 + ) + var knowledge_system := EventKnowledgeSystemScript.new() + var records: Array[KnownEventStateRecord] = [speaker_record] + knowledge_system.restore(records) + + assert_false( + knowledge_system.knows_event(SimulationIds.PLAYER_ACTOR_ID, int(event.data["event_id"])) + ) + var communicated := knowledge_system.communicate_event_to_player(event, 1, 45) + assert_not_null(communicated) + assert_eq(communicated.get_knower_id(), SimulationIds.PLAYER_ACTOR_ID) + assert_eq(communicated.get_source_actor_id(), 1) + assert_eq(communicated.get_source_npc_id(), 1) + assert_eq(communicated.get_hop_count(), 1) + assert_true(is_equal_approx(communicated.get_confidence(), 0.9)) + assert_eq(communicated.get_source_acquisition_method(), speaker_record.get_acquisition_method()) + assert_null(knowledge_system.communicate_event(event, SimulationIds.PLAYER_ACTOR_ID, 2, 46)) + + +func test_player_performed_world_event_is_known_without_a_witness() -> void: + var event := EconomicEventRecord.create( + 22, + SimulationIds.EVENT_STORAGE_DEPOSITED, + 41, + SimulationIds.PLAYER_ACTOR_ID, + SimulationIds.PLAYER_INVENTORY_ID, + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD, + 1.0, + Vector3(1000.0, 0.0, 1000.0) + ) + var knowledge_system := EventKnowledgeSystemScript.new() + var learned := knowledge_system.observe_event(event, []) + + assert_eq(learned.size(), 1) + assert_eq(learned[0].get_knower_id(), SimulationIds.PLAYER_ACTOR_ID) + assert_eq(learned[0].get_acquisition_method(), SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED) + + +func test_pinned_knowledge_is_bounded_and_survives_ordinary_retention() -> void: + var knowledge_system := EventKnowledgeSystemScript.new() + var records: Array[KnownEventStateRecord] = [] + for event_id in range(1, 6): + records.append( + KnownEventStateRecord.create( + 0, + event_id, + SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED, + KnownEventStateRecord.NO_SOURCE_NPC_ID, + event_id + ) + ) + knowledge_system.restore(records) + + assert_true(knowledge_system.pin_event(0, 1)) + var no_lasting: Array[KnownEventStateRecord] = [] + var forgotten := knowledge_system.maintain_retention(5, 100, no_lasting, false) + assert_eq(forgotten.size(), 1) + assert_eq(forgotten[0].get_event_id(), 2) + assert_eq(knowledge_system.get_pinned_event_ids(0), [1]) + assert_eq(knowledge_system.get_retained_event_ids(0, [], 0), [1, 5, 4, 3]) + assert_true(knowledge_system.set_event_salience(0, 3, 2.0)) + assert_eq(knowledge_system.get_record(0, 3).get_salience(), 1.0) + assert_true(knowledge_system.set_event_confidence(0, 3, -1.0)) + assert_eq(knowledge_system.get_record(0, 3).get_confidence(), 0.0) + + var pin_limit_system := EventKnowledgeSystemScript.new() + var pin_limit_records: Array[KnownEventStateRecord] = [] + for event_id in range(1, 6): + pin_limit_records.append(KnownEventStateRecord.create(0, event_id)) + pin_limit_system.restore(pin_limit_records) + for event_id in range(1, EventKnowledgeSystemScript.MAX_PINNED_FACTS_PER_KNOWER + 1): + assert_true(pin_limit_system.pin_event(0, event_id)) + assert_false( + pin_limit_system.pin_event(0, EventKnowledgeSystemScript.MAX_PINNED_FACTS_PER_KNOWER + 1) + ) + + +func test_relationship_v1_migrates_all_dimensions_and_exact_causes() -> void: + var legacy_data := { + "schema_version": RelationshipStateRecord.LEGACY_SCHEMA_VERSION, + "observer_id": 1, + "subject_id": 2, + "familiarity": 0.4, + "trust": 0.6, + "last_trust_cause_event_id": 8, + } + var relationship := RelationshipStateRecord.from_dictionary(legacy_data) + + assert_not_null(relationship) + assert_eq( + int(relationship.to_dictionary()["schema_version"]), RelationshipStateRecord.SCHEMA_VERSION + ) + assert_eq(relationship.get_affection(), 0.0) + assert_eq(relationship.get_respect(), 0.0) + assert_eq(relationship.get_fear(), 0.0) + assert_eq(relationship.get_obligation(), 0.0) + assert_eq(relationship.get_hostility(), 0.0) + assert_eq(relationship.get_last_trust_cause_event_id(), 8) + + for index in range(RelationshipStateRecord.DIMENSIONS.size()): + var dimension: StringName = RelationshipStateRecord.DIMENSIONS[index] + var cause_event_id := 20 + index + assert_true(relationship.adjust_dimension(dimension, 0.1, cause_event_id) > 0.0) + assert_eq(relationship.get_last_cause_event_id(dimension), cause_event_id) + assert_true(relationship.adjust_hostility(2.0, 99) > 0.0) + assert_eq(relationship.get_hostility(), 1.0) + assert_eq(relationship.get_last_hostility_cause_event_id(), 99) + assert_not_null(RelationshipStateRecord.from_dictionary(relationship.to_dictionary())) + + var player_observer := RelationshipStateRecord.create(SimulationIds.PLAYER_ACTOR_ID, 3, 0.2) + assert_not_null(RelationshipStateRecord.from_dictionary(player_observer.to_dictionary())) + + +func test_sparse_relationship_queries_are_deterministic_and_bounded() -> void: + var relationship_system := RelationshipSystemScript.new() + for subject_id in range(10): + var relationship := relationship_system.get_or_create_relationship( + SimulationIds.PLAYER_ACTOR_ID, subject_id, float(subject_id) / 10.0 + ) + assert_not_null(relationship) + assert_true( + ( + relationship_system.adjust_relationship_dimension( + SimulationIds.PLAYER_ACTOR_ID, 0, RelationshipStateRecord.DIMENSION_RESPECT, 1.0, 77 + ) + > 0.0 + ) + ) + + var sorted := relationship_system.get_relationships_for_observer(SimulationIds.PLAYER_ACTOR_ID) + assert_eq(sorted.size(), 10) + assert_eq(sorted[0].get_subject_id(), 0) + assert_eq(sorted[9].get_subject_id(), 9) + var salient := relationship_system.get_salient_relationships(SimulationIds.PLAYER_ACTOR_ID, 100) + assert_eq(salient.size(), RelationshipSystemScript.MAX_BOUNDED_RELATIONSHIPS_PER_QUERY) + assert_eq(salient[0].get_subject_id(), 0) + assert_eq( + relationship_system.get_relationship_cause_event_id( + SimulationIds.PLAYER_ACTOR_ID, 0, RelationshipStateRecord.DIMENSION_RESPECT + ), + 77 + )