rimlike/scenes/ai/cooking_provider.gd
megaproxy 87a7beb22b split CookingProvider out of CraftingProvider — fixes starvation
Player report: pawns starve even with harvested crops because cooking
never happens. Root cause: CraftingProvider handled both crafting-skill
and cooking-skill bills with priority 4, below Plant=5 and Chop=5 in
Decision's tiebreaker. Pawns endlessly harvested + chopped instead of
cooking the food already on the floor; raw +25 vegetable couldn't
outpace HUNGER_DECAY × 3 pawns.

CraftingProvider now filters bills to required_skill == &"crafting"
only. New CookingProvider (category=&"cooking", priority=6) handles
required_skill == &"cooking" bills (bread, meal_from_vegetables) with
identical find/score logic including the ingredient2 buffer flow.

pawn.work_priorities default now includes &"cooking": 3 (matches the
9-category design spec). decision.gd category-list comment updated.
WorkPriorityMatrix gains a "Cook" column.

MCP runtime verified: pawns now decide `cooking(pri=3) → Craft Veggie
meal at Hearth` immediately after vegetables exist; 2 bread items
appeared by tick 261 of a fresh boot.

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

219 lines
8.8 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 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)