feat: derive capable opportunity helpers

This commit is contained in:
Rijad Zuzo
2026-07-16 10:41:05 +02:00
parent 79dd503343
commit eaa15c076c
17 changed files with 648 additions and 40 deletions
@@ -3,6 +3,7 @@ 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:
@@ -192,6 +193,173 @@ func test_missing_wood_maintenance_does_not_change_the_existing_food_lifecycle()
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_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)
@@ -309,6 +477,27 @@ func _player_resource_states() -> Dictionary:
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
) -> 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
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: