feat: let the player talk to villagers and accept or decline personal requests

This commit is contained in:
2026-08-09 00:10:36 +02:00
parent 5e6c963d23
commit 0936ae6450
10 changed files with 520 additions and 36 deletions
+1
View File
@@ -5,6 +5,7 @@ const KIND_ANIMAL := &"animal"
const KIND_RESOURCE := &"resource" const KIND_RESOURCE := &"resource"
const KIND_PANTRY := &"pantry" const KIND_PANTRY := &"pantry"
const KIND_DEPOSIT := &"deposit" const KIND_DEPOSIT := &"deposit"
const KIND_TALK := &"talk"
const KIND_GUARD := &"guard" const KIND_GUARD := &"guard"
const KIND_STUDY := &"study" const KIND_STUDY := &"study"
+53
View File
@@ -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),
]
)
)
+1
View File
@@ -0,0 +1 @@
uid://i7pjb5605cgr
+78
View File
@@ -72,6 +72,8 @@ func try_interact() -> void:
_execute_resource_interaction(context) _execute_resource_interaction(context)
PlayerInteractionResult.KIND_DEPOSIT: PlayerInteractionResult.KIND_DEPOSIT:
_execute_deposit_interaction(context) _execute_deposit_interaction(context)
PlayerInteractionResult.KIND_TALK:
_execute_talk_interaction(context)
PlayerInteractionResult.KIND_GUARD: PlayerInteractionResult.KIND_GUARD:
simulation_manager.add_safety(3.0) simulation_manager.add_safety(3.0)
interaction_feedback.emit( interaction_feedback.emit(
@@ -131,9 +133,53 @@ func get_interaction_context() -> PlayerInteractionResult:
) )
if is_near_storage(pantry_storage): if is_near_storage(pantry_storage):
return _build_pantry_context() return _build_pantry_context()
var talk_npc := _find_talk_villager()
if talk_npc != null:
return _build_talk_context(talk_npc)
return null 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: func get_nearby_villager_inspection() -> VillagerInspectionResult:
if ( if (
simulation_manager == null 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: func _execute_pantry_interaction(context: PlayerInteractionResult) -> void:
if not context.is_available(): if not context.is_available():
interaction_feedback.emit("The pantry is empty", context.blocked_reason, false) interaction_feedback.emit("The pantry is empty", context.blocked_reason, false)
+30 -30
View File
@@ -56,6 +56,7 @@ var event_knowledge_system := EventKnowledgeSystemScript.new()
var opportunity_system := VillageOpportunitySystem.new() var opportunity_system := VillageOpportunitySystem.new()
var player_quest_system := PlayerQuestSystem.new() var player_quest_system := PlayerQuestSystem.new()
var player_needs := PlayerNeedsSystem.new() var player_needs := PlayerNeedsSystem.new()
var player_negotiation := PlayerNegotiationSystem.new()
var _last_player_tier := PlayerStandingRecord.TIER_STRANGER var _last_player_tier := PlayerStandingRecord.TIER_STRANGER
var storage_states: Dictionary: var storage_states: Dictionary:
get: get:
@@ -88,6 +89,15 @@ func _ready() -> void:
event_recorder.set_npcs(npcs) event_recorder.set_npcs(npcs)
player_needs.configure(economy, event_recorder) player_needs.configure(economy, event_recorder)
player_needs.set_resource_states(resource_states) 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.inventory_changed.connect(_on_economy_inventory_changed)
economy.economic_event_requested.connect(event_recorder.record_economic) economy.economic_event_requested.connect(event_recorder.record_economic)
economy.narrative_event_requested.connect(event_recorder.record_narrative) economy.narrative_event_requested.connect(event_recorder.record_narrative)
@@ -181,7 +191,10 @@ func simulate_tick() -> void:
if debug_logs: if debug_logs:
print("--- Tick ", tick_count, " ---") print("--- Tick ", tick_count, " ---")
animal_care.advance(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() _advance_player_needs()
var village_was_changed := false var village_was_changed := false
_population_view.rebuild(npcs) _population_view.rebuild(npcs)
@@ -594,15 +607,15 @@ func _finish_quest_resolution(resolution: Dictionary) -> void:
if not resolution.has("quest"): if not resolution.has("quest"):
return return
var quest: PlayerQuestRecord = resolution["quest"] var quest: PlayerQuestRecord = resolution["quest"]
if not resolution["completed"]: if resolution["completed"]:
player_quest_expired.emit(quest)
return
player_quest_completed.emit(quest) player_quest_completed.emit(quest)
var standing := player_quest_system.standing var standing := player_quest_system.standing
player_standing_changed.emit(standing) player_standing_changed.emit(standing)
if standing.get_tier() > _last_player_tier: if standing.get_tier() > _last_player_tier:
_last_player_tier = standing.get_tier() _last_player_tier = standing.get_tier()
player_tier_advanced.emit(standing.get_tier(), standing.get_tier_name()) player_tier_advanced.emit(standing.get_tier(), standing.get_tier_name())
return
player_quest_expired.emit(quest)
func try_communicate_at_shared_activity(speaker_id: int, listener_id: int) -> bool: 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: func player_eat(amount: float) -> float:
var eaten := player_needs.eat(amount)
if eaten > 0.0:
player_needs_changed.emit(player_needs.state) player_needs_changed.emit(player_needs.state)
return eaten return player_needs.eat(amount)
func player_gather(node: ResourceNode) -> float: func player_gather(node: ResourceNode) -> float:
@@ -785,33 +796,22 @@ func player_deposit(resource_id: StringName) -> float:
return deposited 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]: func get_player_quests() -> Array[PlayerQuestRecord]:
return player_quest_system.get_all_sorted() 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: func get_latest_player_quest_for_requester(requester_npc_id: int) -> PlayerQuestRecord:
return player_quest_system.get_latest_for_requester(requester_npc_id) return player_quest_system.get_latest_for_requester(requester_npc_id)
@@ -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
@@ -0,0 +1 @@
uid://7oayv5ga327o
+80
View File
@@ -135,6 +135,58 @@ func on_opportunity_resolved(
return {} 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( func on_opportunity_invalidated(
opportunity: OpportunityStateRecord, current_tick: int opportunity: OpportunityStateRecord, current_tick: int
) -> PlayerQuestRecord: ) -> PlayerQuestRecord:
@@ -146,6 +198,34 @@ func on_opportunity_invalidated(
return null 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: func get_active_quest() -> PlayerQuestRecord:
for quest in quests: for quest in quests:
if quest.is_open(): if quest.is_open():
+202
View File
@@ -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)
@@ -0,0 +1 @@
uid://cdh2d6y6jvajo