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. ## ## Pawn is intentionally duck-typed (no class_name reference) to match the ## WorkProvider convention and avoid init-order issues. ## ## See docs/architecture.md "HaulingProvider". 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 # ── regular items ───────────────────────────────────────────────────────── for item in World.items_needing_haul.keys(): # Skip items another pawn is already carrying. if item.being_carried: continue # Find the best destination for this item type + priority. var dest = _find_best_destination_for(item) if dest == null: 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 # ── 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 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() 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