From 3b6e35ba6d574db1c0d7ed1939222cf975fb99d0 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sat, 8 Aug 2026 20:35:07 +0200 Subject: [PATCH] feat: let trusted villagers ask the player directly as standing grows --- simulation/SimulationManager.gd | 44 ++++++++------ simulation/quests/PlayerQuestSystem.gd | 24 +++++--- tests/unit/test_player_quest_system.gd | 70 ++++++++++++++++++++++ tests/unit/test_player_quest_system.gd.uid | 1 + world/ui/quest_journal_hud.gd | 7 +++ 5 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 tests/unit/test_player_quest_system.gd create mode 100644 tests/unit/test_player_quest_system.gd.uid diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index cfde0e3..f8293ad 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -28,6 +28,7 @@ signal player_quest_opened(quest: PlayerQuestRecord) signal player_quest_completed(quest: PlayerQuestRecord) signal player_quest_expired(quest: PlayerQuestRecord) signal player_standing_changed(standing: PlayerStandingRecord) +signal player_tier_advanced(tier: int, tier_name: String) var village := SimVillage.new() @@ -51,6 +52,7 @@ var relationship_system := RelationshipSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new() var opportunity_system := VillageOpportunitySystem.new() var player_quest_system := PlayerQuestSystem.new() +var _last_player_tier := PlayerStandingRecord.TIER_STRANGER var storage_states: Dictionary: get: return economy.storage_states @@ -578,19 +580,30 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void: func _resolve_animal_care_quest(event: EconomicEventRecord) -> void: - var animal_id := StringName(event.data["destination_id"]) - var animal_state := animal_care.get_state(animal_id) + var animal_state := animal_care.get_state(StringName(event.data["destination_id"])) var resolution := player_quest_system.on_animal_fed(animal_state, event, tick_count) + _finish_quest_resolution(resolution) + + +func _finish_quest_resolution(resolution: Dictionary) -> void: if not resolution.has("quest"): return var quest: PlayerQuestRecord = resolution["quest"] if resolution["completed"]: player_quest_completed.emit(quest) - player_standing_changed.emit(player_quest_system.standing) + _emit_standing_change_and_tier() else: player_quest_expired.emit(quest) +func _emit_standing_change_and_tier() -> void: + 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()) + + 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) @@ -674,20 +687,18 @@ func _update_opportunity_from_event(event: EconomicEventRecord) -> void: if changed.get_status() == OpportunityStateRecord.STATUS_OPEN: opportunity_opened.emit(changed) var quest := player_quest_system.consider_opportunity_opened( - changed, get_active_opportunity_player_response(), tick_count + changed, + get_active_opportunity_player_response(), + tick_count, + player_quest_system.can_request_personally(changed) ) if quest != null: player_quest_opened.emit(quest) return opportunity_resolved.emit(changed, event) - var resolution := player_quest_system.on_opportunity_resolved(changed, event, tick_count) - if resolution.has("quest"): - var quest: PlayerQuestRecord = resolution["quest"] - if resolution["completed"]: - player_quest_completed.emit(quest) - player_standing_changed.emit(player_quest_system.standing) - else: - player_quest_expired.emit(quest) + _finish_quest_resolution( + player_quest_system.on_opportunity_resolved(changed, event, tick_count) + ) _maintain_event_knowledge(false) @@ -780,22 +791,20 @@ func get_active_player_quest() -> PlayerQuestRecord: func _consider_animal_care_quests() -> void: var pantry: StorageStateRecord = get_pantry() for animal_state in animal_care.get_all_states(): - var requester := _find_animal_requester(animal_state) var quest := player_quest_system.consider_animal_care( - animal_state, requester, tick_count, pantry + animal_state, _nearest_npc_id(animal_state.get_position()), tick_count, pantry ) if quest != null: player_quest_opened.emit(quest) -func _find_animal_requester(animal_state: AnimalStateRecord) -> int: - var animal_position := animal_state.get_position() +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(animal_position) + var distance := npc.position.distance_squared_to(from_position) if distance < nearest_distance: nearest_distance = distance nearest_id = npc.id @@ -1172,6 +1181,7 @@ func restore_state(record: SimulationStateRecord) -> bool: player_quest_system.restore( record.player_quests, int(record.simulation.get("next_quest_id", 0)), record.player_standing ) + _last_player_tier = player_quest_system.standing.get_tier() _maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0) wander_random_sources.clear() var wander_streams: Array = record.simulation["wander_random_streams"] diff --git a/simulation/quests/PlayerQuestSystem.gd b/simulation/quests/PlayerQuestSystem.gd index 7838dd2..9394f4e 100644 --- a/simulation/quests/PlayerQuestSystem.gd +++ b/simulation/quests/PlayerQuestSystem.gd @@ -4,6 +4,8 @@ extends RefCounted const PANTRY_STANDING_REWARD := 8.0 const WOOD_STANDING_REWARD := 8.0 const ANIMAL_CARE_STANDING_REWARD := 6.0 +const PERSONAL_TRUST_THRESHOLD := 0.5 +const MIN_PERSONAL_REQUEST_TIER := PlayerStandingRecord.TIER_KNOWN_HAND var quests: Array[PlayerQuestRecord] = [] var next_quest_id := 0 @@ -77,14 +79,12 @@ func on_animal_fed( func consider_opportunity_opened( opportunity: OpportunityStateRecord, player_response: OpportunityPlayerResponseResult, - current_tick: int + current_tick: int, + personal_request_eligible := false ) -> PlayerQuestRecord: - if ( - opportunity == null - or not opportunity.is_open() - or player_response == null - or current_tick < 0 - ): + if opportunity == null or not opportunity.is_open() or current_tick < 0: + return null + if player_response == null and not personal_request_eligible: return null if _find_open_quest_for_opportunity(opportunity.get_opportunity_id()) != null: return null @@ -107,6 +107,16 @@ func consider_opportunity_opened( return quest +func can_request_personally(opportunity: OpportunityStateRecord) -> bool: + if ( + opportunity == null + or not opportunity.is_open() + or standing.get_tier() < MIN_PERSONAL_REQUEST_TIER + ): + return false + return standing.get_gratitude(opportunity.get_interested_npc_id()) >= PERSONAL_TRUST_THRESHOLD + + func on_opportunity_resolved( opportunity: OpportunityStateRecord, resolution_event: EconomicEventRecord, current_tick: int ) -> Dictionary: diff --git a/tests/unit/test_player_quest_system.gd b/tests/unit/test_player_quest_system.gd new file mode 100644 index 0000000..a3a9ec9 --- /dev/null +++ b/tests/unit/test_player_quest_system.gd @@ -0,0 +1,70 @@ +extends GutTest + +const PlayerQuestSystemScript := preload("res://simulation/quests/PlayerQuestSystem.gd") + + +func test_personal_request_needs_standing_tier_and_gratitude() -> void: + var system := PlayerQuestSystemScript.new() + var opportunity := _open_opportunity() + var pantry := _pantry() + + assert_false(system.can_request_personally(opportunity)) + for _grant in 6: + system.standing.grant_standing(3.0, opportunity.get_interested_npc_id()) + assert_true( + system.can_request_personally(opportunity), + "Known Hand standing plus personal gratitude should make the NPC ask you directly" + ) + var quest := system.consider_opportunity_opened(opportunity, null, 10, true) + assert_not_null(quest) + assert_eq(quest.get_requester_npc_id(), opportunity.get_interested_npc_id()) + + +func test_no_personal_request_below_trust_threshold() -> void: + var system := PlayerQuestSystemScript.new() + var opportunity := _open_opportunity() + system.standing.grant_standing(16.0, opportunity.get_interested_npc_id()) + system.standing.data["gratitude"][opportunity.get_interested_npc_id()] = 0.4 + + assert_false(system.can_request_personally(opportunity)) + + +func test_standing_tier_progression() -> void: + var standing := PlayerStandingRecord.create() + assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_STRANGER) + standing.grant_standing(14.0, 3) + assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_STRANGER) + standing.grant_standing(2.0, 3) + assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_KNOWN_HAND) + assert_eq(standing.get_tier_name(), "Known Hand") + standing.grant_standing(20.0, 3) + assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_TRUSTED) + + +func test_standing_serialization_round_trip() -> void: + var standing := PlayerStandingRecord.create() + standing.grant_standing(35.0, 3) + var restored := PlayerStandingRecord.from_dictionary(standing.to_dictionary()) + assert_not_null(restored) + assert_eq(restored.get_standing(), 35.0) + assert_eq(restored.get_tier(), PlayerStandingRecord.TIER_TRUSTED) + assert_eq(restored.get_gratitude(3), 0.1) + + +func _open_opportunity() -> OpportunityStateRecord: + return OpportunityStateRecord.create( + 0, + 10, + 5, + 3, + 1.0, + SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY, + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD + ) + + +func _pantry() -> StorageStateRecord: + return StorageStateRecord.create( + SimulationIds.STORAGE_VILLAGE_PANTRY, {SimulationIds.RESOURCE_FOOD: 0.0}, 100.0 + ) diff --git a/tests/unit/test_player_quest_system.gd.uid b/tests/unit/test_player_quest_system.gd.uid new file mode 100644 index 0000000..1ae91c3 --- /dev/null +++ b/tests/unit/test_player_quest_system.gd.uid @@ -0,0 +1 @@ +uid://b2jmmtiyvaqll diff --git a/world/ui/quest_journal_hud.gd b/world/ui/quest_journal_hud.gd index 0e133f2..1c589ea 100644 --- a/world/ui/quest_journal_hud.gd +++ b/world/ui/quest_journal_hud.gd @@ -34,6 +34,8 @@ func _ready() -> void: simulation_manager.player_quest_expired.connect(_on_quest_expired) if simulation_manager.has_signal("player_standing_changed"): simulation_manager.player_standing_changed.connect(_on_standing_changed) + if simulation_manager.has_signal("player_tier_advanced"): + simulation_manager.player_tier_advanced.connect(_on_tier_advanced) if simulation_manager.has_signal("opportunity_resolved"): simulation_manager.opportunity_resolved.connect(_on_opportunity_resolved) if simulation_manager.has_signal("state_restored"): @@ -53,6 +55,11 @@ func _on_standing_changed(_standing: PlayerStandingRecord) -> void: _refresh() +func _on_tier_advanced(_tier: int, tier_name: String) -> void: + _refresh() + _show_thanks("Standing grows", "The village now calls you a %s." % tier_name) + + func _on_opportunity_resolved( _opportunity: OpportunityStateRecord, _cause_event: EconomicEventRecord ) -> void: