diff --git a/scenes/ai/cooking_provider.gd b/scenes/ai/cooking_provider.gd new file mode 100644 index 0000000..e81cea3 --- /dev/null +++ b/scenes/ai/cooking_provider.gd @@ -0,0 +1,219 @@ +class_name CookingProvider extends WorkProvider +## WorkProvider for the Cooking work category. Slots into the 5-layer pawn AI +## (Decision → WorkProvider → Job + JobRunner) as layer 2. +## +## Handles only bills whose recipe.required_skill == &"cooking" (bread, +## meal_from_vegetables, etc.). Crafting-skill bills (plank, stone_block, flour, +## iron_smelt, gold_smelt) are handled by CraftingProvider (category = &"crafting"). +## +## Priority 6 — equal to Construction, above Plant (5) and Chop (5). Cooking is +## colony-critical; raw-food harvesting must not eclipse it. +## +## 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). +## +## ingredient2 buffer flow is wired identically to CraftingProvider (deposit-loop +## before primary pickup) so future two-ingredient cooking recipes (stew, etc.) +## work without further changes. +## +## See docs/architecture.md "CookingProvider" 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 = &"cooking" + # Priority 6 — equal to Construction. Cooking is colony-critical and must + # not be eclipsed by Plant (5) or Chop (5) gathering more raw food. + priority = 6 + + +# ── WorkProvider override ───────────────────────────────────────────────────── + +## Returns a cook 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 item has stack_size >= ingredient2_count, only one +## deposit trip is needed. Multiple smaller stacks each get their own trip. +## +## 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 + # CookingProvider only handles cooking-skill bills. + # Crafting-skill bills are handled by CraftingProvider. + if b.recipe.required_skill != &"cooking": + 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 + # Cache the primary ingredient ref so _tick_pickup can validate it is still + # available when the pawn arrives (guards against concurrent haul/cooking races). + j.ingredient_item = best_src1 + + if not ing2_items.is_empty(): + # Two-ingredient path: deposit ingredient2 item(s) at workbench buffer, + # then fetch the primary ingredient and cook. + # One deposit trip per item in ing2_items (stack-aware: fewer trips when + # item.stack_size covers multiple units). + 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 cook. + 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. +## Global search; 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. +## 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) diff --git a/scenes/ai/crafting_provider.gd b/scenes/ai/crafting_provider.gd index 4dc7a34..ab3b4bb 100644 --- a/scenes/ai/crafting_provider.gd +++ b/scenes/ai/crafting_provider.gd @@ -79,6 +79,10 @@ func find_best_for(pawn) -> Job: var b = wb.bills[i] if not b.is_active(): continue + # CraftingProvider only handles crafting-skill bills. + # Cooking-skill bills are handled by CookingProvider (category = &"cooking"). + if b.recipe.required_skill != &"crafting": + continue # Skill threshold check — pawn must meet the bill's minimum. if pawn.get_skill(b.recipe.required_skill) < b.recipe.skill_threshold: diff --git a/scenes/ai/decision.gd b/scenes/ai/decision.gd index dd63fac..8e53443 100644 --- a/scenes/ai/decision.gd +++ b/scenes/ai/decision.gd @@ -44,8 +44,8 @@ static func pick_next_job(pawn, work_providers: Array) -> Job: # and need-threshold providers (eat/sleep/doctor fire when hunger/sleep # thresholds trigger, not via player priority). We skip the priority filter # for them so a pawn can never accidentally starve because the player set - # &"eat" to OFF. The player-configurable list is the 7 work categories: - # construction / chop / plant / mine / crafting / haul / clean + # &"eat" to OFF. The player-configurable list is the 8 work categories: + # construction / cooking / chop / plant / mine / crafting / haul / clean # Doctor IS in the matrix (player can opt a pawn out of doctor duty) but # the needs-driven "go heal yourself" path bypasses this filter at Layer 3. # diff --git a/scenes/pawn/pawn.gd b/scenes/pawn/pawn.gd index 0c40f4e..43a5c69 100644 --- a/scenes/pawn/pawn.gd +++ b/scenes/pawn/pawn.gd @@ -122,6 +122,7 @@ var skills: Dictionary = {} # to_dict / from_dict. var work_priorities: Dictionary = { &"construction": 3, + &"cooking": 3, &"chop": 3, &"plant": 3, &"mine": 3, diff --git a/scenes/ui/work_priority_matrix.gd b/scenes/ui/work_priority_matrix.gd index 1318392..9a90690 100644 --- a/scenes/ui/work_priority_matrix.gd +++ b/scenes/ui/work_priority_matrix.gd @@ -21,10 +21,11 @@ class_name WorkPriorityMatrix extends CanvasLayer ## round-trips pawn.work_priorities (via pawn.to_dict) will reflect correctly. const CATEGORIES: Array[StringName] = [ - &"construction", &"chop", &"plant", &"mine", &"crafting", &"haul", &"clean", &"doctor" + &"construction", &"cooking", &"chop", &"plant", &"mine", &"crafting", &"haul", &"clean", &"doctor" ] const CATEGORY_LABELS: Dictionary = { &"construction": "Build", + &"cooking": "Cook", &"chop": "Chop", &"plant": "Plant", &"mine": "Mine", diff --git a/scenes/world/world.gd b/scenes/world/world.gd index 2704ccb..f872a45 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -21,6 +21,7 @@ const PLACEHOLDER_SOURCE_ID: int = 0 const BEAUTY_SYSTEM_SCRIPT: Script = preload("res://scenes/world/beauty_system.gd") const DIRTINESS_SYSTEM_SCRIPT: Script = preload("res://scenes/world/dirtiness_system.gd") const CLEANING_PROVIDER_SCRIPT: Script = preload("res://scenes/ai/cleaning_provider.gd") +const COOKING_PROVIDER_SCRIPT: Script = preload("res://scenes/ai/cooking_provider.gd") const INDOOR_TINT_SCRIPT: Script = preload("res://scenes/world/indoor_tint_overlay.gd") # Phase 14 — grave / burial entities (scripts only; no .tscn needed). const GRAVEYARD_ZONE_SCRIPT: Script = preload("res://scenes/world/graveyard_zone.gd") @@ -215,14 +216,21 @@ func _ready() -> void: # completion (Tree.fell, Wall._complete, etc.). World.designation_ctl = designation_ctl - # Register all 9 providers — Decision iterates by .priority desc. - # doctor=9 > sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4 - # ≈ crafting=4 > haul=3 > clean=2. - # Phase 17 will tune these via the work-priority matrix UI. + # CookingProvider — runtime-instantiated (same pattern as CleaningProvider). + # priority 6 = equal to Construction, above Plant(5)/Chop(5). + var cooking_provider := Node.new() + cooking_provider.set_script(COOKING_PROVIDER_SCRIPT) + cooking_provider.name = "CookingProvider" + add_child(cooking_provider) + + # Register all 10 providers — Decision iterates by .priority desc. + # doctor=9 > sleep=8 > eat=7 > construction=6 = cooking=6 > chop=5 ≈ plant=5 + # > mine=4 ≈ crafting=4 > haul=3 > clean=2. World.register_work_provider(doctor_provider) World.register_work_provider(sleep_provider) World.register_work_provider(eat_provider) World.register_work_provider(construction_provider) + World.register_work_provider(cooking_provider) World.register_work_provider(chop_provider) World.register_work_provider(mine_provider) World.register_work_provider(crafting_provider)