class_name EventLogDialog extends AcceptDialog ## View the existing retained event stream. No catalog membership, unlock, ## replay, acquisition, or serialized state is created by this dialog. const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") const EVENT_NAMES := { &"AUTHORITY_ADVANCE_FAILED": "取締りの進行に失敗", &"AUTHORITY_EVIDENCE_DISCOVERED": "証拠が発見された", &"AUTHORITY_EVIDENCE_REGISTERED": "証拠を登録", &"AUTHORITY_HEAT_CHANGED": "取締りの警戒度が変化", &"AUTHORITY_INSPECTION_EVALUATED": "検査の状況を評価", &"AUTHORITY_INSPECTION_EVALUATION_FAILED": "検査の評価に失敗", &"AUTOSAVE_CHECKPOINT_FAILED": "自動保存に失敗", &"AUTOSAVE_CHECKPOINT_SUCCEEDED": "自動保存が完了", &"COMMAND_COMPLETED": "操作が完了", &"COMMAND_FAILED": "操作に失敗", &"DEFENSE_ADVANCE_FAILED": "防衛の進行に失敗", &"DEFENSE_AUTOMATIC_ROUND_FAILED": "防衛の自動ラウンドに失敗", &"DEFENSE_RAID_CREATED": "襲撃が発生", &"DEFENSE_RAID_RESOLVED": "襲撃の決着", &"DEFENSE_RAID_STATE_CHANGED": "襲撃の状況が変化", &"DEFENSE_ROUND_RESOLVED": "防衛ラウンドの結果", &"DEFENSE_SKILL_APPLIED": "防衛スキルを使用", &"ELEVATOR_ARRIVED": "エレベーターが到着", &"ELEVATOR_BOARDED": "エレベーターに乗車", &"ELEVATOR_COMMUTE_CANCELLED": "エレベーター通勤を中止", &"ELEVATOR_OPERATION_CHANGED": "エレベーターの営業設定を変更", &"ELEVATOR_RESCUE_REQUESTED": "救護のエレベーター搬送を依頼", &"ELEVATOR_TRIP_REQUESTED": "エレベーターの移動を依頼", &"ENTITY_RECOVERY_TELEPORTED": "移動できない対象を復旧", &"EVENT_CANCELLED": "イベントを中止", &"EVENT_CHOICE_RESOLVED": "イベントの選択を処理", &"EVENT_ENQUEUED": "イベントが待機", &"EVENT_FINISHED": "イベントが終了", &"EVENT_STARTED": "イベントが開始", &"EXCAVATION_COMPLETED": "掘削が完了", &"FACILITY_COMPLETED": "施設の建築が完了", &"FACILITY_DEMOLISHED": "施設を撤去", &"FACILITY_INFRASTRUCTURE_PROJECTED": "施設を交通設備に反映", &"FACILITY_STATE_CHANGED": "施設の稼働状態が変化", &"GUEST_AUTO_ARRIVED": "来客が到着", &"GUEST_DECISION_RECORDED": "来客が行き先を検討", &"GUEST_DESPAWNED": "来客が退館", &"GUEST_MOVED": "来客が移動", &"GUEST_QUEUE_ABANDONED": "来客が行列を離脱", &"GUEST_RECOVERY_STARTED": "来客の移動復旧を開始", &"GUEST_SERVICE_SYNC_FAILED": "来客のサービス状態更新に失敗", &"GUEST_SPAWNED": "来客を登録", &"GUEST_STATE_CHANGED": "来客の状態が変化", &"INCIDENT_CREATED": "事件が発生", &"INCIDENT_DETECTOR_ADDED": "事件の発見者を追加", &"INSPECTION_PATH_BLOCKED": "検査員の移動経路が遮断", &"INSPECTION_RESOLVED": "検査の決着", &"INSPECTION_SCHEDULED": "検査が予定された", &"INSPECTION_STARTED": "検査が開始", &"INSPECTION_STATE_CHANGED": "検査の状況が変化", &"INSPECTION_WAYPOINT_INSPECTED": "検査地点の調査が完了", &"INSPECTION_WAYPOINT_REACHED": "検査員が検査地点に到着", &"MANA_STATE_CHANGED": "魔力の供給状態が変化", &"PATH_ACTOR_CROSSLINK_FAILED": "移動状態の整合確認に失敗", &"PATH_ACTOR_STUCK": "移動が滞留", &"RELATIONSHIP_REGISTERED": "人物の関係を登録", &"RELATIONSHIP_ROUTE_LOCK_CHANGED": "人物のルート状態が変化", &"RELATIONSHIP_SHIELD_CHANGED": "人物のシールド値が変化", &"RELATIONSHIP_VALUE_CHANGED": "人物の関係値が変化", &"SAVE_CHECKPOINT_FAILED": "保存に失敗", &"SAVE_CHECKPOINT_SUCCEEDED": "保存が完了", &"SAVE_LOADED": "保存データを読み込み", &"SAVE_LOAD_FAILED": "保存データの読み込みに失敗", &"SAVE_MIGRATION_FAILED": "保存データの移行に失敗", &"SAVE_MIGRATION_SUCCEEDED": "保存データの移行が完了", &"SAVE_RECOVERY_CANDIDATE_LOADED": "選択した復旧データを読み込み", &"SAVE_RECOVERY_FAILED": "保存データの復旧に失敗", &"SAVE_RECOVERY_LOADED": "保存データを復旧", &"SERVICE_COMPLETED": "サービスが完了", &"SERVICE_FAILED": "サービスに失敗", &"SERVICE_INTERRUPTED": "サービスが中断", &"SERVICE_STARTED": "サービスが開始", &"STAFF_CANDIDATE_HIRED": "従業員を雇用", &"STAFF_COLLAPSED": "従業員が倒れた", &"STAFF_COLLAPSE_WITNESSED": "従業員の気絶が目撃された", &"STAFF_MOVED": "従業員が移動", &"STAFF_REGISTERED": "従業員を登録", &"STAFF_REMOVED": "従業員が離脱", &"STAFF_RESCUED": "従業員の救護が完了", &"STAFF_RESCUE_PICKED_UP": "従業員の救護搬送を開始", &"STAFF_SHIFT_REGISTERED": "勤務シフトを登録", &"STAFF_SHIFT_STATE_CHANGED": "勤務シフトの状態が変化", &"STAFF_TASK_ASSIGNED": "仕事を割り当て", &"STAFF_TASK_COMPLETED": "仕事が完了", &"STAFF_TASK_REGISTERED": "仕事を登録", &"STAFF_TASK_REOPENED": "仕事を再募集", &"STAFF_WORK_ASSIGNMENT_CHANGED": "従業員の配属を変更", &"WORLD_TREE_RANK_ADVANCED": "世界樹のランクが上昇", } var _runtime: Variant var _event_list: ItemList var _details: Label var _details_scroll: ScrollContainer var _empty_label: Label var _rows: Array[Dictionary] = [] var _selected_event_id := "" var _skin_styles: Array[StyleBoxFlat] = [] func configure(runtime: Variant) -> void: _runtime = runtime func _ready() -> void: name = "EventLogDialog" title = "最近のイベント" get_ok_button().text = "閉じる" var column := VBoxContainer.new() column.custom_minimum_size = Vector2(440, 300) add_child(column) _empty_label = Label.new() _empty_label.name = "EventLogEmptyLabel" _empty_label.text = "イベントの記録はまだありません。" column.add_child(_empty_label) _event_list = ItemList.new() _event_list.name = "EventLogList" _event_list.custom_minimum_size.y = 150 _event_list.size_flags_vertical = Control.SIZE_EXPAND_FILL _event_list.item_selected.connect(_select_row) column.add_child(_event_list) # The scroll viewport owns the fixed detail height; a wrapped Label which # has not yet been laid out cannot inflate the first popup minimum height. _details_scroll = ScrollContainer.new() _details_scroll.name = "EventLogDetailsScroll" _details_scroll.custom_minimum_size.y = 90 _details_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED column.add_child(_details_scroll) _details = Label.new() _details.name = "EventLogDetails" _details.custom_minimum_size.x = 420 _details.size_flags_horizontal = Control.SIZE_EXPAND_FILL _details.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _details_scroll.add_child(_details) _build_skin() apply_layer_skin(LayerSkin.active_layer(self)) func show_events() -> void: refresh() popup_centered(Vector2i(540, 400)) ## Full defensive dictionaries are returned for ownership/restore checks. ## Publication order and every existing field/type are preserved. func get_event_rows() -> Array[Dictionary]: var rows: Array[Dictionary] = [] if not is_instance_valid(_runtime): return rows var stream: Variant = _runtime.get("domain_events") if stream == null or not stream.has_method("recent_events"): return rows var seen_ids: Dictionary = {} for event: SimulationDomainEvent in stream.recent_events(): var dict: Dictionary = event.to_dictionary() var eid: String = String(dict.get("event_id", "")) if not eid.is_empty(): if seen_ids.has(eid): continue seen_ids[eid] = true rows.append(dict) # Bound to recent 256 entries in strict sequence if rows.size() > 256: rows = rows.slice(rows.size() - 256) return rows func get_selected_event() -> Dictionary: for row: Dictionary in _rows: if String(row["event_id"]) == _selected_event_id: return row.duplicate(true) return {} func refresh() -> void: if _event_list == null: return _rows = get_event_rows() _event_list.clear() var selected_index := -1 for index: int in range(_rows.size()): var row: Dictionary = _rows[index] var presentation := get_event_presentation(row) _event_list.add_item("%s %s — %s" % [presentation[&"time"], presentation[&"title"], presentation[&"targets"]]) _event_list.set_item_tooltip(index, presentation[&"supplement"]) _event_list.set_item_metadata(index, String(row["event_id"])) if String(row["event_id"]) == _selected_event_id: selected_index = index _empty_label.visible = _rows.is_empty() if selected_index < 0 and not _rows.is_empty(): selected_index = 0 if selected_index >= 0: _event_list.select(selected_index) _select_row(selected_index) else: _selected_event_id = "" _details.text = "" func _select_row(index: int) -> void: if index < 0 or index >= _rows.size(): return var row: Dictionary = _rows[index] _selected_event_id = String(row["event_id"]) var presentation := get_event_presentation(row) var detail_text := "%s\n%s\n対象(現在の名称):%s" % [ presentation[&"time"], presentation[&"title"], presentation[&"targets"], ] if not String(presentation.get(&"extra", "")).is_empty(): detail_text += "\n" + String(presentation[&"extra"]) _details.text = detail_text _details.tooltip_text = presentation[&"supplement"] ## This is presentation of existing notification facts, not a story catalog. ## Unknown kinds/targets remain readable placeholders; raw metadata is only a ## tooltip supplement. Names are resolved from current validated definitions. func get_event_presentation(row: Dictionary) -> Dictionary: var names: Array[String] = [] var raw_ids: Array[String] = [] var unknown_count := 0 for raw_id: Variant in row.get("entity_ids", []): var entity_id := String(raw_id) raw_ids.append(entity_id) var resolved := _entity_name(StringName(entity_id)) if resolved.is_empty(): unknown_count += 1 else: names.append(resolved) if unknown_count > 0: names.append("名称を確認できない対象(%d件)" % unknown_count) var title_text := String(EVENT_NAMES.get(StringName(row.get("event_type", "")), "未識別の記録")) var extras: Array[String] = [] # Check for amount / gold / cost var payload: Dictionary = row.get("payload", {}) if row.get("payload") is Dictionary else {} var amount_val: Variant = row.get("amount", payload.get("amount", row.get("gold", payload.get("gold", null)))) if amount_val != null and (amount_val is int or amount_val is float) and int(amount_val) != 0: extras.append("金額:G%+d" % int(amount_val) if int(amount_val) < 0 else "金額:G%d" % int(amount_val)) # Check for reason var reason_val: Variant = row.get("reason", payload.get("reason", row.get("error", payload.get("error", "")))) if not String(reason_val).is_empty(): extras.append("理由:%s" % String(reason_val)) return { &"title": title_text, &"time": _format_minute(int(row.get("absolute_minute", 0))), &"targets": "、".join(names) if not names.is_empty() else "対象の指定なし", &"extra": " ".join(extras), &"supplement": "補足情報\n記録:%s\n種別:%s\n対象:%s\n発行元:%s%s" % [ row.get("event_id", ""), row.get("event_type", ""), "、".join(raw_ids), row.get("source_service", ""), "\n" + "\n".join(extras) if not extras.is_empty() else "", ], } func _entity_name(entity_id: StringName) -> String: if not is_instance_valid(_runtime): return "" var catalog: Variant = _runtime.get("mvp_content_catalog") if catalog != null: for content_type: StringName in [&"staff_candidates", &"unique_characters", &"guest_classes", &"species"]: var entry: Dictionary = catalog.get_entry(content_type, entity_id) if not entry.is_empty(): return String(entry.get(&"display_name", "")) var facilities: Variant = _runtime.get("facility_service") var definitions: Variant = _runtime.get("definition_registry") if facilities != null and definitions != null: var facility: Variant = facilities.get_instance(entity_id) if facility != null: var instance_definition: Variant = definitions.get_facility(facility.definition_id) if instance_definition != null: var floor_name := "%dF" % (facility.origin.y + 1) if facility.origin.y >= 0 else "B%d" % -facility.origin.y return "%s %s・%s" % [instance_definition.display_name, floor_name, "客用" if facility.origin.z == 0 else "裏方"] var registered_definition: Variant = definitions.get_facility(entity_id) if registered_definition != null: return String(registered_definition.display_name) var guests: Variant = _runtime.get("guest_service") if guests != null and guests.get_guest(entity_id) != null: return "来客" return "" func _format_minute(minute: int) -> String: return "%d日目 %02d:%02d" % [minute / 1440 + 1, (minute % 1440) / 60, minute % 60] func _build_skin() -> void: theme = Theme.new() theme.set_stylebox("panel", "AcceptDialog", _skin_box(Color("f4e3bd"), &"panel_parchment")) var border := _skin_box(Color("49311f"), &"panel_outer") border.expand_margin_top = 32 theme.set_stylebox("embedded_border", "Window", border) theme.set_color("title_color", "Window", Color("fff0cf")) for label: Label in [_empty_label, _details]: label.add_theme_color_override("font_color", Color("55371e")) LayerSkin.register_text(label, 0) _event_list.add_theme_stylebox_override("panel", _skin_box(Color("f4e3bd"), &"panel_parchment")) # Reuse the existing dark button palette with light selected text. for key: StringName in [&"selected", &"selected_focus"]: _event_list.add_theme_stylebox_override(key, _skin_box(Color("65452d"), &"button_pressed")) for key: StringName in [&"normal", &"hover", &"pressed", &"disabled", &"focus"]: var role: StringName = &"button_normal" if key in [&"hover", &"focus"]: role = &"button_hover" elif key == &"pressed": role = &"button_pressed" elif key == &"disabled": role = &"button_disabled" get_ok_button().add_theme_stylebox_override(key, _skin_box(Color("65452d"), role)) LayerSkin.register_toggle_text(get_ok_button(), 0) func _skin_box(color: Color, role: StringName) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = color style.border_color = Color("a96f2f") style.set_border_width_all(2) style.set_content_margin_all(10) LayerSkin.register_style(style, role, 0) _skin_styles.append(style) return style func apply_layer_skin(layer: int) -> void: for style: StyleBoxFlat in _skin_styles: LayerSkin.apply_style(style, layer) for label: Label in [_empty_label, _details]: if label != null: LayerSkin.register_text(label, layer) if _event_list != null: _event_list.add_theme_color_override("font_color", Color("d8eeea") if layer == 1 else Color("55371e")) _event_list.add_theme_color_override("font_selected_color", Color("fff0cf")) LayerSkin.register_toggle_text(get_ok_button(), layer)