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>
This commit is contained in:
megaproxy 2026-05-12 13:16:25 +01:00
parent 335ccf52b2
commit 708080a022
4 changed files with 115 additions and 0 deletions

View file

@ -59,3 +59,7 @@ signal pawn_priority_changed(pawn, category: StringName, level: int) ## Emitted
signal alert_added(severity: StringName, text: String, focus_tile: Vector2i) ## Emitted by gameplay subsystems to surface a player notice. severity = info | warn | danger. signal alert_added(severity: StringName, text: String, focus_tile: Vector2i) ## Emitted by gameplay subsystems to surface a player notice. severity = info | warn | danger.
signal request_wolf_spawn(count: int) ## Phase 15 EventCatalog → WolfSpawner. Decouples threat-event effects from spawner. signal request_wolf_spawn(count: int) ## Phase 15 EventCatalog → WolfSpawner. Decouples threat-event effects from spawner.
signal day_ended(summary: Dictionary) ## Emitted by Clock at dusk→night boundary; carries the end-of-day recap dict. signal day_ended(summary: Dictionary) ## Emitted by Clock at dusk→night boundary; carries the end-of-day recap dict.
# Phase 18 — Alert wiring (dangling signals surfaced to AlertsLog).
signal no_stockpile_accepts(item_type: StringName, tile: Vector2i) ## Emitted by HaulingProvider when an item needs haul but no stockpile accepts it (rate-limited per item_type).
signal bill_blocked(recipe_label: String, reason: StringName, focus_tile: Vector2i) ## Emitted by CraftingProvider when a bill cannot proceed. reason: missing_ingredient | skill_too_low | no_workbench.

View file

@ -12,12 +12,22 @@ class_name CraftingProvider extends WorkProvider
## search is global (no per-bench radius restriction — Phase 17 polish item per ## search is global (no per-bench radius restriction — Phase 17 polish item per
## docs/architecture.md "Ingredient acquisition radius"). ## 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 ## Workbench and Pawn are intentionally duck-typed (no class_name reference) to match
## WorkProvider convention and avoid init-order issues. Only Item.Quality and ## WorkProvider convention and avoid init-order issues. Only Item.Quality and
## QualityCalc are referenced by class_name (in job_runner.gd, not here). ## QualityCalc are referenced by class_name (in job_runner.gd, not here).
## ##
## See docs/architecture.md "CraftingProvider" and docs/design.md "Bills". ## 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: func _init() -> void:
category = &"crafting" category = &"crafting"
@ -53,11 +63,13 @@ func find_best_for(pawn) -> Job:
# 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:
_emit_bill_blocked(b.recipe.label, &"skill_too_low", wb)
continue continue
# Confirm a qualifying ingredient exists on the floor. # Confirm a qualifying ingredient exists on the floor.
var src = _find_ingredient_item(b.recipe.ingredient_type) var src = _find_ingredient_item(b.recipe.ingredient_type)
if src == null: if src == null:
_emit_bill_blocked(b.recipe.label, &"missing_ingredient", wb)
continue continue
# Score: total Manhattan travel distance pawn → ingredient → workbench. # Score: total Manhattan travel distance pawn → ingredient → workbench.
@ -103,3 +115,14 @@ func _find_ingredient_item(item_type: StringName):
## Manhattan distance between two Vector2i tile coordinates. ## Manhattan distance between two Vector2i tile coordinates.
func _manhattan(a: Vector2i, b: Vector2i) -> int: func _manhattan(a: Vector2i, b: Vector2i) -> int:
return abs(a.x - b.x) + abs(a.y - b.y) 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)

View file

@ -11,11 +11,22 @@ class_name HaulingProvider extends WorkProvider
## higher-priority destination has space — enabling the "items flow upward" ## higher-priority destination has space — enabling the "items flow upward"
## priority cascade described in design.md. ## priority cascade described in design.md.
## ##
## When an item needs hauling but no stockpile accepts it, the provider emits
## EventBus.no_stockpile_accepts once per item_type per ALERT_COOLDOWN_TICKS
## ticks (30 in-game seconds at 20 Hz).
##
## Pawn is intentionally duck-typed (no class_name reference) to match the ## Pawn is intentionally duck-typed (no class_name reference) to match the
## WorkProvider convention and avoid init-order issues. ## WorkProvider convention and avoid init-order issues.
## ##
## See docs/architecture.md "HaulingProvider". ## See docs/architecture.md "HaulingProvider".
## Rate-limit for no_stockpile_accepts alerts: one emit per item_type per
## 30 in-game seconds (20 Hz × 30 s = 600 ticks).
const ALERT_COOLDOWN_TICKS: int = 600
## Per-item-type cooldown map: StringName → tick at which the next emit is allowed.
var _alert_cooldown: Dictionary = {}
func _init() -> void: func _init() -> void:
category = &"haul" category = &"haul"
@ -41,6 +52,11 @@ func find_best_for(pawn) -> Job:
var best_dist: int = 999999 var best_dist: int = 999999
var best_is_corpse: bool = false var best_is_corpse: bool = false
# Tracks the first item_type that needed hauling but had no valid destination.
# Used to emit no_stockpile_accepts once per cooldown window.
var first_orphan_type: StringName = &""
var first_orphan_tile: Vector2i = Vector2i(-1, -1)
# ── regular items ───────────────────────────────────────────────────────── # ── regular items ─────────────────────────────────────────────────────────
for item in World.items_needing_haul.keys(): for item in World.items_needing_haul.keys():
# Skip items another pawn is already carrying. # Skip items another pawn is already carrying.
@ -50,6 +66,10 @@ func find_best_for(pawn) -> Job:
# Find the best destination for this item type + priority. # Find the best destination for this item type + priority.
var dest = _find_best_destination_for(item) var dest = _find_best_destination_for(item)
if dest == null: if dest == null:
# Item needs hauling but no stockpile accepts it — record first occurrence.
if first_orphan_type == &"":
first_orphan_type = item.item_type
first_orphan_tile = item.tile
continue continue
var drop: Vector2i = dest.find_drop_position(item) var drop: Vector2i = dest.find_drop_position(item)
@ -72,6 +92,12 @@ func find_best_for(pawn) -> Job:
best_drop_cell = drop best_drop_cell = drop
best_is_corpse = false best_is_corpse = false
# Emit alert for the first orphaned item_type, rate-limited per type.
if first_orphan_type != &"":
if _alert_cooldown.get(first_orphan_type, 0) <= Sim.tick:
EventBus.no_stockpile_accepts.emit(first_orphan_type, first_orphan_tile)
_alert_cooldown[first_orphan_type] = Sim.tick + ALERT_COOLDOWN_TICKS
# ── Phase 14: corpses ───────────────────────────────────────────────────── # ── Phase 14: corpses ─────────────────────────────────────────────────────
# Corpses route to GraveSlot StorageDestinations exactly like items, but # Corpses route to GraveSlot StorageDestinations exactly like items, but
# use PICKUP_CORPSE / DEPOSIT_CORPSE toils (since Corpse is not an Item). # use PICKUP_CORPSE / DEPOSIT_CORPSE toils (since Corpse is not an Item).

View file

@ -73,6 +73,16 @@ func _ready() -> void:
EventBus.storyteller_event_fired.connect(_on_storyteller_event) EventBus.storyteller_event_fired.connect(_on_storyteller_event)
if EventBus.has_signal("day_ended"): if EventBus.has_signal("day_ended"):
EventBus.day_ended.connect(_on_day_ended) EventBus.day_ended.connect(_on_day_ended)
if EventBus.has_signal("room_too_large"):
EventBus.room_too_large.connect(_on_room_too_large)
# Catch-up: room_too_large emits during World init (cabin demo room),
# which runs before this CanvasLayer mounts. Replay existing too-large
# rooms now so the boot-state cabin warning shows up in the log.
_catch_up_room_too_large()
if EventBus.has_signal("no_stockpile_accepts"):
EventBus.no_stockpile_accepts.connect(_on_no_stockpile_accepts)
if EventBus.has_signal("bill_blocked"):
EventBus.bill_blocked.connect(_on_bill_blocked)
Audit.log("alerts_log", "AlertsLog ready") Audit.log("alerts_log", "AlertsLog ready")
@ -84,6 +94,12 @@ func _exit_tree() -> void:
EventBus.storyteller_event_fired.disconnect(_on_storyteller_event) EventBus.storyteller_event_fired.disconnect(_on_storyteller_event)
if EventBus.has_signal("day_ended") and EventBus.day_ended.is_connected(_on_day_ended): if EventBus.has_signal("day_ended") and EventBus.day_ended.is_connected(_on_day_ended):
EventBus.day_ended.disconnect(_on_day_ended) EventBus.day_ended.disconnect(_on_day_ended)
if EventBus.has_signal("room_too_large") and EventBus.room_too_large.is_connected(_on_room_too_large):
EventBus.room_too_large.disconnect(_on_room_too_large)
if EventBus.has_signal("no_stockpile_accepts") and EventBus.no_stockpile_accepts.is_connected(_on_no_stockpile_accepts):
EventBus.no_stockpile_accepts.disconnect(_on_no_stockpile_accepts)
if EventBus.has_signal("bill_blocked") and EventBus.bill_blocked.is_connected(_on_bill_blocked):
EventBus.bill_blocked.disconnect(_on_bill_blocked)
# ── public API ──────────────────────────────────────────────────────────────── # ── public API ────────────────────────────────────────────────────────────────
@ -254,6 +270,52 @@ func _on_day_ended(summary: Dictionary) -> void:
Audit.log("alerts_log", "day_ended logged: %s" % text) Audit.log("alerts_log", "day_ended logged: %s" % text)
## Boot catch-up: scan World.rooms for rooms exceeding the auto-roof cap and
## replay them through the translator. RoomDetector emits room_too_large during
## World init, before this CanvasLayer mounts; without this catch-up the cabin
## warning (always present at boot in the demo seed) never reaches the log.
func _catch_up_room_too_large() -> void:
for id in World.rooms:
var r = World.rooms[id]
if r != null and r.tile_count() > Room.ROOM_AUTOROOF_CAP:
_on_room_too_large(r.bounds.position, r.tile_count())
## Translates room_too_large → alert_added (warn).
func _on_room_too_large(top_left: Vector2i, cell_count: int) -> void:
EventBus.alert_added.emit(
&"warn",
"Room too large to roof (%d tiles). Split with an interior wall." % cell_count,
top_left,
)
Audit.log("alerts_log", "room_too_large translated: %d tiles at %s" % [cell_count, top_left])
## Translates no_stockpile_accepts → alert_added (warn).
func _on_no_stockpile_accepts(item_type: StringName, tile: Vector2i) -> void:
EventBus.alert_added.emit(
&"warn",
"No stockpile accepts %s" % String(item_type),
tile,
)
Audit.log("alerts_log", "no_stockpile_accepts translated: %s at %s" % [String(item_type), tile])
## Translates bill_blocked → alert_added (warn).
func _on_bill_blocked(recipe_label: String, reason: StringName, focus_tile: Vector2i) -> void:
var reason_str: String = {
&"missing_ingredient": "missing ingredient",
&"skill_too_low": "skill too low",
&"no_workbench": "no workbench reachable",
}.get(reason, String(reason))
EventBus.alert_added.emit(
&"warn",
"Bill blocked: %s%s" % [recipe_label, reason_str],
focus_tile,
)
Audit.log("alerts_log", "bill_blocked translated: %s%s" % [recipe_label, reason_str])
# ── helpers ─────────────────────────────────────────────────────────────────── # ── helpers ───────────────────────────────────────────────────────────────────
func _format_timestamp() -> String: func _format_timestamp() -> String: