class_name ConstructionProvider extends WorkProvider ## WorkProvider for the "construction" work category. ## ## Scans World.build_queue for the nearest buildable site (Wall, Floor, Door, ## Crate, Workbench — anything exposing is_buildable() / on_build_tick() / ## tile / label()) and returns a two-toil Job: ## walk_to() → build_at(site.get_path()) ## ## The BUILD toil calls on_build_tick() once per sim tick; the entity internally ## tracks build_progress and calls _complete() when BUILD_TICKS is reached. The ## toil finishes automatically when is_buildable() returns false. ## ## Phase 5 simplification: materials are infinite — no haul-materials step. ## Phase 6+ will prepend walk_to(material_pile) + pickup() toils before walk_to(site). ## ## Phase 6 fix — wall-trap bug: walls call set_cell_walkable(false) on completion. ## If the pawn is STANDING ON the wall tile when that fires, the pawn is now ## on a solid cell and AStarGrid2D refuses to plan any subsequent path. Fix: ## walls (and any other site that returns true from blocks_pathing_when_complete) ## are built from an adjacent walkable tile. Floors / Doors / Crates / Workbenches ## stay walkable after completion, so on-tile building is fine for them. ## ## Duck-typing note: build-site entities are referenced without class_name to ## avoid registration-order issues. We rely only on: ## site.tile: Vector2i ## site.is_buildable() -> bool ## site.label() -> String ## site.get_path() -> NodePath ## Optionally: ## site.blocks_pathing_when_complete() -> bool # if absent, assumed false func _init() -> void: category = &"construction" # Higher than chop (5), mine (4), haul (3), rest (0). Players expect their # build orders to be serviced before the pawn goes off to chop trees. priority = 6 ## Returns a Job targeting the nearest buildable site, or null if none exists. ## `pawn` is duck-typed: must expose .tile (Vector2i). func find_best_for(pawn) -> Job: var best = null var best_dist: int = 999999 for site in World.build_queue: if not site.is_buildable(): continue if Job.is_target_taken_by_other(site, pawn): continue # Reachability — same pattern as HaulingProvider. Without this gate, an # unreachable build site (e.g. a door painted on a tile that already has # a pre-built wall) is offered every tick, Decision picks it over lower- # priority work like chop, and JobRunner cancels the doomed walk each # tick — a busy-spin that starves all elective work. For pathing-blocking # sites (walls) we check from an adjacent tile; for others, from the tile. var probe_tile: Vector2i = site.tile if site.has_method("blocks_pathing_when_complete") and site.blocks_pathing_when_complete(): probe_tile = _find_adjacent_walkable(site.tile, pawn.tile) if probe_tile == site.tile: continue # no walkable neighbour at all → boxed-in site, skip silently if pawn.tile != probe_tile and World.pathfinder.find_path(pawn.tile, probe_tile).is_empty(): continue var d: int = abs(site.tile.x - pawn.tile.x) + abs(site.tile.y - pawn.tile.y) if d < best_dist: best_dist = d best = site if best == null: return null # Pick where the pawn should stand. For sites that block pathing once built # (walls) we route to an adjacent walkable cell; otherwise on-tile is fine. var stand_tile: Vector2i = best.tile if best.has_method("blocks_pathing_when_complete") and best.blocks_pathing_when_complete(): stand_tile = _find_adjacent_walkable(best.tile, pawn.tile) if stand_tile == best.tile: # No walkable neighbour — the site is fully boxed in. Skip and try later. Audit.log("construction", "%s build %s at %s has no adjacent stand tile" % [pawn.pawn_name, best.label(), best.tile]) return null var j := Job.new() j.label = "Build %s at %s" % [best.label(), best.tile] j.target_node = best j.toils.append(Toil.walk_to(stand_tile)) j.toils.append(Toil.build_at(best.get_path())) return j # ── helpers ───────────────────────────────────────────────────────────────── ## Finds the 4-neighbour of `target` nearest to `prefer_near` that the pathfinder ## currently treats as walkable. Returns `target` itself if no walkable neighbour ## exists (caller treats that as "skip this site"). func _find_adjacent_walkable(target: Vector2i, prefer_near: Vector2i) -> Vector2i: var offsets: Array[Vector2i] = [ Vector2i(0, -1), Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0), ] var best: Vector2i = target var best_dist: int = 999999 for off in offsets: var t: Vector2i = target + off if World.pathfinder == null: continue if not World.pathfinder.is_walkable(t): continue var d: int = abs(t.x - prefer_near.x) + abs(t.y - prefer_near.y) if d < best_dist: best_dist = d best = t return best