class_name CraftingProvider extends WorkProvider ## WorkProvider for the Crafting 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.workbenches for the best ## (Workbench, Bill) pair the pawn qualifies for, then builds a 4-toil job: ## walk_to(ingredient.tile) → pickup → walk_to(wb.tile) → craft_at(wb, bill_index) ## ## Scoring: Manhattan distance pawn→ingredient + ingredient→workbench (lower wins). ## ## Phase 6 simplification: pawn must be empty-handed (one task at a time); ingredient ## search is global (no per-bench radius restriction — Phase 17 polish item per ## docs/architecture.md "Ingredient acquisition radius"). ## ## When a bill cannot proceed it emits EventBus.bill_blocked once per ## (workbench × reason) per BILL_BLOCKED_COOLDOWN_TICKS (60 in-game seconds). ## ## Workbench and Pawn are intentionally duck-typed (no class_name reference) to match ## WorkProvider convention and avoid init-order issues. Only Item.Quality and ## QualityCalc are referenced by class_name (in job_runner.gd, not here). ## ## See docs/architecture.md "CraftingProvider" and docs/design.md "Bills". ## Rate-limit for bill_blocked alerts: one emit per (workbench × reason) per ## 60 in-game seconds (20 Hz × 60 s = 1200 ticks). const BILL_BLOCKED_COOLDOWN_TICKS: int = 1200 ## Per-(workbench_id|reason) cooldown map: String → tick at which next emit is allowed. var _bill_blocked_cooldown: Dictionary = {} func _init() -> void: category = &"crafting" # Priority 4 — above haul (3), below chop (5). # Phase 6 demo ordering; final 9-category matrix is authored in Phase 17. priority = 4 # ── WorkProvider override ───────────────────────────────────────────────────── ## Returns a craft Job for `pawn`, or null if no valid work exists. ## Pawn must expose: .carried_item, .tile (Vector2i), .get_skill(StringName) -> int. ## ## Single-ingredient recipes produce a 4-toil job: ## walk_to(ing1) → pickup → walk_to(wb) → craft_at(wb, bill_index) ## ## Two-ingredient recipes (ingredient2_type != &"") build the toil sequence: ## For each item needed to satisfy ingredient2_count (one trip per item): ## walk_to(ing2_item) → pickup → walk_to(wb) → deposit_at_wb(wb) ## Then: walk_to(ing1) → pickup → craft_at(wb, bill_index) ## Stack-aware: if a single wood item has stack_size >= ingredient2_count, only ## one deposit trip is needed. Multiple smaller stacks each get their own trip. ## The primary ingredient (ingredient_type / corpse) is always the last pickup ## before crafting; ingredient2 is deposited in the buffer beforehand. ## ## No-ingredient recipes (ingredient_type == &"") produce a 2-toil job: ## walk_to(wb) → craft_at(wb, bill_index) func find_best_for(pawn) -> Job: # Skip if pawn is already carrying something — deposit first. if pawn.get("carried_item") != null: return null var best_wb = null var best_bill = null var best_bill_index: int = -1 var best_src1 = null var best_src2 = null var best_dist: int = 999999 for wb in World.workbenches: # Duck-type guard: skip workbenches that aren't fully set up yet. if not wb.get("_completed"): continue # Skip workbenches another pawn has already claimed for crafting. if Job.is_target_taken_by_other(wb, pawn): continue for i in wb.bills.size(): var b = wb.bills[i] if not b.is_active(): continue # Skill threshold check — pawn must meet the bill's minimum. if pawn.get_skill(b.recipe.required_skill) < b.recipe.skill_threshold: _emit_bill_blocked(b.recipe.label, &"skill_too_low", wb) continue # Ingredient availability check. # Gate on ingredient_type being non-empty (ingredient_count is informational; # the canonical "no ingredient" signal is ingredient_type == &""). var src1 = null var src2 = null if b.recipe.ingredient_type != &"": src1 = _find_ingredient_item(b.recipe.ingredient_type) if src1 == null: _emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb) continue # Two-ingredient check: enough secondary ingredient must be on the floor. # ingredient2_count items (or fewer stacks totalling that count) required. if b.recipe.ingredient2_type != &"": var ing2_items := _find_ingredient_items_for_count(b.recipe.ingredient2_type, b.recipe.ingredient2_count) if ing2_items.is_empty(): _emit_bill_blocked(b.recipe.label, &"missing_ingredient2", wb) continue # Use the first item as src2 for the scoring distance heuristic. src2 = ing2_items[0] # Score: total Manhattan travel distance including both ingredient trips. # No-ingredient: pawn → wb. # One ingredient: pawn → ing1 → wb. # Two ingredients: pawn → ing1 → wb → ing2 → wb. var d: int = _manhattan(pawn.tile, wb.tile) if src1 != null: d = _manhattan(pawn.tile, src1.tile) + _manhattan(src1.tile, wb.tile) if src2 != null: d += _manhattan(wb.tile, src2.tile) + _manhattan(src2.tile, wb.tile) if d < best_dist: best_dist = d best_wb = wb best_bill = b best_bill_index = i best_src1 = src1 best_src2 = src2 if best_wb == null: return null # Re-resolve ingredient items to guard against concurrent assignment races. if best_bill.recipe.ingredient_type != &"": best_src1 = _find_ingredient_item(best_bill.recipe.ingredient_type) if best_src1 == null: return null var ing2_items: Array = [] if best_bill.recipe.ingredient2_type != &"": ing2_items = _find_ingredient_items_for_count(best_bill.recipe.ingredient2_type, best_bill.recipe.ingredient2_count) if ing2_items.is_empty(): return null var j := Job.new() j.label = "Craft %s at %s" % [best_bill.recipe.label, best_wb.get("label_text") if best_wb.get("label_text") != null else "workbench"] j.target_node = best_wb if not ing2_items.is_empty(): # Two-ingredient path: deposit ingredient2 item(s) at workbench buffer, # then fetch the primary ingredient and craft. # One deposit trip per item in ing2_items (stack-aware: fewer trips when # item.stack_size covers multiple units, e.g. a 5-stack of wood = 1 trip). for ing2 in ing2_items: j.toils.append(Toil.walk_to(ing2.tile)) j.toils.append(Toil.pickup()) j.toils.append(Toil.walk_to(best_wb.tile)) j.toils.append(Toil.deposit_at_wb(best_wb.get_path())) if best_src1 != null: j.toils.append(Toil.walk_to(best_src1.tile)) j.toils.append(Toil.pickup()) elif best_src1 != null: # Single-ingredient path: carry ing1 directly to craft. j.toils.append(Toil.walk_to(best_src1.tile)) j.toils.append(Toil.pickup()) j.toils.append(Toil.walk_to(best_wb.tile)) j.toils.append(Toil.craft_at(best_wb.get_path(), best_bill_index)) return j # ── private helpers ─────────────────────────────────────────────────────────── ## Returns the first on-floor Item of matching type that is not being carried. ## Phase 6 simplification: global search, first match wins (no nearest-first ## at this layer — distance is factored into the outer loop scoring instead). func _find_ingredient_item(item_type: StringName): for it in World.items: if it.being_carried: continue if it.item_type == item_type: return it return null ## Returns a list of on-floor Items of `item_type` whose combined stack_size ## totals at least `needed_count`. Returns an empty array if not enough exist. ## Stack-aware: a single item with stack_size >= needed_count yields one entry. ## Multiple smaller stacks are collected until the total is met. ## This powers the multi-trip deposit loop for ingredient2_count > 1 recipes. func _find_ingredient_items_for_count(item_type: StringName, needed_count: int) -> Array: var result: Array = [] var accumulated: int = 0 for it in World.items: if it.being_carried: continue if it.item_type != item_type: continue result.append(it) accumulated += int(it.get("stack_size") if it.get("stack_size") != null else 1) if accumulated >= needed_count: return result # Not enough items found. return [] ## Manhattan distance between two Vector2i tile coordinates. func _manhattan(a: Vector2i, b: Vector2i) -> int: return abs(a.x - b.x) + abs(a.y - b.y) ## Emits EventBus.bill_blocked for the given workbench and reason, rate-limited ## to once per (workbench × reason) per BILL_BLOCKED_COOLDOWN_TICKS. func _emit_bill_blocked(recipe_label: String, reason: StringName, wb) -> void: var key: String = "%s|%s" % [wb.get_instance_id(), reason] if _bill_blocked_cooldown.get(key, 0) > Sim.tick: return _bill_blocked_cooldown[key] = Sim.tick + BILL_BLOCKED_COOLDOWN_TICKS var focus: Vector2i = wb.get("tile") if wb.get("tile") != null else Vector2i(-1, -1) EventBus.bill_blocked.emit(recipe_label, reason, focus)