class_name DefenseManagementPanel extends VBoxContainer const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") ## Player controls for an incident that already exists. Threat creation, ## combat balancing, skill authoring and automatic combat belong to Runtime. signal status_requested(message: String, success: bool) const KEY_PREFIX := "ui.defense." const COMMANDS := [&"register_defense_combatant", &"register_defense_asset", &"start_defense_raid", &"set_defense_retreat_policy", &"begin_next_defense_wave", &"retreat_defenders"] const STATE_NAMES := ["準備中", "戦闘中", "次の襲撃待ち", "撃退", "突破された", "撤退", "準備終了"] const SOURCE_NAMES := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"} const POLICIES := [&"HOLD", &"MANUAL", &"THRESHOLD", &"PROTECT_AND_RETREAT"] const POLICY_LABELS := ["持ち場を守る", "自分で撤退を指示", "体力が減ったら撤退", "体力・施設の損傷で撤退"] var selected_raid_id: StringName = &"" var selected_staff_id: StringName = &"" var selected_objective_id: StringName = &"" var raid_option: OptionButton var staff_option: OptionButton var objective_option: OptionButton var policy_option: OptionButton var threshold_spin: SpinBox var summary_label: Label var defenders_label: Label var assets_label: Label var staff_label: Label var objective_label: Label var result_label: Label var register_staff_button: Button var register_objective_button: Button var start_button: Button var policy_button: Button var next_wave_button: Button var retreat_button: Button var confirmation: ConfirmationDialog var _runtime: Variant var _audio_submitter := Callable() var _style_button: Callable var _report: Dictionary = {} var _raid_labels: Dictionary = {} var _pending: Dictionary = {} var _queue_instance_id := 0 var _sequence := 1 var _built := false var _confirmation_context: Dictionary = {} var _policy_drafts: Dictionary = {} var _refreshing := false func configure(runtime: Variant, style_button := Callable()) -> void: _runtime = runtime _style_button = style_button func _ready() -> void: name = "DefenseManagementPanel" size_flags_horizontal = Control.SIZE_EXPAND_FILL add_theme_constant_override("separation", 6) _build_interface() _built = true if _runtime != null: if _runtime.has_signal(&"state_updated"): _runtime.connect(&"state_updated", _on_snapshot_published) elif _runtime.has_signal(&"snapshot_published"): _runtime.connect(&"snapshot_published", _on_snapshot_published) if _runtime.has_signal(&"command_completed"): _runtime.connect(&"command_completed", _on_command_completed) refresh() func _build_interface() -> void: raid_option = _option("DefenseRaidOption") raid_option.fit_to_longest_item = false raid_option.clip_text = true raid_option.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS raid_option.item_selected.connect(func(index: int) -> void: select_raid(StringName(raid_option.get_item_metadata(index)))) add_child(raid_option) summary_label = _label("", "DefenseSummary") add_child(summary_label) defenders_label = _label("", "DefenseParticipants") add_child(defenders_label) assets_label = _label("", "DefenseAssets") add_child(assets_label) add_child(HSeparator.new()) add_child(_label("参加する従業員")) staff_option = _option("DefenseStaffOption") staff_option.item_selected.connect(func(index: int) -> void: selected_staff_id = StringName(staff_option.get_item_metadata(index)) _refresh_controls()) add_child(staff_option) staff_label = _label("", "DefenseStaffStatus") add_child(staff_label) register_staff_button = _button("防衛に参加", "DefenseRegisterStaff", _register_staff) add_child(register_staff_button) add_child(_label("防衛する施設")) objective_option = _option("DefenseObjectiveOption") objective_option.item_selected.connect(func(index: int) -> void: selected_objective_id = StringName(objective_option.get_item_metadata(index)) _refresh_controls()) add_child(objective_option) objective_label = _label("", "DefenseObjectiveStatus") add_child(objective_label) register_objective_button = _button("防衛対象に登録", "DefenseRegisterObjective", _register_objective) add_child(register_objective_button) add_child(HSeparator.new()) add_child(_label("撤退の方針")) policy_option = _option("DefenseRetreatPolicy") for i: int in POLICIES.size(): policy_option.add_item(POLICY_LABELS[i]) policy_option.set_item_metadata(i, POLICIES[i]) policy_option.item_selected.connect(func(_index: int) -> void: _store_policy_draft()) add_child(policy_option) var threshold_row := HBoxContainer.new() threshold_row.add_child(_label("撤退する残量(%)")) threshold_spin = SpinBox.new() threshold_spin.name = "DefenseRetreatThreshold" threshold_spin.min_value = 0 threshold_spin.max_value = 100 threshold_spin.step = 1 threshold_spin.value_changed.connect(func(_value: float) -> void: _store_policy_draft()) threshold_row.add_child(threshold_spin) add_child(threshold_row) policy_button = _button("撤退方針を適用", "DefenseApplyPolicy", _apply_policy) add_child(policy_button) var actions := HBoxContainer.new() start_button = _button("防衛を開始", "DefenseStart", func() -> void: _submit(&"start_defense_raid")) next_wave_button = _button("次の襲撃に対応", "DefenseNextWave", func() -> void: _submit(&"begin_next_defense_wave")) retreat_button = _button("撤退する", "DefenseRetreat", _request_retreat_confirmation) for button: Button in [start_button, next_wave_button, retreat_button]: button.size_flags_horizontal = Control.SIZE_EXPAND_FILL actions.add_child(button) add_child(actions) result_label = _label("", "DefenseResult") add_child(result_label) confirmation = ConfirmationDialog.new() confirmation.name = "DefenseConfirmation" confirmation.ok_button_text = "実行する" confirmation.cancel_button_text = "戻る" confirmation.confirmed.connect(_confirm_operation) confirmation.canceled.connect(func() -> void: _confirmation_context.clear()) add_child(confirmation) confirmation.get_label().add_theme_color_override("font_color", Color("55371e")) LayerSkin.register_text(confirmation.get_label(), LayerSkin.active_layer(self)) if _style_button.is_valid(): for button: Button in [confirmation.get_ok_button(), confirmation.get_cancel_button()]: _style_button.call(button) ## Reset optional saved presentation without choosing a live raid implicitly. func clear_selection() -> void: _policy_drafts.clear() if result_label != null: result_label.text = "" select_raid(&"", false) func select_raid(raid_id: StringName, select_first_if_empty: bool = true) -> void: selected_raid_id = raid_id selected_staff_id = &"" selected_objective_id = &"" _confirmation_context.clear() if confirmation != null: confirmation.hide() refresh(select_first_if_empty) func refresh(select_first_if_empty: bool = true) -> void: if not _built: return var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null if queue != null and queue.get_instance_id() != _queue_instance_id: _queue_instance_id = queue.get_instance_id() _policy_drafts.clear() _confirmation_context.clear() confirmation.hide() for option: OptionButton in [raid_option, staff_option, objective_option, policy_option]: option.get_popup().hide() _synchronize_commands(queue) _report = _runtime.call(&"get_defense_management_report", selected_raid_id) if _runtime != null and _runtime.has_method(&"get_defense_management_report") else {} if selected_raid_id.is_empty() and not select_first_if_empty: # Runtime supplies its normal first-raid report. This inspector alone # presents no selection; it never changes the raid or queued commands. _report[&"selected"] = {} _report[&"defenders"] = [] _report[&"assets"] = [] _report[&"objectives"] = [] _report[&"can_control"] = false _raid_labels = _build_raid_labels(_report.get(&"raids", [])) selected_raid_id = StringName((_report.get(&"selected", {}) as Dictionary).get(&"raid_id", &"")) selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id, select_first_if_empty) selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id, select_first_if_empty) selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id, select_first_if_empty) var row: Dictionary = _report.get(&"selected", {}) var draft: Dictionary = _policy_drafts.get(selected_raid_id, row) var policy := StringName(draft.get(&"retreat_policy", &"HOLD")) _refreshing = true policy_option.select(maxi(POLICIES.find(policy), 0)) threshold_spin.set_value_no_signal(int(draft.get(&"defender_retreat_threshold", 0))) _refreshing = false summary_label.text = ("進行中・保存済みの防衛戦はありません。" if (_report.get(&"raids", []) as Array).is_empty() else "防衛戦を選択してください。") if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"] var names: Array[String] = [] for defender: Dictionary in _report.get(&"defenders", []): var employee := _row(&"staff", &"staff_id", defender[&"actor_id"]) names.append("%s 体力%d/%d・戦闘%d" % [employee.get(&"display_name", defender[&"actor_id"]), defender[&"health"], defender[&"max_health"], defender[&"combat"]]) defenders_label.text = "参加者:" + ("、".join(names) if not names.is_empty() else "未登録") names.clear() for asset: Dictionary in _report.get(&"assets", []): var facility := _row(&"objectives", &"facility_id", asset[&"asset_id"]) names.append("%s 耐久%d/%d" % [facility.get(&"display_name", asset[&"asset_id"]), asset[&"integrity"], asset[&"max_integrity"]]) assets_label.text = "対象:" + ("、".join(names) if not names.is_empty() else "未登録") _refresh_controls() func _refresh_controls() -> void: var row: Dictionary = _report.get(&"selected", {}) var state := int(row.get(&"state", -1)) var pending := _has_pending(selected_raid_id) var blocked := row.is_empty() or pending or not bool(_report.get(&"can_control", false)) var staff := _row(&"staff", &"staff_id", selected_staff_id) var objective := _row(&"objectives", &"facility_id", selected_objective_id) staff_label.text = "従業員がいません。" if staff.is_empty() else "体力%d/100・戦闘%d\n%s" % [staff[&"health"], staff[&"combat"], "参加できます。参加中は配属を変更できません。" if staff[&"can_register"] else staff[&"reason"]] objective_label.text = "防衛対象がありません。" if objective.is_empty() else "耐久%d/10000\n%s" % [objective[&"durability"], "登録できます。" if objective[&"can_register"] else objective[&"reason"]] register_staff_button.disabled = blocked or staff.is_empty() or not bool(staff.get(&"can_register", false)) register_objective_button.disabled = blocked or objective.is_empty() or not bool(objective.get(&"can_register", false)) start_button.visible = state == DefenseService.RaidState.PREPARING start_button.disabled = blocked or not (row.get(&"missing_objectives", []) as Array).is_empty() next_wave_button.visible = state == DefenseService.RaidState.BETWEEN_WAVES next_wave_button.disabled = blocked retreat_button.visible = state in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES] retreat_button.disabled = blocked var terminal := state in DefenseService.TERMINAL_STATES or state < 0 policy_option.disabled = blocked or terminal var chosen_policy := StringName(policy_option.get_item_metadata(policy_option.selected)) threshold_spin.editable = not blocked and not terminal and chosen_policy in [&"THRESHOLD", &"PROTECT_AND_RETREAT"] policy_button.disabled = blocked or terminal staff_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or staff.is_empty() objective_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or objective.is_empty() if pending: result_label.text = "指示を受理しました。次の時間更新で反映します。" func _register_staff() -> void: var staff := _row(&"staff", &"staff_id", selected_staff_id) if staff.is_empty() or not bool(staff.get(&"can_register", false)): return _submit(&"register_defense_combatant", {&"actor_id": selected_staff_id, &"side": &"DEFENDER", &"combat": int(staff[&"combat"])}) func _register_objective() -> void: var facility := _row(&"objectives", &"facility_id", selected_objective_id) if facility.is_empty() or not bool(facility.get(&"can_register", false)): return # Plain objectives have no defense-device bonus. Omit numeric overrides and # use the existing Runtime command defaults and current live durability. _submit(&"register_defense_asset", {&"asset_id": selected_objective_id, &"kind": &"OBJECTIVE"}) func _apply_policy() -> void: _submit(&"set_defense_retreat_policy", {&"policy": policy_option.get_item_metadata(policy_option.selected), &"threshold": int(threshold_spin.value)}) func _store_policy_draft() -> void: if _refreshing or selected_raid_id.is_empty() or _has_pending(selected_raid_id): return _policy_drafts[selected_raid_id] = {&"retreat_policy": policy_option.get_item_metadata(policy_option.selected), &"defender_retreat_threshold": int(threshold_spin.value)} _refresh_controls() func _request_retreat_confirmation() -> void: refresh() var row: Dictionary = _report.get(&"selected", {}) if _has_pending(selected_raid_id) or selected_raid_id.is_empty() or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: return _confirmation_context = {&"raid_id": selected_raid_id, &"queue_instance_id": _queue_instance_id} confirmation.title = "撤退の確認" confirmation.dialog_text = "撤退すると防衛戦は終了します。参加者の状態と施設の損傷が反映されます。" confirmation.popup_centered(Vector2i(430, 170)) func _confirm_operation() -> void: var context := _confirmation_context.duplicate() _confirmation_context.clear() var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null if queue == null or queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)) or context.get(&"raid_id", &"") != selected_raid_id: _set_result("対象が変わりました。防衛戦を選び直してください。", false) refresh() return refresh() if context.get(&"raid_id", &"") != selected_raid_id: _set_result("対象が変わりました。防衛戦を選び直してください。", false) return var row: Dictionary = _report.get(&"selected", {}) if _has_pending(selected_raid_id) or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: _set_result("現在の防衛戦では撤退を指示できません。", false) return _submit(&"retreat_defenders") func _submit(command_type: StringName, patch: Dictionary = {}) -> void: if selected_raid_id.is_empty() or _has_pending(selected_raid_id) or command_type not in COMMANDS: return var payload := patch.duplicate(true) payload[&"raid_id"] = selected_raid_id var key := StringName("%s%08d" % [KEY_PREFIX, _sequence]) _sequence += 1 var result: Variant = _submit_ui_command(command_type, payload, &"player", selected_raid_id, 75, -1, key) if result == null or not result.has_method(&"is_success"): _set_result("防衛の指示に応答がありません。", false) return var queue: Variant = _runtime.get(&"command_queue") var queued := false for command: Dictionary in queue.create_snapshot().get("pending", []): queued = queued or int(command.get("command_id", 0)) == int(result.command_id) if result.is_success() and queued: _pending[int(result.command_id)] = payload else: _show_result(result, payload) refresh() func _show_result(result: Variant, payload: Dictionary) -> void: if result.is_success() and payload.has(&"policy"): _policy_drafts.erase(StringName(payload.get(&"raid_id", &""))) _set_result("防衛の指示を反映しました。" if result.is_success() else "指示を反映できませんでした。体調・業務中の状態・対象施設を確認してください。", result.is_success()) func _synchronize_commands(queue: Variant) -> void: _pending.clear() for metadata: Dictionary in queue.get_accepted_command_metadata(): var key := String(metadata.get("idempotency_key", "")) if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int(): _sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1) for command: Dictionary in queue.create_snapshot().get("pending", []): if StringName(command.get("command_type", &"")) in COMMANDS: _pending[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true) func _on_command_completed(result: Variant) -> void: if not _pending.has(int(result.command_id)): return var payload: Dictionary = _pending[int(result.command_id)] _pending.erase(int(result.command_id)) _show_result(result, payload) refresh() func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: if is_visible_in_tree(): refresh() func _has_pending(raid_id: StringName) -> bool: for payload: Dictionary in _pending.values(): if payload.get(&"raid_id", &"") == raid_id: return true return false func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Dictionary: for row: Dictionary in _report.get(key, []): if row.get(id_key, &"") == entity_id: return row return {} func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName, select_first_if_empty: bool = true) -> StringName: if option.get_popup().visible: return selected option.clear() if rows.is_empty(): option.add_item("対象なし") option.set_item_metadata(0, &"") return &"" var index := -1 if selected.is_empty() and not select_first_if_empty else 0 for row: Dictionary in rows: var id := StringName(row[id_key]) var display_name := String(_raid_labels.get(id, _raid_base_label(row))) if id_key == &"raid_id" else String(row.get(&"display_name", id)) option.add_item(display_name) if id_key == &"raid_id": option.set_item_tooltip(option.item_count - 1, display_name) option.set_item_metadata(option.item_count - 1, id) if id == selected: index = option.item_count - 1 option.select(index) return &"" if index < 0 else StringName(option.get_item_metadata(index)) ## Cause labels are display-only. Never infer cause from an internal ID or ## source_id, and never use the label as an option's metadata or command target. func _build_raid_labels(rows: Array) -> Dictionary: var totals: Dictionary = {} for row: Dictionary in rows: var base := _raid_base_label(row) totals[base] = int(totals.get(base, 0)) + 1 var seen: Dictionary = {} var labels: Dictionary = {} for row: Dictionary in rows: var base := _raid_base_label(row) seen[base] = int(seen.get(base, 0)) + 1 labels[StringName(row[&"raid_id"])] = base + ("(%d)" % int(seen[base]) if int(totals[base]) > 1 else "") return labels func _raid_base_label(row: Dictionary) -> String: var cause := String(SOURCE_NAMES.get(String(row.get(&"source_kind", "")), "防衛戦")) var raw_minute: Variant = row.get(&"created_at", null) if typeof(raw_minute) != TYPE_INT or int(raw_minute) < 0: return cause + " · 時刻不明" var minute := int(raw_minute) return "%s · %d日目 %02d:%02d" % [cause, minute / 1440 + 1, minute % 1440 / 60, minute % 60] func _set_result(message: String, success: bool) -> void: result_label.text = message LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self)) status_requested.emit(message, success) func _label(text_value: String, node_name := "") -> Label: var label := Label.new() if not node_name.is_empty(): label.name = node_name label.text = text_value label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.add_theme_font_size_override("font_size", 13) label.add_theme_color_override("font_color", Color("55371e")) LayerSkin.register_text(label, LayerSkin.active_layer(self)) return label func _option(node_name: String) -> OptionButton: var option := OptionButton.new() option.name = node_name option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if _style_button.is_valid(): _style_button.call(option) return option func _button(text_value: String, node_name: String, callback: Callable) -> Button: var button := Button.new() button.name = node_name button.text = text_value button.custom_minimum_size.y = 30 button.pressed.connect(callback) if _style_button.is_valid(): _style_button.call(button) return button func configure_audio(submitter: Callable) -> void: _audio_submitter = submitter func _submit_ui_command( command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player", target_id: StringName = &"", priority := 0, execute_at := -1, idempotency_key: StringName = &"" ) -> Variant: if _audio_submitter.is_valid(): return _audio_submitter.call(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key) return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)