extends GutTest const VillageOpportunitySystemScript := preload( "res://simulation/opportunities/VillageOpportunitySystem.gd" ) const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.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_capable_helper_requires_exact_knowledge_trust_and_a_real_supply_route() -> void: var fixture := _open_fixture() var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var knowledge: EventKnowledgeSystem = fixture["knowledge"] var interested: SimNPC = fixture["npcs"][0] var helper := _npc(1, 20.0) var npcs: Array[SimNPC] = [interested, helper] var relationships := RelationshipSystemScript.new() var trusted := RelationshipStateRecord.create(helper.id, interested.id, 0.8, 0.7) relationships.restore([trusted]) var source := _resource_state( &"orchard_berries", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 2.0, 3.0 ) var resource_states := {source.get_node_id(): source} var opportunity: OpportunityStateRecord = system.get_open_opportunity() assert_null( system.find_capable_helper( opportunity, pantry, npcs, knowledge, relationships, resource_states ) ) _remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10) relationships.restore([RelationshipStateRecord.create(helper.id, interested.id, 0.8, 0.59)]) assert_null( system.find_capable_helper( opportunity, pantry, npcs, knowledge, relationships, resource_states ) ) relationships.restore([trusted]) var opportunity_before := opportunity.to_dictionary() var source_before := source.to_dictionary() var helper_inventory_before := helper.inventory.duplicate(true) var result: OpportunityHelperResult = system.find_capable_helper( opportunity, pantry, npcs, knowledge, relationships, resource_states ) assert_not_null(result) assert_eq(result.opportunity_id, opportunity.get_opportunity_id()) assert_eq(result.helper_npc_id, helper.id) assert_eq(result.action_id, SimulationIds.ACTION_GATHER_FOOD) assert_true(result.source_id.is_empty()) assert_eq(result.available_source_count, 1) assert_eq(result.resource_id, SimulationIds.RESOURCE_FOOD) assert_eq(result.trigger_event_id, opportunity.get_trigger_event_id()) assert_eq(result.trust, 0.7) assert_true(result.profession_match) assert_false(result.uses_inventory) assert_string_contains(result.reason, "Knows the need") assert_eq(opportunity.to_dictionary(), opportunity_before) assert_eq(source.to_dictionary(), source_before) assert_eq(helper.inventory, helper_inventory_before) func test_ready_inventory_then_trust_and_stable_id_rank_helpers_deterministically() -> void: var fixture := _open_fixture() var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var knowledge: EventKnowledgeSystem = fixture["knowledge"] var interested: SimNPC = fixture["npcs"][0] var trusted_gatherer := _npc(1, 20.0) var lower_id_carrier := _npc(2, 20.0) var stronger_carrier := _npc(5, 20.0) lower_id_carrier.profession = SimulationIds.PROFESSION_GUARD stronger_carrier.profession = SimulationIds.PROFESSION_GUARD lower_id_carrier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) stronger_carrier.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) var npcs: Array[SimNPC] = [interested, trusted_gatherer, lower_id_carrier, stronger_carrier] var opportunity: OpportunityStateRecord = system.get_open_opportunity() for helper in [trusted_gatherer, lower_id_carrier, stronger_carrier]: _remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10) var relationships := RelationshipSystemScript.new() ( relationships . restore( [ RelationshipStateRecord.create(trusted_gatherer.id, interested.id, 0.9, 0.95), RelationshipStateRecord.create(lower_id_carrier.id, interested.id, 0.7, 0.7), RelationshipStateRecord.create(stronger_carrier.id, interested.id, 0.7, 0.8), ] ) ) var source := _resource_state( &"berry_source", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 2.0 ) var result: OpportunityHelperResult = system.find_capable_helper( opportunity, pantry, npcs, knowledge, relationships, {source.get_node_id(): source} ) assert_eq(result.helper_npc_id, stronger_carrier.id) assert_eq(result.action_id, SimulationIds.ACTION_DEPOSIT_FOOD) assert_eq(result.source_id, SimulationIds.npc_inventory_id(stronger_carrier.id)) assert_true(result.uses_inventory) assert_eq(result.available_source_count, 0) assert_false(result.profession_match) assert_eq(result.trust, 0.8) ( relationships . restore( [ RelationshipStateRecord.create(trusted_gatherer.id, interested.id, 0.9, 0.95), RelationshipStateRecord.create(lower_id_carrier.id, interested.id, 0.7, 0.8), RelationshipStateRecord.create(stronger_carrier.id, interested.id, 0.7, 0.8), ] ) ) result = system.find_capable_helper( opportunity, pantry, npcs, knowledge, relationships, {source.get_node_id(): source} ) assert_eq(result.helper_npc_id, lower_id_carrier.id) func test_player_response_requires_no_helper_and_a_real_player_supply_route() -> void: var fixture := _open_fixture() var system: VillageOpportunitySystem = fixture["system"] var pantry: StorageStateRecord = fixture["pantry"] var opportunity: OpportunityStateRecord = system.get_open_opportunity() var player_source := _resource_state( &"player_berries", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 1.0 ) var npc_only_source := _resource_state( &"npc_berries", SimulationIds.ACTION_GATHER_FOOD, SimulationIds.RESOURCE_FOOD, 2.0, 0.0, 0.0, false ) var resource_states := { player_source.get_node_id(): player_source, npc_only_source.get_node_id(): npc_only_source, } assert_true(player_source.reserve(99)) var opportunity_before := opportunity.to_dictionary() var pantry_before := pantry.to_dictionary() var source_before := player_source.to_dictionary() var response: OpportunityPlayerResponseResult = system.find_player_response( opportunity, pantry, resource_states, false ) assert_not_null(response) assert_eq(response.opportunity_id, opportunity.get_opportunity_id()) assert_eq(response.trigger_event_id, opportunity.get_trigger_event_id()) assert_eq(response.action_id, SimulationIds.ACTION_GATHER_FOOD) assert_eq(response.resource_id, SimulationIds.RESOURCE_FOOD) assert_eq(response.target_id, SimulationIds.STORAGE_VILLAGE_PANTRY) assert_eq(response.available_source_count, 1) assert_string_contains(response.reason, "No capable helper") assert_string_contains(response.reason, "player-usable finite Food source") assert_eq(opportunity.to_dictionary(), opportunity_before) assert_eq(pantry.to_dictionary(), pantry_before) assert_eq(player_source.to_dictionary(), source_before) assert_null(system.find_player_response(opportunity, pantry, resource_states, true)) player_source.set_enabled(false) assert_null(system.find_player_response(opportunity, pantry, resource_states, false)) var full_pantry := StorageStateRecord.create( SimulationIds.STORAGE_VILLAGE_PANTRY, {String(SimulationIds.RESOURCE_WOOD): 1.0}, 1.0 ) player_source.set_enabled(true) assert_null(system.find_player_response(opportunity, full_pantry, resource_states, false)) var wrong_target := StorageStateRecord.create(&"not_the_pantry", {}, 1.0) assert_null(system.find_player_response(opportunity, wrong_target, resource_states, false)) func test_wood_helper_uses_available_finite_source_and_respects_reservations() -> void: var system := VillageOpportunitySystemScript.new() var interested := _npc(0, 20.0) var helper := _npc(1, 20.0) helper.profession = SimulationIds.PROFESSION_WOODCUTTER var npcs: Array[SimNPC] = [interested, helper] var knowledge := EventKnowledgeSystem.new() var opportunity := OpportunityStateRecord.create( 0, 10, 4, interested.id, 1.0, SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD, SimulationIds.STORAGE_VILLAGE_WOODPILE, SimulationIds.RESOURCE_WOOD ) system.restore([opportunity], 1) _remember(knowledge, helper.id, opportunity.get_trigger_event_id(), 10) var relationships := RelationshipSystemScript.new() relationships.restore([RelationshipStateRecord.create(helper.id, interested.id, 0.7, 0.7)]) var preferred_source := _resource_state( &"safe_grove", SimulationIds.ACTION_GATHER_WOOD, SimulationIds.RESOURCE_WOOD, 2.0, 5.0, 0.1 ) var fallback_source := _resource_state( &"rough_grove", SimulationIds.ACTION_GATHER_WOOD, SimulationIds.RESOURCE_WOOD, 2.0, 1.0, 0.8 ) var sources := { fallback_source.get_node_id(): fallback_source, preferred_source.get_node_id(): preferred_source, } var woodpile := StorageStateRecord.create( SimulationIds.STORAGE_VILLAGE_WOODPILE, {String(SimulationIds.RESOURCE_WOOD): 0.0} ) var result: OpportunityHelperResult = system.find_capable_helper( opportunity, woodpile, npcs, knowledge, relationships, sources ) assert_eq(result.helper_npc_id, helper.id) assert_eq(result.action_id, SimulationIds.ACTION_GATHER_WOOD) assert_true(result.source_id.is_empty()) assert_eq(result.available_source_count, 2) assert_true(result.profession_match) assert_true(preferred_source.reserve(99)) result = system.find_capable_helper( opportunity, woodpile, npcs, knowledge, relationships, sources ) assert_eq(result.available_source_count, 1) assert_true(fallback_source.reserve(99)) assert_null( system.find_capable_helper(opportunity, woodpile, npcs, knowledge, relationships, sources) ) 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 _resource_state( node_id: StringName, action_id: StringName, resource_id: StringName, amount: float, discovery_priority: float = 0.0, safety_risk: float = 0.0, can_player_use: bool = true, can_npcs_use: bool = true ) -> ResourceStateRecord: var node := ResourceNode.new() node.node_id = node_id node.action_id = action_id node.resource_id = resource_id node.initial_amount = amount node.yield_per_action = 1.0 node.discovery_priority = discovery_priority node.safety_risk = safety_risk node.can_player_use = can_player_use node.can_npcs_use = can_npcs_use var state := ResourceStateRecord.create_from_node(node) node.free() return state 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()