extends GutTest const VillageOpportunitySystemScript := preload( "res://simulation/opportunities/VillageOpportunitySystem.gd" ) func test_opening_requires_current_knowledge_and_care() -> void: var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var npc := _npc(0, 90.0) var npcs: Array[SimNPC] = [npc] var knowledge := EventKnowledgeSystem.new() var trigger := _withdrawal_event(4, 8, npc.id) assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {})) _remember(knowledge, npc.id, 4, 8) npc.hunger = VillageOpportunitySystem.CARE_HUNGER_THRESHOLD - 0.01 assert_null(system.consider_event(trigger, 8, pantry, npcs, knowledge, {})) assert_true(system.get_all_sorted().is_empty()) assert_eq(system.next_opportunity_id, 0) func test_highest_hunger_then_lowest_id_deterministically_owns_the_opportunity() -> void: var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var lower_hunger := _npc(1, 85.0) var high_id := _npc(5, 92.0) var low_id := _npc(2, 92.0) var npcs: Array[SimNPC] = [high_id, lower_hunger, low_id] var knowledge := EventKnowledgeSystem.new() var trigger := _withdrawal_event(7, 10, lower_hunger.id) for npc in npcs: _remember(knowledge, npc.id, 7, 10) var pantry_before := pantry.to_dictionary() var tasks_before: Array[StringName] = [] for npc in npcs: tasks_before.append(npc.current_task) var opened: OpportunityStateRecord = system.consider_event( trigger, 10, pantry, npcs, knowledge, {} ) assert_not_null(opened) assert_eq(opened.get_opportunity_id(), 0) assert_eq(opened.get_interested_npc_id(), low_id.id) assert_eq(opened.get_trigger_event_id(), 7) assert_eq(opened.get_created_tick(), 10) assert_eq(opened.get_opportunity_type(), SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY) assert_eq(opened.get_status(), OpportunityStateRecord.STATUS_OPEN) assert_eq(opened.get_target_id(), SimulationIds.STORAGE_VILLAGE_PANTRY) assert_eq(opened.get_resource_id(), SimulationIds.RESOURCE_FOOD) assert_eq(opened.get_target_amount(), 1.0) assert_eq(pantry.to_dictionary(), pantry_before) for index in npcs.size(): assert_eq(npcs[index].current_task, tasks_before[index]) assert_eq(system.next_opportunity_id, 1) func test_near_equal_hunger_still_uses_the_exact_highest_value() -> void: var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var lower_id := _npc(1, 92.0) var slightly_hungrier := _npc(9, 92.000001) var npcs: Array[SimNPC] = [lower_id, slightly_hungrier] var knowledge := EventKnowledgeSystem.new() var trigger := _withdrawal_event(8, 10, lower_id.id) for npc in npcs: _remember(knowledge, npc.id, 8, 10) var opened: OpportunityStateRecord = system.consider_event( trigger, 10, pantry, npcs, knowledge, {} ) assert_not_null(opened) assert_eq(opened.get_interested_npc_id(), slightly_hungrier.id) func test_late_knowledge_can_open_old_event_and_open_opportunity_dedupes() -> void: var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var npc := _npc(3, 88.0) var npcs: Array[SimNPC] = [npc] var knowledge := EventKnowledgeSystem.new() var original_trigger := _withdrawal_event(10, 4, npc.id) assert_null(system.consider_event(original_trigger, 4, pantry, npcs, knowledge, {})) _remember(knowledge, npc.id, 10, 12) var opened: OpportunityStateRecord = system.consider_event( original_trigger, 12, pantry, npcs, knowledge, {} ) assert_not_null(opened) assert_eq(opened.get_created_tick(), 12) var duplicate_trigger := _withdrawal_event(11, 13, npc.id) _remember(knowledge, npc.id, 11, 13) assert_null(system.consider_event(duplicate_trigger, 13, pantry, npcs, knowledge, {})) assert_eq(system.get_all_sorted().size(), 1) assert_eq(system.next_opportunity_id, 1) func test_only_strict_later_npc_deposit_resolves_without_mutating_supply() -> void: var fixture := _open_fixture() var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var npcs: Array[SimNPC] = fixture["npcs"] var knowledge: EventKnowledgeSystem = fixture["knowledge"] pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0) var food_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD) var invalid_events: Array[EconomicEventRecord] = [ _supply_event(20, 11, SimulationIds.EVENT_RESOURCE_EXTRACTED, 0), _supply_event(21, 11, SimulationIds.EVENT_STORAGE_DEPOSITED, -1), EconomicEventRecord.create( 25, SimulationIds.EVENT_STORAGE_DEPOSITED, 11, 0, &"not_the_actor_inventory", SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.RESOURCE_FOOD, 1.0 ), EconomicEventRecord.create( 22, SimulationIds.EVENT_STORAGE_DEPOSITED, 11, 0, SimulationIds.npc_inventory_id(0), SimulationIds.STORAGE_VILLAGE_WOODPILE, SimulationIds.RESOURCE_FOOD, 1.0 ), EconomicEventRecord.create( 23, SimulationIds.EVENT_STORAGE_DEPOSITED, 11, 0, SimulationIds.npc_inventory_id(0), SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.RESOURCE_FOOD, 0.0 ), ] for event in invalid_events: assert_null(system.consider_event(event, 11, pantry, npcs, knowledge, {})) assert_eq(pantry.get_amount(SimulationIds.RESOURCE_FOOD), food_before) assert_eq(system.get_open_opportunity().get_status(), OpportunityStateRecord.STATUS_OPEN) var valid_supply := _supply_event(24, 12, SimulationIds.EVENT_STORAGE_DEPOSITED, 0) var resolved: OpportunityStateRecord = system.consider_event( valid_supply, 12, pantry, npcs, knowledge, {} ) assert_not_null(resolved) assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED) assert_eq(resolved.get_resolution_event_id(), 24) assert_eq(resolved.get_resolved_tick(), 12) assert_null(system.get_open_opportunity()) assert_eq(pantry.get_amount(SimulationIds.RESOURCE_FOOD), food_before) func test_player_extraction_into_pantry_is_the_other_valid_resolution() -> void: var fixture := _open_fixture() var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var npcs: Array[SimNPC] = fixture["npcs"] var knowledge: EventKnowledgeSystem = fixture["knowledge"] pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0) var player_supply := _supply_event(30, 14, SimulationIds.EVENT_RESOURCE_EXTRACTED, -1) assert_null(system.consider_event(player_supply, 14, pantry, npcs, knowledge, {})) assert_not_null(system.get_open_opportunity()) var resolved: OpportunityStateRecord = system.consider_event( player_supply, 14, pantry, npcs, knowledge, _player_resource_states() ) assert_not_null(resolved) assert_eq(resolved.get_resolution_event_id(), 30) assert_eq(resolved.get_status(), OpportunityStateRecord.STATUS_RESOLVED) func test_missing_wood_maintenance_does_not_change_the_existing_food_lifecycle() -> void: var fixture := _open_fixture() var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var npcs: Array[SimNPC] = fixture["npcs"] var food_need: OpportunityStateRecord = system.get_open_opportunity() npcs[0].is_dead = true assert_null(system.maintain_open_opportunity(1000, 10, npcs, pantry, null)) assert_eq(food_need.get_status(), OpportunityStateRecord.STATUS_OPEN) assert_eq(food_need.get_closed_tick(), -1) func test_restore_preserves_ids_sorted_queries_and_resolves_only_oldest_open() -> void: var older := OpportunityStateRecord.create(2, 5, 4, 7) var newer := OpportunityStateRecord.create(5, 8, 7, 9) var restored_records: Array[OpportunityStateRecord] = [newer, older] var system := VillageOpportunitySystemScript.new() system.restore(restored_records, 6) assert_eq(system.next_opportunity_id, 6) assert_eq(system.get_all_sorted().map(_opportunity_id), [2, 5]) assert_eq(system.get_open_opportunity(), older) assert_eq(system.get_latest_for_npc(9), newer) assert_null(system.get_latest_for_npc(100)) var pantry := _empty_pantry() pantry.deposit(SimulationIds.RESOURCE_FOOD, 1.0) var npcs: Array[SimNPC] = [_npc(0, 10.0)] var supply := _supply_event(40, 10, SimulationIds.EVENT_STORAGE_DEPOSITED, 0) var resolved: OpportunityStateRecord = system.consider_event( supply, 10, pantry, npcs, EventKnowledgeSystem.new(), {} ) assert_eq(resolved, older) assert_eq(older.get_status(), OpportunityStateRecord.STATUS_RESOLVED) assert_eq(newer.get_status(), OpportunityStateRecord.STATUS_OPEN) assert_eq(system.get_open_opportunity(), newer) func test_record_parser_rejects_malformed_state_combinations() -> void: var valid := OpportunityStateRecord.create(3, 10, 8, 2) assert_not_null(OpportunityStateRecord.from_dictionary(valid.to_dictionary())) var invalid_open := valid.to_dictionary() invalid_open["resolution_event_id"] = 9 assert_null(OpportunityStateRecord.from_dictionary(invalid_open)) var invalid_type := valid.to_dictionary() invalid_type["opportunity_type"] = "generic_quest" assert_null(OpportunityStateRecord.from_dictionary(invalid_type)) var invalid_target := valid.to_dictionary() invalid_target["target_amount"] = 2.0 assert_null(OpportunityStateRecord.from_dictionary(invalid_target)) var invalid_resolved := valid.to_dictionary() invalid_resolved["status"] = String(OpportunityStateRecord.STATUS_RESOLVED) invalid_resolved["resolution_event_id"] = 12 invalid_resolved["resolved_tick"] = 9 assert_null(OpportunityStateRecord.from_dictionary(invalid_resolved)) invalid_resolved["resolved_tick"] = 10 invalid_resolved["resolution_event_id"] = valid.get_trigger_event_id() assert_null(OpportunityStateRecord.from_dictionary(invalid_resolved)) func _open_fixture() -> Dictionary: var system := VillageOpportunitySystemScript.new() var pantry := _empty_pantry() var npc := _npc(0, 90.0) var npcs: Array[SimNPC] = [npc] var knowledge := EventKnowledgeSystem.new() var trigger := _withdrawal_event(1, 10, npc.id) _remember(knowledge, npc.id, 1, 10) assert_not_null(system.consider_event(trigger, 10, pantry, npcs, knowledge, {})) return {"system": system, "pantry": pantry, "npcs": npcs, "knowledge": knowledge} func _empty_pantry() -> StorageStateRecord: return StorageStateRecord.create( SimulationIds.STORAGE_VILLAGE_PANTRY, {String(SimulationIds.RESOURCE_FOOD): 0.0} ) func _npc(npc_id: int, hunger: float) -> SimNPC: var npc := SimNPC.new(npc_id, "NPC %d" % npc_id, SimulationIds.PROFESSION_FARMER, 5.0, 5.0) npc.hunger = hunger npc.is_dead = false return npc func _withdrawal_event(event_id: int, tick: int, actor_id: int) -> EconomicEventRecord: return EconomicEventRecord.create( event_id, SimulationIds.EVENT_STORAGE_WITHDRAWN, tick, actor_id, SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.npc_inventory_id(actor_id), SimulationIds.RESOURCE_FOOD, 1.0 ) func _supply_event( event_id: int, tick: int, event_type: StringName, actor_id: int ) -> EconomicEventRecord: var source_id := &"player_resource" if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED and actor_id >= 0: source_id = SimulationIds.npc_inventory_id(actor_id) return EconomicEventRecord.create( event_id, event_type, tick, actor_id, source_id, SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.RESOURCE_FOOD, 1.0 ) func _player_resource_states() -> Dictionary: var resource_node := ResourceNode.new() resource_node.node_id = &"player_resource" resource_node.action_id = SimulationIds.ACTION_GATHER_FOOD resource_node.resource_id = SimulationIds.RESOURCE_FOOD resource_node.can_player_use = true var resource_state := ResourceStateRecord.create_from_node(resource_node) var result := {resource_node.node_id: resource_state} resource_node.free() return result func _remember( knowledge: EventKnowledgeSystem, knower_id: int, event_id: int, acquired_tick: int ) -> void: var records := knowledge.get_all_sorted() records.append( KnownEventStateRecord.create( knower_id, event_id, SimulationIds.KNOWLEDGE_ACQUISITION_PERFORMED, KnownEventStateRecord.NO_SOURCE_NPC_ID, acquired_tick ) ) knowledge.restore(records) func _opportunity_id(opportunity: OpportunityStateRecord) -> int: return opportunity.get_opportunity_id()