class_name AlertsLog extends CanvasLayer ## Phase 17 — Scrollable alerts / event log bottom-sheet. ## ## Opened via TopBar "Log" button (injected by main.gd). ## Subscribes to: ## EventBus.alert_added(severity, text, focus_tile) ## EventBus.storyteller_event_fired(event) — translated to an alert entry ## EventBus.day_ended(summary) — produces a one-line day-summary entry ## ## Internal ring buffer capped at RING_CAP (50) entries; oldest dropped. ## Session-scoped — no save/load round-trip required for MVP. ## ## Severity icons (inline text prefix): ## info → "[I]" blue ## warn → "[!]" yellow ## danger → "[!!]" red ## ## Timestamp format: "Day D, HH:MM" const RING_CAP: int = 50 const PANEL_HEIGHT: float = 500.0 const SEVERITY_COLORS: Dictionary = { &"info": Color(0.30, 0.55, 0.95, 1.0), &"warn": Color(0.95, 0.80, 0.10, 1.0), &"danger": Color(0.90, 0.15, 0.10, 1.0), } const SEVERITY_PREFIX: Dictionary = { &"info": "[I]", &"warn": "[!]", &"danger": "[!!]", } ## Maps EventDef.Category → default alert severity for storyteller events. const STORYTELLER_SEVERITY: Dictionary = { 0: &"info", # NUDGE 1: &"info", # SEASONAL 2: &"info", # WANDERER 3: &"danger", # THREAT 4: &"warn", # DISEASE 5: &"info", # RESOURCE 6: &"info", # LORE 7: &"info", # MILESTONE } ## Unread badge count — increments on add, resets on open. var _unread_count: int = 0 ## Ring buffer of alert entry dicts: ## { "severity": StringName, "timestamp": String, "text": String, "focus_tile": Vector2i } var _entries: Array = [] var _root: Control = null var _scroll: ScrollContainer = null var _vbox: VBoxContainer = null var _badge_btn: Button = null ## The button node injected from TopBar (the "Log" button). ## AlertsLog updates its text with the unread count badge. var log_button: Button = null func _ready() -> void: layer = 19 _build_ui() _root.visible = false EventBus.alert_added.connect(_on_alert_added) if EventBus.has_signal("storyteller_event_fired"): EventBus.storyteller_event_fired.connect(_on_storyteller_event) if EventBus.has_signal("day_ended"): EventBus.day_ended.connect(_on_day_ended) Audit.log("alerts_log", "AlertsLog ready") func _exit_tree() -> void: if EventBus.alert_added.is_connected(_on_alert_added): EventBus.alert_added.disconnect(_on_alert_added) if EventBus.has_signal("storyteller_event_fired") and EventBus.storyteller_event_fired.is_connected(_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): EventBus.day_ended.disconnect(_on_day_ended) # ── public API ──────────────────────────────────────────────────────────────── func open() -> void: _rebuild_list() _root.visible = true _unread_count = 0 _update_badge() Audit.log("alerts_log", "opened (entries=%d)" % _entries.size()) func close() -> void: _root.visible = false Audit.log("alerts_log", "closed") # ── UI construction ─────────────────────────────────────────────────────────── func _build_ui() -> void: var backdrop := ColorRect.new() backdrop.name = "Backdrop" backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) backdrop.color = Color(0.0, 0.0, 0.0, 0.45) backdrop.mouse_filter = Control.MOUSE_FILTER_STOP backdrop.gui_input.connect(_on_backdrop_input) add_child(backdrop) _root = Control.new() _root.name = "LogPanel" _root.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) _root.custom_minimum_size = Vector2(0.0, PANEL_HEIGHT) _root.offset_top = -PANEL_HEIGHT _root.offset_bottom = 0.0 _root.mouse_filter = Control.MOUSE_FILTER_STOP backdrop.add_child(_root) var bg := PanelContainer.new() bg.name = "BG" bg.set_anchors_preset(Control.PRESET_FULL_RECT) _root.add_child(bg) var outer_vbox := VBoxContainer.new() outer_vbox.add_theme_constant_override("separation", 4) bg.add_child(outer_vbox) # Header. var header := HBoxContainer.new() header.add_theme_constant_override("separation", 8) outer_vbox.add_child(header) var title := Label.new() title.text = "Alerts" title.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(title) var close_btn := Button.new() close_btn.text = "Close" close_btn.focus_mode = Control.FOCUS_NONE close_btn.custom_minimum_size = Vector2(72.0, 36.0) close_btn.pressed.connect(close) header.add_child(close_btn) # Scrollable entry list. _scroll = ScrollContainer.new() _scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL outer_vbox.add_child(_scroll) _vbox = VBoxContainer.new() _vbox.add_theme_constant_override("separation", 4) _vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL _scroll.add_child(_vbox) # ── entry management ────────────────────────────────────────────────────────── func _push_entry(severity: StringName, text: String, focus_tile: Vector2i) -> void: var entry: Dictionary = { "severity": severity, "timestamp": _format_timestamp(), "text": text, "focus_tile": focus_tile, } _entries.push_front(entry) # newest first if _entries.size() > RING_CAP: _entries.resize(RING_CAP) _unread_count += 1 _update_badge() func _rebuild_list() -> void: for child in _vbox.get_children(): child.queue_free() for entry in _entries: _add_entry_row(entry) func _add_entry_row(entry: Dictionary) -> void: var sev: StringName = entry.get("severity", &"info") var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) _vbox.add_child(row) # Severity color icon label. var icon_lbl := Label.new() icon_lbl.text = SEVERITY_PREFIX.get(sev, "[i]") icon_lbl.modulate = SEVERITY_COLORS.get(sev, Color.WHITE) icon_lbl.custom_minimum_size = Vector2(32.0, 0.0) row.add_child(icon_lbl) # Timestamp. var ts_lbl := Label.new() ts_lbl.text = entry.get("timestamp", "") ts_lbl.custom_minimum_size = Vector2(90.0, 0.0) ts_lbl.add_theme_font_size_override("font_size", 11) row.add_child(ts_lbl) # Text — expands to fill. var text_lbl := Label.new() text_lbl.text = entry.get("text", "") text_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL text_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART row.add_child(text_lbl) # "Go there" button if focus tile is valid. var ft: Vector2i = entry.get("focus_tile", Vector2i(-1, -1)) if ft != Vector2i(-1, -1): var go_btn := Button.new() go_btn.text = "Go" go_btn.focus_mode = Control.FOCUS_NONE go_btn.custom_minimum_size = Vector2(48.0, 32.0) var tile := ft go_btn.pressed.connect(func() -> void: _pan_to(tile)) row.add_child(go_btn) # ── signal handlers ─────────────────────────────────────────────────────────── func _on_alert_added(severity: StringName, text: String, focus_tile: Vector2i) -> void: _push_entry(severity, text, focus_tile) Audit.log("alerts_log", "[%s] %s" % [severity, text]) func _on_storyteller_event(event) -> void: # Translate EventDef → alert entry. severity derived from category enum value. var cat_int: int = int(event.get("category") if event.get("category") != null else 0) var sev: StringName = STORYTELLER_SEVERITY.get(cat_int, &"info") var title_text: String = event.get("title") if event.get("title") != null else "Event" var body_text: String = event.get("body") if event.get("body") != null else "" var ft: Vector2i = Vector2i(-1, -1) if event.get("focus_tile") != null: ft = event.focus_tile _push_entry(sev, "%s — %s" % [title_text, body_text], ft) func _on_day_ended(summary: Dictionary) -> void: var day: int = int(summary.get("day", 0)) var season: StringName = StringName(summary.get("season", &"")) var pawns: int = int(summary.get("pawns_alive", 0)) var tension: float = float(summary.get("tension", 0.0)) var wolves: int = int(summary.get("wolves_alive", 0)) var text: String = "Day %d ended — %s — pawns: %d, wolves: %d, tension: %.0f" % [ day, String(season), pawns, wolves, tension ] _push_entry(&"info", text, Vector2i(-1, -1)) Audit.log("alerts_log", "day_ended logged: %s" % text) # ── helpers ─────────────────────────────────────────────────────────────────── func _format_timestamp() -> String: if Clock == null: return "" return "Day %d, %s" % [Clock.current_day(), Clock.time_string()] func _update_badge() -> void: if log_button == null: return if _unread_count > 0: log_button.text = "Log [%d]" % _unread_count else: log_button.text = "Log" func _pan_to(tile: Vector2i) -> void: var cam = get_node_or_null("/root/Main/World/CameraRig") if cam == null: Audit.log("alerts_log", "pan_to: CameraRig not found") return if cam.has_method("pan_to_tile"): cam.pan_to_tile(tile) else: cam.position = Vector2(tile.x * 16 + 8, tile.y * 16 + 8) close() func _on_backdrop_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: close() elif event is InputEventScreenTouch and event.pressed: close()