rimlike/scenes/ai/hauling_provider.gd
megaproxy ab53808a53 bump HaulingProvider priority 3 → 5 — fixes output piling up at workbench
Player report: "where is all the bread and meals going? It's not in
the crate." Cooking was working; output was spawning at the Hearth
tile and never being hauled. Root cause: Hauling priority 3 was below
every gathering/production provider (Plant=5, Chop=5, Cooking=6,
Construction=6, Crafting=4, Mine=4) so the always-busy 3-pawn colony
never reached idle-enough-to-haul. EatProvider (7) also ate food
directly off the workbench tile before any haul could fire.

Bumped to 5 — same tier as Plant and Chop. MCP verified: 1 grain + 1
wood arrived in cabin crate within 3000 ticks at ULTRA, and pawns are
visibly mid-haul ("Haul bread x1 -> (50,23)"). Cooking still fires in
parallel.

Phase 6 placeholder priorities flagged for Phase 20 tuning anyway.
This is an interim bump that keeps the loop visible to players.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:39:19 +01:00

277 lines
11 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name HaulingProvider extends WorkProvider
## WorkProvider for the Hauling work category. Slots into the 5-layer pawn AI
## (Decision → WorkProvider → Job + JobRunner) as layer 2.
##
## Each call to find_best_for(pawn) scans World.items_needing_haul for the
## item closest to `pawn` that has a valid, reachable destination, then builds
## a 4-toil haul job: walk → pickup → walk → deposit.
##
## sweep_for_better_destinations() is a periodic helper (called by World every
## ~100 sim ticks) that marks items in lower-priority destinations dirty when a
## higher-priority destination has space — enabling the "items flow upward"
## priority cascade described in design.md.
##
## When an item needs hauling but no stockpile accepts it, the provider emits
## EventBus.no_stockpile_accepts once per item_type per ALERT_COOLDOWN_TICKS
## ticks (30 in-game seconds at 20 Hz).
##
## Pawn is intentionally duck-typed (no class_name reference) to match the
## WorkProvider convention and avoid init-order issues.
##
## See docs/architecture.md "HaulingProvider".
## Rate-limit for no_stockpile_accepts alerts: one emit per item_type per
## 30 in-game seconds (20 Hz × 30 s = 600 ticks).
const ALERT_COOLDOWN_TICKS: int = 600
## Per-item-type cooldown map: StringName → tick at which the next emit is allowed.
var _alert_cooldown: Dictionary = {}
## Tick stamp: the last sim tick on which we incremented haul_retry_count for
## at least one orphaned item. Guards against double-counting when multiple
## pawns call find_best_for on the same tick (all share this provider instance).
var _last_orphan_tick: int = -1
func _init() -> void:
category = &"haul"
# Priority 5 — same tier as Plant and Chop. At priority 3 (the original
# Phase-6 placeholder) pawns were eclipsed by every gathering provider
# and crafting output piled up at the workbench tile forever, never
# reaching player-painted storage. Player playtest 2026-05-16: "where is
# all the bread going?". Memory.md flags the 9-category matrix as
# Phase-20 tuning territory; this is an interim bump that keeps the loop
# visible.
priority = 5
# Reset haul_rejected items whenever stockpile layout changes so a newly-
# painted stockpile or filter edit unblocks previously-rejected items.
EventBus.stockpile_layout_changed.connect(_on_stockpile_layout_changed)
# ── WorkProvider override ─────────────────────────────────────────────────────
## Returns a haul Job for `pawn`, or null if no valid work exists.
## Picks the item (or corpse) closest to `pawn` (Manhattan distance) that has
## an open slot in the highest-priority destination accepting its type.
## Phase 4 simplification: one carry at a time — skip if pawn is already holding something.
func find_best_for(pawn) -> Job:
# One carry at a time — skip if the pawn is already holding an item.
if pawn.get("carried_item") != null:
return null
var best_item = null
var best_dest = null
var best_drop_cell: Vector2i = Vector2i(-1, -1)
var best_dist: int = 999999
var best_is_corpse: bool = false
# Tracks the first item_type that needed hauling but had no valid destination.
# Used to emit no_stockpile_accepts once per cooldown window.
var first_orphan_type: StringName = &""
var first_orphan_tile: Vector2i = Vector2i(-1, -1)
# ── regular items ─────────────────────────────────────────────────────────
for item in World.items_needing_haul.keys():
# Skip items that already exhausted their retries — they stay out of
# haul consideration until a stockpile layout change resets them.
if item.haul_rejected:
continue
# Skip items another pawn is already carrying.
if item.being_carried:
continue
# 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)
if dest == null:
# Item needs hauling but no stockpile accepts it — record first occurrence.
if first_orphan_type == &"":
first_orphan_type = item.item_type
first_orphan_tile = item.tile
# Increment the per-item retry counter at most once per sim tick,
# guarded by _last_orphan_tick so multiple pawn calls in the same
# tick don't multiply-count the same item.
if _last_orphan_tick < Sim.tick:
_last_orphan_tick = Sim.tick
item.haul_retry_count += 1
if item.haul_retry_count >= Item.MAX_HAUL_RETRIES:
item.haul_rejected = true
World.items_needing_haul.erase(item)
Audit.log("hauling", "item %s at %s rejected after %d retries" % [
item.item_type, item.tile, item.haul_retry_count
])
continue
var drop: Vector2i = dest.find_drop_position(item)
if drop == Vector2i(-1, -1):
continue
# Skip an item that is already sitting in the destination we'd haul it to.
# Avoids pointless re-haul of an item that is exactly where it should be.
# (Phase 16 refines this once the item→destination link is persisted.)
var current_dest = _destination_for_tile(item.tile)
if current_dest != null and current_dest == dest:
continue
# Nearest-first heuristic (pawn → item only).
var d: int = abs(item.tile.x - pawn.tile.x) + abs(item.tile.y - pawn.tile.y)
if d < best_dist:
best_dist = d
best_item = item
best_dest = dest
best_drop_cell = drop
best_is_corpse = false
# Emit alert for the first orphaned item_type, rate-limited per type.
if first_orphan_type != &"":
if _alert_cooldown.get(first_orphan_type, 0) <= Sim.tick:
EventBus.no_stockpile_accepts.emit(first_orphan_type, first_orphan_tile)
_alert_cooldown[first_orphan_type] = Sim.tick + ALERT_COOLDOWN_TICKS
# ── Phase 14: corpses ─────────────────────────────────────────────────────
# Corpses route to GraveSlot StorageDestinations exactly like items, but
# use PICKUP_CORPSE / DEPOSIT_CORPSE toils (since Corpse is not an Item).
for corpse in World.corpses:
# Skip corpses another pawn is already carrying.
if corpse.get_meta("being_carried_corpse", false):
continue
# 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:
continue
var drop: Vector2i = dest.find_drop_position(corpse)
if drop == Vector2i(-1, -1):
continue
var d: int = abs(corpse.tile.x - pawn.tile.x) + abs(corpse.tile.y - pawn.tile.y)
if d < best_dist:
best_dist = d
best_item = corpse
best_dest = dest
best_drop_cell = drop
best_is_corpse = true
if best_item == null:
return null
var j := Job.new()
# target_node is the item/corpse being picked up — the carry-from side.
# Other pawns' HaulingProviders read this to avoid double-claiming the same item.
j.target_node = best_item
if best_is_corpse:
j.label = "Haul corpse '%s' -> (%d,%d)" % [
best_item.deceased_name,
best_drop_cell.x,
best_drop_cell.y,
]
j.toils.append(Toil.walk_to(best_item.tile))
j.toils.append(Toil.pickup_corpse())
j.toils.append(Toil.walk_to(best_drop_cell))
j.toils.append(Toil.deposit_corpse())
else:
j.label = "Haul %s x%d -> (%d,%d)" % [
best_item.item_type,
best_item.stack_size,
best_drop_cell.x,
best_drop_cell.y,
]
j.toils.append(Toil.walk_to(best_item.tile))
j.toils.append(Toil.pickup())
j.toils.append(Toil.walk_to(best_drop_cell))
j.toils.append(Toil.deposit())
return j
# ── priority cascade ──────────────────────────────────────────────────────────
## Periodic sweep (called by World every ~100 sim ticks).
## Walks all items NOT already in the dirty set and marks them dirty when:
## (a) they are loose on the floor with no destination covering their tile, OR
## (b) they are in a stockpile but a higher-priority destination now has room.
## Returns the count of newly marked items (logged when > 0).
## This is the mechanism that makes "items flow up" to Critical stockpiles.
func sweep_for_better_destinations() -> int:
var count: int = 0
for item in World.items:
if item.being_carried:
continue
# Already flagged — HaulingProvider will handle it.
if World.items_needing_haul.has(item):
continue
var current = _destination_for_tile(item.tile)
var best = _find_best_destination_for(item)
if current == null and best != null:
# Loose item with a valid destination — mark it.
World.items_needing_haul[item] = true
count += 1
elif current != null and best != null:
# Item is stored, but a better destination exists.
if int(best.priority) < int(current.priority):
World.items_needing_haul[item] = true
count += 1
if count > 0:
Audit.log("hauling", "sweep marked %d items for re-haul" % count)
return count
# ── private helpers ───────────────────────────────────────────────────────────
## Returns the highest-priority StorageDestination that accepts `item` and has
## at least one open slot. Among equal-priority destinations, first found wins.
## Returns null when no destination qualifies.
func _find_best_destination_for(item):
var best = null
for dest in World.stockpiles:
if not dest.accepts(item):
continue
if dest.find_drop_position(item) == Vector2i(-1, -1):
continue
# Lower enum int = higher priority (CRITICAL=0 beats HIGH=1).
if best == null or int(dest.priority) < int(best.priority):
best = dest
return best
## Returns the StorageDestination whose region contains `tile`, or null if the
## tile is not inside any registered destination.
func _destination_for_tile(tile: Vector2i):
for dest in World.stockpiles:
if dest.covers_tile(tile):
return dest
return null
## Resets haul_rejected / haul_retry_count on every item so a stockpile layout
## change (new zone, filter edit, priority change) gives them a fresh chance.
## Rejected items are re-entered into items_needing_haul so sweep_for_better_
## destinations() can re-evaluate them on the next periodic pass.
func _on_stockpile_layout_changed() -> void:
var reset_count: int = 0
for item in World.items:
if item.haul_rejected or item.haul_retry_count > 0:
item.haul_rejected = false
item.haul_retry_count = 0
# Re-enqueue only if not already in the haul set and not carried.
if not item.being_carried and not World.items_needing_haul.has(item):
World.items_needing_haul[item] = true
reset_count += 1
if reset_count > 0:
Audit.log("hauling", "stockpile layout changed — reset %d rejected/retried items" % reset_count)