rimlike/scenes/ai/hauling_provider.gd
megaproxy e7a2407af2 Job: target_node claim so pawns don't cluster on the same target
Visible bug: with 1 wall ghost queued, all 3 pawns picked the same site;
2 stood idle while 1 built. Same shape would affect chop/mine/haul/etc.

Design: Job carries an untyped target_node ref (the tree/rock/build-site/
crop/item/patient/workbench/etc the job is acting on). Job.is_target_taken_
by_other(target, excluding_pawn) does an O(pawns) scan of live job state to
ask 'is anyone else already working this?'. Each WorkProvider's find_best_
for() now skips claimed targets in its scan and sets j.target_node before
returning. No per-entity claim state, no .claim()/.release() bookkeeping,
no save-format change — target_node is intentionally not serialized
because pawns re-decide and re-bind naturally after load.

Providers updated: construction / chop / mine / plant (harvest path) /
hauling (item AND corpse) / cleaning (target is Vector2i tile not Node;
field is untyped, doc'd) / doctor / crafting (workbench).

Not touched: rest (everyone shares the rest tile, that's fine), eat /
sleep (food and beds have their own availability gates; flagged as a
followup if multi-pawn food contention surfaces).

Verified MCP runtime: fresh boot, 3 pawns picked 3 distinct wall sites
(44,28)/(45,28)/(44,27) with distinct target_node refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:45:44 +01:00

221 lines
8.5 KiB
GDScript
Raw 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 = {}
func _init() -> void:
category = &"haul"
# Priority 3 — below chop (5) and mine (4); above rest (1).
# Adjusted once the full 9-category matrix is authored in Phase 17.
priority = 3
# ── 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 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
# 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
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
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