diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index 6b9d5dc..f4685e8 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -5,6 +5,7 @@ ```text SimulationManager tick -> ActionExecutionSystem advances needs and working progress + -> VillageOpportunitySystem re-derives the current capable helper -> ActionSelectionSystem chooses an action when the NPC is idle -> SimulationManager stores the selected action -> npc_target_requested @@ -24,6 +25,9 @@ SimulationManager tick - queries `RelationshipSystem` after personal survival/schedule overrides, so a trusted starving acquaintance can redirect ordinary work toward food gathering while the pantry is low; +- accepts the current ephemeral `OpportunityHelperResult` and, only for its + matching NPC, selects the reported ordinary gather/deposit action after + urgent self-care and sleep/meal schedule branches; - evaluates current utility scores; - consumes the NPC's deterministic decision RNG; - returns an action ID, optional urgent-duration override, branch reason, @@ -68,9 +72,10 @@ SimulationManager remains the orchestrator and event boundary. It owns simulation records, invokes the focused systems, stores selected actions and targets, and translates presentation callbacks into simulation transitions. It also publishes the latest `ActionSelectionResult` for presentation; the UI -does not recompute decisions. At completion it atomically pays any -definition-backed stored-resource cost before applying the action effect. A -late shortfall suppresses the effect and records a `task_blocked` fact. The +does not recompute decisions. Each idle selection re-derives the capable helper +instead of consulting persisted assignment state. At completion it atomically +pays any definition-backed stored-resource cost before applying the action +effect. A late shortfall suppresses the effect and records a `task_blocked` fact. The manager also captures actor/nearby knowledge before forwarding newly recorded events into `RelationshipSystem`. When an NPC arrives beside a worker at the same non-storage activity site, the manager may transfer one direct known fact diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index b1c4aed..76cb90e 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -11,6 +11,7 @@ results. SimulationClock -> SimulationManager orchestrates one deterministic tick -> ActionExecutionSystem advances needs and work + -> VillageOpportunitySystem re-derives the current capable helper -> ActionSelectionSystem chooses an action -> ActionTargetResolver resolves a stable target ID -> VillageEconomy performs inventory/storage transactions @@ -45,7 +46,8 @@ would otherwise obscure that lifecycle: open/resolved/invalidated lifecycle for the proven pantry-food and blocked- 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; + finite-resource state without changing resources or assigning tasks. The + ordinary action selector consumes that result only for the matching idle NPC; - `simulation/persistence/` owns save-slot file safety; - `simulation/state/` owns versioned serialized record contracts; - `simulation/definitions/` owns stable IDs and immutable action/profession @@ -103,7 +105,9 @@ improving ownership. event IDs rather than object references or prose. - Opportunity records reference stable NPC, storage, resource, trigger-event, and resolution-event IDs. Their generator may observe authoritative state - and history, but it does not mutate the economy or command NPC behavior. + and history, but it does not mutate the economy or command NPC behavior. The + manager re-queries capable helpers at idle selection boundaries; any selected + supply task then follows the ordinary persisted NPC-task and target contracts. - Prefer one tested vertical behavior over a generic framework with no proven consumers. diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 6ed2c3f..278f518 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -736,13 +736,19 @@ Completed: 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. +29. Autonomous capable helper: the matching idle villager now consumes that + ephemeral result through ordinary action selection after urgent self-care + and sleep/meal precedence. Existing target resolution chooses and reserves + the finite source, while no quest acceptance, reward, or helper assignment + is introduced. Next: -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`. +1. Prove one direct information-to-help chain: transfer the exact open-need + trigger at an existing shared activity, then let the newly informed trusted + villager be re-derived and respond through the same autonomous selector. + Keep generic dialogue and multi-hop rumours 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 6a24363..9d7815a 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -112,6 +112,15 @@ 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. +At an idle NPC decision boundary, `SimulationManager` performs that query again +and gives the ephemeral result to `ActionSelectionSystem`. Personal survival, +low-energy rest, mourning, and sleep/meal schedule branches retain precedence; +otherwise only the named helper selects the reported ordinary gather/deposit +action. From there, existing target resolution chooses and reserves an exact +finite resource or storage target. The current NPC task may serialize normally +while in progress, but no opportunity-to-helper assignment or acceptance state +exists in the save. + The food-loop regression verifies this chain: ```text @@ -151,6 +160,7 @@ 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 -with one read-only helper query, not a generic quest, reward, acceptance, -dialogue, helper assignment, or capable-helper framework. +The current opportunity family is likewise a bounded two-type projection with +one read-only helper query and one ordinary action-selection consumer, 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 bf10a85..65711f3 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -792,11 +792,28 @@ The first bounded capable-helper query is complete: - 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. +The first bounded autonomous helper consumer is complete: + +- when an NPC becomes idle, `SimulationManager` re-derives the current helper + and passes that ephemeral result into ordinary action selection; +- only the matching helper can select the reported gather/deposit action, and + starvation, critical hunger, mourning, low energy, sleep, and meal behavior + keep precedence; +- the resulting task uses the existing definition, target resolver, finite-node + reservation, travel, extraction, inventory, and storage transaction paths; +- no helper assignment, acceptance, reward, opportunity-specific event, or RNG + draw is added, while an in-progress ordinary task continues through the + existing NPC save fields and task-start history; +- focused headless and Jajce runtime regressions prove autonomous selection, + precedence, finite-source targeting, reservation, and restore-time re-query. + +Milestone 7 is not complete. The immediate next slice should connect the +existing one-hop fact transfer to this path: prove that a direct witness or +performer can communicate an open need's exact trigger at a real shared +activity, after which the newly informed trusted villager is re-derived as the +helper and responds through the same autonomous selection contract. Keep this +as a truthful event-reference transfer, not generic dialogue or multi-hop +rumour infrastructure. Recently completed: diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 8c8d5aa..216db39 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -619,8 +619,10 @@ These are expected prototype constraints, not necessarily isolated bugs: `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. 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. + explains a capable helper, and the matching idle NPC can consume it through + ordinary action selection after urgent self-care and schedule precedence. + 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. @@ -911,10 +913,20 @@ 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 first autonomous helper consumer is complete. At each idle decision +boundary the manager re-derives capability, and only the matching helper may +select the reported ordinary gather/deposit action. Urgent self-care and +sleep/meal behavior still win; accepted supply work uses the existing target +resolver, finite-node reservation, travel, inventory, and transaction paths. +The ordinary in-progress NPC task can save normally, but no helper assignment, +quest acceptance, reward, or opportunity-specific event is persisted; ordinary +task-start history remains unchanged. + +Milestone 7 remains in progress. The immediate next slice should prove the +combined information-to-help path: communicate an open need's exact trigger +once at a real shared activity, then re-derive the newly informed trusted NPC as +the autonomous helper. Keep the transfer direct and source-backed rather than +introducing generic dialogue or multi-hop rumours. The remaining simulation-garden target still aims for: diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 943b0a0..abf484e 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -202,6 +202,11 @@ 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. +When an idle derived helper chooses the reported supply action, only the +existing NPC task, task-state, target, travel, and reservation fields serialize. +The helper result remains absent: restore retains any in-progress ordinary task +and independently re-derives current capability from restored facts. + ## Resource authority `SimulationManager` owns `ResourceStateRecord` instances independently of the diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index b9b6f10..3b1b843 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -156,7 +156,6 @@ func simulate_tick() -> void: tick_count += 1 if debug_logs: print("--- Tick ", tick_count, " ---") - var village_was_changed := false for npc in npcs: village_was_changed = _simulate_npc_tick(npc) or village_was_changed @@ -207,7 +206,8 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void: return if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]: return - var selection := action_selector.select_action(npc, village, clock.time_of_day(), npcs) + var helper := get_active_opportunity_helper() + var selection := action_selector.select_action(npc, village, clock.time_of_day(), npcs, helper) if selection == null: return latest_decisions[npc.id] = selection diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 2f83e8b..bb4470d 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -18,7 +18,11 @@ var relationship_system: RefCounted func select_action( - npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = [] + npc: SimNPC, + village: SimVillage, + time_of_day: float = 0.5, + all_npcs: Array = [], + opportunity_helper: OpportunityHelperResult = null ) -> ActionSelectionResult: if npc.is_dead: return null @@ -98,6 +102,17 @@ func select_action( return ActionSelectionResult.new( SimulationIds.ACTION_GATHER_FOOD, -1.0, "Hungry; pantry is empty" ) + var is_opportunity_helper := _is_current_opportunity_helper(npc, opportunity_helper) + if is_opportunity_helper and npc.energy < 25.0: + return ActionSelectionResult.new( + SimulationIds.ACTION_REST, -1.0, "Energy is low; resting before helping" + ) + if is_opportunity_helper: + return ActionSelectionResult.new( + opportunity_helper.action_id, + -1.0, + "Responding to village need: %s" % opportunity_helper.reason + ) if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0: return ActionSelectionResult.new( SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage" @@ -131,6 +146,24 @@ func select_action( return _choose_best_work_action(npc, village) +func _is_current_opportunity_helper( + npc: SimNPC, opportunity_helper: OpportunityHelperResult +) -> bool: + return ( + opportunity_helper != null + and opportunity_helper.helper_npc_id == npc.id + and ( + opportunity_helper.action_id + in [ + SimulationIds.ACTION_GATHER_FOOD, + SimulationIds.ACTION_GATHER_WOOD, + SimulationIds.ACTION_DEPOSIT_FOOD, + SimulationIds.ACTION_DEPOSIT_WOOD, + ] + ) + ) + + func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult: var scores := { SimulationIds.ACTION_GATHER_FOOD: diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index df67811..9c6cdb8 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -28,6 +28,7 @@ func _initialize() -> void: func _run() -> void: _test_selection_and_execution_are_separate() + _test_opportunity_helper_selection_preserves_precedence() _test_target_resolution_and_travel_are_separate() if failures.is_empty(): @@ -91,6 +92,57 @@ func _test_selection_and_execution_are_separate() -> void: ) +func _test_opportunity_helper_selection_preserves_precedence() -> void: + var village := SimVillage.new() + village.food = 100.0 + village.wood = 100.0 + village.safety = 100.0 + village.knowledge = 100.0 + village.update_modifiers() + village.update_priorities() + var npc := SimNPC.new(702, "OpportunityHelper", SimulationIds.PROFESSION_WANDERER, 5.0, 5.0) + npc.hunger = 20.0 + npc.energy = 80.0 + var helper := _helper_result(npc.id, SimulationIds.ACTION_GATHER_WOOD) + var selector := ActionSelectionSystem.new() + var selection := selector.select_action(npc, village, 0.5, [npc], helper) + _check( + ( + selection.action_id == SimulationIds.ACTION_GATHER_WOOD + and "Responding to village need" in selection.reason + and "Knows the need" in selection.reason + ), + "The matching derived helper should choose its reported ordinary supply action" + ) + + npc.energy = 20.0 + selection = selector.select_action(npc, village, 0.5, [npc], helper) + _check( + selection.action_id == SimulationIds.ACTION_REST, + "Urgent low energy should take precedence over an opportunity response" + ) + npc.energy = 50.0 + selection = selector.select_action(npc, village, 0.0, [npc], helper) + _check( + selection.action_id == SimulationIds.ACTION_SLEEP, + "The sleep schedule should take precedence over an opportunity response" + ) + npc.energy = 80.0 + npc.hunger = 90.0 + selection = selector.select_action(npc, village, 0.5, [npc], helper) + _check( + selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD, + "Critical hunger should take precedence over an opportunity response" + ) + npc.hunger = 20.0 + helper = _helper_result(npc.id + 1, SimulationIds.ACTION_GATHER_WOOD) + selection = selector.select_action(npc, village, 0.5, [npc], helper) + _check( + "Responding to village need" not in selection.reason, + "A helper result must not redirect a different NPC" + ) + + func _test_target_resolution_and_travel_are_separate() -> void: var adapter := FakeActiveWorld.new() adapter.resource_candidates = [ @@ -185,6 +237,28 @@ func _on_travel_requested(_npc: SimNPC, target_position: Vector3) -> void: travel_requests.append(target_position) +func _helper_result(helper_id: int, action_id: StringName) -> OpportunityHelperResult: + return ( + OpportunityHelperResult + . new( + { + "opportunity_id": 9, + "helper_npc_id": helper_id, + "action_id": action_id, + "source_id": "", + "resource_id": SimulationIds.RESOURCE_WOOD, + "trigger_event_id": 14, + "trust": 0.7, + "familiarity": 0.8, + "uses_inventory": false, + "available_source_count": 1, + "profession_match": false, + "reason": "Knows the need; trust 0.70 toward Neighbour; has 1 finite source", + } + ) + ) + + func _check(condition: bool, message: String) -> void: if not condition: failures.append(message) diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index a870ca9..fd62521 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -317,6 +317,30 @@ func _run() -> void: ), "A real missing-wood fact should surface through wood-specific UI without a food cue" ) + witness.current_task = SimulationIds.ACTION_IDLE + witness.task_state = SimNPC.TASK_STATE_IDLE + witness.task_complete = true + witness.target_id = &"" + witness.has_travel_target = false + witness.hunger = 20.0 + witness.energy = 80.0 + witness.mourning_ticks = 0 + simulation_manager.simulate_tick() + var runtime_decision: ActionSelectionResult = simulation_manager.get_latest_decision(witness.id) + var helper_resource: ResourceStateRecord = simulation_manager.get_resource_state( + witness.target_id + ) + _check( + ( + runtime_decision != null + and runtime_decision.action_id == SimulationIds.ACTION_GATHER_WOOD + and "Responding to village need" in runtime_decision.reason + and witness.task_state == SimNPC.TASK_STATE_TRAVELING + and helper_resource != null + and helper_resource.get_reserved_by() == witness.id + ), + "The runtime helper should autonomously enter normal target resolution and reservation" + ) contributor.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0) _check( is_equal_approx( diff --git a/tests/pantry_crisis_demo_test.gd b/tests/pantry_crisis_demo_test.gd index a0eb047..d3f97c1 100644 --- a/tests/pantry_crisis_demo_test.gd +++ b/tests/pantry_crisis_demo_test.gd @@ -109,13 +109,16 @@ func _run() -> void: ) manager.simulate_tick() + var deposit_decision: ActionSelectionResult = manager.get_latest_decision(helper.id) _check( ( helper.current_task == SimulationIds.ACTION_DEPOSIT_FOOD and helper.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY and helper.has_travel_target + and deposit_decision != null + and "Responding to village need" in deposit_decision.reason ), - "The helper should autonomously choose the real pantry deposit after gathering" + "The re-derived helper should autonomously choose the real pantry deposit" ) helper_visual.global_position = helper.travel_target_position manager.synchronize_npc_position(helper.id, helper_visual.global_position) diff --git a/tests/wood_shortage_opportunity_test.gd b/tests/wood_shortage_opportunity_test.gd index 883fd0b..45f4e3e 100644 --- a/tests/wood_shortage_opportunity_test.gd +++ b/tests/wood_shortage_opportunity_test.gd @@ -107,6 +107,26 @@ func _run() -> void: manager.get_state_checksum() == checksum_before_helper_query, "Capable-helper discovery should not mutate authoritative simulation state" ) + witness.current_task = SimulationIds.ACTION_IDLE + witness.task_state = SimNPC.TASK_STATE_IDLE + witness.task_complete = true + witness.target_id = &"" + witness.has_travel_target = false + witness.hunger = 20.0 + witness.energy = 80.0 + witness.mourning_ticks = 0 + manager.simulate_tick() + var helper_decision: ActionSelectionResult = manager.get_latest_decision(witness.id) + _check( + ( + helper_decision != null + and helper_decision.action_id == helper_result.action_id + and "Responding to village need" in helper_decision.reason + and witness.current_task == SimulationIds.ACTION_GATHER_WOOD + and witness.task_state == SimNPC.TASK_STATE_TRAVELING + ), + "An idle derived helper should autonomously select the ordinary wood supply action" + ) var active_json: String = manager.serialize_state() var active_restored := _create_manager(1202) @@ -127,8 +147,12 @@ func _run() -> void: "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" + ( + restored_helper != null + and restored_helper.helper_npc_id == witness.id + and active_restored.npcs[witness.id].current_task == SimulationIds.ACTION_GATHER_WOOD + ), + "Active restore should keep the ordinary task and re-derive the helper without assignment state" ) active_restored.free() diff --git a/world/demo/DemoController.gd b/world/demo/DemoController.gd index 5dc7865..771ec9b 100644 --- a/world/demo/DemoController.gd +++ b/world/demo/DemoController.gd @@ -222,11 +222,17 @@ func begin_pantry_restock() -> bool: var interested := _get_npc(_interested_villager_id) if helper == null or interested == null or simulation_manager.get_active_opportunity() == null: return false + var opportunity_helper: OpportunityHelperResult = ( + simulation_manager.get_active_opportunity_helper() + ) + if opportunity_helper == null or opportunity_helper.helper_npc_id != helper.id: + return false var selection: ActionSelectionResult = simulation_manager.action_selector.select_action( helper, simulation_manager.village, simulation_manager.clock.time_of_day(), - simulation_manager.npcs + simulation_manager.npcs, + opportunity_helper ) if ( selection == null