From 6cdeea6e4607d4ed10c7c4e4d31e89855fe66fad Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sat, 8 Aug 2026 20:27:59 +0200 Subject: [PATCH] feat: generate animal-care quests and render them in the journal --- simulation/SimulationManager.gd | 47 ++++++++++++ simulation/animals/animal_care_system.gd | 14 ++++ simulation/definitions/SimulationIds.gd | 1 + simulation/quests/PlayerQuestSystem.gd | 72 ++++++++++++++++++ simulation/state/PlayerQuestRecord.gd | 89 +++++++++++++++-------- simulation/state/SimulationStateRecord.gd | 22 +++++- tests/player_quest_standing_test.gd | 85 +++++++++++++++++++++- world/ui/quest_journal_hud.gd | 22 ++++++ 8 files changed, 314 insertions(+), 38 deletions(-) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 320b473..cfde0e3 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -173,6 +173,7 @@ func simulate_tick() -> void: if debug_logs: print("--- Tick ", tick_count, " ---") animal_care.advance(tick_count) + _consider_animal_care_quests() var village_was_changed := false _population_view.rebuild(npcs) for npc in npcs: @@ -570,10 +571,26 @@ func _on_economic_event_recorded(event: EconomicEventRecord) -> void: ) for learned_record in learned_records: _apply_new_event_knowledge(learned_record, event) + if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED: + _resolve_animal_care_quest(event) _update_opportunity_from_event(event) economic_event_recorded.emit(event) +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 resolution := player_quest_system.on_animal_fed(animal_state, event, tick_count) + 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) + else: + player_quest_expired.emit(quest) + + 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) @@ -760,6 +777,31 @@ func get_active_player_quest() -> PlayerQuestRecord: return player_quest_system.get_active_quest() +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 + ) + if quest != null: + player_quest_opened.emit(quest) + + +func _find_animal_requester(animal_state: AnimalStateRecord) -> int: + var animal_position := animal_state.get_position() + 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) + 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() @@ -846,6 +888,11 @@ func get_pantry() -> StorageStateRecord: return economy.get_pantry() +func get_animal_display_name(animal_id: StringName) -> String: + var animal_state := animal_care.get_state(animal_id) + return animal_state.get_display_name() if animal_state != null else "" + + func get_woodpile() -> StorageStateRecord: return economy.get_woodpile() diff --git a/simulation/animals/animal_care_system.gd b/simulation/animals/animal_care_system.gd index c89242a..4011087 100644 --- a/simulation/animals/animal_care_system.gd +++ b/simulation/animals/animal_care_system.gd @@ -99,6 +99,20 @@ func get_state(animal_id: StringName) -> AnimalStateRecord: return states.get(animal_id) as AnimalStateRecord +func get_all_states() -> Array[AnimalStateRecord]: + var all_states: Array[AnimalStateRecord] = [] + for animal_id in states.keys(): + var animal_state := states[animal_id] as AnimalStateRecord + if animal_state != null: + all_states.append(animal_state) + all_states.sort_custom(_sort_states_by_id) + return all_states + + +static func _sort_states_by_id(first: AnimalStateRecord, second: AnimalStateRecord) -> bool: + return String(first.get_animal_id()) < String(second.get_animal_id()) + + func reserve(animal_id: StringName, agent_id: int) -> bool: var animal_state := get_state(animal_id) return animal_state != null and animal_state.can_npc_feed() and animal_state.reserve(agent_id) diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index e523384..22f94e3 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -53,6 +53,7 @@ const EVENT_ANIMAL_FED := &"animal_fed" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" +const OPPORTUNITY_FEED_HUNGRY_ANIMAL := &"feed_hungry_animal" const OPPORTUNITY_STATUS_OPEN := &"open" const OPPORTUNITY_STATUS_RESOLVED := &"resolved" const OPPORTUNITY_STATUS_INVALIDATED := &"invalidated" diff --git a/simulation/quests/PlayerQuestSystem.gd b/simulation/quests/PlayerQuestSystem.gd index b03ad7e..7838dd2 100644 --- a/simulation/quests/PlayerQuestSystem.gd +++ b/simulation/quests/PlayerQuestSystem.gd @@ -3,12 +3,77 @@ extends RefCounted const PANTRY_STANDING_REWARD := 8.0 const WOOD_STANDING_REWARD := 8.0 +const ANIMAL_CARE_STANDING_REWARD := 6.0 var quests: Array[PlayerQuestRecord] = [] var next_quest_id := 0 var standing := PlayerStandingRecord.create() +func consider_animal_care( + animal_state: AnimalStateRecord, + requester_npc_id: int, + current_tick: int, + pantry: StorageStateRecord +) -> PlayerQuestRecord: + if ( + animal_state == null + or requester_npc_id < 0 + or current_tick < 0 + or pantry == null + or not animal_state.needs_feed() + or not animal_state.can_player_feed_animal() + ): + return null + var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) + if ( + definition == null + or not definition.has_completion_cost() + or ( + pantry.get_amount(definition.completion_cost_resource_id) + < definition.completion_cost_amount + ) + ): + return null + if _find_open_quest_for_animal(animal_state.get_animal_id()) != null: + return null + var quest := PlayerQuestRecord.create( + next_quest_id, + PlayerQuestRecord.NO_OPPORTUNITY, + requester_npc_id, + SimulationIds.OPPORTUNITY_FEED_HUNGRY_ANIMAL, + definition.completion_cost_resource_id, + animal_state.get_animal_id(), + definition.completion_cost_amount, + current_tick, + ANIMAL_CARE_STANDING_REWARD, + animal_state.get_animal_id() + ) + next_quest_id += 1 + quests.append(quest) + return quest + + +func on_animal_fed( + animal_state: AnimalStateRecord, resolution_event: EconomicEventRecord, current_tick: int +) -> Dictionary: + if animal_state == null or resolution_event == null: + return {} + var quest := _find_open_quest_for_animal(animal_state.get_animal_id()) + if quest == 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 consider_opportunity_opened( opportunity: OpportunityStateRecord, player_response: OpportunityPlayerResponseResult, @@ -108,6 +173,13 @@ func _find_open_quest_for_opportunity(opportunity_id: int) -> PlayerQuestRecord: return null +func _find_open_quest_for_animal(animal_id: StringName) -> PlayerQuestRecord: + for quest in quests: + if quest.is_open() and quest.has_animal_target() and quest.get_animal_id() == animal_id: + return quest + return null + + func _standing_reward_for(opportunity: OpportunityStateRecord) -> float: match opportunity.get_opportunity_type(): SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: diff --git a/simulation/state/PlayerQuestRecord.gd b/simulation/state/PlayerQuestRecord.gd index c37203e..9cc2583 100644 --- a/simulation/state/PlayerQuestRecord.gd +++ b/simulation/state/PlayerQuestRecord.gd @@ -1,10 +1,12 @@ class_name PlayerQuestRecord extends RefCounted -const SCHEMA_VERSION := 1 +const SCHEMA_VERSION := 2 +const LEGACY_SCHEMA_VERSION := 1 const STATUS_OPEN := &"open" const STATUS_COMPLETED := &"completed" const STATUS_EXPIRED := &"expired" +const NO_OPPORTUNITY := -1 var data: Dictionary @@ -22,7 +24,8 @@ static func create( target_id: StringName, target_amount: float, created_tick: int, - standing_reward: float + standing_reward: float, + animal_id: StringName = &"" ) -> PlayerQuestRecord: return ( PlayerQuestRecord @@ -41,16 +44,22 @@ static func create( "standing_reward": standing_reward, "resolution_event_id": -1, "resolved_tick": -1, + "animal_id": String(animal_id), } ) ) static func from_dictionary(record_data: Dictionary) -> PlayerQuestRecord: - if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + var version := int(record_data.get("schema_version", -1)) + if version not in [LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]: return null + var normalized := record_data.duplicate(true) + if version == LEGACY_SCHEMA_VERSION: + normalized["schema_version"] = SCHEMA_VERSION + normalized["animal_id"] = "" if not ( - record_data + normalized . has_all( [ "quest_id", @@ -65,25 +74,26 @@ static func from_dictionary(record_data: Dictionary) -> PlayerQuestRecord: "standing_reward", "resolution_event_id", "resolved_tick", + "animal_id", ] ) ): 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"]) + var quest_id := int(normalized["quest_id"]) + var opportunity_id := int(normalized["opportunity_id"]) + var requester_npc_id := int(normalized["requester_npc_id"]) + var quest_type := StringName(normalized["quest_type"]) + var resource_id := StringName(normalized["resource_id"]) + var target_id := StringName(normalized["target_id"]) + var target_amount := float(normalized["target_amount"]) + var status := StringName(normalized["status"]) + var created_tick := int(normalized["created_tick"]) + var standing_reward := float(normalized["standing_reward"]) + var resolution_event_id := int(normalized["resolution_event_id"]) + var resolved_tick := int(normalized["resolved_tick"]) + var animal_id := StringName(normalized["animal_id"]) if ( quest_id < 0 - or opportunity_id < 0 or requester_npc_id < 0 or quest_type.is_empty() or resource_id.is_empty() @@ -94,6 +104,8 @@ static func from_dictionary(record_data: Dictionary) -> PlayerQuestRecord: or not is_finite(standing_reward) or standing_reward <= 0.0 or resolution_event_id < -1 + or (opportunity_id < 0 and animal_id.is_empty()) + or (not animal_id.is_empty() and opportunity_id != NO_OPPORTUNITY) ): return null match status: @@ -108,22 +120,27 @@ static func from_dictionary(record_data: Dictionary) -> PlayerQuestRecord: 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 + 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), + "created_tick": created_tick, + "standing_reward": standing_reward, + "resolution_event_id": resolution_event_id, + "resolved_tick": resolved_tick, + "animal_id": String(animal_id), + } + ) ) - 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: @@ -174,6 +191,14 @@ func get_resolved_tick() -> int: return int(data["resolved_tick"]) +func get_animal_id() -> StringName: + return StringName(data["animal_id"]) + + +func has_animal_target() -> bool: + return not get_animal_id().is_empty() + + func is_open() -> bool: return get_status() == STATUS_OPEN diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 41ed43a..6b14d81 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -494,16 +494,23 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: 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() + var has_valid_requester := npc_ids.has(requester_id) + var has_valid_opportunity := ( + quest_record.has_animal_target() or opportunity_ids.has(quest_opportunity_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) + or not has_valid_requester + or not has_valid_opportunity ): return null if ( quest_record.get_status() == PlayerQuestRecord.STATUS_OPEN - and not opportunity_records_by_id.has(quest_opportunity_id) + and ( + (not quest_record.has_animal_target()) + and not opportunity_records_by_id.has(quest_opportunity_id) + ) ): return null if ( @@ -511,8 +518,15 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: and not event_ids.has(quest_record.get_resolution_event_id()) ): return null + var dedup_key := ( + "animal:%s" % String(quest_record.get_animal_id()) + if quest_record.has_animal_target() + else "opp:%d" % quest_opportunity_id + ) + if quest_ids.has(quest_id) or quest_opportunity_ids.has(dedup_key): + return null quest_ids[quest_id] = true - quest_opportunity_ids[quest_opportunity_id] = true + quest_opportunity_ids[dedup_key] = true highest_quest_id = maxi(highest_quest_id, quest_id) record.player_quests.append(quest_record) diff --git a/tests/player_quest_standing_test.gd b/tests/player_quest_standing_test.gd index 4db69c3..25c0ad3 100644 --- a/tests/player_quest_standing_test.gd +++ b/tests/player_quest_standing_test.gd @@ -133,8 +133,89 @@ func _run() -> void: ), "A stale need should expire its quest without granting standing", ) - berry.queue_free() - stale_tree.queue_free() + berry.free() + stale_tree.free() + + var animal_manager := _create_manager(906) + animal_manager.player_quest_opened.connect(_on_quest_opened) + animal_manager.player_quest_completed.connect(_on_quest_completed) + var caretaker: SimNPC = animal_manager.npcs[0] + var pantry: StorageStateRecord = animal_manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + pantry.deposit(SimulationIds.RESOURCE_FOOD, 5.0) + animal_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + caretaker.position = Vector3.ZERO + var goat := AnimalNode.new() + goat.name = "Dunja" + goat.animal_id = SimulationIds.ANIMAL_DUNJA + goat.display_name = "Dunja" + goat.species_id = SimulationIds.SPECIES_GOAT + goat.initial_enabled = true + goat.can_npcs_feed = true + goat.can_player_feed = true + goat.initial_hunger = 0.0 + root.add_child(goat) + _check( + animal_manager.animal_care.register_node(goat), + "The animal-care quest branch should register a real named goat" + ) + var animal_state: AnimalStateRecord = animal_manager.animal_care.get_state( + SimulationIds.ANIMAL_DUNJA + ) + _check(animal_state != null, "The animal-care quest branch needs Dunja's state") + if animal_state == null: + _finish() + return + animal_state.set_hunger(animal_state.FEED_THRESHOLD + 1.0) + animal_manager.simulate_tick() + var animal_quest: PlayerQuestRecord = animal_manager.get_active_player_quest() + _check( + ( + animal_quest != null + and animal_quest.has_animal_target() + and animal_quest.get_animal_id() == SimulationIds.ANIMAL_DUNJA + and animal_quest.get_quest_type() == SimulationIds.OPPORTUNITY_FEED_HUNGRY_ANIMAL + and animal_quest.get_requester_npc_id() == caretaker.id + ), + "A hungry, player-feedable goat should generate an animal-care quest for a nearby villager", + ) + var standing_before_animal: float = animal_manager.get_player_standing().get_standing() + _check( + animal_manager.feed_animal(SimulationIds.ANIMAL_DUNJA), + "The player should resolve the animal quest through the ordinary feed command" + ) + var animal_latest: PlayerQuestRecord = animal_manager.get_latest_player_quest_for_requester( + caretaker.id + ) + _check( + ( + completed_quest_ids.has(animal_quest.get_quest_id()) + and animal_latest != null + and animal_latest.get_status() == PlayerQuestRecord.STATUS_COMPLETED + and is_equal_approx( + animal_manager.get_player_standing().get_standing(), + standing_before_animal + animal_quest.get_standing_reward() + ) + ), + "Feeding the goat should complete its quest and grant standing", + ) + var animal_saved: String = animal_manager.serialize_state() + var animal_restored := _create_manager(907) + _check( + animal_restored.restore_state_from_json(animal_saved), + "The completed animal-care quest should restore through the current schema" + ) + _check( + ( + animal_restored.get_state_checksum() == animal_manager.get_state_checksum() + and animal_restored.get_active_player_quest() == null + ), + "Restored animal-care state should preserve checksum and closed quest", + ) + animal_restored.free() + goat.free() + animal_manager.free() + _finish() manager.free() invalidated.free() _finish() diff --git a/world/ui/quest_journal_hud.gd b/world/ui/quest_journal_hud.gd index 314e026..0e133f2 100644 --- a/world/ui/quest_journal_hud.gd +++ b/world/ui/quest_journal_hud.gd @@ -77,6 +77,19 @@ func _refresh() -> void: if quest == null: quest_kicker.text = "" quest_label.text = "" + elif quest.has_animal_target(): + var requester_name := _get_npc_name(quest.get_requester_npc_id()) + var animal_name := _get_animal_name(quest.get_animal_id()) + quest_kicker.text = "ACTIVE QUEST" + quest_label.text = ( + "%s ยท bring %.0f %s to %s" + % [ + requester_name, + quest.get_target_amount(), + String(quest.get_resource_id()).replace("_", " "), + animal_name, + ] + ) else: var requester_name := _get_npc_name(quest.get_requester_npc_id()) quest_kicker.text = "ACTIVE QUEST" @@ -141,3 +154,12 @@ func _get_npc_name(npc_id: int) -> String: if npc.id == npc_id: return npc.npc_name return "Someone" + + +func _get_animal_name(animal_id: StringName) -> String: + if ( + simulation_manager.has_method("get_animal_display_name") + and simulation_manager.get_animal_display_name(animal_id) != "" + ): + return simulation_manager.get_animal_display_name(animal_id) + return String(animal_id).replace("_", " ").capitalize()