From 9e6a6afcf5e59594b14876b49de675d2033ff497 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sat, 8 Aug 2026 17:44:27 +0200 Subject: [PATCH] feat: add player standing and quest generation from village needs --- simulation/SimulationManager.gd | 159 ++++------ simulation/events/SimulationEventRecorder.gd | 113 +++++++ .../events/SimulationEventRecorder.gd.uid | 1 + simulation/quests/PlayerQuestSystem.gd | 121 ++++++++ simulation/quests/PlayerQuestSystem.gd.uid | 1 + simulation/state/PlayerQuestRecord.gd | 199 ++++++++++++ simulation/state/PlayerQuestRecord.gd.uid | 1 + simulation/state/PlayerStandingRecord.gd | 120 ++++++++ simulation/state/PlayerStandingRecord.gd.uid | 1 + simulation/state/SimulationStateRecord.gd | 68 ++++- tests/player_quest_standing_test.gd | 282 ++++++++++++++++++ tests/player_quest_standing_test.gd.uid | 1 + 12 files changed, 967 insertions(+), 100 deletions(-) create mode 100644 simulation/events/SimulationEventRecorder.gd create mode 100644 simulation/events/SimulationEventRecorder.gd.uid create mode 100644 simulation/quests/PlayerQuestSystem.gd create mode 100644 simulation/quests/PlayerQuestSystem.gd.uid create mode 100644 simulation/state/PlayerQuestRecord.gd create mode 100644 simulation/state/PlayerQuestRecord.gd.uid create mode 100644 simulation/state/PlayerStandingRecord.gd create mode 100644 simulation/state/PlayerStandingRecord.gd.uid create mode 100644 tests/player_quest_standing_test.gd create mode 100644 tests/player_quest_standing_test.gd.uid diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 49420be..320b473 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -1,6 +1,7 @@ extends Node const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") +const SimulationEventRecorderScript := preload("res://simulation/events/SimulationEventRecorder.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_system.gd") const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd") @@ -23,6 +24,10 @@ signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord) signal opportunity_opened(opportunity: OpportunityStateRecord) signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord) signal opportunity_invalidated(opportunity: OpportunityStateRecord) +signal player_quest_opened(quest: PlayerQuestRecord) +signal player_quest_completed(quest: PlayerQuestRecord) +signal player_quest_expired(quest: PlayerQuestRecord) +signal player_standing_changed(standing: PlayerStandingRecord) var village := SimVillage.new() @@ -39,11 +44,13 @@ var tick_count := 0 var wander_random_sources := {} var resource_states: Dictionary = {} var event_log := SimulationEventLogScript.new() +var event_recorder := SimulationEventRecorderScript.new() var economy := VillageEconomyScript.new() var animal_care := AnimalCareSystemScript.new() var relationship_system := RelationshipSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new() var opportunity_system := VillageOpportunitySystem.new() +var player_quest_system := PlayerQuestSystem.new() var storage_states: Dictionary: get: return economy.storage_states @@ -71,11 +78,13 @@ const NPC_NAMES := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"] func _ready() -> void: add_to_group("simulation_manager") event_log.event_recorded.connect(_on_economic_event_recorded) + event_recorder.configure(event_log, Callable(self, "get_tick_count_for_recording")) + event_recorder.set_npcs(npcs) economy.inventory_changed.connect(_on_economy_inventory_changed) - economy.economic_event_requested.connect(_record_economic_event) - economy.narrative_event_requested.connect(record_narrative_event) - animal_care.economic_event_requested.connect(_record_economic_event_at) - animal_care.narrative_event_requested.connect(record_narrative_event) + economy.economic_event_requested.connect(event_recorder.record_economic) + economy.narrative_event_requested.connect(event_recorder.record_narrative) + animal_care.economic_event_requested.connect(event_recorder.record_economic_at) + animal_care.narrative_event_requested.connect(event_recorder.record_narrative) action_selector.relationship_system = relationship_system var definition_errors := SimulationDefinitions.validate() if not definition_errors.is_empty(): @@ -175,6 +184,9 @@ func simulate_tick() -> void: ) if invalidated != null: opportunity_invalidated.emit(invalidated) + var expired_quest := player_quest_system.on_opportunity_invalidated(invalidated, tick_count) + if expired_quest != null: + player_quest_expired.emit(expired_quest) if tick_count % get_knowledge_review_interval() == 0: _maintain_event_knowledge(true) if debug_logs: @@ -239,7 +251,7 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void: var display_name := ( definition.display_name if definition != null else String(selection.action_id) ) - record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name) + event_recorder.record_narrative(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name) func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void: @@ -247,7 +259,7 @@ func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: release_npc_reservation(npc.id) npc_died.emit(npc) npc_task_changed.emit(npc, previous_task, npc.current_task) - record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id) + event_recorder.record_narrative(SimulationIds.EVENT_NPC_DIED, npc.id) _notify_mourning(npc) if debug_logs: print("[SimulationManager] NPC died: ", npc.npc_name) @@ -293,7 +305,7 @@ func _apply_action_completion( SimulationIds.ACTION_SLEEP: npc.energy = minf(npc.energy + 40.0, 100.0) npc.position = npc.home_position - record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id) + event_recorder.record_narrative(SimulationIds.EVENT_NPC_SLEPT, npc.id) SimulationIds.ACTION_FEED_ANIMAL: pass SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER: @@ -320,7 +332,7 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void: var resource_id := resource_state.get_resource_id() npc.add_inventory(resource_id, extracted) npc_inventory_changed.emit(npc, resource_id, npc.get_inventory_amount(resource_id)) - _record_economic_event( + event_recorder.record_economic( SimulationIds.EVENT_RESOURCE_EXTRACTED, npc.id, resource_state.get_node_id(), @@ -329,7 +341,7 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void: extracted ) if resource_state.get_amount_remaining() <= 0.0: - record_narrative_event( + event_recorder.record_narrative( SimulationIds.EVENT_RESOURCE_DEPLETED, npc.id, resource_state.get_node_id() ) if debug_logs: @@ -540,93 +552,8 @@ func _notify_mourning(dead_npc: SimNPC) -> void: print("[SimulationManager] ", mourner.npc_name, " is mourning ", dead_npc.npc_name) -func _record_economic_event( - event_type: StringName, - actor_id: int, - source_id: StringName, - destination_id: StringName, - item_id: StringName, - amount: float -) -> void: - _record_economic_event_at( - event_type, - actor_id, - source_id, - destination_id, - item_id, - amount, - _get_event_world_position(actor_id, source_id, destination_id) - ) - - -func _record_economic_event_at( - event_type: StringName, - actor_id: int, - source_id: StringName, - destination_id: StringName, - item_id: StringName, - amount: float, - world_position: Vector3 -) -> void: - event_log.record_economic( - tick_count, event_type, actor_id, source_id, destination_id, item_id, amount, world_position - ) - - -func record_narrative_event( - event_type: StringName, - actor_id: int, - source_id: StringName = &"", - action_display: String = "", - action_id: StringName = &"", - item_id: StringName = &"", - required_amount: float = 0.0 -) -> void: - _record_narrative_event_at( - event_type, - actor_id, - source_id, - action_display, - _get_event_world_position(actor_id, source_id, &""), - action_id, - item_id, - required_amount - ) - - -func _record_narrative_event_at( - event_type: StringName, - actor_id: int, - source_id: StringName, - action_display: String, - world_position: Vector3, - action_id: StringName = &"", - item_id: StringName = &"", - required_amount: float = 0.0 -) -> void: - event_log.record_narrative( - tick_count, - event_type, - actor_id, - source_id, - action_display, - world_position, - action_id, - item_id, - required_amount - ) - - -func _get_event_world_position( - actor_id: int, source_id: StringName, destination_id: StringName -) -> Vector3: - for npc in npcs: - if npc.id != actor_id: - continue - if not npc.target_id.is_empty() and npc.target_id in [source_id, destination_id]: - return npc.travel_target_position - return npc.position - return Vector3.ZERO +func get_tick_count_for_recording() -> int: + return tick_count func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]: @@ -729,8 +656,21 @@ func _update_opportunity_from_event(event: EconomicEventRecord) -> void: return 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 + ) + 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) _maintain_event_knowledge(false) @@ -816,6 +756,22 @@ func get_active_opportunity() -> OpportunityStateRecord: return opportunity_system.get_open_opportunity() +func get_active_player_quest() -> PlayerQuestRecord: + return player_quest_system.get_active_quest() + + +func get_player_quests() -> Array[PlayerQuestRecord]: + return player_quest_system.get_all_sorted() + + +func get_latest_player_quest_for_requester(requester_npc_id: int) -> PlayerQuestRecord: + return player_quest_system.get_latest_for_requester(requester_npc_id) + + +func get_player_standing() -> PlayerStandingRecord: + return player_quest_system.standing + + func get_active_opportunity_helper() -> OpportunityHelperResult: var active := get_active_opportunity() if active == null: @@ -1015,7 +971,7 @@ func harvest_resource_node(node: ResourceNode) -> float: if node.interaction_point != null else node.global_position ) - _record_economic_event_at( + event_recorder.record_economic_at( SimulationIds.EVENT_RESOURCE_EXTRACTED, -1, resource_state.get_node_id(), @@ -1033,7 +989,7 @@ func harvest_resource_node(node: ResourceNode) -> float: event_position ) if resource_state.get_amount_remaining() <= 0.0: - _record_narrative_event_at( + event_recorder.record_narrative_at( SimulationIds.EVENT_RESOURCE_DEPLETED, -1, resource_state.get_node_id(), @@ -1101,6 +1057,7 @@ func create_state_record() -> SimulationStateRecord: "wander_random_streams": wander_streams, "next_event_id": next_event_id, "next_opportunity_id": opportunity_system.next_opportunity_id, + "next_quest_id": player_quest_system.next_quest_id, "cycle_duration_seconds": clock.cycle_duration_seconds } record.village = VillageStateRecord.capture(village) @@ -1125,6 +1082,9 @@ func create_state_record() -> SimulationStateRecord: record.event_knowledge.append(known_event) for opportunity in opportunity_system.get_all_sorted(): record.opportunities.append(opportunity) + record.player_standing = player_quest_system.standing + for quest in player_quest_system.get_all_sorted(): + record.player_quests.append(quest) return record @@ -1162,6 +1122,9 @@ func restore_state(record: SimulationStateRecord) -> bool: relationship_system.restore(record.relationships) event_knowledge_system.restore(record.event_knowledge) opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"])) + player_quest_system.restore( + record.player_quests, int(record.simulation.get("next_quest_id", 0)), record.player_standing + ) _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/events/SimulationEventRecorder.gd b/simulation/events/SimulationEventRecorder.gd new file mode 100644 index 0000000..c1a84d7 --- /dev/null +++ b/simulation/events/SimulationEventRecorder.gd @@ -0,0 +1,113 @@ +class_name SimulationEventRecorder +extends RefCounted + +const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") + +var event_log: RefCounted +var npcs: Array[SimNPC] = [] +var tick_provider: Callable + + +func configure(log: RefCounted, tick_source: Callable) -> void: + event_log = log + tick_provider = tick_source + + +func set_npcs(npc_list: Array[SimNPC]) -> void: + npcs = npc_list + + +func record_economic( + event_type: StringName, + actor_id: int, + source_id: StringName, + destination_id: StringName, + item_id: StringName, + amount: float +) -> void: + record_economic_at( + event_type, + actor_id, + source_id, + destination_id, + item_id, + amount, + get_event_world_position(actor_id, source_id, destination_id) + ) + + +func record_economic_at( + event_type: StringName, + actor_id: int, + source_id: StringName, + destination_id: StringName, + item_id: StringName, + amount: float, + world_position: Vector3 +) -> void: + event_log.record_economic( + tick_provider.call(), + event_type, + actor_id, + source_id, + destination_id, + item_id, + amount, + world_position + ) + + +func record_narrative( + event_type: StringName, + actor_id: int, + source_id: StringName = &"", + action_display: String = "", + action_id: StringName = &"", + item_id: StringName = &"", + required_amount: float = 0.0 +) -> void: + record_narrative_at( + event_type, + actor_id, + source_id, + action_display, + get_event_world_position(actor_id, source_id, &""), + action_id, + item_id, + required_amount + ) + + +func record_narrative_at( + event_type: StringName, + actor_id: int, + source_id: StringName, + action_display: String, + world_position: Vector3, + action_id: StringName = &"", + item_id: StringName = &"", + required_amount: float = 0.0 +) -> void: + event_log.record_narrative( + tick_provider.call(), + event_type, + actor_id, + source_id, + action_display, + world_position, + action_id, + item_id, + required_amount + ) + + +func get_event_world_position( + actor_id: int, source_id: StringName, destination_id: StringName +) -> Vector3: + for npc in npcs: + if npc.id != actor_id: + continue + if not npc.target_id.is_empty() and npc.target_id in [source_id, destination_id]: + return npc.travel_target_position + return npc.position + return Vector3.ZERO diff --git a/simulation/events/SimulationEventRecorder.gd.uid b/simulation/events/SimulationEventRecorder.gd.uid new file mode 100644 index 0000000..d6f41c9 --- /dev/null +++ b/simulation/events/SimulationEventRecorder.gd.uid @@ -0,0 +1 @@ +uid://yl07qwlrpxjs diff --git a/simulation/quests/PlayerQuestSystem.gd b/simulation/quests/PlayerQuestSystem.gd new file mode 100644 index 0000000..b03ad7e --- /dev/null +++ b/simulation/quests/PlayerQuestSystem.gd @@ -0,0 +1,121 @@ +class_name PlayerQuestSystem +extends RefCounted + +const PANTRY_STANDING_REWARD := 8.0 +const WOOD_STANDING_REWARD := 8.0 + +var quests: Array[PlayerQuestRecord] = [] +var next_quest_id := 0 +var standing := PlayerStandingRecord.create() + + +func consider_opportunity_opened( + opportunity: OpportunityStateRecord, + player_response: OpportunityPlayerResponseResult, + current_tick: int +) -> PlayerQuestRecord: + if ( + opportunity == null + or not opportunity.is_open() + or player_response == null + or current_tick < 0 + ): + return null + if _find_open_quest_for_opportunity(opportunity.get_opportunity_id()) != null: + return null + var reward := _standing_reward_for(opportunity) + if reward <= 0.0: + return null + var quest := PlayerQuestRecord.create( + next_quest_id, + opportunity.get_opportunity_id(), + opportunity.get_interested_npc_id(), + opportunity.get_opportunity_type(), + opportunity.get_resource_id(), + opportunity.get_target_id(), + opportunity.get_target_amount(), + current_tick, + reward + ) + next_quest_id += 1 + quests.append(quest) + return quest + + +func on_opportunity_resolved( + opportunity: OpportunityStateRecord, resolution_event: EconomicEventRecord, current_tick: int +) -> Dictionary: + var quest := _find_open_quest_for_opportunity(opportunity.get_opportunity_id()) + if quest == null or resolution_event == null: + return {} + var event_id := int(resolution_event.data["event_id"]) + var actor_id := int(resolution_event.data["actor_id"]) + if actor_id < 0: + if not quest.complete(event_id, current_tick): + return {} + standing.grant_standing(quest.get_standing_reward(), quest.get_requester_npc_id()) + return {"quest": quest, "completed": true} + if quest.expire(current_tick): + return {"quest": quest, "completed": false} + return {} + + +func on_opportunity_invalidated( + opportunity: OpportunityStateRecord, current_tick: int +) -> PlayerQuestRecord: + var quest := _find_open_quest_for_opportunity(opportunity.get_opportunity_id()) + if quest == null: + return null + if quest.expire(current_tick): + return quest + return null + + +func get_active_quest() -> PlayerQuestRecord: + for quest in quests: + if quest.is_open(): + return quest + return null + + +func get_all_sorted() -> Array[PlayerQuestRecord]: + var sorted := quests.duplicate() + sorted.sort_custom(_sort_by_id) + return sorted + + +func get_latest_for_requester(requester_npc_id: int) -> PlayerQuestRecord: + for index in range(quests.size() - 1, -1, -1): + var quest := quests[index] + if quest.get_requester_npc_id() == requester_npc_id: + return quest + return null + + +func restore( + records: Array[PlayerQuestRecord], restored_next_id: int, standing_record: PlayerStandingRecord +) -> void: + quests = records.duplicate() + quests.sort_custom(_sort_by_id) + next_quest_id = maxi(restored_next_id, 0) + standing = standing_record if standing_record != null else PlayerStandingRecord.create() + + +func _find_open_quest_for_opportunity(opportunity_id: int) -> PlayerQuestRecord: + for quest in quests: + if quest.is_open() and quest.get_opportunity_id() == opportunity_id: + return quest + return null + + +func _standing_reward_for(opportunity: OpportunityStateRecord) -> float: + match opportunity.get_opportunity_type(): + SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: + return PANTRY_STANDING_REWARD + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + return WOOD_STANDING_REWARD + return 0.0 + + +static func _sort_by_id(first: PlayerQuestRecord, second: PlayerQuestRecord) -> bool: + return first.get_quest_id() < second.get_quest_id() diff --git a/simulation/quests/PlayerQuestSystem.gd.uid b/simulation/quests/PlayerQuestSystem.gd.uid new file mode 100644 index 0000000..9925385 --- /dev/null +++ b/simulation/quests/PlayerQuestSystem.gd.uid @@ -0,0 +1 @@ +uid://cojertujqx8ts diff --git a/simulation/state/PlayerQuestRecord.gd b/simulation/state/PlayerQuestRecord.gd new file mode 100644 index 0000000..c37203e --- /dev/null +++ b/simulation/state/PlayerQuestRecord.gd @@ -0,0 +1,199 @@ +class_name PlayerQuestRecord +extends RefCounted + +const SCHEMA_VERSION := 1 +const STATUS_OPEN := &"open" +const STATUS_COMPLETED := &"completed" +const STATUS_EXPIRED := &"expired" + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + quest_id: int, + opportunity_id: int, + requester_npc_id: int, + quest_type: StringName, + resource_id: StringName, + target_id: StringName, + target_amount: float, + created_tick: int, + standing_reward: float +) -> PlayerQuestRecord: + return ( + PlayerQuestRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "quest_id": quest_id, + "opportunity_id": opportunity_id, + "requester_npc_id": requester_npc_id, + "quest_type": String(quest_type), + "resource_id": String(resource_id), + "target_id": String(target_id), + "target_amount": target_amount, + "status": String(STATUS_OPEN), + "created_tick": created_tick, + "standing_reward": standing_reward, + "resolution_event_id": -1, + "resolved_tick": -1, + } + ) + ) + + +static func from_dictionary(record_data: Dictionary) -> PlayerQuestRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "quest_id", + "opportunity_id", + "requester_npc_id", + "quest_type", + "resource_id", + "target_id", + "target_amount", + "status", + "created_tick", + "standing_reward", + "resolution_event_id", + "resolved_tick", + ] + ) + ): + return null + var quest_id := int(record_data["quest_id"]) + var opportunity_id := int(record_data["opportunity_id"]) + var requester_npc_id := int(record_data["requester_npc_id"]) + var quest_type := StringName(record_data["quest_type"]) + var resource_id := StringName(record_data["resource_id"]) + var target_id := StringName(record_data["target_id"]) + var target_amount := float(record_data["target_amount"]) + var status := StringName(record_data["status"]) + var created_tick := int(record_data["created_tick"]) + var standing_reward := float(record_data["standing_reward"]) + var resolution_event_id := int(record_data["resolution_event_id"]) + var resolved_tick := int(record_data["resolved_tick"]) + if ( + quest_id < 0 + or opportunity_id < 0 + or requester_npc_id < 0 + or quest_type.is_empty() + or resource_id.is_empty() + or target_id.is_empty() + or not is_finite(target_amount) + or target_amount <= 0.0 + or created_tick < 0 + or not is_finite(standing_reward) + or standing_reward <= 0.0 + or resolution_event_id < -1 + ): + return null + match status: + STATUS_OPEN: + if resolution_event_id != -1 or resolved_tick != -1: + return null + STATUS_COMPLETED: + if resolution_event_id < 0 or resolved_tick < created_tick: + return null + STATUS_EXPIRED: + if resolution_event_id != -1 or resolved_tick < created_tick: + return null + _: + return null + var record := create( + quest_id, + opportunity_id, + requester_npc_id, + quest_type, + resource_id, + target_id, + target_amount, + created_tick, + standing_reward + ) + if status == STATUS_COMPLETED: + record.complete(resolution_event_id, resolved_tick) + elif status == STATUS_EXPIRED: + record.expire(resolved_tick) + return record + + +func get_quest_id() -> int: + return int(data["quest_id"]) + + +func get_opportunity_id() -> int: + return int(data["opportunity_id"]) + + +func get_requester_npc_id() -> int: + return int(data["requester_npc_id"]) + + +func get_quest_type() -> StringName: + return StringName(data["quest_type"]) + + +func get_resource_id() -> StringName: + return StringName(data["resource_id"]) + + +func get_target_id() -> StringName: + return StringName(data["target_id"]) + + +func get_target_amount() -> float: + return float(data["target_amount"]) + + +func get_status() -> StringName: + return StringName(data["status"]) + + +func get_created_tick() -> int: + return int(data["created_tick"]) + + +func get_standing_reward() -> float: + return float(data["standing_reward"]) + + +func get_resolution_event_id() -> int: + return int(data["resolution_event_id"]) + + +func get_resolved_tick() -> int: + return int(data["resolved_tick"]) + + +func is_open() -> bool: + return get_status() == STATUS_OPEN + + +func complete(resolution_event_id: int, resolved_tick: int) -> bool: + if not is_open() or resolution_event_id < 0 or resolved_tick < get_created_tick(): + return false + data["status"] = String(STATUS_COMPLETED) + data["resolution_event_id"] = resolution_event_id + data["resolved_tick"] = resolved_tick + return true + + +func expire(resolved_tick: int) -> bool: + if not is_open() or resolved_tick < get_created_tick(): + return false + data["status"] = String(STATUS_EXPIRED) + data["resolved_tick"] = resolved_tick + return true + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/PlayerQuestRecord.gd.uid b/simulation/state/PlayerQuestRecord.gd.uid new file mode 100644 index 0000000..306765b --- /dev/null +++ b/simulation/state/PlayerQuestRecord.gd.uid @@ -0,0 +1 @@ +uid://rix0e3rygdoo diff --git a/simulation/state/PlayerStandingRecord.gd b/simulation/state/PlayerStandingRecord.gd new file mode 100644 index 0000000..896fb64 --- /dev/null +++ b/simulation/state/PlayerStandingRecord.gd @@ -0,0 +1,120 @@ +class_name PlayerStandingRecord +extends RefCounted + +const SCHEMA_VERSION := 1 +const MAX_STANDING := 100.0 +const TIER_STRANGER := 0 +const TIER_KNOWN_HAND := 1 +const TIER_TRUSTED := 2 +const TIER_VILLAGE_STEWARD := 3 +const TIER_VOICE_OF_JAJCE := 4 + +const TIER_THRESHOLDS := { + TIER_STRANGER: 0.0, + TIER_KNOWN_HAND: 15.0, + TIER_TRUSTED: 35.0, + TIER_VILLAGE_STEWARD: 60.0, + TIER_VOICE_OF_JAJCE: 85.0, +} + +const TIER_NAMES := { + TIER_STRANGER: "Stranger", + TIER_KNOWN_HAND: "Known Hand", + TIER_TRUSTED: "Trusted", + TIER_VILLAGE_STEWARD: "Village Steward", + TIER_VOICE_OF_JAJCE: "Voice of Jajce", +} + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create() -> PlayerStandingRecord: + return ( + PlayerStandingRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "standing": 0.0, + "resolved_needs": 0, + "gratitude": {}, + } + ) + ) + + +static func from_dictionary(record_data: Dictionary) -> PlayerStandingRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not record_data.has_all(["standing", "resolved_needs", "gratitude"]): + return null + var standing := float(record_data["standing"]) + var resolved_needs := int(record_data["resolved_needs"]) + var gratitude = record_data["gratitude"] + if ( + not is_finite(standing) + or standing < 0.0 + or standing > MAX_STANDING + or resolved_needs < 0 + or not gratitude is Dictionary + ): + return null + var normalized_gratitude := {} + for raw_npc_id in gratitude: + var npc_id := int(raw_npc_id) + var value := float(gratitude[raw_npc_id]) + if npc_id < 0 or not is_finite(value) or value < 0.0 or value > 1.0: + return null + normalized_gratitude[npc_id] = value + return ( + PlayerStandingRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "standing": standing, + "resolved_needs": resolved_needs, + "gratitude": normalized_gratitude, + } + ) + ) + + +func grant_standing(amount: float, requester_npc_id: int) -> void: + if not is_finite(amount) or amount <= 0.0 or requester_npc_id < 0: + return + data["standing"] = minf(get_standing() + amount, MAX_STANDING) + data["resolved_needs"] = int(data["resolved_needs"]) + 1 + var gratitude := get_gratitude(requester_npc_id) + data["gratitude"][requester_npc_id] = minf(gratitude + 0.1, 1.0) + + +func get_standing() -> float: + return float(data["standing"]) + + +func get_resolved_needs() -> int: + return int(data["resolved_needs"]) + + +func get_gratitude(npc_id: int) -> float: + return float(data["gratitude"].get(npc_id, 0.0)) + + +func get_tier() -> int: + var current := TIER_STRANGER + for tier in [TIER_VOICE_OF_JAJCE, TIER_VILLAGE_STEWARD, TIER_TRUSTED, TIER_KNOWN_HAND]: + if get_standing() >= float(TIER_THRESHOLDS[tier]): + current = tier + break + return current + + +func get_tier_name() -> String: + return String(TIER_NAMES[get_tier()]) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/PlayerStandingRecord.gd.uid b/simulation/state/PlayerStandingRecord.gd.uid new file mode 100644 index 0000000..bad3daf --- /dev/null +++ b/simulation/state/PlayerStandingRecord.gd.uid @@ -0,0 +1 @@ +uid://v827brnqruus diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 693ae2d..41ed43a 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 @@ -13,6 +13,7 @@ const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8 const ANIMAL_LEGACY_SCHEMA_VERSION := 9 const ROUTINE_LEGACY_SCHEMA_VERSION := 10 const PREVIOUS_SCHEMA_VERSION := 7 +const PLAYER_LEGACY_SCHEMA_VERSION := 11 var simulation: Dictionary var village: VillageStateRecord @@ -24,6 +25,8 @@ var economic_events: Array[EconomicEventRecord] = [] var relationships: Array[RelationshipStateRecord] = [] var event_knowledge: Array[KnownEventStateRecord] = [] var opportunities: Array[OpportunityStateRecord] = [] +var player_standing: PlayerStandingRecord +var player_quests: Array[PlayerQuestRecord] = [] func to_dictionary() -> Dictionary: @@ -52,6 +55,9 @@ func to_dictionary() -> Dictionary: var opportunity_data: Array[Dictionary] = [] for opportunity_record in opportunities: opportunity_data.append(opportunity_record.to_dictionary()) + var player_quest_data: Array[Dictionary] = [] + for player_quest_record in player_quests: + player_quest_data.append(player_quest_record.to_dictionary()) return { "schema": SCHEMA_NAME, @@ -65,7 +71,9 @@ func to_dictionary() -> Dictionary: "economic_events": event_data, "relationships": relationship_data, "event_knowledge": knowledge_data, - "opportunities": opportunity_data + "opportunities": opportunity_data, + "player_standing": player_standing.to_dictionary(), + "player_quests": player_quest_data } @@ -97,6 +105,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: OPPORTUNITY_LEGACY_SCHEMA_VERSION, ANIMAL_LEGACY_SCHEMA_VERSION, ROUTINE_LEGACY_SCHEMA_VERSION, + PLAYER_LEGACY_SCHEMA_VERSION, ] ): record_data = _migrate_legacy(record_data, version) @@ -116,6 +125,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "relationships", "event_knowledge", "opportunities", + "player_standing", + "player_quests", ] ) ): @@ -418,6 +429,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: record.relationships.append(relationship_record) var opportunity_ids := {} + var opportunity_records_by_id := {} var trigger_event_ids := {} var resolution_event_ids := {} var has_open_opportunity := false @@ -452,12 +464,58 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: ): return null opportunity_ids[opportunity_id] = true + opportunity_records_by_id[opportunity_id] = opportunity_record trigger_event_ids[trigger_event_id] = true highest_opportunity_id = maxi(highest_opportunity_id, opportunity_id) record.opportunities.append(opportunity_record) if int(record.simulation["next_opportunity_id"]) <= highest_opportunity_id: return null + var standing_data = record_data["player_standing"] + if not standing_data is Dictionary: + return null + var standing_record := PlayerStandingRecord.from_dictionary(standing_data) + if standing_record == null: + return null + record.player_standing = standing_record + + var player_quest_data = record_data["player_quests"] + if not player_quest_data is Array: + return null + var quest_ids := {} + var quest_opportunity_ids := {} + var highest_quest_id := -1 + for item in player_quest_data: + if not item is Dictionary: + return null + var quest_record := PlayerQuestRecord.from_dictionary(item) + if quest_record == null: + return null + var quest_id := quest_record.get_quest_id() + var quest_opportunity_id := quest_record.get_opportunity_id() + var requester_id := quest_record.get_requester_npc_id() + if ( + quest_ids.has(quest_id) + or quest_opportunity_ids.has(quest_opportunity_id) + or not npc_ids.has(requester_id) + or not opportunity_ids.has(quest_opportunity_id) + ): + return null + if ( + quest_record.get_status() == PlayerQuestRecord.STATUS_OPEN + and not opportunity_records_by_id.has(quest_opportunity_id) + ): + return null + if ( + quest_record.get_resolution_event_id() >= 0 + and not event_ids.has(quest_record.get_resolution_event_id()) + ): + return null + quest_ids[quest_id] = true + quest_opportunity_ids[quest_opportunity_id] = true + highest_quest_id = maxi(highest_quest_id, quest_id) + record.player_quests.append(quest_record) + return record @@ -772,6 +830,12 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary var opportunity_simulation_data: Dictionary = migrated.get("simulation", {}) opportunity_simulation_data["next_opportunity_id"] = 0 migrated["simulation"] = opportunity_simulation_data + if version <= PLAYER_LEGACY_SCHEMA_VERSION: + migrated["player_standing"] = PlayerStandingRecord.create().to_dictionary() + migrated["player_quests"] = [] + var quest_simulation_data: Dictionary = migrated.get("simulation", {}) + quest_simulation_data["next_quest_id"] = 0 + migrated["simulation"] = quest_simulation_data return migrated diff --git a/tests/player_quest_standing_test.gd b/tests/player_quest_standing_test.gd new file mode 100644 index 0000000..4db69c3 --- /dev/null +++ b/tests/player_quest_standing_test.gd @@ -0,0 +1,282 @@ +extends SceneTree + +var failures: Array[String] = [] +var opened_quest_ids: Array[int] = [] +var completed_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_completed.connect(_on_quest_completed) + manager.player_quest_expired.connect(_on_quest_expired) + var berry := _register_berry(manager, &"quest_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 + actor.hunger = 60.0 + interested.hunger = 90.0 + manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0) + _prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0)) + _check( + manager.try_communicate_at_shared_activity(actor.id, interested.id), + "The quest proof should open the same known pantry shortage" + ) + _check(manager.get_active_opportunity() != null, "The quest proof needs an active opportunity") + var quest: PlayerQuestRecord = manager.get_active_player_quest() + _check( + ( + quest != null + and quest.get_requester_npc_id() == interested.id + and quest.get_quest_type() == SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY + and quest.get_resource_id() == SimulationIds.RESOURCE_FOOD + and quest.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY + and is_equal_approx(quest.get_target_amount(), 1.0) + and quest.get_standing_reward() > 0.0 + and quest.is_open() + and opened_quest_ids == [quest.get_quest_id()] + ), + "A player-actionable shortage should generate one quest for its worried villager", + ) + if quest == null: + manager.free() + _finish() + return + + var no_helper_route: OpportunityPlayerResponseResult = ( + manager.get_active_opportunity_player_response() + ) + var standing_before: PlayerStandingRecord = manager.get_player_standing() + _check( + ( + no_helper_route != null + and is_equal_approx(standing_before.get_standing(), 0.0) + and standing_before.get_resolved_needs() == 0 + and is_equal_approx(standing_before.get_gratitude(interested.id), 0.0) + ), + "Standing should start at zero with no gratitude before any player help", + ) + + var checksum_before_resolve: String = manager.get_state_checksum() + var serialized_before: String = manager.serialize_state() + var berry_harvested: float = manager.harvest_resource_node(berry) + _check( + is_equal_approx(berry_harvested, 1.0), + "Completing the quest should go through the ordinary player harvest command" + ) + var standing_after: PlayerStandingRecord = manager.get_player_standing() + var completed: PlayerQuestRecord = manager.get_active_player_quest() + var latest: PlayerQuestRecord = manager.get_latest_player_quest_for_requester(interested.id) + _check( + ( + completed == null + and latest != null + and latest.get_status() == PlayerQuestRecord.STATUS_COMPLETED + and completed_quest_ids == [latest.get_quest_id()] + and is_equal_approx(standing_after.get_standing(), quest.get_standing_reward()) + and standing_after.get_resolved_needs() == 1 + and is_equal_approx(standing_after.get_gratitude(interested.id), 0.1) + and manager.get_state_checksum() != checksum_before_resolve + ), + "A real player supply should complete the quest, grant standing, and raise gratitude", + ) + + var saved_json: String = serialized_before + var pre_completion_state: String = manager.serialize_state() + var restored := _create_manager(902) + _check( + restored.restore_state_from_json(pre_completion_state), + "The completed quest and standing should restore through the current schema" + ) + _check( + ( + restored.get_state_checksum() == manager.get_state_checksum() + and restored.get_active_player_quest() == null + and restored.get_latest_player_quest_for_requester(interested.id) != null + and is_equal_approx( + restored.get_player_standing().get_standing(), quest.get_standing_reward() + ) + and is_equal_approx(restored.get_player_standing().get_gratitude(interested.id), 0.1) + ), + "Restore should re-derive completed quest and standing without reopening the need", + ) + restored.free() + + var invalidated := _create_manager(905) + invalidated.player_quest_expired.connect(_on_quest_expired) + var stale_tree := _register_tree(invalidated, &"quest_stale_tree") + var stale_setup := _open_missing_wood_need(invalidated) + _check( + invalidated.get_active_player_quest() != null, "The invalidation branch needs an open quest" + ) + var stale_quest: PlayerQuestRecord = invalidated.get_active_player_quest() + var stale_interested_id: int = stale_setup["interested"].id + var review_interval: int = invalidated.get_knowledge_review_interval() + invalidated.tick_count = stale_quest.get_created_tick() + review_interval + invalidated.call("_maintain_event_knowledge", true) + invalidated.simulate_tick() + _check( + ( + expired_quest_ids.has(stale_quest.get_quest_id()) + and invalidated.get_active_player_quest() == null + and is_equal_approx( + invalidated.get_player_standing().get_gratitude(stale_interested_id), 0.0 + ) + ), + "A stale need should expire its quest without granting standing", + ) + berry.queue_free() + stale_tree.queue_free() + manager.free() + invalidated.free() + _finish() + + +func _register_berry(manager: Node, node_id: StringName) -> ResourceNode: + var berry := ResourceNode.new() + berry.name = "QuestBerryBush" + berry.node_id = node_id + 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" + berry.add_child(interaction_point) + root.add_child(berry) + _check( + manager.register_resource_node(berry), + "The quest proof should bind a real finite food ResourceNode" + ) + return berry + + +func _register_tree(manager: Node, node_id: StringName) -> ResourceNode: + var tree := ResourceNode.new() + tree.name = "QuestTree" + tree.node_id = node_id + tree.action_id = SimulationIds.ACTION_GATHER_WOOD + tree.resource_id = SimulationIds.RESOURCE_WOOD + tree.initial_amount = 1.0 + tree.yield_per_action = 1.0 + tree.debug_label_enabled = false + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + tree.add_child(interaction_point) + root.add_child(tree) + _check( + manager.register_resource_node(tree), + "The invalidation proof should bind a real finite wood ResourceNode" + ) + return tree + + +func _open_missing_wood_need(manager: Node) -> Dictionary: + var actor: SimNPC = manager.npcs[0] + var witness: SimNPC = manager.npcs[1] + actor.position = Vector3.ZERO + witness.position = Vector3(4.0, 0.0, 0.0) + var woodpile: StorageStateRecord = manager.get_woodpile() + woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD)) + woodpile.deposit(SimulationIds.RESOURCE_WOOD, 0.5) + manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD) + actor.set_task(SimulationIds.ACTION_PATROL, 1.0) + actor.start_working() + manager.simulate_tick() + var opportunity: OpportunityStateRecord = manager.get_active_opportunity() + _check( + opportunity != null, "The invalidation branch needs the performer's known blocked-work need" + ) + return {"actor": actor, "interested": actor} + + +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 _open_via_communication(manager: Node) -> Dictionary: + 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 + actor.hunger = 60.0 + interested.hunger = 90.0 + manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0) + _prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0)) + _check( + manager.try_communicate_at_shared_activity(actor.id, interested.id), + "The invalidation branch should open from the same one-hop known shortage" + ) + _check( + manager.get_active_opportunity() != null, + "The invalidation branch needs an active opportunity" + ) + return {"actor": actor, "interested": interested} + + +func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void: + for npc in [first, second]: + npc.set_task(SimulationIds.ACTION_PATROL) + npc.target_id = &"guard_post" + npc.start_working() + first.position = position + second.position = position + Vector3(1.0, 0.0, 0.0) + + +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_completed(quest: PlayerQuestRecord) -> void: + completed_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 quest generation and standing passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/player_quest_standing_test.gd.uid b/tests/player_quest_standing_test.gd.uid new file mode 100644 index 0000000..9a67cf2 --- /dev/null +++ b/tests/player_quest_standing_test.gd.uid @@ -0,0 +1 @@ +uid://s2vxgyidroc0