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:
parent
335ccf52b2
commit
708080a022
4 changed files with 115 additions and 0 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue