rimlike/scenes/ai/hauling_provider.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

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

272 lines
11 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 = {}
## 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 3 — below chop (5) and mine (4); above rest (1).
# Adjusted once the full 9-category matrix is authored in Phase 17.
priority = 3
# 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)