diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index e523384..006265a 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -1,6 +1,8 @@ class_name SimulationIds extends RefCounted +const PLAYER_ACTOR_ID := -1 + const ACTION_IDLE := &"idle" const ACTION_DEAD := &"dead" const ACTION_SLEEP := &"sleep" diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index e14a6a8..3e87cee 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -11,20 +11,25 @@ func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[Kno var learned: Array[KnownEventStateRecord] = [] if not is_knowable_event(event): return learned - var actor := _find_npc(int(event.data["actor_id"]), npcs) - if actor == null: + var actor_id := int(event.data["actor_id"]) + var is_player_actor := actor_id == SimulationIds.PLAYER_ACTOR_ID + var actor := null if is_player_actor else _find_npc(actor_id, npcs) + if actor == null and not is_player_actor: return learned var event_id := int(event.data["event_id"]) var acquired_tick := int(event.data["tick"]) - var actor_record := _remember( - actor.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, acquired_tick - ) - if actor_record != null: - learned.append(actor_record) + if actor != null: + var actor_record := _remember( + actor.id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, acquired_tick + ) + if actor_record != null: + learned.append(actor_record) var witness_radius_squared := WITNESS_RADIUS * WITNESS_RADIUS var event_position := event.get_world_position() for npc in npcs: - if npc.id == actor.id or npc.is_dead: + if npc.is_dead: + continue + if not is_player_actor and npc.id == actor.id: continue if npc.position.distance_squared_to(event_position) > witness_radius_squared: continue @@ -247,6 +252,17 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool: return false if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: return item_id == SimulationIds.RESOURCE_FOOD + if event_type == SimulationIds.EVENT_RESOURCE_EXTRACTED: + var player_actor_id := int(event.data["actor_id"]) + if player_actor_id != SimulationIds.PLAYER_ACTOR_ID: + return false + return ( + StringName(event.data["destination_id"]) + in [ + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.STORAGE_VILLAGE_WOODPILE, + ] + ) if event_type != SimulationIds.EVENT_STORAGE_WITHDRAWN: return false var actor_id := int(event.data["actor_id"]) diff --git a/simulation/relationships/RelationshipSystem.gd b/simulation/relationships/RelationshipSystem.gd index 9b43cb9..f3569c4 100644 --- a/simulation/relationships/RelationshipSystem.gd +++ b/simulation/relationships/RelationshipSystem.gd @@ -4,6 +4,7 @@ const AID_HUNGER_THRESHOLD := 80.0 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 var relationships: Dictionary = {} @@ -58,23 +59,47 @@ func apply_event( event: EconomicEventRecord, npcs: Array[SimNPC], knower_ids: Array[int] ) -> Array[RelationshipStateRecord]: var changed: Array[RelationshipStateRecord] = [] - if StringName(event.data["event_type"]) != SimulationIds.EVENT_STORAGE_DEPOSITED: - return changed - if StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD: + var event_type := StringName(event.data["event_type"]) + var item_id := StringName(event.data["item_id"]) + if item_id != SimulationIds.RESOURCE_FOOD: return changed var contributor_id := int(event.data["actor_id"]) var event_id := int(event.data["event_id"]) - if contributor_id < 0 or event_id < 0 or float(event.data["amount"]) <= 0.0: + var is_food_supply := false + if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: + is_food_supply = true + elif ( + event_type == SimulationIds.EVENT_RESOURCE_EXTRACTED + and contributor_id == SimulationIds.PLAYER_ACTOR_ID + ): + is_food_supply = ( + StringName(event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY + ) + if ( + not is_food_supply + or event_id < 0 + or float(event.data["amount"]) <= 0.0 + or (contributor_id < 0 and contributor_id != SimulationIds.PLAYER_ACTOR_ID) + ): return changed for observer in npcs: - if observer.id == contributor_id or observer.is_dead: + if observer.is_dead: + continue + if contributor_id != SimulationIds.PLAYER_ACTOR_ID and observer.id == contributor_id: continue if observer.id not in knower_ids: continue if observer.hunger < AID_HUNGER_THRESHOLD: continue var relationship := get_relationship(observer.id, contributor_id) - if relationship == null or relationship.get_familiarity() <= 0.0: + if relationship == null: + if contributor_id != SimulationIds.PLAYER_ACTOR_ID: + continue + relationship = RelationshipStateRecord.create( + observer.id, contributor_id, PLAYER_FAMILIARITY_SEED + ) + _add(relationship) + if relationship.get_familiarity() <= 0.0: continue if event_id <= relationship.get_last_trust_cause_event_id(): continue diff --git a/simulation/state/RelationshipStateRecord.gd b/simulation/state/RelationshipStateRecord.gd index 25461e5..fbba9f9 100644 --- a/simulation/state/RelationshipStateRecord.gd +++ b/simulation/state/RelationshipStateRecord.gd @@ -46,7 +46,10 @@ static func from_dictionary(record_data: Dictionary) -> RelationshipStateRecord: var familiarity := float(record_data["familiarity"]) var trust := float(record_data["trust"]) var cause_event_id := int(record_data["last_trust_cause_event_id"]) - if observer_id < 0 or subject_id < 0 or observer_id == subject_id: + var is_player_subject := subject_id == SimulationIds.PLAYER_ACTOR_ID + if observer_id < 0 or observer_id == subject_id: + return null + if not is_player_subject and subject_id < 0: return null if familiarity < 0.0 or familiarity > 1.0 or trust < 0.0 or trust > 1.0: return null diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 693ae2d..f11f3d6 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,7 +2,7 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 11 +const SCHEMA_VERSION := 12 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 @@ -12,6 +12,7 @@ const RETENTION_LEGACY_SCHEMA_VERSION := 6 const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8 const ANIMAL_LEGACY_SCHEMA_VERSION := 9 const ROUTINE_LEGACY_SCHEMA_VERSION := 10 +const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11 const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary @@ -97,6 +98,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: OPPORTUNITY_LEGACY_SCHEMA_VERSION, ANIMAL_LEGACY_SCHEMA_VERSION, ROUTINE_LEGACY_SCHEMA_VERSION, + PLAYER_RELATIONSHIP_SCHEMA_VERSION, ] ): record_data = _migrate_legacy(record_data, version) @@ -386,7 +388,10 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null var observer_id := relationship_record.get_observer_id() var subject_id := relationship_record.get_subject_id() - if not npc_ids.has(observer_id) or not npc_ids.has(subject_id): + var is_player_subject := subject_id == SimulationIds.PLAYER_ACTOR_ID + if not npc_ids.has(observer_id): + return null + if not is_player_subject and not npc_ids.has(subject_id): return null var relationship_key := "%d:%d" % [observer_id, subject_id] if relationship_keys.has(relationship_key): @@ -404,15 +409,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null if cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT: var cause_event := event_records_by_id[cause_event_id] as EconomicEventRecord - if ( - int(cause_event.data["actor_id"]) != subject_id - or ( - StringName(cause_event.data["event_type"]) - != SimulationIds.EVENT_STORAGE_DEPOSITED - ) - or StringName(cause_event.data["item_id"]) != SimulationIds.RESOURCE_FOOD - or float(cause_event.data["amount"]) <= 0.0 - ): + if not _is_valid_relationship_cause(cause_event, subject_id, is_player_subject): return null relationship_keys[relationship_key] = true record.relationships.append(relationship_record) @@ -672,6 +669,27 @@ static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids: ) +static func _is_valid_relationship_cause( + cause_event: EconomicEventRecord, subject_id: int, is_player_subject: bool +) -> bool: + if not is_player_subject: + return ( + int(cause_event.data["actor_id"]) == subject_id + and ( + StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED + ) + and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and float(cause_event.data["amount"]) > 0.0 + ) + return ( + int(cause_event.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID + and StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED + and (StringName(cause_event.data["destination_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY) + and StringName(cause_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and float(cause_event.data["amount"]) > 0.0 + ) + + static func _is_valid_supply_resolution( event: EconomicEventRecord, opportunity: OpportunityStateRecord, @@ -766,6 +784,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary OPPORTUNITY_LEGACY_SCHEMA_VERSION, ANIMAL_LEGACY_SCHEMA_VERSION, ROUTINE_LEGACY_SCHEMA_VERSION, + PLAYER_RELATIONSHIP_SCHEMA_VERSION, ] ): migrated["opportunities"] = [] diff --git a/tests/player_social_consequence_test.gd b/tests/player_social_consequence_test.gd new file mode 100644 index 0000000..e2163a5 --- /dev/null +++ b/tests/player_social_consequence_test.gd @@ -0,0 +1,178 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var manager := _create_manager() + var witness: SimNPC = manager.npcs[0] + var hungry_witness: SimNPC = manager.npcs[1] + var distant: SimNPC = manager.npcs[2] + witness.hunger = 20.0 + hungry_witness.hunger = 85.0 + witness.position = Vector3(4.0, 0.0, 0.0) + hungry_witness.position = Vector3(5.0, 0.0, 0.0) + distant.position = Vector3(40.0, 0.0, 0.0) + + var berry := ResourceNode.new() + berry.name = "PlayerSocialBerry" + berry.node_id = &"player_social_berry" + berry.action_id = SimulationIds.ACTION_GATHER_FOOD + berry.resource_id = SimulationIds.RESOURCE_FOOD + berry.initial_amount = 1.0 + berry.yield_per_action = 1.0 + berry.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + interaction_point.position = Vector3.ZERO + berry.add_child(interaction_point) + root.add_child(berry) + await process_frame + _check( + manager.register_resource_node(berry), + "Player supply test should bind a real finite ResourceNode" + ) + _check( + is_equal_approx(manager.harvest_resource_node(berry), 1.0), + "Player harvesting should supply the real pantry" + ) + + var supply_event := _latest_event_of_type(manager, SimulationIds.EVENT_RESOURCE_EXTRACTED) + var supply_event_id := int(supply_event.data["event_id"]) + _check( + ( + int(supply_event.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID + and ( + StringName(supply_event.data["destination_id"]) + == SimulationIds.STORAGE_VILLAGE_PANTRY + ) + ), + "The player supply should be recorded as a pantry food extraction by the player actor" + ) + _check( + ( + manager.npc_knows_event(witness.id, supply_event_id) + and manager.npc_knows_event(hungry_witness.id, supply_event_id) + and not manager.npc_knows_event(distant.id, supply_event_id) + ), + "Nearby living villagers should witness the player restock while distant ones stay unaware" + ) + + var calm_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship( + witness.id, SimulationIds.PLAYER_ACTOR_ID + ) + _check( + calm_relationship == null, "A non-hungry witness should not gain trust toward the player" + ) + var hungry_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship( + hungry_witness.id, SimulationIds.PLAYER_ACTOR_ID + ) + _check( + ( + hungry_relationship != null + and is_equal_approx(hungry_relationship.get_familiarity(), 0.5) + and is_equal_approx(hungry_relationship.get_trust(), 0.65) + and hungry_relationship.get_last_trust_cause_event_id() == supply_event_id + ), + "A hungry witness should gain directed trust toward the player from the real supply event" + ) + + var saved_json: String = manager.serialize_state() + var restored := _create_manager(902) + _check( + restored.restore_state_from_json(saved_json), + "A player relationship should survive schema-v12 save and restore" + ) + _check( + ( + restored.get_state_checksum() == manager.get_state_checksum() + and restored.npc_knows_event(hungry_witness.id, supply_event_id) + and ( + restored.relationship_system.get_relationship( + hungry_witness.id, SimulationIds.PLAYER_ACTOR_ID + ) + != null + ) + ), + "Restored player trust should preserve checksum, evidence, and the directed relationship" + ) + restored.free() + + berry.queue_free() + manager.free() + + var legacy_v11 := _create_v11_legacy() + var migrated := SimulationStateRecord.from_dictionary(legacy_v11) + _check( + ( + migrated != null + and migrated.simulation.get("next_opportunity_id", -1) >= 0 + and not migrated.opportunities.is_empty() + ), + "World schema v11 should migrate to v12 without dropping opportunity history" + ) + + _finish() + + +func _create_v11_legacy() -> Dictionary: + var manager := _create_manager(901) + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + var actor: SimNPC = manager.npcs[0] + actor.position = Vector3.ZERO + actor.hunger = 85.0 + manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0) + manager.economy.deposit_inventory(actor, SimulationIds.RESOURCE_FOOD) + var record: Dictionary = manager.create_state_record().to_dictionary() + record["schema_version"] = 11 + manager.free() + return record + + +func _create_manager(seed_value: int = 901) -> 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(2.0, 0.0, 0.0), + Vector3(10.0, 0.0, 0.0), + Vector3(12.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 _latest_event_of_type(manager: Node, event_type: StringName) -> EconomicEventRecord: + var events: Array = manager.economic_events + for index in range(events.size() - 1, -1, -1): + var event := events[index] as EconomicEventRecord + if StringName(event.data["event_type"]) == event_type: + return event + return null + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Player social consequence passed: supply -> witness -> trust") + 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) diff --git a/tests/player_social_consequence_test.gd.uid b/tests/player_social_consequence_test.gd.uid new file mode 100644 index 0000000..c951984 --- /dev/null +++ b/tests/player_social_consequence_test.gd.uid @@ -0,0 +1 @@ +uid://bfl0hnlgwvu8a diff --git a/world/ui/village_whisper_hud.gd b/world/ui/village_whisper_hud.gd index fa184e8..fd18247 100644 --- a/world/ui/village_whisper_hud.gd +++ b/world/ui/village_whisper_hud.gd @@ -34,6 +34,7 @@ func _ready() -> void: simulation_manager.event_knowledge_changed.connect(_on_helper_facts_changed) simulation_manager.event_knowledge_forgotten.connect(_on_helper_fact_forgotten) simulation_manager.relationship_changed.connect(_on_helper_relationship_changed) + simulation_manager.relationship_changed.connect(_on_player_trust_changed) simulation_manager.npc_inventory_changed.connect(_on_helper_inventory_changed) simulation_manager.village_changed.connect(_on_village_changed) simulation_manager.state_restored.connect(_on_state_restored) @@ -112,6 +113,31 @@ func _on_helper_relationship_changed( _refresh_player_response() +func _on_player_trust_changed( + relationship: RelationshipStateRecord, cause_event: EconomicEventRecord +) -> void: + if relationship == null or cause_event == null: + return + if relationship.get_subject_id() != SimulationIds.PLAYER_ACTOR_ID: + return + if _is_active_need_supply(cause_event): + return + var observer_name := _get_npc_name(relationship.get_observer_id()) + _show_feedback( + "Trust grows", "%s trusts you — you stocked the pantry." % observer_name, &"player_trust" + ) + + +func _is_active_need_supply(cause_event: EconomicEventRecord) -> bool: + var opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity() + if opportunity == null: + return false + return ( + opportunity.get_target_id() == StringName(cause_event.data["destination_id"]) + and opportunity.get_resource_id() == StringName(cause_event.data["item_id"]) + ) + + func _on_helper_inventory_changed(_npc: SimNPC, _item_id: StringName, _amount: float) -> void: _refresh_player_response()