diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 723422d..b1c4aed 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -43,7 +43,9 @@ would otherwise obscure that lifecycle: - `simulation/opportunities/VillageOpportunitySystem.gd` observes immutable event references plus current NPC/storage state, then owns the bounded shared open/resolved/invalidated lifecycle for the proven pantry-food and blocked- - work wood consumers without changing resources or assigning tasks; + work wood consumers. It also derives one ephemeral `OpportunityHelperResult` + from knowledge, directed relationships, inventory, action definitions, and + finite-resource state without changing resources or assigning tasks; - `simulation/persistence/` owns save-slot file safety; - `simulation/state/` owns versioned serialized record contracts; - `simulation/definitions/` owns stable IDs and immutable action/profession diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index a1a18ea..6ed2c3f 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -731,13 +731,18 @@ Completed: player tree-harvest resolutions use real economy history; interested-party death and one-day staleness close the need cleanly. The compact UI explains the blocked action without reusing the pantry's hunger cue. +28. Explainable capable helper: an open food or wood need now names one + deterministic informed and trusted villager plus the ordinary gather or + deposit action and real inventory/resource route that makes them capable. + The result is re-derived after restore and does not assign work or alter the + simulation. Next: -1. Add the first read-only capable-helper query for an open need. Expose one - deterministic viable villager and the real knowledge, relationship, action, - inventory, and resource facts behind that result before allowing it to - affect autonomous selection, as sequenced in `LEARNING_ROADMAP.md`. +1. Let the currently derived helper consume the query through ordinary + autonomous action selection, after urgent self-care and schedule precedence. + Re-query instead of persisting assignment, and keep acceptance/rewards out + of scope, as sequenced in `LEARNING_ROADMAP.md`. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index 430b3b1..6a24363 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -99,6 +99,19 @@ opening; invalidation emits no event and stores the close tick and stable reason. Resolution or invalidation returns the trigger to normal bounded retention. +For an open need, the same system can derive one capable helper without +assigning work. A candidate must know the exact trigger and have a familiar +directed relationship with at least 0.6 trust toward the interested villager. +Enough carried inventory produces the matching deposit route; otherwise a +matching enabled, NPC-usable, sufficiently stocked and available finite +ResourceNode state produces the gather route. Ranking is deterministic and +prefers ready inventory, trust, profession fit, familiarity, then stable NPC +ID. The returned opportunity/trigger/helper/action/resource, inventory source +or available finite-source count, and reason are an ephemeral query result. It +does not select an exact gather target; that remains with `ActiveWorldAdapter` +and `ActionTargetResolver`. No event, reservation, RNG draw, task mutation, or +serialized assignment is created. + The food-loop regression verifies this chain: ```text @@ -123,7 +136,9 @@ The village summary also shows one compact active need with its real target progress and interested villager; only that villager's inspector retains its open/closed detail and names the exact supplier for a resolution. The empty-bowl world cue remains specific to the pantry shortage rather than representing a -wood need as hunger. +wood need as hunger. The active summary additionally names the currently +derived helper, supply action, trust fact, and inventory or finite-source route, +or states that no informed trusted villager is currently able to supply it. ## Deliberate limits @@ -136,5 +151,6 @@ hearing, personalized reinforcement/decay, multi-hop rumours, secrecy, false beliefs, and multi-event causal graphs belong in later event/history slices. They should extend this record family without making prose authoritative or recomputing old evidence from current positions. -The current opportunity family is likewise a bounded two-consumer projection, -not a generic quest, reward, acceptance, dialogue, or capable-helper framework. +The current opportunity family is likewise a bounded two-consumer projection +with one read-only helper query, not a generic quest, reward, acceptance, +dialogue, helper assignment, or capable-helper framework. diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 5ee6431..bf10a85 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -775,11 +775,28 @@ The second bounded Milestone 7 consumer is complete: - the two proven consumers now share `VillageOpportunitySystem` and the common lifecycle fields, while their evidence and care rules remain explicit. -Milestone 7 is not complete. The immediate next slice is a bounded -capable-helper query for an open need: derive one viable helper from existing -knowledge, relationship, action, inventory, and resource facts and expose the -reason for that result. Keep it read-only first; only a later proven slice -should let opportunity awareness influence autonomous task selection. +The first bounded capable-helper query is complete: + +- an open need derives at most one living helper who knows its exact trigger, + has an existing familiar directed relationship with at least 0.6 trust toward + the interested villager, and is not that villager; +- the helper must either carry enough matching inventory to reach the target or + have a matching enabled, NPC-usable, sufficiently stocked finite resource + that is not reserved by someone else; +- ready inventory wins first, followed by higher trust, matching gather + profession, familiarity, and lowest stable NPC ID; +- `OpportunityHelperResult` exposes the exact opportunity, trigger, helper, + action, inventory source or finite-source count, resource, relationship + values, and concise reason without mutating RNG, tasks, reservations, + resources, or persisted state; +- food and wood scenarios re-derive the same result after restore, and the + village summary explains the current helper route from real simulation facts. + +Milestone 7 is not complete. The immediate next slice is one bounded autonomous +consumer of this query: let the currently derived helper choose the reported +gather/deposit action through ordinary selection while preserving urgent +self-care and schedule precedence. Re-query rather than persisting assignment, +and do not add quest acceptance or reward state. Recently completed: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 3d10665..8c8d5aa 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -618,8 +618,9 @@ These are expected prototype constraints, not necessarily isolated bugs: - The bounded opportunity family supports one open `restock_empty_pantry` or `supply_missing_wood` need from exact known evidence. Both have exact NPC and player supply resolutions; the wood need also closes deterministically on - interested-party death or one-day staleness. There is still no capable-helper - assignment, acceptance, rewards, free-form dialogue, or quest log. + interested-party death or one-day staleness. One read-only query derives and + explains a capable helper, but there is still no helper assignment, + acceptance, rewards, free-form dialogue, or quest log. - The reason inspector exposes current decisions, utility rejections, one exact relationship cause, and a compact person-history view that distinguishes importance-ranked retained memories from objective personal actions. @@ -902,10 +903,18 @@ v9 and nested opportunity schema v2 preserve open, resolved, and invalidated history. The two consumers now share `VillageOpportunitySystem`, while their evidence and care rules remain explicit. -Milestone 7 remains in progress. The immediate next slice is a read-only, -deterministic capable-helper query for an open need, derived from existing -knowledge, relationship, action, inventory, and resource facts before it is -allowed to influence autonomous task selection. +The first capable-helper query is complete. An open food or wood need now +derives one living informed villager with a familiar trusted directed tie and a +real inventory-deposit or finite-resource gather route. Inventory readiness, +trust, profession fit, familiarity, finite-source availability, and stable IDs +produce one deterministic result. The village summary explains that result; +save/load re-derives it, and querying changes no task, reservation, RNG stream, +resource, event, or checksum. + +Milestone 7 remains in progress. The immediate next slice is one bounded +autonomous consumer: allow the currently derived helper to choose its reported +ordinary supply action after urgent self-care and schedule rules, without +persisted assignment, quest acceptance, or reward state. The remaining simulation-garden target still aims for: diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index e53fe0c..943b0a0 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -196,6 +196,12 @@ lasting during deterministic memory maintenance. Resolution or invalidation releases that fact back to the normal bounded-retention rules; neither deletes objective event or opportunity history. +`OpportunityHelperResult` is deliberately absent from this schema. It is +re-derived from the open opportunity plus current NPC knowledge, directed +relationship, inventory, action-definition, storage-capacity, and finite- +resource records. Save/restore regressions require the same helper/action/route +result and an unchanged checksum before and after querying. + ## Resource authority `SimulationManager` owns `ResourceStateRecord` instances independently of the @@ -212,7 +218,9 @@ defaults. ResourceStateRecord v3 adds `safety_risk`, `comfort_distance`, and `discovery_priority` so unloaded resources keep the same target-selection meaning after save/restore. Nested v1 and v2 resource records are migrated -explicitly. +explicitly. Current-schema parsing also canonicalizes numeric, boolean, and ID +variants so a freshly registered finite source retains a byte-stable checksum +after a JSON round trip. ## Local quicksave boundary diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index fa14ea3..b9b6f10 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -806,23 +806,32 @@ func get_active_opportunity() -> OpportunityStateRecord: return opportunity_system.get_open_opportunity() +func get_active_opportunity_helper() -> OpportunityHelperResult: + var active := get_active_opportunity() + if active == null: + return null + return opportunity_system.find_capable_helper( + active, + economy.get_storage(active.get_target_id()), + npcs, + event_knowledge_system, + relationship_system, + resource_states + ) + + func get_latest_opportunity_for_npc(npc_id: int) -> OpportunityStateRecord: return opportunity_system.get_latest_for_npc(npc_id) func get_opportunity_trigger_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: - if opportunity == null: - return null - return event_log.get_by_id(opportunity.get_trigger_event_id()) + var event_id := opportunity.get_trigger_event_id() if opportunity != null else -1 + return event_log.get_by_id(event_id) func get_opportunity_resolution_event(opportunity: OpportunityStateRecord) -> EconomicEventRecord: - if ( - opportunity == null - or opportunity.get_resolution_event_id() == OpportunityStateRecord.NO_EVENT_ID - ): - return null - return event_log.get_by_id(opportunity.get_resolution_event_id()) + var event_id := opportunity.get_resolution_event_id() if opportunity != null else -1 + return event_log.get_by_id(event_id) func npc_knows_event(npc_id: int, event_id: int) -> bool: @@ -1106,7 +1115,6 @@ func create_state_record() -> SimulationStateRecord: wander_streams.append( {"npc_id": int(npc_id), "seed": str(source.seed), "state": str(source.state)} ) - record.simulation = { "seed": simulation_seed, "tick_interval": tick_interval, @@ -1121,7 +1129,6 @@ func create_state_record() -> SimulationStateRecord: record.village = VillageStateRecord.capture(village) for npc in npcs: record.npcs.append(NPCStateRecord.capture(npc)) - var sorted_resource_ids: Array = resource_states.keys() sorted_resource_ids.sort() for resource_id in sorted_resource_ids: @@ -1158,7 +1165,6 @@ func restore_state_from_json(json_text: String) -> bool: func restore_state(record: SimulationStateRecord) -> bool: if record == null: return false - simulation_seed = int(record.simulation["seed"]) tick_interval = float(record.simulation["tick_interval"]) tick_count = int(record.simulation["tick_count"]) @@ -1171,7 +1177,6 @@ func restore_state(record: SimulationStateRecord) -> bool: economy.restore_storage(record.storages) event_log.restore(record.economic_events, int(record.simulation["next_event_id"])) latest_decisions.clear() - npcs.clear() for npc_record in record.npcs: npcs.append(npc_record.restore(debug_logs)) @@ -1179,7 +1184,6 @@ func restore_state(record: SimulationStateRecord) -> bool: event_knowledge_system.restore(record.event_knowledge) opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"])) _maintain_event_knowledge(tick_count % get_knowledge_review_interval() == 0) - wander_random_sources.clear() var wander_streams: Array = record.simulation["wander_random_streams"] for stream_data in wander_streams: @@ -1187,12 +1191,10 @@ func restore_state(record: SimulationStateRecord) -> bool: source.seed = String(stream_data["seed"]).to_int() source.state = String(stream_data["state"]).to_int() wander_random_sources[int(stream_data["npc_id"])] = source - resource_states.clear() for resource_record in record.resources: resource_states[resource_record.get_node_id()] = resource_record register_loaded_resource_nodes() - village_changed.emit(village) state_restored.emit() return true diff --git a/simulation/opportunities/OpportunityHelperResult.gd b/simulation/opportunities/OpportunityHelperResult.gd new file mode 100644 index 0000000..b4820c2 --- /dev/null +++ b/simulation/opportunities/OpportunityHelperResult.gd @@ -0,0 +1,30 @@ +class_name OpportunityHelperResult +extends RefCounted + +var opportunity_id: int +var helper_npc_id: int +var action_id: StringName +var source_id: StringName +var resource_id: StringName +var trigger_event_id: int +var trust: float +var familiarity: float +var uses_inventory: bool +var available_source_count: int +var profession_match: bool +var reason: String + + +func _init(result: Dictionary) -> void: + opportunity_id = int(result["opportunity_id"]) + helper_npc_id = int(result["helper_npc_id"]) + action_id = StringName(result["action_id"]) + source_id = StringName(result["source_id"]) + resource_id = StringName(result["resource_id"]) + trigger_event_id = int(result["trigger_event_id"]) + trust = float(result["trust"]) + familiarity = float(result["familiarity"]) + uses_inventory = bool(result["uses_inventory"]) + available_source_count = int(result["available_source_count"]) + profession_match = bool(result["profession_match"]) + reason = String(result["reason"]) diff --git a/simulation/opportunities/OpportunityHelperResult.gd.uid b/simulation/opportunities/OpportunityHelperResult.gd.uid new file mode 100644 index 0000000..b4d791e --- /dev/null +++ b/simulation/opportunities/OpportunityHelperResult.gd.uid @@ -0,0 +1 @@ +uid://o8t4og3iq8dv diff --git a/simulation/opportunities/VillageOpportunitySystem.gd b/simulation/opportunities/VillageOpportunitySystem.gd index 480df38..aee2a32 100644 --- a/simulation/opportunities/VillageOpportunitySystem.gd +++ b/simulation/opportunities/VillageOpportunitySystem.gd @@ -112,6 +112,196 @@ func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord: return null +func find_capable_helper( + opportunity: OpportunityStateRecord, + target_storage: StorageStateRecord, + npcs: Array[SimNPC], + knowledge_system: EventKnowledgeSystem, + relationship_system: RefCounted, + resource_states: Dictionary +) -> OpportunityHelperResult: + if ( + opportunity == null + or not opportunity.is_open() + or target_storage == null + or knowledge_system == null + or relationship_system == null + ): + return null + var interested := _find_npc(opportunity.get_interested_npc_id(), npcs) + if interested == null or interested.is_dead: + return null + var remaining := maxf( + opportunity.get_target_amount() - target_storage.get_amount(opportunity.get_resource_id()), + 0.0 + ) + if remaining <= 0.0 or target_storage.get_available_capacity() < remaining: + return null + var action_ids := _get_supply_action_ids(opportunity.get_resource_id()) + if action_ids.is_empty(): + return null + var gather_definition := SimulationDefinitions.get_action(action_ids["gather"]) + var deposit_definition := SimulationDefinitions.get_action(action_ids["deposit"]) + if gather_definition == null or deposit_definition == null: + return null + var candidates: Array[Dictionary] = [] + for npc in npcs: + var candidate := _build_helper_candidate( + npc, + opportunity, + remaining, + gather_definition, + deposit_definition, + interested.npc_name, + knowledge_system, + relationship_system, + resource_states + ) + if not candidate.is_empty(): + candidates.append(candidate) + if candidates.is_empty(): + return null + candidates.sort_custom(_sort_helper_candidates) + var best := candidates[0] + return ( + OpportunityHelperResult + . new( + { + "opportunity_id": opportunity.get_opportunity_id(), + "helper_npc_id": (best["npc"] as SimNPC).id, + "action_id": best["action_id"], + "source_id": best["source_id"], + "resource_id": opportunity.get_resource_id(), + "trigger_event_id": opportunity.get_trigger_event_id(), + "trust": best["trust"], + "familiarity": best["familiarity"], + "uses_inventory": best["uses_inventory"], + "available_source_count": best["available_source_count"], + "profession_match": best["profession_match"], + "reason": best["reason"], + } + ) + ) + + +func _build_helper_candidate( + npc: SimNPC, + opportunity: OpportunityStateRecord, + remaining: float, + gather_definition: ActionDefinition, + deposit_definition: ActionDefinition, + interested_name: String, + knowledge_system: EventKnowledgeSystem, + relationship_system: RefCounted, + resource_states: Dictionary +) -> Dictionary: + if npc.is_dead or npc.id == opportunity.get_interested_npc_id(): + return {} + if not knowledge_system.knows_event(npc.id, opportunity.get_trigger_event_id()): + return {} + var relationship: RelationshipStateRecord = relationship_system.get_relationship( + npc.id, opportunity.get_interested_npc_id() + ) + if ( + relationship == null + or not relationship_system.is_trusted_for_help(npc.id, opportunity.get_interested_npc_id()) + ): + return {} + var carried := npc.get_inventory_amount(opportunity.get_resource_id()) + var uses_inventory := carried >= remaining + var available_source_count := 0 + if not uses_inventory: + available_source_count = _count_available_resource_sources( + npc.id, remaining, opportunity.get_resource_id(), gather_definition, resource_states + ) + if available_source_count == 0: + return {} + var action_definition := deposit_definition if uses_inventory else gather_definition + var source_id := SimulationIds.npc_inventory_id(npc.id) if uses_inventory else &"" + var profession_match := ( + not gather_definition.preferred_profession_id.is_empty() + and npc.profession == gather_definition.preferred_profession_id + ) + var supply_fact := ( + "carries %.1f %s" % [carried, String(opportunity.get_resource_id()).capitalize()] + if uses_inventory + else ( + "has %d available finite %s source%s" + % [ + available_source_count, + String(opportunity.get_resource_id()).capitalize(), + "" if available_source_count == 1 else "s", + ] + ) + ) + return { + "npc": npc, + "action_id": action_definition.action_id, + "source_id": source_id, + "trust": relationship.get_trust(), + "familiarity": relationship.get_familiarity(), + "uses_inventory": uses_inventory, + "available_source_count": available_source_count, + "profession_match": profession_match, + "reason": + ( + "Knows the need; trust %.2f toward %s; %s" + % [relationship.get_trust(), interested_name, supply_fact] + ), + } + + +static func _get_supply_action_ids(resource_id: StringName) -> Dictionary: + match resource_id: + SimulationIds.RESOURCE_FOOD: + return { + "gather": SimulationIds.ACTION_GATHER_FOOD, + "deposit": SimulationIds.ACTION_DEPOSIT_FOOD, + } + SimulationIds.RESOURCE_WOOD: + return { + "gather": SimulationIds.ACTION_GATHER_WOOD, + "deposit": SimulationIds.ACTION_DEPOSIT_WOOD, + } + return {} + + +static func _count_available_resource_sources( + npc_id: int, + remaining: float, + resource_id: StringName, + gather_definition: ActionDefinition, + resource_states: Dictionary +) -> int: + var count := 0 + for value in resource_states.values(): + var state := value as ResourceStateRecord + if ( + state == null + or state.get_action_id() != gather_definition.resource_action_id + or state.get_resource_id() != resource_id + or not state.can_npc_use() + or not state.is_available_for(npc_id) + or state.get_yield_per_action() <= 0.0 + or state.get_amount_remaining() < remaining + ): + continue + count += 1 + return count + + +static func _sort_helper_candidates(first: Dictionary, second: Dictionary) -> bool: + if first["uses_inventory"] != second["uses_inventory"]: + return bool(first["uses_inventory"]) + if first["trust"] != second["trust"]: + return float(first["trust"]) > float(second["trust"]) + if first["profession_match"] != second["profession_match"]: + return bool(first["profession_match"]) + if first["familiarity"] != second["familiarity"]: + return float(first["familiarity"]) > float(second["familiarity"]) + return (first["npc"] as SimNPC).id < (second["npc"] as SimNPC).id + + func _try_open_food( event: EconomicEventRecord, current_tick: int, diff --git a/simulation/relationships/RelationshipSystem.gd b/simulation/relationships/RelationshipSystem.gd index 5e718f9..e4671c6 100644 --- a/simulation/relationships/RelationshipSystem.gd +++ b/simulation/relationships/RelationshipSystem.gd @@ -38,6 +38,15 @@ func get_relationship(observer_id: int, subject_id: int) -> RelationshipStateRec return relationships.get(_key(observer_id, subject_id)) as RelationshipStateRecord +func is_trusted_for_help(observer_id: int, subject_id: int) -> bool: + var relationship := get_relationship(observer_id, subject_id) + return ( + relationship != null + and relationship.get_familiarity() > 0.0 + and relationship.get_trust() >= TRUSTED_HELP_THRESHOLD + ) + + func increase_shared_work_familiarity(first_id: int, second_id: int) -> void: var first_to_second := _get_or_create(first_id, second_id) var second_to_first := _get_or_create(second_id, first_id) diff --git a/simulation/state/ResourceStateRecord.gd b/simulation/state/ResourceStateRecord.gd index 91c2884..e789c7d 100644 --- a/simulation/state/ResourceStateRecord.gd +++ b/simulation/state/ResourceStateRecord.gd @@ -66,7 +66,21 @@ static func from_dictionary(record_data: Dictionary) -> ResourceStateRecord: var action_id := StringName(record_data["action_id"]) if not action_id.is_empty() and SimulationDefinitions.get_action(action_id) == null: return null - return ResourceStateRecord.new(record_data) + var normalized := record_data.duplicate(true) + normalized["schema_version"] = SCHEMA_VERSION + normalized["node_id"] = String(record_data["node_id"]) + normalized["action_id"] = String(record_data["action_id"]) + normalized["resource_id"] = String(record_data["resource_id"]) + normalized["amount_remaining"] = float(record_data["amount_remaining"]) + normalized["yield_per_action"] = float(record_data["yield_per_action"]) + normalized["reserved_by"] = int(record_data["reserved_by"]) + normalized["enabled"] = bool(record_data["enabled"]) + normalized["can_npcs_use"] = bool(record_data["can_npcs_use"]) + normalized["can_player_use"] = bool(record_data["can_player_use"]) + normalized["safety_risk"] = float(record_data["safety_risk"]) + normalized["comfort_distance"] = float(record_data["comfort_distance"]) + normalized["discovery_priority"] = float(record_data["discovery_priority"]) + return ResourceStateRecord.new(normalized) static func _migrate_v1(legacy_data: Dictionary) -> Dictionary: diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index aecff57..a870ca9 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -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 ), diff --git a/tests/pantry_crisis_demo_test.gd b/tests/pantry_crisis_demo_test.gd index 78fd846..a0eb047 100644 --- a/tests/pantry_crisis_demo_test.gd +++ b/tests/pantry_crisis_demo_test.gd @@ -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) diff --git a/tests/unit/test_village_opportunity_system.gd b/tests/unit/test_village_opportunity_system.gd index 6d833fb..d12494f 100644 --- a/tests/unit/test_village_opportunity_system.gd +++ b/tests/unit/test_village_opportunity_system.gd @@ -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: diff --git a/tests/wood_shortage_opportunity_test.gd b/tests/wood_shortage_opportunity_test.gd index e6cb3bd..883fd0b 100644 --- a/tests/wood_shortage_opportunity_test.gd +++ b/tests/wood_shortage_opportunity_test.gd @@ -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 diff --git a/world/ui/ui.gd b/world/ui/ui.gd index c3421c6..def068c 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -264,6 +264,7 @@ func _build_active_opportunity_display() -> String: var interested_name := _get_npc_name(opportunity.get_interested_npc_id()) 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 ( @@ -271,12 +272,26 @@ func _build_active_opportunity_display() -> String: + "◆ 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 ) return ( "\n\nVillage need\n" + "◆ Restock the empty pantry\n" + "%s is worried about the shortage\n" % interested_name + "%s %.0f / %.0f" % [resource_name, current_amount, opportunity.get_target_amount()] + + helper_text + ) + + +func _build_opportunity_helper_display() -> String: + if not simulation_manager.has_method("get_active_opportunity_helper"): + return "" + var helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper() + if helper == null: + return "\nPossible helper: none informed, trusted, and able to supply" + return ( + "\nPossible helper: %s — %s\n%s" + % [_get_npc_name(helper.helper_npc_id), _get_action_name(helper.action_id), helper.reason] )