346 lines
15 KiB
GDScript
346 lines
15 KiB
GDScript
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)
|