From 0936ae645053399f13a01f38fa4358ab1e15a177 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 9 Aug 2026 00:10:36 +0200 Subject: [PATCH] feat: let the player talk to villagers and accept or decline personal requests --- player/PlayerInteractionResult.gd | 1 + player/PlayerTalkResult.gd | 53 +++++ player/PlayerTalkResult.gd.uid | 1 + player/player.gd | 78 +++++++ simulation/SimulationManager.gd | 72 +++---- simulation/quests/PlayerNegotiationSystem.gd | 67 ++++++ .../quests/PlayerNegotiationSystem.gd.uid | 1 + simulation/quests/PlayerQuestSystem.gd | 80 +++++++ tests/player_talk_negotiation_test.gd | 202 ++++++++++++++++++ tests/player_talk_negotiation_test.gd.uid | 1 + 10 files changed, 520 insertions(+), 36 deletions(-) create mode 100644 player/PlayerTalkResult.gd create mode 100644 player/PlayerTalkResult.gd.uid create mode 100644 simulation/quests/PlayerNegotiationSystem.gd create mode 100644 simulation/quests/PlayerNegotiationSystem.gd.uid create mode 100644 tests/player_talk_negotiation_test.gd create mode 100644 tests/player_talk_negotiation_test.gd.uid diff --git a/player/PlayerInteractionResult.gd b/player/PlayerInteractionResult.gd index c326cc4..39f417c 100644 --- a/player/PlayerInteractionResult.gd +++ b/player/PlayerInteractionResult.gd @@ -5,6 +5,7 @@ const KIND_ANIMAL := &"animal" const KIND_RESOURCE := &"resource" const KIND_PANTRY := &"pantry" const KIND_DEPOSIT := &"deposit" +const KIND_TALK := &"talk" const KIND_GUARD := &"guard" const KIND_STUDY := &"study" diff --git a/player/PlayerTalkResult.gd b/player/PlayerTalkResult.gd new file mode 100644 index 0000000..5aeb84c --- /dev/null +++ b/player/PlayerTalkResult.gd @@ -0,0 +1,53 @@ +class_name PlayerTalkResult +extends RefCounted + +var npc_id: int +var npc_name: String +var greeting: String +var has_need: bool +var need_text: String +var quest_available: bool +var quest_reward: float +var can_accept: bool +var can_decline: bool + + +func _init( + talk_npc_id: int, + talk_npc_name: String, + talk_greeting: String, + talk_has_need: bool, + talk_need_text: String, + talk_quest_available: bool, + talk_quest_reward: float, + talk_can_accept: bool, + talk_can_decline: bool +) -> void: + npc_id = talk_npc_id + npc_name = talk_npc_name + greeting = talk_greeting + has_need = talk_has_need + need_text = talk_need_text + quest_available = talk_quest_available + quest_reward = talk_quest_reward + can_accept = talk_can_accept + can_decline = talk_can_decline + + +func cache_key() -> String: + return ( + "|" + . join( + [ + str(npc_id), + npc_name, + greeting, + str(has_need), + need_text, + str(quest_available), + "%.2f" % quest_reward, + str(can_accept), + str(can_decline), + ] + ) + ) diff --git a/player/PlayerTalkResult.gd.uid b/player/PlayerTalkResult.gd.uid new file mode 100644 index 0000000..37d81a9 --- /dev/null +++ b/player/PlayerTalkResult.gd.uid @@ -0,0 +1 @@ +uid://i7pjb5605cgr diff --git a/player/player.gd b/player/player.gd index b2a31e0..28b142a 100644 --- a/player/player.gd +++ b/player/player.gd @@ -72,6 +72,8 @@ func try_interact() -> void: _execute_resource_interaction(context) PlayerInteractionResult.KIND_DEPOSIT: _execute_deposit_interaction(context) + PlayerInteractionResult.KIND_TALK: + _execute_talk_interaction(context) PlayerInteractionResult.KIND_GUARD: simulation_manager.add_safety(3.0) interaction_feedback.emit( @@ -131,9 +133,53 @@ func get_interaction_context() -> PlayerInteractionResult: ) if is_near_storage(pantry_storage): return _build_pantry_context() + var talk_npc := _find_talk_villager() + if talk_npc != null: + return _build_talk_context(talk_npc) return null +func _find_talk_villager() -> SimNPC: + if ( + simulation_manager == null + or world_view_manager == null + or not world_view_manager.has_method("find_nearest_active_npc_id") + ): + return null + var npc_id: int = world_view_manager.find_nearest_active_npc_id( + global_position, interaction_range + ) + if npc_id < 0: + return null + var npc := _find_npc(npc_id) + if npc == null or npc.is_dead: + return null + return npc + + +func _build_talk_context(npc: SimNPC) -> PlayerInteractionResult: + var talk: PlayerTalkResult = simulation_manager.get_villager_talk(npc.id) + if talk == null or not talk.has_need: + return null + var prompt := "Talk to %s" % npc.npc_name + if talk.quest_available: + prompt = "Accept %s's request" % npc.npc_name + elif talk.can_accept: + prompt = "Offer to help %s" % npc.npc_name + var detail := talk.need_text + if talk.quest_available: + detail = "Help +%.0f Standing" % talk.quest_reward + return PlayerInteractionResult.new( + PlayerInteractionResult.KIND_TALK, + SimulationIds.ACTION_IDLE, + SimulationIds.npc_inventory_id(npc.id), + npc.npc_name, + prompt, + detail, + null + ) + + func get_nearby_villager_inspection() -> VillagerInspectionResult: if ( simulation_manager == null @@ -437,6 +483,38 @@ func _execute_deposit_interaction(context: PlayerInteractionResult) -> void: ) +func _execute_talk_interaction(context: PlayerInteractionResult) -> void: + var npc_id := _talk_npc_id_from_target(context.target_id) + if npc_id < 0: + return + var quest: PlayerQuestRecord = simulation_manager.accept_villager_request(npc_id) + if quest == null: + interaction_feedback.emit( + "%s declines" % context.display_name, + "They have no request you can accept right now.", + false + ) + return + interaction_feedback.emit( + "Request accepted", + ( + "%s asked you for help ยท +%.0f Standing on completion" + % [context.display_name, quest.get_standing_reward()] + ), + true + ) + + +func _talk_npc_id_from_target(target_id: StringName) -> int: + var prefix := "npc_" + var suffix := "_inventory" + var raw := String(target_id) + if not raw.begins_with(prefix) or not raw.ends_with(suffix): + return -1 + var id_text := raw.trim_prefix(prefix).trim_suffix(suffix) + return id_text.to_int() + + func _execute_pantry_interaction(context: PlayerInteractionResult) -> void: if not context.is_available(): interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index aed6b4d..50617fc 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -56,6 +56,7 @@ var event_knowledge_system := EventKnowledgeSystemScript.new() var opportunity_system := VillageOpportunitySystem.new() var player_quest_system := PlayerQuestSystem.new() var player_needs := PlayerNeedsSystem.new() +var player_negotiation := PlayerNegotiationSystem.new() var _last_player_tier := PlayerStandingRecord.TIER_STRANGER var storage_states: Dictionary: get: @@ -88,6 +89,15 @@ func _ready() -> void: event_recorder.set_npcs(npcs) player_needs.configure(economy, event_recorder) player_needs.set_resource_states(resource_states) + player_negotiation.configure( + player_quest_system, + Callable(self, "_find_npc_by_id"), + Callable(self, "get_active_opportunity"), + Callable(self, "get_active_opportunity_player_response"), + player_quest_opened, + player_quest_expired, + player_standing_changed + ) economy.inventory_changed.connect(_on_economy_inventory_changed) economy.economic_event_requested.connect(event_recorder.record_economic) economy.narrative_event_requested.connect(event_recorder.record_narrative) @@ -181,7 +191,10 @@ func simulate_tick() -> void: if debug_logs: print("--- Tick ", tick_count, " ---") animal_care.advance(tick_count) - _consider_animal_care_quests() + for quest in player_quest_system.consider_animal_care_quests( + animal_care, npcs, get_pantry(), tick_count + ): + player_quest_opened.emit(quest) _advance_player_needs() var village_was_changed := false _population_view.rebuild(npcs) @@ -594,15 +607,15 @@ func _finish_quest_resolution(resolution: Dictionary) -> void: if not resolution.has("quest"): return var quest: PlayerQuestRecord = resolution["quest"] - if not resolution["completed"]: - player_quest_expired.emit(quest) + if resolution["completed"]: + player_quest_completed.emit(quest) + var standing := player_quest_system.standing + player_standing_changed.emit(standing) + if standing.get_tier() > _last_player_tier: + _last_player_tier = standing.get_tier() + player_tier_advanced.emit(standing.get_tier(), standing.get_tier_name()) return - player_quest_completed.emit(quest) - var standing := player_quest_system.standing - player_standing_changed.emit(standing) - if standing.get_tier() > _last_player_tier: - _last_player_tier = standing.get_tier() - player_tier_advanced.emit(standing.get_tier(), standing.get_tier_name()) + player_quest_expired.emit(quest) func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bool: @@ -764,10 +777,8 @@ func get_player_state() -> PlayerStateRecord: func player_eat(amount: float) -> float: - var eaten := player_needs.eat(amount) - if eaten > 0.0: - player_needs_changed.emit(player_needs.state) - return eaten + player_needs_changed.emit(player_needs.state) + return player_needs.eat(amount) func player_gather(node: ResourceNode) -> float: @@ -785,33 +796,22 @@ func player_deposit(resource_id: StringName) -> float: return deposited -func _consider_animal_care_quests() -> void: - var pantry: StorageStateRecord = get_pantry() - for animal_state in animal_care.get_all_states(): - var quest := player_quest_system.consider_animal_care( - animal_state, _nearest_npc_id(animal_state.get_position()), tick_count, pantry - ) - if quest != null: - player_quest_opened.emit(quest) - - -func _nearest_npc_id(from_position: Vector3) -> int: - var nearest_id := -1 - var nearest_distance := INF - for npc in npcs: - if npc.is_dead: - continue - var distance := npc.position.distance_squared_to(from_position) - if distance < nearest_distance: - nearest_distance = distance - nearest_id = npc.id - return nearest_id - - func get_player_quests() -> Array[PlayerQuestRecord]: return player_quest_system.get_all_sorted() +func get_villager_talk(npc_id: int) -> PlayerTalkResult: + return player_negotiation.get_talk(npc_id) + + +func accept_villager_request(npc_id: int) -> PlayerQuestRecord: + return player_negotiation.accept_request(npc_id, tick_count) + + +func decline_villager_request(npc_id: int) -> PlayerQuestRecord: + return player_negotiation.decline_request(npc_id, tick_count) + + func get_latest_player_quest_for_requester(requester_npc_id: int) -> PlayerQuestRecord: return player_quest_system.get_latest_for_requester(requester_npc_id) diff --git a/simulation/quests/PlayerNegotiationSystem.gd b/simulation/quests/PlayerNegotiationSystem.gd new file mode 100644 index 0000000..7a0ef20 --- /dev/null +++ b/simulation/quests/PlayerNegotiationSystem.gd @@ -0,0 +1,67 @@ +class_name PlayerNegotiationSystem +extends RefCounted + +var quest_system: PlayerQuestSystem +var npc_lookup: Callable +var opportunity_provider: Callable +var player_response_provider: Callable +var quest_opened_signal: Signal +var quest_expired_signal: Signal +var standing_changed_signal: Signal + + +func configure( + quests: PlayerQuestSystem, + lookup: Callable, + opportunity_source: Callable, + response_source: Callable, + opened: Signal, + expired: Signal, + standing: Signal +) -> void: + quest_system = quests + npc_lookup = lookup + opportunity_provider = opportunity_source + player_response_provider = response_source + quest_opened_signal = opened + quest_expired_signal = expired + standing_changed_signal = standing + + +func get_talk(npc_id: int) -> PlayerTalkResult: + var npc := npc_lookup.call(npc_id) as SimNPC + if npc == null or npc.is_dead: + return null + return quest_system.get_talk_for_requester(npc.id, npc.npc_name, opportunity_provider.call()) + + +func accept_request(npc_id: int, current_tick: int) -> PlayerQuestRecord: + var npc := npc_lookup.call(npc_id) as SimNPC + if npc == null: + return null + var opportunity: OpportunityStateRecord = opportunity_provider.call() + if opportunity == null or opportunity.get_interested_npc_id() != npc.id: + return null + var result: Dictionary = quest_system.accept_opportunity_quest( + opportunity, player_response_provider.call(), current_tick + ) + var quest := result.get("quest") as PlayerQuestRecord + if quest != null and result.get("created", false) and not quest_opened_signal.is_null(): + quest_opened_signal.emit(quest) + return quest + + +func decline_request(npc_id: int, current_tick: int) -> PlayerQuestRecord: + var npc := npc_lookup.call(npc_id) as SimNPC + if npc == null: + return null + var opportunity: OpportunityStateRecord = opportunity_provider.call() + if opportunity == null or opportunity.get_interested_npc_id() != npc.id: + return null + var quest := quest_system.decline_opportunity_quest(opportunity, current_tick) + if quest != null: + if not quest_expired_signal.is_null(): + quest_expired_signal.emit(quest) + if not standing_changed_signal.is_null(): + standing_changed_signal.emit(quest_system.standing) + return quest diff --git a/simulation/quests/PlayerNegotiationSystem.gd.uid b/simulation/quests/PlayerNegotiationSystem.gd.uid new file mode 100644 index 0000000..7e87320 --- /dev/null +++ b/simulation/quests/PlayerNegotiationSystem.gd.uid @@ -0,0 +1 @@ +uid://7oayv5ga327o diff --git a/simulation/quests/PlayerQuestSystem.gd b/simulation/quests/PlayerQuestSystem.gd index 9394f4e..fb9374d 100644 --- a/simulation/quests/PlayerQuestSystem.gd +++ b/simulation/quests/PlayerQuestSystem.gd @@ -135,6 +135,58 @@ func on_opportunity_resolved( return {} +func get_talk_for_requester( + requester_id: int, requester_name: String, opportunity: OpportunityStateRecord +) -> PlayerTalkResult: + var has_need := opportunity != null and opportunity.is_open() + var need_text := "" + if has_need: + need_text = "I'm worried about the %s." % String(opportunity.get_resource_id()).capitalize() + var quest := get_latest_for_requester(requester_id) + var quest_available := quest != null and quest.is_open() + var reward := quest.get_standing_reward() if quest_available else 0.0 + var can_accept := has_need and (quest_available or can_request_personally(opportunity)) + return PlayerTalkResult.new( + requester_id, + requester_name, + "%s greets you." % requester_name, + has_need, + need_text, + quest_available, + reward, + can_accept, + has_need + ) + + +func accept_opportunity_quest( + opportunity: OpportunityStateRecord, + player_response: OpportunityPlayerResponseResult, + current_tick: int +) -> Dictionary: + var existing := _find_open_quest_for_opportunity(opportunity.get_opportunity_id()) + if existing != null: + return {"quest": existing, "created": false} + var created := consider_opportunity_opened( + opportunity, player_response, current_tick, can_request_personally(opportunity) + ) + return {"quest": created, "created": created != null} + + +func decline_opportunity_quest( + opportunity: OpportunityStateRecord, current_tick: int +) -> PlayerQuestRecord: + var quest := _find_open_quest_for_opportunity(opportunity.get_opportunity_id()) + if quest == null: + return null + if not quest.expire(current_tick): + return null + var requester_id := quest.get_requester_npc_id() + var gratitude := standing.get_gratitude(requester_id) + standing.data["gratitude"][requester_id] = maxf(gratitude - 0.1, 0.0) + return quest + + func on_opportunity_invalidated( opportunity: OpportunityStateRecord, current_tick: int ) -> PlayerQuestRecord: @@ -146,6 +198,34 @@ func on_opportunity_invalidated( return null +func consider_animal_care_quests( + animal_care: AnimalCareSystem, + npcs: Array[SimNPC], + pantry: StorageStateRecord, + current_tick: int +) -> Array[PlayerQuestRecord]: + var opened: Array[PlayerQuestRecord] = [] + for animal_state in animal_care.get_all_states(): + var requester := _nearest_npc_id(animal_state.get_position(), npcs) + var quest := consider_animal_care(animal_state, requester, current_tick, pantry) + if quest != null: + opened.append(quest) + return opened + + +static func _nearest_npc_id(from_position: Vector3, npcs: Array[SimNPC]) -> int: + var nearest_id := -1 + var nearest_distance := INF + for npc in npcs: + if npc.is_dead: + continue + var distance := npc.position.distance_squared_to(from_position) + if distance < nearest_distance: + nearest_distance = distance + nearest_id = npc.id + return nearest_id + + func get_active_quest() -> PlayerQuestRecord: for quest in quests: if quest.is_open(): diff --git a/tests/player_talk_negotiation_test.gd b/tests/player_talk_negotiation_test.gd new file mode 100644 index 0000000..b6fb1d1 --- /dev/null +++ b/tests/player_talk_negotiation_test.gd @@ -0,0 +1,202 @@ +extends SceneTree + +var failures: Array[String] = [] +var opened_quest_ids: Array[int] = [] +var expired_quest_ids: Array[int] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var manager := _create_manager() + manager.player_quest_opened.connect(_on_quest_opened) + manager.player_quest_expired.connect(_on_quest_expired) + var berry := _register_resource(manager, &"talk_berry_bush") + var actor: SimNPC = manager.npcs[0] + var interested: SimNPC = manager.npcs[2] + _set_pantry_amount(manager, 1.0) + for npc in manager.npcs: + npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0) + actor.position = Vector3.ZERO + interested.position = Vector3(4.0, 0.0, 0.0) + actor.hunger = 60.0 + interested.hunger = 90.0 + manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0) + var opportunity: OpportunityStateRecord = manager.get_active_opportunity() + _check( + opportunity != null and opportunity.get_interested_npc_id() == interested.id, + "The talk proof needs an open need owned by the inspected villager", + ) + if opportunity == null: + berry.free() + manager.free() + _finish() + return + + var talk: PlayerTalkResult = manager.get_villager_talk(interested.id) + _check( + ( + talk != null + and talk.npc_id == interested.id + and talk.npc_name == interested.npc_name + and talk.has_need + and talk.can_accept + and interested.npc_name in talk.greeting + ), + "Talking should greet the villager and surface their real need", + ) + + var checksum_before: String = manager.get_state_checksum() + var accepted: PlayerQuestRecord = manager.accept_villager_request(interested.id) + print( + " debug accepted=", + accepted, + " opened=", + opened_quest_ids, + " active=", + manager.get_active_player_quest(), + " checksum_same=", + manager.get_state_checksum() == checksum_before, + " resolved=", + manager.get_player_standing().get_resolved_needs(), + ) + _check( + ( + accepted != null + and accepted.is_open() + and accepted.get_requester_npc_id() == interested.id + and opened_quest_ids == [accepted.get_quest_id()] + and manager.get_state_checksum() == checksum_before + and manager.get_player_standing().get_resolved_needs() == 0 + ), + "Accepting a request should surface the same open quest without granting standing yet", + ) + var accepted_again: PlayerQuestRecord = manager.accept_villager_request(interested.id) + _check( + accepted_again != null and accepted_again.get_quest_id() == accepted.get_quest_id(), + "Re-accepting should not duplicate the open quest", + ) + + var carry_state: PlayerStateRecord = manager.get_player_state() + carry_state.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + var deposited: float = manager.player_deposit(SimulationIds.RESOURCE_FOOD) + _check( + ( + is_equal_approx(deposited, 1.0) + and manager.get_active_player_quest() == null + and manager.get_player_standing().get_resolved_needs() == 1 + ), + "Completing the accepted request through a real deposit should resolve it", + ) + + var declined := _create_manager(904) + declined.player_quest_opened.connect(_on_quest_opened) + declined.player_quest_expired.connect(_on_quest_expired) + var berry2 := _register_resource(declined, &"talk_berry_bush_2") + var actor2: SimNPC = declined.npcs[0] + var interested2: SimNPC = declined.npcs[2] + _set_pantry_amount(declined, 1.0) + for npc in declined.npcs: + npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0) + actor2.position = Vector3.ZERO + interested2.position = Vector3(4.0, 0.0, 0.0) + actor2.hunger = 60.0 + interested2.hunger = 90.0 + declined.economy.withdraw_to_inventory(actor2, SimulationIds.RESOURCE_FOOD, 1.0) + var declined_quest: PlayerQuestRecord = declined.get_active_player_quest() + _check(declined_quest != null, "The decline branch needs an open quest") + if declined_quest == null: + berry.free() + berry2.free() + manager.free() + declined.free() + _finish() + return + var gratitude_before: float = declined.get_player_standing().get_gratitude(interested2.id) + var refused: PlayerQuestRecord = declined.decline_villager_request(interested2.id) + _check( + ( + refused != null + and refused.get_quest_id() == declined_quest.get_quest_id() + and expired_quest_ids.has(declined_quest.get_quest_id()) + and declined.get_active_player_quest() == null + and declined.get_player_standing().get_gratitude(interested2.id) <= gratitude_before + ), + "Declining a personal request should close the quest without granting gratitude", + ) + + berry.free() + berry2.free() + manager.free() + declined.free() + _finish() + + +func _register_resource(manager: Node, node_id: StringName) -> ResourceNode: + var node := ResourceNode.new() + node.name = "TalkResource" + node.node_id = node_id + node.action_id = SimulationIds.ACTION_GATHER_FOOD + node.resource_id = SimulationIds.RESOURCE_FOOD + node.initial_amount = 2.0 + node.yield_per_action = 2.0 + node.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + node.add_child(interaction_point) + root.add_child(node) + _check( + manager.register_resource_node(node), + "The talk proof should bind a real finite food ResourceNode" + ) + return node + + +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 _set_pantry_amount(manager: Node, amount: float) -> void: + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + pantry.deposit(SimulationIds.RESOURCE_FOOD, amount) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + + +func _on_quest_opened(quest: PlayerQuestRecord) -> void: + opened_quest_ids.append(quest.get_quest_id()) + + +func _on_quest_expired(quest: PlayerQuestRecord) -> void: + expired_quest_ids.append(quest.get_quest_id()) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Player talk and personal negotiation passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/player_talk_negotiation_test.gd.uid b/tests/player_talk_negotiation_test.gd.uid new file mode 100644 index 0000000..18b486e --- /dev/null +++ b/tests/player_talk_negotiation_test.gd.uid @@ -0,0 +1 @@ +uid://cdh2d6y6jvajo