From f435c3c46738ba94a2fcc439e4e146de6ab7d1a8 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 12 May 2026 13:58:47 +0100 Subject: [PATCH] Hauling/JobRunner: fail unreachable walks; pre-check haul reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding bugs made hauling appear broken when targets were behind walls. User report: 'i set a stockpile and there is stuff to move' — items sat indefinitely. JobRunner._tick_walk treated 'path is empty' (unreachable) by marking the walk toil done and silently advancing to the next toil. Pickup/deposit then ran at the pawn's CURRENT tile instead of the intended target — 'Bram pickup: no item at (44, 25)' for an item that lived at (45, 21). The job 'completed' wrongly. Now an unreachable walk cancel_job()'s, letting Decision pick something else next tick. HaulingProvider didn't pre-check reachability before handing out a job. With the JobRunner fix alone, Decision would have re-picked the same unreachable haul every tick (busy-spin at 20 Hz). Now the item loop and corpse loop both skip targets where find_path is empty from pawn.tile. Cost: ~10 us pathfind per candidate; trivial at MVP scale. Verified MCP runtime: bread at (45, 21) (reachable) hauled end-to-end to the stockpile at (15, 62). Bread at (50, 21) (unreachable behind the cabin wall arrangement) correctly skipped — no job assigned, no busy spin in the log. Bram completed the haul and picked up his next job (Harvest wheat) naturally. Note: the JobRunner unreachable-cancel fix also helps any other provider whose walk_to leg fails — chop/mine/construction were silently 'finishing' the same way when targets walled off. They now cancel cleanly too. Their providers don't yet pre-check reachability, so they could cancel-loop on unreachable targets if nothing else is queued — left for a followup once a real case surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- scenes/ai/hauling_provider.gd | 9 +++++++++ scenes/ai/job_runner.gd | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scenes/ai/hauling_provider.gd b/scenes/ai/hauling_provider.gd index 94d669b..6eab1c5 100644 --- a/scenes/ai/hauling_provider.gd +++ b/scenes/ai/hauling_provider.gd @@ -65,6 +65,12 @@ func find_best_for(pawn) -> Job: # Skip items another pawn has already claimed (target_node = item node). if Job.is_target_taken_by_other(item, pawn): continue + # Reachability gate — don't hand out a haul this pawn can't path to. + # Without this, JobRunner cancels the job on the walk toil and Decision + # reassigns it next tick, busy-spinning at 20 Hz with no progress. + # Pre-check is O(pathfinder.find_path) ~10 μs; cheap at MVP scale. + if pawn.tile != item.tile and World.pathfinder.find_path(pawn.tile, item.tile).is_empty(): + continue # Find the best destination for this item type + priority. var dest = _find_best_destination_for(item) @@ -111,6 +117,9 @@ func find_best_for(pawn) -> Job: # Skip corpses another pawn has already claimed. if Job.is_target_taken_by_other(corpse, pawn): continue + # Reachability gate — same rationale as the item loop above. + if pawn.tile != corpse.tile and World.pathfinder.find_path(pawn.tile, corpse.tile).is_empty(): + continue var dest = _find_best_destination_for(corpse) if dest == null: diff --git a/scenes/ai/job_runner.gd b/scenes/ai/job_runner.gd index de80ce3..6bae384 100644 --- a/scenes/ai/job_runner.gd +++ b/scenes/ai/job_runner.gd @@ -179,11 +179,16 @@ func _tick_walk(t) -> void: return var path: Array[Vector2i] = pathfinder.find_path(pawn.tile, dest) if path.is_empty(): + # Unreachable — abort the entire job so Decision can pick something + # else next tick. Previously this marked the walk toil done and the + # subsequent toils (pickup, deposit, etc.) ran at the wrong tile, + # making haul/pickup silently fail with "no item at ". Audit.log( "job_runner", - "%s unreachable: %s → %s" % [pawn.pawn_name, pawn.tile, dest] + "%s unreachable: %s → %s (canceling job '%s')" % + [pawn.pawn_name, pawn.tile, dest, job.label] ) - t.done = true + cancel_job() return pawn.walk_along_path(path) t.data["started"] = true