From 82e44a9f5d23952352c209a41ac0ab79a0ffb5ce Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Tue, 11 Aug 2026 00:32:22 +0200 Subject: [PATCH] feat: add weak-villager and damaged-roof village needs --- simulation/SimulationManager.gd | 14 ++ simulation/definitions/SimulationIds.gd | 4 + simulation/knowledge/EventKnowledgeSystem.gd | 8 + .../opportunities/VillageOpportunitySystem.gd | 143 ++++++++++- simulation/state/OpportunityStateRecord.gd | 4 +- simulation/state/SimulationStateRecord.gd | 71 +++++- tests/village_care_opportunity_test.gd | 230 ++++++++++++++++++ tests/village_care_opportunity_test.gd.uid | 1 + world/ui/ui.gd | 107 ++++++-- world/ui/village_whisper_hud.gd | 10 + 10 files changed, 547 insertions(+), 45 deletions(-) create mode 100644 tests/village_care_opportunity_test.gd create mode 100644 tests/village_care_opportunity_test.gd.uid diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 18fbd08..917daba 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -232,6 +232,8 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void: return if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]: return + if _is_weak_villager_state(npc): + record_narrative_event(SimulationIds.EVENT_VILLAGER_WEAK, npc.id) var helper := get_active_opportunity_helper() var animal_feed_available := animal_care.has_available_feed_target(npc.id) var selection := action_selector.select_action( @@ -249,6 +251,16 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void: record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name) +func _is_weak_villager_state(npc: SimNPC) -> bool: + return ( + npc.is_starving + and npc.energy < 25.0 + and village.food <= 0.0 + and npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) < 1.0 + and opportunity_system.get_open_opportunity() == null + ) + + func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void: if not previous_target.is_empty(): release_npc_reservation(npc.id) @@ -301,6 +313,8 @@ func _apply_action_completion( npc.energy = minf(npc.energy + 40.0, 100.0) npc.position = npc.home_position record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id) + if village.safety < 25.0 and opportunity_system.get_open_opportunity() == null: + record_narrative_event(SimulationIds.EVENT_HOME_DAMAGED, npc.id) SimulationIds.ACTION_FEED_ANIMAL: pass SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER: diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 5a2603d..c4a672c 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -53,9 +53,13 @@ const EVENT_TASK_STARTED := &"task_started" const EVENT_TASK_BLOCKED := &"task_blocked" const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const EVENT_ANIMAL_FED := &"animal_fed" +const EVENT_VILLAGER_WEAK := &"villager_weak" +const EVENT_HOME_DAMAGED := &"home_damaged" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" +const OPPORTUNITY_FEED_WEAK_VILLAGER := &"feed_weak_villager" +const OPPORTUNITY_REPAIR_HOME_ROOF := &"repair_home_roof" const OPPORTUNITY_STATUS_OPEN := &"open" const OPPORTUNITY_STATUS_RESOLVED := &"resolved" const OPPORTUNITY_STATUS_INVALIDATED := &"invalidated" diff --git a/simulation/knowledge/EventKnowledgeSystem.gd b/simulation/knowledge/EventKnowledgeSystem.gd index 3e87cee..92fee85 100644 --- a/simulation/knowledge/EventKnowledgeSystem.gd +++ b/simulation/knowledge/EventKnowledgeSystem.gd @@ -248,6 +248,14 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool: ) and float(event.data.get("required_amount", 0.0)) > 0.0 ) + if ( + event_type + in [ + SimulationIds.EVENT_VILLAGER_WEAK, + SimulationIds.EVENT_HOME_DAMAGED, + ] + ): + return int(event.data["actor_id"]) >= 0 if float(event.data["amount"]) <= 0.0: return false if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: diff --git a/simulation/opportunities/VillageOpportunitySystem.gd b/simulation/opportunities/VillageOpportunitySystem.gd index 311e057..3a3efda 100644 --- a/simulation/opportunities/VillageOpportunitySystem.gd +++ b/simulation/opportunities/VillageOpportunitySystem.gd @@ -2,6 +2,7 @@ class_name VillageOpportunitySystem extends RefCounted const CARE_HUNGER_THRESHOLD := 80.0 +const WEAK_ENERGY_THRESHOLD := 25.0 var opportunities: Array[OpportunityStateRecord] = [] var next_opportunity_id := 0 @@ -29,7 +30,13 @@ func consider_event( return opened if woodpile == null: return null - return _try_open_wood(event, current_tick, woodpile, npcs, knowledge_system) + opened = _try_open_wood(event, current_tick, woodpile, npcs, knowledge_system) + if opened != null: + return opened + opened = _try_open_weak(event, current_tick, pantry, npcs, knowledge_system) + if opened != null: + return opened + return _try_open_roof(event, current_tick, woodpile, npcs, knowledge_system) func maintain_open_opportunity( @@ -42,7 +49,14 @@ func maintain_open_opportunity( var opportunity := get_open_opportunity() if opportunity == null or current_tick < opportunity.get_created_tick(): return null - if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + if ( + opportunity.get_opportunity_type() + not in [ + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD, + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER, + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF, + ] + ): return null var interested := _find_npc(opportunity.get_interested_npc_id(), npcs) if interested == null or interested.is_dead: @@ -53,15 +67,17 @@ func maintain_open_opportunity( ) else null ) - var target_storage := _get_target_storage(opportunity, pantry, woodpile) - var condition_is_stale := ( - target_storage == null - or ( - target_storage.get_amount(opportunity.get_resource_id()) - >= opportunity.get_target_amount() + var condition_is_stale := current_tick - opportunity.get_created_tick() >= maxi(max_age, 1) + if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + var target_storage := _get_target_storage(opportunity, pantry, woodpile) + condition_is_stale = ( + condition_is_stale + or target_storage == null + or ( + target_storage.get_amount(opportunity.get_resource_id()) + >= opportunity.get_target_amount() + ) ) - or current_tick - opportunity.get_created_tick() >= maxi(max_age, 1) - ) if not condition_is_stale: return null return ( @@ -452,6 +468,95 @@ func _try_open_wood( return opportunity +func _try_open_weak( + event: EconomicEventRecord, + current_tick: int, + pantry: StorageStateRecord, + npcs: Array[SimNPC], + knowledge_system: EventKnowledgeSystem +) -> OpportunityStateRecord: + if knowledge_system == null or get_open_opportunity() != null: + return null + if not _is_weak_trigger(event): + return null + if pantry.get_amount(SimulationIds.RESOURCE_FOOD) > 0.0: + return null + var actor := _find_npc(int(event.data["actor_id"]), npcs) + if ( + actor == null + or actor.is_dead + or actor.hunger < CARE_HUNGER_THRESHOLD + or actor.energy >= WEAK_ENERGY_THRESHOLD + or not knowledge_system.knows_event(actor.id, int(event.data["event_id"])) + ): + return null + var opportunity := OpportunityStateRecord.create( + next_opportunity_id, + current_tick, + int(event.data["event_id"]), + actor.id, + OpportunityStateRecord.DEFAULT_TARGET_AMOUNT, + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER, + SimulationIds.STORAGE_VILLAGE_PANTRY, + SimulationIds.RESOURCE_FOOD + ) + next_opportunity_id += 1 + opportunities.append(opportunity) + return opportunity + + +func _try_open_roof( + event: EconomicEventRecord, + current_tick: int, + woodpile: StorageStateRecord, + npcs: Array[SimNPC], + knowledge_system: EventKnowledgeSystem +) -> OpportunityStateRecord: + if knowledge_system == null or get_open_opportunity() != null: + return null + if not _is_home_damaged_trigger(event): + return null + if ( + woodpile.get_amount(SimulationIds.RESOURCE_WOOD) + >= OpportunityStateRecord.DEFAULT_TARGET_AMOUNT + ): + return null + var actor := _find_npc(int(event.data["actor_id"]), npcs) + if ( + actor == null + or actor.is_dead + or not knowledge_system.knows_event(actor.id, int(event.data["event_id"])) + ): + return null + var opportunity := OpportunityStateRecord.create( + next_opportunity_id, + current_tick, + int(event.data["event_id"]), + actor.id, + OpportunityStateRecord.DEFAULT_TARGET_AMOUNT, + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF, + SimulationIds.STORAGE_VILLAGE_WOODPILE, + SimulationIds.RESOURCE_WOOD + ) + next_opportunity_id += 1 + opportunities.append(opportunity) + return opportunity + + +static func _is_weak_trigger(event: EconomicEventRecord) -> bool: + return ( + StringName(event.data["event_type"]) == SimulationIds.EVENT_VILLAGER_WEAK + and int(event.data["actor_id"]) >= 0 + ) + + +static func _is_home_damaged_trigger(event: EconomicEventRecord) -> bool: + return ( + StringName(event.data["event_type"]) == SimulationIds.EVENT_HOME_DAMAGED + and int(event.data["actor_id"]) >= 0 + ) + + func _try_resolve( event: EconomicEventRecord, pantry: StorageStateRecord, @@ -462,6 +567,17 @@ func _try_resolve( var opportunity := get_open_opportunity() if opportunity == null: return null + if ( + opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER + and _is_valid_supply(event, opportunity, npcs, resource_states) + ): + if int(event.data["tick"]) < opportunity.get_created_tick(): + return null + if int(event.data["event_id"]) <= opportunity.get_trigger_event_id(): + return null + if not opportunity.resolve(int(event.data["event_id"]), int(event.data["tick"])): + return null + return opportunity var target_storage := _get_target_storage(opportunity, pantry, woodpile) if ( target_storage == null @@ -528,6 +644,13 @@ static func _is_valid_supply( npcs: Array[SimNPC], resource_states: Dictionary ) -> bool: + if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + return ( + StringName(event.data["event_type"]) == SimulationIds.EVENT_ITEM_CONSUMED + and int(event.data["actor_id"]) == opportunity.get_interested_npc_id() + and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and float(event.data["amount"]) > 0.0 + ) if ( StringName(event.data["item_id"]) != opportunity.get_resource_id() or StringName(event.data["destination_id"]) != opportunity.get_target_id() diff --git a/simulation/state/OpportunityStateRecord.gd b/simulation/state/OpportunityStateRecord.gd index 9fa79f1..b6de54d 100644 --- a/simulation/state/OpportunityStateRecord.gd +++ b/simulation/state/OpportunityStateRecord.gd @@ -95,13 +95,13 @@ static func from_dictionary(record_data: Dictionary) -> OpportunityStateRecord: if opportunity_id < 0 or created_tick < 0 or trigger_event_id < 0 or interested_npc_id < 0: return null match opportunity_type: - SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY: + SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY, SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: if ( target_id != SimulationIds.STORAGE_VILLAGE_PANTRY or resource_id != SimulationIds.RESOURCE_FOOD ): return null - SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD, SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF: if ( target_id != SimulationIds.STORAGE_VILLAGE_WOODPILE or resource_id != SimulationIds.RESOURCE_WOOD diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index fdf710c..1025a57 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -573,6 +573,12 @@ static func _is_valid_opportunity( SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: if not _is_valid_missing_wood_trigger(trigger_event, opportunity, npc_ids): return false + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + if not _is_valid_weak_trigger(trigger_event, opportunity, npc_ids): + return false + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF: + if not _is_valid_home_damaged_trigger(trigger_event, opportunity, npc_ids): + return false _: return false var trigger_tick := int(trigger_event.data["tick"]) @@ -594,25 +600,41 @@ static func _is_valid_opportunity( "%d:%d" % [opportunity.get_interested_npc_id(), opportunity.get_trigger_event_id()] ) var evidence := knowledge_records_by_key.get(evidence_key) as KnownEventStateRecord + var is_weak_need := ( + opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER + ) + var interested_alive := ( + interested_record != null and not bool(interested_record.data["is_dead"]) + ) + var storage_satisfied := ( + is_weak_need + or ( + target_storage != null + and ( + target_storage.get_amount(opportunity.get_resource_id()) + < opportunity.get_target_amount() + ) + ) + ) return ( resolution_event_id == OpportunityStateRecord.NO_EVENT_ID and resolved_tick == -1 - and interested_record != null - and ( - opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD - or not bool(interested_record.data["is_dead"]) - ) + and interested_alive and target_storage != null - and ( - target_storage.get_amount(opportunity.get_resource_id()) - < opportunity.get_target_amount() - ) + and storage_satisfied and evidence != null and evidence.get_acquired_tick() <= created_tick ) if status == OpportunityStateRecord.STATUS_INVALIDATED: if ( - opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD + ( + opportunity.get_opportunity_type() + not in [ + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD, + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER, + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF, + ] + ) or resolution_event_id != OpportunityStateRecord.NO_EVENT_ID or resolved_tick != -1 or opportunity.get_closed_tick() < created_tick @@ -681,6 +703,26 @@ static func _is_valid_pantry_empty_trigger(event: EconomicEventRecord, npc_ids: ) +static func _is_valid_weak_trigger( + event: EconomicEventRecord, opportunity: OpportunityStateRecord, npc_ids: Dictionary +) -> bool: + return ( + npc_ids.has(int(event.data["actor_id"])) + and int(event.data["actor_id"]) == opportunity.get_interested_npc_id() + and StringName(event.data["event_type"]) == SimulationIds.EVENT_VILLAGER_WEAK + ) + + +static func _is_valid_home_damaged_trigger( + event: EconomicEventRecord, opportunity: OpportunityStateRecord, npc_ids: Dictionary +) -> bool: + return ( + npc_ids.has(int(event.data["actor_id"])) + and int(event.data["actor_id"]) == opportunity.get_interested_npc_id() + and StringName(event.data["event_type"]) == SimulationIds.EVENT_HOME_DAMAGED + ) + + static func _is_valid_relationship_cause( cause_event: EconomicEventRecord, subject_id: int, is_player_subject: bool ) -> bool: @@ -721,6 +763,15 @@ static func _is_valid_supply_resolution( ) -> bool: var actor_id := int(event.data["actor_id"]) var event_type := StringName(event.data["event_type"]) + if ( + opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER + and event_type == SimulationIds.EVENT_ITEM_CONSUMED + ): + return ( + actor_id == opportunity.get_interested_npc_id() + and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and float(event.data["amount"]) > 0.0 + ) if ( StringName(event.data["destination_id"]) != opportunity.get_target_id() or StringName(event.data["item_id"]) != opportunity.get_resource_id() diff --git a/tests/village_care_opportunity_test.gd b/tests/village_care_opportunity_test.gd new file mode 100644 index 0000000..32ecf2b --- /dev/null +++ b/tests/village_care_opportunity_test.gd @@ -0,0 +1,230 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_feed_weak_villager() + _test_repair_home_roof() + _finish() + + +func _test_feed_weak_villager() -> void: + var manager := _create_manager(951) + var weak: SimNPC = manager.npcs[0] + var supplier: SimNPC = manager.npcs[1] + _empty_pantry(manager) + for npc in manager.npcs: + npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0) + weak.position = Vector3.ZERO + supplier.position = Vector3(2.0, 0.0, 0.0) + weak.hunger = 95.0 + weak.is_starving = true + weak.energy = 10.0 + manager.economy.withdraw_to_inventory(weak, SimulationIds.RESOURCE_FOOD, 1.0) + weak.task_state = SimNPC.TASK_STATE_IDLE + weak.task_complete = true + manager.call("_select_action_if_idle", weak, SimNPC.TASK_STATE_IDLE) + var opportunity: OpportunityStateRecord = manager.get_active_opportunity() + _check( + ( + opportunity != null + and opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER + and opportunity.get_interested_npc_id() == weak.id + and opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD + ), + "A starving weak villager with an empty pantry should open a feed-weak-villager need" + ) + if opportunity == null: + manager.free() + return + + supplier.position = Vector3(2.0, 0.0, 0.0) + var supplier_relationship: RelationshipStateRecord = ( + manager.relationship_system.get_relationship(supplier.id, weak.id) + ) + _check( + ( + supplier_relationship != null + and ( + supplier_relationship.increase_trust(0.15, RelationshipStateRecord.NO_CAUSE_EVENT) + > 0.0 + ) + ), + "Feed-weak setup should make the supplier a trusted known helper" + ) + supplier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + var helper: OpportunityHelperResult = manager.get_active_opportunity_helper() + _check( + ( + helper != null + and helper.helper_npc_id == supplier.id + and helper.action_id == SimulationIds.ACTION_DEPOSIT_FOOD + ), + "A trusted informed supplier should be derived as the capable helper" + ) + _check( + is_equal_approx( + manager.economy.deposit_inventory(supplier, SimulationIds.RESOURCE_FOOD), 1.0 + ), + "A real supplier should restock the pantry for the weak villager" + ) + _check( + manager.get_active_opportunity() != null, + "Stocking the pantry alone must not close a feed-weak-villager need" + ) + weak.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) + _check(manager.economy.consume_npc_food(weak), "The weak villager should eat the supplied food") + var resolved: OpportunityStateRecord = manager.get_latest_opportunity_for_npc(weak.id) + var resolution: EconomicEventRecord = manager.get_opportunity_resolution_event(resolved) + _check( + ( + resolved != null + and resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED + and resolution != null + and (StringName(resolution.data["event_type"]) == SimulationIds.EVENT_ITEM_CONSUMED) + and int(resolution.data["actor_id"]) == weak.id + ), + "A weak-villager need should resolve only when the interested villager actually eats" + ) + + var saved_json: String = manager.serialize_state() + var restored := _create_manager(952) + _check( + restored.restore_state_from_json(saved_json), + "A feed-weak-villager need should survive save and restore" + ) + _check( + ( + restored.get_state_checksum() == manager.get_state_checksum() + and ( + restored.get_latest_opportunity_for_npc(weak.id).get_status() + == OpportunityStateRecord.STATUS_RESOLVED + ) + ), + "Restore should preserve the resolved feed-weak history and checksum" + ) + restored.free() + + _empty_pantry(manager) + weak.hunger = 95.0 + weak.is_starving = true + weak.energy = 5.0 + weak.task_state = SimNPC.TASK_STATE_IDLE + weak.task_complete = true + manager.call("_select_action_if_idle", weak, SimNPC.TASK_STATE_IDLE) + var reopened: OpportunityStateRecord = manager.get_active_opportunity() + _check( + ( + reopened != null + and reopened.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER + ), + "A renewed weak condition should open a new feed-weak-villager need" + ) + var created_tick: int = reopened.get_created_tick() + var invalidated: OpportunityStateRecord = manager.opportunity_system.maintain_open_opportunity( + created_tick + manager.get_knowledge_review_interval(), + manager.get_knowledge_review_interval(), + manager.npcs, + manager.get_pantry(), + manager.get_woodpile() + ) + _check( + ( + invalidated != null + and invalidated.get_status() == OpportunityStateRecord.STATUS_INVALIDATED + and ( + invalidated.get_invalidation_reason() + == SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE + ) + ), + "An unresolved weak-villager need should close as stale after one simulated day" + ) + manager.free() + + +func _test_repair_home_roof() -> void: + var manager := _create_manager(953) + var homeowner: SimNPC = manager.npcs[0] + var supplier: SimNPC = manager.npcs[1] + var woodpile: StorageStateRecord = manager.get_woodpile() + woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD)) + manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD) + manager.village.safety = 15.0 + homeowner.position = Vector3.ZERO + var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_SLEEP) + manager.call("_apply_action_completion", homeowner, SimulationIds.ACTION_SLEEP, definition) + var opportunity: OpportunityStateRecord = manager.get_active_opportunity() + _check( + ( + opportunity != null + and opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF + and opportunity.get_interested_npc_id() == homeowner.id + and opportunity.get_resource_id() == SimulationIds.RESOURCE_WOOD + ), + "A sleepless night in a low-safety village should open a damaged-roof wood need" + ) + if opportunity == null: + manager.free() + return + + supplier.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0) + _check( + is_equal_approx( + manager.economy.deposit_inventory(supplier, SimulationIds.RESOURCE_WOOD), 1.0 + ), + "A real supplier should deposit wood for the roof repair" + ) + var resolved: OpportunityStateRecord = manager.get_latest_opportunity_for_npc(homeowner.id) + _check( + ( + resolved != null + and resolved.get_status() == OpportunityStateRecord.STATUS_RESOLVED + and manager.get_active_opportunity() == null + ), + "A real wood deposit should resolve the damaged-roof need" + ) + manager.free() + + +func _empty_pantry(manager: Node) -> void: + var pantry: StorageStateRecord = manager.get_pantry() + pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD)) + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + + +func _create_manager(seed_value: int = 951) -> 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 _finish() -> void: + if failures.is_empty(): + print("[TEST] Village care opportunities passed: weak villager + damaged roof") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/village_care_opportunity_test.gd.uid b/tests/village_care_opportunity_test.gd.uid new file mode 100644 index 0000000..7a9c418 --- /dev/null +++ b/tests/village_care_opportunity_test.gd.uid @@ -0,0 +1 @@ +uid://cw18rdabwuedd diff --git a/world/ui/ui.gd b/world/ui/ui.gd index 866b619..92b4b2b 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -268,15 +268,41 @@ func _build_active_opportunity_display() -> String: var resource_name := String(opportunity.get_resource_id()).capitalize() var current_amount := _get_opportunity_current_amount(opportunity) var helper_text := _build_opportunity_helper_display() - if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: - var action_name := _get_opportunity_action_name(opportunity) - return ( - "\n\nVillage need\n" - + "◆ Supply wood for blocked work\n" - + "%s could not finish %s\n" % [interested_name, action_name] - + "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()] - + helper_text - ) + match opportunity.get_opportunity_type(): + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + var action_name := _get_opportunity_action_name(opportunity) + return ( + "\n\nVillage need\n" + + "◆ Supply wood for blocked work\n" + + "%s could not finish %s\n" % [interested_name, action_name] + + ( + "%s %.0f / %.0f" + % [resource_name, current_amount, opportunity.get_target_amount()] + ) + + helper_text + ) + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + return ( + "\n\nVillage need\n" + + "◆ Bring food to %s\n" % interested_name + + "%s is too weak to gather\n" % interested_name + + ( + "%s %.0f / %.0f" + % [resource_name, current_amount, opportunity.get_target_amount()] + ) + + helper_text + ) + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF: + return ( + "\n\nVillage need\n" + + "◆ Repair %s's roof\n" % interested_name + + "%s is worried about their damaged home\n" % interested_name + + ( + "%s %.0f / %.0f" + % [resource_name, current_amount, opportunity.get_target_amount()] + ) + + helper_text + ) return ( "\n\nVillage need\n" + "◆ Restock the empty pantry\n" @@ -328,15 +354,34 @@ func _build_npc_opportunity_display(npc: SimNPC) -> String: var resource_name := String(opportunity.get_resource_id()).capitalize() if opportunity.get_status() == OpportunityStateRecord.STATUS_OPEN: var current_amount := _get_opportunity_current_amount(opportunity) - if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: - return ( - "Open village need\n" - + "◆ Find wood for %s\n" % _get_opportunity_action_name(opportunity) - + ( - "%s %.0f / %.0f\n\n" - % [resource_name, current_amount, opportunity.get_target_amount()] + match opportunity.get_opportunity_type(): + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + return ( + "Open village need\n" + + "◆ Find wood for %s\n" % _get_opportunity_action_name(opportunity) + + ( + "%s %.0f / %.0f\n\n" + % [resource_name, current_amount, opportunity.get_target_amount()] + ) + ) + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + return ( + "Open village need\n" + + "◆ %s is too weak to gather\n" % npc.npc_name + + ( + "%s %.0f / %.0f\n\n" + % [resource_name, current_amount, opportunity.get_target_amount()] + ) + ) + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF: + return ( + "Open village need\n" + + "◆ %s's roof needs wood\n" % npc.npc_name + + ( + "%s %.0f / %.0f\n\n" + % [resource_name, current_amount, opportunity.get_target_amount()] + ) ) - ) return ( "Open village need\n" + "◆ Restock the empty pantry\n" @@ -364,12 +409,28 @@ func _build_npc_opportunity_display(npc: SimNPC) -> String: if resolution_event != null: var actor_id := int(resolution_event.data["actor_id"]) actor_name = "Player" if actor_id < 0 else _get_npc_name(actor_id) - if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: - return ( - "Resolved village need\n" - + "◆ %s supplied the woodpile\n" % actor_name - + "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()] - ) + match opportunity.get_opportunity_type(): + SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD: + return ( + "Resolved village need\n" + + "◆ %s supplied the woodpile\n" % actor_name + + "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()] + ) + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + return ( + "Resolved village need\n" + + ( + "◆ %s ate the supplied food\n" + % _get_npc_name(opportunity.get_interested_npc_id()) + ) + + "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()] + ) + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF: + return ( + "Resolved village need\n" + + "◆ %s repaired the roof\n" % actor_name + + "%s target %.0f\n\n" % [resource_name, opportunity.get_target_amount()] + ) return ( "Resolved village need\n" + "◆ %s restocked the pantry\n" % actor_name diff --git a/world/ui/village_whisper_hud.gd b/world/ui/village_whisper_hud.gd index fd18247..df06c92 100644 --- a/world/ui/village_whisper_hud.gd +++ b/world/ui/village_whisper_hud.gd @@ -201,6 +201,11 @@ func _on_state_restored() -> void: func _build_need_message(opportunity: OpportunityStateRecord) -> String: var interested_name := _get_npc_name(opportunity.get_interested_npc_id()) + match opportunity.get_opportunity_type(): + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + return "%s is too weak to gather food." % interested_name + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF: + return "%s's roof needs wood." % interested_name if opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD: return "%s worries over the empty pantry." % interested_name var trigger: EconomicEventRecord = simulation_manager.get_opportunity_trigger_event(opportunity) @@ -227,6 +232,11 @@ func _get_destination_name(target_id: StringName) -> String: func _get_need_name(opportunity: OpportunityStateRecord) -> String: + match opportunity.get_opportunity_type(): + SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: + return "weak-villager plea" + SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF: + return "damaged-roof report" return ( "empty-pantry worry" if opportunity.get_resource_id() == SimulationIds.RESOURCE_FOOD