feat: derive capable opportunity helpers
This commit is contained in:
@@ -239,6 +239,8 @@ func _run() -> void:
|
||||
)
|
||||
await process_frame
|
||||
contributor = simulation_manager.npcs[contributor.id]
|
||||
witness = simulation_manager.npcs[witness.id]
|
||||
listener = simulation_manager.npcs[listener.id]
|
||||
contributor_visual = world_view.active_npc_visuals[contributor.id]
|
||||
player_opportunity = simulation_manager.get_active_opportunity()
|
||||
_check(
|
||||
@@ -277,15 +279,39 @@ func _run() -> void:
|
||||
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
)
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var woodpile_node := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||
)
|
||||
contributor.position = woodpile_node.get_interaction_position()
|
||||
witness.position = contributor.position
|
||||
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
contributor.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
contributor.travel_target_position = contributor.position
|
||||
contributor.start_working()
|
||||
simulation_manager.simulate_tick()
|
||||
village_ui.selected_npc_index = contributor.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
|
||||
_check(
|
||||
(
|
||||
runtime_helper != null
|
||||
and runtime_helper.helper_npc_id == witness.id
|
||||
and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||
),
|
||||
"Runtime helper query should derive the informed trusted woodcutter"
|
||||
)
|
||||
var runtime_helper_reason := (
|
||||
"Knows the need; trust %.2f toward %s" % [runtime_helper.trust, contributor.npc_name]
|
||||
if runtime_helper != null
|
||||
else "missing helper"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
"◆ Supply wood for blocked work" in village_stats_label.text
|
||||
and "could not finish Study" in village_stats_label.text
|
||||
and "Possible helper: %s — Gather Wood" % witness.npc_name in village_stats_label.text
|
||||
and runtime_helper_reason in village_stats_label.text
|
||||
and "◆ Find wood for Study" in inspector_label.text
|
||||
and not contributor_visual.get_node("OpportunityConcernRoot").visible
|
||||
),
|
||||
|
||||
@@ -53,6 +53,19 @@ func _run() -> void:
|
||||
not caption.visible,
|
||||
"The cinematic caption should remain hidden until the full sequence starts"
|
||||
)
|
||||
var derived_helper: OpportunityHelperResult = manager.get_active_opportunity_helper()
|
||||
_check(
|
||||
(
|
||||
derived_helper != null
|
||||
and derived_helper.helper_npc_id == helper.id
|
||||
and derived_helper.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||
and derived_helper.resource_id == SimulationIds.RESOURCE_FOOD
|
||||
and derived_helper.source_id.is_empty()
|
||||
and derived_helper.available_source_count > 0
|
||||
and not derived_helper.uses_inventory
|
||||
),
|
||||
"The open need should read-only derive the staged trusted helper and a finite food route"
|
||||
)
|
||||
|
||||
_check(demo.begin_pantry_restock(), "A trusted helper should begin the real food run")
|
||||
var decision: ActionSelectionResult = manager.get_latest_decision(helper.id)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -64,6 +64,49 @@ func _run() -> void:
|
||||
),
|
||||
"The performer and nearby witness should know the exact fact while its owner retains it"
|
||||
)
|
||||
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||
var helper_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
|
||||
witness.id, actor.id
|
||||
)
|
||||
_check(helper_relationship != null, "Nearby household witnesses should have a directed tie")
|
||||
if helper_relationship != null:
|
||||
var relationship_records: Array[RelationshipStateRecord] = []
|
||||
for relationship in manager.relationship_system.get_all_sorted():
|
||||
if relationship == helper_relationship:
|
||||
relationship_records.append(
|
||||
RelationshipStateRecord.create(
|
||||
witness.id, actor.id, helper_relationship.get_familiarity(), 0.7
|
||||
)
|
||||
)
|
||||
else:
|
||||
relationship_records.append(relationship)
|
||||
manager.relationship_system.restore(relationship_records)
|
||||
var helper_tree := _create_helper_tree()
|
||||
root.add_child(helper_tree)
|
||||
await process_frame
|
||||
_check(
|
||||
manager.register_resource_node(helper_tree), "Helper query should bind a finite NPC tree"
|
||||
)
|
||||
var checksum_before_helper_query: String = manager.get_state_checksum()
|
||||
var helper_result: OpportunityHelperResult = manager.get_active_opportunity_helper()
|
||||
_check(
|
||||
(
|
||||
helper_result != null
|
||||
and helper_result.helper_npc_id == witness.id
|
||||
and helper_result.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||
and helper_result.source_id.is_empty()
|
||||
and helper_result.available_source_count == 1
|
||||
and helper_result.trigger_event_id == trigger_id
|
||||
and helper_result.profession_match
|
||||
and not helper_result.uses_inventory
|
||||
and "Knows the need" in helper_result.reason
|
||||
),
|
||||
"The query should identify one informed trusted woodcutter with finite tree supply"
|
||||
)
|
||||
_check(
|
||||
manager.get_state_checksum() == checksum_before_helper_query,
|
||||
"Capable-helper discovery should not mutate authoritative simulation state"
|
||||
)
|
||||
|
||||
var active_json: String = manager.serialize_state()
|
||||
var active_restored := _create_manager(1202)
|
||||
@@ -71,13 +114,21 @@ func _run() -> void:
|
||||
active_restored.restore_state_from_json(active_json),
|
||||
"An active missing-wood need should survive schema-v9 restore"
|
||||
)
|
||||
var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper()
|
||||
_check(
|
||||
active_restored.get_state_checksum() == manager.get_state_checksum(),
|
||||
"Active restore should preserve the authoritative checksum"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
active_restored.get_state_checksum() == manager.get_state_checksum()
|
||||
and active_restored.get_active_opportunity() != null
|
||||
active_restored.get_active_opportunity() != null
|
||||
and active_restored.is_known_event_lasting(actor.id, trigger_id)
|
||||
),
|
||||
"Active restore should preserve identity, checksum, and protected evidence"
|
||||
"Active restore should preserve opportunity identity and protected evidence"
|
||||
)
|
||||
_check(
|
||||
restored_helper != null and restored_helper.helper_npc_id == witness.id,
|
||||
"Active restore should re-derive the same capable helper"
|
||||
)
|
||||
active_restored.free()
|
||||
|
||||
@@ -118,6 +169,8 @@ func _run() -> void:
|
||||
)
|
||||
resolved_restored.free()
|
||||
manager.free()
|
||||
helper_tree.queue_free()
|
||||
await process_frame
|
||||
|
||||
var player_manager := _create_manager(1204)
|
||||
var player_actor := _open_missing_wood_need(player_manager)
|
||||
@@ -278,6 +331,15 @@ func _create_player_tree() -> ResourceNode:
|
||||
return tree
|
||||
|
||||
|
||||
func _create_helper_tree() -> ResourceNode:
|
||||
var tree := _create_player_tree()
|
||||
tree.name = "HelperTree"
|
||||
tree.node_id = &"helper_tree"
|
||||
tree.can_player_use = false
|
||||
tree.discovery_priority = 4.0
|
||||
return tree
|
||||
|
||||
|
||||
func _create_manager(seed_value: int) -> Node:
|
||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||
manager.simulation_seed = seed_value
|
||||
|
||||
Reference in New Issue
Block a user