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>
182 lines
7.2 KiB
GDScript
182 lines
7.2 KiB
GDScript
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)
|