rimlike/scenes/ai/crafting_provider.gd
megaproxy 708080a022 Alerts: wire room_too_large, no_stockpile_accepts, bill_blocked
Three alert signals had no UI subscribers — gameplay failures vanished
silently. Now all three feed AlertsLog via translator handlers that
forward to the generic alert_added sink.

- EventBus: new no_stockpile_accepts(item_type, tile) and
  bill_blocked(recipe_label, reason, focus_tile) signals.
- HaulingProvider: per-item-type 30s cooldown; emits when find_best_for
  scan finishes with viable items but no destinations.
- CraftingProvider: per-(workbench, reason) 60s cooldown; emits at the
  skill_too_low and missing_ingredient continue sites. no_workbench
  reason declared for future use but not emitted (the iteration shape
  has no natural site for it).
- AlertsLog: connect + disconnect for all three signals using the same
  has_signal-guarded pattern; translator handlers convert to localized
  alert_added(severity, text, focus_tile).
- AlertsLog catch-up: room_too_large emits during World init, before
  this CanvasLayer mounts. _catch_up_room_too_large() in _ready scans
  World.rooms for rooms > ROOM_AUTOROOF_CAP and replays them, so the
  pre-built cabin's 24-tile-too-large warning lands in the log on every
  boot. Hauling/bill signals fire at runtime so they need no catch-up.

Verified runtime: cabin warning shows up in AlertsLog with severity
'warn' and focus_tile (45, 24) — the cabin top-left.

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

128 lines
5.1 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.
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_src = 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
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
# Confirm a qualifying ingredient exists on the floor.
var src = _find_ingredient_item(b.recipe.ingredient_type)
if src == null:
_emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb)
continue
# Score: total Manhattan travel distance pawn → ingredient → workbench.
var d: int = _manhattan(pawn.tile, src.tile) + _manhattan(src.tile, wb.tile)
if d < best_dist:
best_dist = d
best_wb = wb
best_bill = b
best_bill_index = i
best_src = src
if best_wb == null:
return null
# Re-resolve the source item in case multiple bills tied on the same item.
var src_item = _find_ingredient_item(best_bill.recipe.ingredient_type)
if src_item == 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.toils.append(Toil.walk_to(src_item.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)