rimlike/scenes/ai/crafting_provider.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

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

182 lines
7.2 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 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 != &"") produce a 7-toil job:
## walk_to(ing1) → pickup → walk_to(wb) → deposit_at_wb(wb)
## → walk_to(ing2) → pickup → craft_at(wb, bill_index)
## The first ingredient is stashed in wb.deposited_inputs; _tick_craft consumes both.
##
## 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: secondary ingredient must also be on the floor.
if b.recipe.ingredient2_type != &"":
src2 = _find_ingredient_item(b.recipe.ingredient2_type)
if src2 == null:
_emit_bill_blocked(b.recipe.label, &"missing_ingredient2", wb)
continue
# 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
if best_bill.recipe.ingredient2_type != &"":
best_src2 = _find_ingredient_item(best_bill.recipe.ingredient2_type)
if best_src2 == null:
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 best_src1 != null and best_src2 != null:
# Two-ingredient path: deposit ing1 at wb, then fetch ing2 and 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.deposit_at_wb(best_wb.get_path()))
j.toils.append(Toil.walk_to(best_src2.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
## 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)