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>
This commit is contained in:
parent
f30c7a858b
commit
87a7beb22b
6 changed files with 240 additions and 7 deletions
219
scenes/ai/cooking_provider.gd
Normal file
219
scenes/ai/cooking_provider.gd
Normal file
|
|
@ -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)
|
||||||
|
|
@ -79,6 +79,10 @@ func find_best_for(pawn) -> Job:
|
||||||
var b = wb.bills[i]
|
var b = wb.bills[i]
|
||||||
if not b.is_active():
|
if not b.is_active():
|
||||||
continue
|
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.
|
# Skill threshold check — pawn must meet the bill's minimum.
|
||||||
if pawn.get_skill(b.recipe.required_skill) < b.recipe.skill_threshold:
|
if pawn.get_skill(b.recipe.required_skill) < b.recipe.skill_threshold:
|
||||||
|
|
|
||||||
|
|
@ -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
|
# and need-threshold providers (eat/sleep/doctor fire when hunger/sleep
|
||||||
# thresholds trigger, not via player priority). We skip the priority filter
|
# thresholds trigger, not via player priority). We skip the priority filter
|
||||||
# for them so a pawn can never accidentally starve because the player set
|
# 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:
|
# &"eat" to OFF. The player-configurable list is the 8 work categories:
|
||||||
# construction / chop / plant / mine / crafting / haul / clean
|
# construction / cooking / chop / plant / mine / crafting / haul / clean
|
||||||
# Doctor IS in the matrix (player can opt a pawn out of doctor duty) but
|
# 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.
|
# the needs-driven "go heal yourself" path bypasses this filter at Layer 3.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ var skills: Dictionary = {}
|
||||||
# to_dict / from_dict.
|
# to_dict / from_dict.
|
||||||
var work_priorities: Dictionary = {
|
var work_priorities: Dictionary = {
|
||||||
&"construction": 3,
|
&"construction": 3,
|
||||||
|
&"cooking": 3,
|
||||||
&"chop": 3,
|
&"chop": 3,
|
||||||
&"plant": 3,
|
&"plant": 3,
|
||||||
&"mine": 3,
|
&"mine": 3,
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,11 @@ class_name WorkPriorityMatrix extends CanvasLayer
|
||||||
## round-trips pawn.work_priorities (via pawn.to_dict) will reflect correctly.
|
## round-trips pawn.work_priorities (via pawn.to_dict) will reflect correctly.
|
||||||
|
|
||||||
const CATEGORIES: Array[StringName] = [
|
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 = {
|
const CATEGORY_LABELS: Dictionary = {
|
||||||
&"construction": "Build",
|
&"construction": "Build",
|
||||||
|
&"cooking": "Cook",
|
||||||
&"chop": "Chop",
|
&"chop": "Chop",
|
||||||
&"plant": "Plant",
|
&"plant": "Plant",
|
||||||
&"mine": "Mine",
|
&"mine": "Mine",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const PLACEHOLDER_SOURCE_ID: int = 0
|
||||||
const BEAUTY_SYSTEM_SCRIPT: Script = preload("res://scenes/world/beauty_system.gd")
|
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 DIRTINESS_SYSTEM_SCRIPT: Script = preload("res://scenes/world/dirtiness_system.gd")
|
||||||
const CLEANING_PROVIDER_SCRIPT: Script = preload("res://scenes/ai/cleaning_provider.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")
|
const INDOOR_TINT_SCRIPT: Script = preload("res://scenes/world/indoor_tint_overlay.gd")
|
||||||
# Phase 14 — grave / burial entities (scripts only; no .tscn needed).
|
# Phase 14 — grave / burial entities (scripts only; no .tscn needed).
|
||||||
const GRAVEYARD_ZONE_SCRIPT: Script = preload("res://scenes/world/graveyard_zone.gd")
|
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.).
|
# completion (Tree.fell, Wall._complete, etc.).
|
||||||
World.designation_ctl = designation_ctl
|
World.designation_ctl = designation_ctl
|
||||||
|
|
||||||
# Register all 9 providers — Decision iterates by .priority desc.
|
# CookingProvider — runtime-instantiated (same pattern as CleaningProvider).
|
||||||
# doctor=9 > sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4
|
# priority 6 = equal to Construction, above Plant(5)/Chop(5).
|
||||||
# ≈ crafting=4 > haul=3 > clean=2.
|
var cooking_provider := Node.new()
|
||||||
# Phase 17 will tune these via the work-priority matrix UI.
|
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(doctor_provider)
|
||||||
World.register_work_provider(sleep_provider)
|
World.register_work_provider(sleep_provider)
|
||||||
World.register_work_provider(eat_provider)
|
World.register_work_provider(eat_provider)
|
||||||
World.register_work_provider(construction_provider)
|
World.register_work_provider(construction_provider)
|
||||||
|
World.register_work_provider(cooking_provider)
|
||||||
World.register_work_provider(chop_provider)
|
World.register_work_provider(chop_provider)
|
||||||
World.register_work_provider(mine_provider)
|
World.register_work_provider(mine_provider)
|
||||||
World.register_work_provider(crafting_provider)
|
World.register_work_provider(crafting_provider)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue