From 708080a022832ceb67a61af20fc6f5a23cddf95c Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 12 May 2026 13:16:25 +0100 Subject: [PATCH] Alerts: wire room_too_large, no_stockpile_accepts, bill_blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- autoload/event_bus.gd | 4 +++ scenes/ai/crafting_provider.gd | 23 +++++++++++++ scenes/ai/hauling_provider.gd | 26 ++++++++++++++ scenes/ui/alerts_log.gd | 62 ++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index 8718064..dc9a8b2 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -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 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. + +# 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. diff --git a/scenes/ai/crafting_provider.gd b/scenes/ai/crafting_provider.gd index aa8aeae..46f667c 100644 --- a/scenes/ai/crafting_provider.gd +++ b/scenes/ai/crafting_provider.gd @@ -12,12 +12,22 @@ class_name CraftingProvider extends WorkProvider ## 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" @@ -53,11 +63,13 @@ func find_best_for(pawn) -> Job: # 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. @@ -103,3 +115,14 @@ func _find_ingredient_item(item_type: StringName): ## 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) diff --git a/scenes/ai/hauling_provider.gd b/scenes/ai/hauling_provider.gd index e3441d2..9eb165d 100644 --- a/scenes/ai/hauling_provider.gd +++ b/scenes/ai/hauling_provider.gd @@ -11,11 +11,22 @@ class_name HaulingProvider extends WorkProvider ## higher-priority destination has space — enabling the "items flow upward" ## 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 ## WorkProvider convention and avoid init-order issues. ## ## 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: category = &"haul" @@ -41,6 +52,11 @@ func find_best_for(pawn) -> Job: var best_dist: int = 999999 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 ───────────────────────────────────────────────────────── for item in World.items_needing_haul.keys(): # 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. var dest = _find_best_destination_for(item) 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 var drop: Vector2i = dest.find_drop_position(item) @@ -72,6 +92,12 @@ func find_best_for(pawn) -> Job: best_drop_cell = drop 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 ───────────────────────────────────────────────────── # Corpses route to GraveSlot StorageDestinations exactly like items, but # use PICKUP_CORPSE / DEPOSIT_CORPSE toils (since Corpse is not an Item). diff --git a/scenes/ui/alerts_log.gd b/scenes/ui/alerts_log.gd index 4e1d06d..86505e1 100644 --- a/scenes/ui/alerts_log.gd +++ b/scenes/ui/alerts_log.gd @@ -73,6 +73,16 @@ func _ready() -> void: EventBus.storyteller_event_fired.connect(_on_storyteller_event) if EventBus.has_signal("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") @@ -84,6 +94,12 @@ func _exit_tree() -> void: EventBus.storyteller_event_fired.disconnect(_on_storyteller_event) if EventBus.has_signal("day_ended") and EventBus.day_ended.is_connected(_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 ──────────────────────────────────────────────────────────────── @@ -254,6 +270,52 @@ func _on_day_ended(summary: Dictionary) -> void: 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 ─────────────────────────────────────────────────────────────────── func _format_timestamp() -> String: