class_name StaffManagementPanel extends VBoxContainer const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") ## Roster and assignment editing only. Runtime owns Shift, staffing, movement, ## and persistence; this panel never edits a Staff or Shift instance directly. signal status_requested(message: String, success: bool) const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment" const KEY_PREFIX := "ui.staff_assignment." const DISMISSAL_COMMAND: StringName = &"dismiss_staff" const DISMISSAL_KEY_PREFIX := "ui.staff_dismissal." const ROLE_NAMES := { &"attendant": "接客", &"bartender": "酒場", &"clerk": "会計", &"cleaner": "清掃", &"cook": "調理", &"guard": "警備", &"maintenance": "保守", &"masseur": "施術", &"medic": "医療", &"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者", } const TASK_NAMES := { &"facility_clean": "清掃の作業", &"rescue_pickup": "救護の迎え", &"rescue_transport": "救護の搬送", } const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"] var summary_label: Label var roster_list: VBoxContainer var roster_buttons: Dictionary = {} var editor_panel: VBoxContainer var staff_detail_label: Label var facility_option: OptionButton var shift_option: OptionButton var apply_button: Button var dismiss_button: Button var dismiss_confirm_box: VBoxContainer var dismiss_confirm_label: Label var dismiss_confirm_button: Button var dismiss_cancel_button: Button var result_label: Label var selected_staff_id: StringName = &"" var _runtime: Variant var _audio_submitter := Callable() var _style_button: Callable var _staff_rows: Array[Dictionary] = [] var _facility_rows: Array[Dictionary] = [] var _draft_facility_id: StringName = &"" var _draft_template_id: StringName = &"DAY" var _draft_dirty := false var _sequence := 1 var _dismissal_sequence := 1 var _pending_commands: Dictionary = {} var _pending_dismissals: Dictionary = {} var _confirming_dismissal_id: StringName = &"" var _queue_instance_id := 0 var _built := false func configure(runtime: Variant, style_button := Callable()) -> void: _runtime = runtime _style_button = style_button func _ready() -> void: name = "StaffManagementPanel" size_flags_horizontal = Control.SIZE_EXPAND_FILL add_theme_constant_override("separation", 5) _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: summary_label = _label("まだ従業員を雇っていません。候補から雇用してください。") summary_label.name = "HiredStaffSummary" add_child(summary_label) var scroll := ScrollContainer.new() scroll.name = "HiredStaffScroll" scroll.custom_minimum_size.y = 120 scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED add_child(scroll) roster_list = VBoxContainer.new() roster_list.name = "HiredStaffList" roster_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL roster_list.add_theme_constant_override("separation", 3) scroll.add_child(roster_list) editor_panel = VBoxContainer.new() editor_panel.name = "StaffAssignmentEditor" editor_panel.add_theme_constant_override("separation", 5) add_child(editor_panel) staff_detail_label = _label("") staff_detail_label.name = "StaffAssignmentDetail" editor_panel.add_child(staff_detail_label) editor_panel.add_child(_label("配属先")) facility_option = OptionButton.new() facility_option.name = "StaffFacilityOption" facility_option.fit_to_longest_item = false facility_option.custom_minimum_size = Vector2(0, 30) facility_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL facility_option.item_selected.connect(_on_facility_changed) _style(facility_option) editor_panel.add_child(facility_option) editor_panel.add_child(_label("勤務帯")) shift_option = OptionButton.new() shift_option.name = "StaffShiftOption" shift_option.fit_to_longest_item = false shift_option.custom_minimum_size.y = 30 shift_option.add_item("日勤 06:00〜18:00") shift_option.set_item_metadata(0, &"DAY") shift_option.add_item("夜勤 18:00〜翌06:00") shift_option.set_item_metadata(1, &"NIGHT") shift_option.item_selected.connect(_on_shift_changed) _style(shift_option) editor_panel.add_child(shift_option) apply_button = Button.new() apply_button.name = "ApplyStaffAssignment" apply_button.text = "配属・勤務帯を変更" apply_button.custom_minimum_size.y = 32 apply_button.pressed.connect(submit_assignment) _style(apply_button) editor_panel.add_child(apply_button) # P16-01-DISMISS: the dismissal shows its severance and reopened Task before the # confirming press; the Runtime command decides and records everything. dismiss_button = Button.new() dismiss_button.name = "DismissStaffButton" dismiss_button.text = "解雇する…" dismiss_button.custom_minimum_size.y = 32 dismiss_button.pressed.connect(request_dismissal) _style(dismiss_button) editor_panel.add_child(dismiss_button) dismiss_confirm_box = VBoxContainer.new() dismiss_confirm_box.name = "DismissStaffConfirm" dismiss_confirm_box.add_theme_constant_override("separation", 3) dismiss_confirm_box.visible = false editor_panel.add_child(dismiss_confirm_box) dismiss_confirm_label = _label("") dismiss_confirm_label.name = "DismissStaffConfirmText" dismiss_confirm_box.add_child(dismiss_confirm_label) var confirm_row := HBoxContainer.new() confirm_row.add_theme_constant_override("separation", 5) dismiss_confirm_box.add_child(confirm_row) dismiss_confirm_button = Button.new() dismiss_confirm_button.name = "ConfirmDismissStaff" dismiss_confirm_button.text = "解雇を確定" dismiss_confirm_button.custom_minimum_size.y = 30 dismiss_confirm_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL dismiss_confirm_button.pressed.connect(confirm_dismissal) _style(dismiss_confirm_button) confirm_row.add_child(dismiss_confirm_button) dismiss_cancel_button = Button.new() dismiss_cancel_button.name = "CancelDismissStaff" dismiss_cancel_button.text = "やめる" dismiss_cancel_button.custom_minimum_size.y = 30 dismiss_cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL dismiss_cancel_button.pressed.connect(cancel_dismissal) _style(dismiss_cancel_button) confirm_row.add_child(dismiss_cancel_button) result_label = _label("") result_label.name = "StaffAssignmentResult" add_child(result_label) func refresh() -> void: if not _built or _runtime == null: return var queue: Variant = _runtime.get(&"command_queue") if queue != null and int(queue.get_instance_id()) != _queue_instance_id: _queue_instance_id = int(queue.get_instance_id()) _draft_dirty = false _confirming_dismissal_id = &"" facility_option.get_popup().hide() shift_option.get_popup().hide() _synchronize_commands(queue) _read_roster() var live_ids: Array[StringName] = [] var unassigned := 0 for row: Dictionary in _staff_rows: var staff_id := StringName(row.get(&"staff_id", &"")) live_ids.append(staff_id) if StringName(row.get(&"assigned_facility_id", &"")).is_empty(): unassigned += 1 var button := roster_buttons.get(staff_id) as Button if button == null: button = Button.new() button.name = "HiredStaff_%s" % staff_id button.custom_minimum_size.y = 32 button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS button.toggle_mode = true button.pressed.connect(select_staff.bind(staff_id)) button.set_drag_forwarding(_staff_drag_data.bind(staff_id), Callable(), Callable()) _style(button) roster_list.add_child(button) roster_buttons[staff_id] = button # Existing buttons may have been created in hire order. Match the # authoritative report order on every refresh, including after Load. roster_list.move_child(button, live_ids.size() - 1) button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""] button.tooltip_text = "施設へドラッグして配属(選択中の勤務帯を適用)\n%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)] for raw_id: Variant in roster_buttons.keys(): if not live_ids.has(StringName(raw_id)): var stale := roster_buttons[raw_id] as Button roster_list.remove_child(stale) stale.queue_free() roster_buttons.erase(raw_id) if not live_ids.has(selected_staff_id): selected_staff_id = live_ids[0] if not live_ids.is_empty() else &"" _draft_dirty = false for raw_id: Variant in roster_buttons: (roster_buttons[raw_id] as Button).set_pressed_no_signal(StringName(raw_id) == selected_staff_id) summary_label.text = "雇用済み %d人 / 未配属 %d人" % [_staff_rows.size(), unassigned] if not _staff_rows.is_empty() else "従業員はまだいません。「候補を見る」から雇用してください。" var scroll := find_child("HiredStaffScroll", true, false) as ScrollContainer scroll.visible = not _staff_rows.is_empty() scroll.custom_minimum_size.y = mini(maxi(_staff_rows.size(), 1) * 35, 140) editor_panel.visible = not selected_staff_id.is_empty() if editor_panel.visible: _refresh_editor() ## Reset optional presentation without selecting the first roster row. ## Opening the roster still uses refresh() to choose its normal default. func clear_selection() -> void: selected_staff_id = &"" _draft_dirty = false _confirming_dismissal_id = &"" if not _built: return result_label.text = "" for raw_id: Variant in roster_buttons: (roster_buttons[raw_id] as Button).set_pressed_no_signal(false) editor_panel.hide() func select_staff(staff_id: StringName) -> void: selected_staff_id = staff_id _draft_dirty = false result_label.text = "" refresh() func _refresh_editor() -> void: if facility_option.get_popup().visible or shift_option.get_popup().visible: return var row := _selected_row() if row.is_empty(): return var current_facility := StringName(row.get(&"assigned_facility_id", &"")) var template := StringName(String(row.get(&"template_id", "DAY")).to_upper()) if not _draft_dirty: _draft_facility_id = current_facility _draft_template_id = &"NIGHT" if template == &"NIGHT" else &"DAY" staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 体力 %d / 疲労 %d / 未払 G%d" % [ row.get(&"display_name", selected_staff_id), _role_name(row), int(row.get(&"daily_wage", 0)), _facility_name(current_facility), "夜勤" if template == &"NIGHT" else "日勤" if template == &"DAY" else "自由勤務", _state_name(row), int(row.get(&"health", 100)), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)), ] staff_detail_label.text += _unique_contract_text(row) if int(row.get(&"state", 0)) == StaffRuntime.State.INCAPACITATED: staff_detail_label.text += "\n" + String(row.get(&"rescue_status", "救護待ち")) if not String(row.get(&"rest_status", "")).is_empty(): staff_detail_label.text += "\n" + String(row[&"rest_status"]) var effective_from := int(row.get(&"effective_from", -1)) var now := int(get_node("/root/GameClock").get("absolute_minute")) if effective_from > now: staff_detail_label.text += "\n反映:%sから" % _minute_text(effective_from) if row.has(&"recurring") and bool(row.get(&"recurring", false)): staff_detail_label.text += "\nこの勤務帯を毎日くり返します。" facility_option.clear() facility_option.add_item("未配属(施設担当を外す)") facility_option.set_item_metadata(0, &"") var selected_index := 0 if _draft_facility_id.is_empty() else -1 for facility: Dictionary in _facility_rows: var facility_id := StringName(facility.get(&"facility_id", &"")) facility_option.add_item(_facility_name(facility_id)) var index := facility_option.item_count - 1 facility_option.set_item_metadata(index, facility_id) if facility_id == _draft_facility_id: selected_index = index if selected_index < 0: facility_option.add_item("選択した施設は現在存在しません") selected_index = facility_option.item_count - 1 facility_option.set_item_metadata(selected_index, _draft_facility_id) facility_option.set_item_disabled(selected_index, true) facility_option.select(selected_index) shift_option.select(1 if _draft_template_id == &"NIGHT" else 0) var pending := _pending_commands.values().has(selected_staff_id) var dismissing := _pending_dismissals.values().has(selected_staff_id) facility_option.disabled = pending or dismissing shift_option.disabled = pending or dismissing _refresh_dismissal(row, dismissing) var can_edit := bool(row.get(&"can_edit", true)) apply_button.disabled = pending or dismissing or not can_edit or facility_option.is_item_disabled(selected_index) apply_button.tooltip_text = _assignment_block_text(row) if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。" if not can_edit: staff_detail_label.text += "\n" + _assignment_block_text(row) apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更" ## P16-01-DISMISS: the Runtime's preview decides whether the dismissal is offered; ## a refused row keeps the button disabled and shows the reason. func _refresh_dismissal(row: Dictionary, dismissing: bool) -> void: var preview: Dictionary = row.get(&"dismissal", {}) var allowed := bool(preview.get(&"can_dismiss", false)) dismiss_button.disabled = dismissing or not allowed dismiss_button.text = "解雇を受理済み…" if dismissing else "解雇する…" if allowed: dismiss_button.tooltip_text = _dismissal_summary(row, preview) else: var reason := StringName(preview.get(&"reason", &"")) var reason_text := _dismissal_reason_text(reason, preview) dismiss_button.tooltip_text = reason_text if not dismissing and not reason_text.is_empty() and not staff_detail_label.text.contains(reason_text): # UNIQUE keeps its existing contract line; the other reasons are labelled. var line := reason_text if reason == &"UNIQUE" else "解雇不可:" + reason_text staff_detail_label.text += "\n" + line if _confirming_dismissal_id != selected_staff_id or dismissing or not allowed: _confirming_dismissal_id = &"" dismiss_confirm_box.visible = not _confirming_dismissal_id.is_empty() if dismiss_confirm_box.visible: dismiss_confirm_label.text = _dismissal_summary(row, preview) func request_dismissal() -> void: if _runtime == null or selected_staff_id.is_empty(): return refresh() var row := _selected_row() var preview: Dictionary = row.get(&"dismissal", {}) if row.is_empty() or not bool(preview.get(&"can_dismiss", false)) or _pending_dismissals.values().has(selected_staff_id): var reason_text := "この従業員の解雇はすでに受理されています。" if _pending_dismissals.values().has(selected_staff_id) \ else _dismissal_reason_text(StringName(preview.get(&"reason", &"")), preview) _set_result(reason_text if not reason_text.is_empty() else "この従業員は解雇できません。", false) return _confirming_dismissal_id = selected_staff_id refresh() func cancel_dismissal() -> void: _confirming_dismissal_id = &"" refresh() func confirm_dismissal() -> void: if _runtime == null or selected_staff_id.is_empty(): return var armed := _confirming_dismissal_id # Re-read the roster first: a load or a changed employee never sends a stale request. refresh() var row := _selected_row() var preview: Dictionary = row.get(&"dismissal", {}) var employee_name := String(row.get(&"display_name", selected_staff_id)) if not armed.is_empty() and armed == selected_staff_id and not row.is_empty() \ and not bool(preview.get(&"can_dismiss", false)): _confirming_dismissal_id = &"" _set_result("%s:%s" % [employee_name, _dismissal_reason_text(StringName(preview.get(&"reason", &"")), preview)], false) refresh() return if armed.is_empty() or armed != selected_staff_id or _confirming_dismissal_id != armed: _set_result("解雇の確認は取り消されました。もう一度「解雇する…」を押してください。", false) return var staff_id := selected_staff_id _confirming_dismissal_id = &"" var key := StringName("%s%08d" % [DISMISSAL_KEY_PREFIX, _dismissal_sequence]) _dismissal_sequence += 1 var result: Variant = _submit_ui_command(DISMISSAL_COMMAND, {&"staff_id": staff_id}, &"player", staff_id, 75, -1, key) if result == null or not result.has_method(&"is_success"): _set_result("解雇に応答がありません。", false) refresh() return var pending := false var queue: Variant = _runtime.get(&"command_queue") if queue != null: for command: Dictionary in queue.create_snapshot().get("pending", []): if int(command.get("command_id", 0)) == int(result.command_id): pending = true break if result.is_success() and pending: _pending_dismissals[int(result.command_id)] = staff_id _set_result("%s の解雇を受理しました。次の時間更新で反映します。" % employee_name, true) else: _show_dismissal_result(result, employee_name) refresh() func _show_dismissal_result(result: Variant, employee_name: String) -> void: var details: Dictionary = result.details if result.details is Dictionary else {} if not result.is_success(): var translated := _dismissal_reason_text(StringName(details.get(&"reason", details.get("reason", &""))), details) _set_result("%s:%s" % [employee_name, translated if not translated.is_empty() else "解雇できませんでした:%s" % String(result.message)], false) return var text := "%s を解雇しました。退職金 G%d を払いました" % [employee_name, int(details.get(&"severance", 0))] if int(details.get(&"severance_top_up", 0)) > 0: text += "(不足 G%d は裏金から補填)" % int(details[&"severance_top_up"]) text += "。" if int(details.get(&"unpaid_wages_preserved", 0)) > 0: text += "未払い G%d は次の0時に精算します。" % int(details[&"unpaid_wages_preserved"]) if not (details.get(&"reopened_task_ids", []) as Array).is_empty(): text += "持っていた作業は開き直しました。" _set_result(text, true) func _dismissal_summary(row: Dictionary, preview: Dictionary) -> String: var severance := int(preview.get(&"severance", 0)) var top_up := maxi(severance - int(preview.get(&"front_cash", 0)), 0) var text := "%sを解雇します。退職金 G%d(日給の3日分)を表金から払います。" % [row.get(&"display_name", selected_staff_id), severance] if top_up > 0: text += "不足 G%d は裏金から補います。" % top_up if int(preview.get(&"unpaid_wages", 0)) > 0: text += "未払い G%d は退職金と別に次の0時に精算します。" % int(preview[&"unpaid_wages"]) var task_names: Array[String] = [] for task_type: Variant in preview.get(&"reopened_task_types", []): task_names.append(String(TASK_NAMES.get(StringName(task_type), "作業"))) if not task_names.is_empty(): text += "持っている%sは開き直して、ほかの従業員に回します。" % "・".join(task_names) return text func _dismissal_reason_text(reason: StringName, details: Dictionary = {}) -> String: match reason: &"": return "" &"UNIQUE": return "ユニーク:雇用・維持費無料 / 解雇・売却不可" &"INSUFFICIENT_FUNDS": return "退職金 G%d を払えません(表金と裏金の合計 G%d)。" % [int(details.get(&"severance", 0)), int(details.get(&"available_funds", 0))] var reasons := { &"NOT_FOUND": "従業員が見つかりません。", &"NOT_GENERAL_EMPLOYEE": "ギルドで雇った一般従業員だけを解雇できます。", &"EXTERNAL_TASK": "物流の受け取り・配達・返送の途中です。終わってから解雇できます。", &"DEFENSE_LOCKED": "襲撃の防衛に参加中は解雇できません。襲撃が終わってから解雇できます。", &"RESCUE_PAIR_ABOARD": "救護の搬送で昇降機に乗っています。着いてから解雇できます。", &"RESERVATION_RELEASE_FAILED": "作業の予約を解けないため解雇できません。", &"BANKRUPT": "運営終了後は解雇できません。", } return String(reasons.get(reason, "")) ## Runtime supplies canonical, owned presentation data after every normal ## load. The panel neither infers a unique identity from wages nor edits a save. func _unique_contract_text(row: Dictionary) -> String: var contract: Dictionary = row.get(&"unique_contract", {}) if contract.is_empty(): return "" var text := "" if bool(contract.get(&"free_employment", false)) and bool(contract.get(&"free_upkeep", false)) and bool(contract.get(&"permanent_removal_forbidden", false)): text = "\nユニーク:雇用・維持費無料 / 解雇・売却不可" for effect: Dictionary in contract.get(&"passive_effects", []): if effect.get(&"effect_id") != &"assigned_floor_revenue_multiplier_bp" or effect.get(&"scope") != &"ASSIGNED_FLOOR_ALL_LAYERS": continue var bonus_percent := int((int(effect[&"multiplier_bp"]) - 10000) / 100) text += "\n[%s] 配置階の全施設売上+%d%%" % [effect[&"display_name"], bonus_percent] if bool(effect.get(&"applied", false)) and effect.has(&"floor_y"): var floor_y := int(effect[&"floor_y"]) var floor_name := "%dF" % (floor_y + 1) if floor_y >= 0 else "B%d" % -floor_y text += "\n対象:%s(表層・裏層の全施設)" % floor_name else: text += "\n未適用(配属先なし・施設不在)" return text func _on_facility_changed(index: int) -> void: _draft_facility_id = StringName(facility_option.get_item_metadata(index)) _draft_dirty = true func _on_shift_changed(index: int) -> void: _draft_template_id = StringName(shift_option.get_item_metadata(index)) _draft_dirty = true func submit_assignment() -> void: if _runtime == null or selected_staff_id.is_empty(): return if _pending_commands.values().has(selected_staff_id): _set_result("この従業員の変更はすでに受理されています。", false) return var key := StringName("%s%08d" % [KEY_PREFIX, _sequence]) _sequence += 1 var result: Variant = _submit_ui_command(ASSIGNMENT_COMMAND, { &"staff_id": selected_staff_id, &"facility_id": _draft_facility_id, &"template_id": _draft_template_id, }, &"player", selected_staff_id, 75, -1, key) if result == null or not result.has_method(&"is_success"): _set_result("配属変更に応答がありません。", false) return var pending := false var queue: Variant = _runtime.get(&"command_queue") if queue != null: for command: Dictionary in queue.create_snapshot().get("pending", []): if int(command.get("command_id", 0)) == int(result.command_id): pending = true break if result.is_success() and pending: _pending_commands[int(result.command_id)] = selected_staff_id _set_result("変更を受理しました。次の時間更新で反映します。", true) else: _show_command_result(result) refresh() func _show_command_result(result: Variant, staff_id: StringName = &"") -> void: var target_id := staff_id if target_id.is_empty(): target_id = StringName(result.details.get(&"staff_id", selected_staff_id)) var employee_name := String(target_id) for row: Dictionary in _staff_rows: if StringName(row.get(&"staff_id", &"")) == target_id: employee_name = String(row.get(&"display_name", target_id)) break if result.is_success(): var details: Dictionary = result.details if result.details is Dictionary else {} if target_id == selected_staff_id: _draft_dirty = false var facility_id := StringName(details.get(&"facility_id", _draft_facility_id)) var template := StringName(details.get(&"template_id", _draft_template_id)) var start := int(details.get(&"effective_from", -1)) var when := "%sから" % _minute_text(start) if start >= 0 else "" _set_result("%s:%s%s・毎日%sに変更しました。" % [employee_name, when, _facility_name(facility_id), "夜勤" if template == &"NIGHT" else "日勤"], true) else: _set_result("%s:%s" % [employee_name, _failure_message(result)], false) func _failure_message(result: Variant) -> String: var details: Dictionary = result.details if result.details is Dictionary else {} var reason := StringName(details.get(&"reason", details.get("reason", &""))) var translated := _assignment_reason_text(reason) if not translated.is_empty(): return translated return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。" func _assignment_block_text(row: Dictionary) -> String: var translated := _assignment_reason_text(StringName(row.get(&"assignment_block_reason", &""))) return translated if not translated.is_empty() else "現在の作業や拘束が終わってから変更できます。" func _assignment_reason_text(reason: StringName) -> String: var reasons := { &"STAFF_NOT_FOUND": "従業員が見つかりません。", &"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。", &"ROLE_MISMATCH": "この施設には役割が合いません。必要な職種を確認してください。", &"NO_ACCESS": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。", &"NO_PATH": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。", &"STAFF_BUSY": "作業中のため変更できません。作業完了後に再試行してください。", &"CAPACITY_EXCEEDED": "配属先の人員上限を超えています。", &"FACILITY_FULL": "配属先の人員上限を超えています。", &"NO_ROUTE": "配属先へ移動できません。通路と従業員ドアを確認してください。", &"INVALID_TEMPLATE": "日勤または夜勤を選択してください。", &"STAFF_UNAVAILABLE": "この従業員は現在、勤務を変更できません。状態をご確認ください。", &"DEFENSE_LOCKED": "防衛参加中は勤務を変更できません。", &"EVENT_LOCKED": "イベント参加中は勤務を変更できません。", &"FACILITY_UNSTAFFED": "この施設は人員を配属する施設ではありません。", &"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。", &"BANKRUPT": "運営終了後は配属を変更できません。", } return reasons.get(reason, "") func _synchronize_commands(queue: Variant) -> void: _pending_commands.clear() _pending_dismissals.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) if key.begins_with(DISMISSAL_KEY_PREFIX) and key.trim_prefix(DISMISSAL_KEY_PREFIX).is_valid_int(): _dismissal_sequence = maxi(_dismissal_sequence, int(key.trim_prefix(DISMISSAL_KEY_PREFIX)) + 1) for command: Dictionary in queue.create_snapshot().get("pending", []): var command_type := StringName(command.get("command_type", &"")) if command_type not in [ASSIGNMENT_COMMAND, DISMISSAL_COMMAND]: continue var payload: Dictionary = command.get("payload", {}) var pending := _pending_commands if command_type == ASSIGNMENT_COMMAND else _pending_dismissals pending[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &""))) func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: if is_visible_in_tree(): refresh() func _on_command_completed(result: Variant) -> void: if _pending_dismissals.has(int(result.command_id)): var dismissed_id := StringName(_pending_dismissals[int(result.command_id)]) _pending_dismissals.erase(int(result.command_id)) var employee_name := String(dismissed_id) for row: Dictionary in _staff_rows: if StringName(row.get(&"staff_id", &"")) == dismissed_id: employee_name = String(row.get(&"display_name", dismissed_id)) break _show_dismissal_result(result, employee_name) refresh() return if not _pending_commands.has(int(result.command_id)): return var staff_id := StringName(_pending_commands[int(result.command_id)]) _pending_commands.erase(int(result.command_id)) _show_command_result(result, staff_id) refresh() func _set_result(message: String, success: bool) -> void: result_label.text = message LayerSkin.register_status_text(result_label, success, _active_layer()) status_requested.emit(message, success) func _read_roster() -> void: _staff_rows.clear() _facility_rows.clear() if _runtime.has_method(&"get_staff_management_report"): var report: Dictionary = _runtime.call(&"get_staff_management_report") _staff_rows.assign(report.get(&"staff", [])) _facility_rows.assign(report.get(&"facilities", [])) return _set_result("従業員データを読み込めません。", false) func _selected_row() -> Dictionary: for row: Dictionary in _staff_rows: if StringName(row.get(&"staff_id", &"")) == selected_staff_id: return row return {} func _facility_name(facility_id: StringName) -> String: if facility_id.is_empty(): return "未配属" for facility: Dictionary in _facility_rows: if StringName(facility.get(&"facility_id", &"")) == facility_id: var suffix := String(facility_id).trim_prefix("facility_").lstrip("0") var location := "" var origin: Variant = facility.get(&"origin", null) if origin is Vector3i: var floor_name := "%dF" % (origin.y + 1) if origin.y >= 0 else "B%d" % -origin.y location = " %s・%s" % [floor_name, "客用" if int(facility.get(&"layer", 0)) == 0 else "裏方"] return "%s%s #%s" % [facility.get(&"display_name", facility_id), location, suffix if not suffix.is_empty() else facility_id] return "撤去済みの施設" func _role_name(row: Dictionary) -> String: var role := StringName(row.get(&"role_id", &"")) return String(ROLE_NAMES.get(role, String(role))) func _state_name(row: Dictionary) -> String: var value := int(row.get(&"state", 0)) return STATE_NAMES[value] if value >= 0 and value < STATE_NAMES.size() else "状態を確認中" func _minute_text(absolute_minute: int) -> String: var day := absolute_minute / 1440 + 1 var hour := posmod(absolute_minute, 1440) / 60 var minute := posmod(absolute_minute, 60) return "%d日目 %02d:%02d" % [day, hour, minute] func _label(text_value: String) -> Label: var label := Label.new() label.text = text_value label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.add_theme_font_size_override("font_size", 12) label.add_theme_color_override("font_color", Color("394937")) LayerSkin.register_text(label, _active_layer()) return label func _active_layer() -> int: var state := get_node_or_null("/root/GameState") return int(state.get("active_layer")) if state != null else 0 func _style(button: Button) -> void: button.add_theme_font_size_override("font_size", 12) if _style_button.is_valid(): _style_button.call(button) func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant: if selected_staff_id != staff_id: select_staff(staff_id) var busy := _pending_commands.values().has(staff_id) or _pending_dismissals.values().has(staff_id) if busy or not bool(_selected_row().get(&"can_edit", false)): _set_result("変更を処理中です。" if busy else _assignment_block_text(_selected_row()), false) return null var preview := Label.new() preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"] (roster_buttons[staff_id] as Control).set_drag_preview(preview) return {&"kind": &"staff_assignment", &"staff_id": staff_id, &"template_id": _draft_template_id, &"queue_instance_id": _queue_instance_id} func assign_from_drop(data: Dictionary, facility_id: StringName) -> void: # A successful load replaces the queue, even when staff/facility IDs match. # Never carry an unfinished pointer gesture into that restored world. refresh() if data.get(&"queue_instance_id", 0) != _queue_instance_id: _set_result("読み込み前のドラッグは取り消しました。従業員を選び直して配置してください。", false) return if facility_id.is_empty(): _set_result("完成した配属先施設へドロップしてください。表裏の層も確認してください。", false) return var staff_id := StringName(data.get(&"staff_id", &"")) if not roster_buttons.has(staff_id): _set_result("従業員が見つかりません。", false) return select_staff(staff_id) _draft_facility_id = facility_id _draft_template_id = StringName(data.get(&"template_id", &"DAY")) _draft_dirty = true submit_assignment() 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)