Files
TheTree_mock/scripts/ui/staff_management_panel.gd
T

739 lines
34 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 += "持っていた作業は開き直しました。"
if not (details.get(&"interrupted_rescue_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)
# A rescue the command interrupts is cancelled, not reopened (SPEC 6.2.1).
var roles: Array = preview.get(&"interrupted_rescue_roles", [])
if roles.has(&"carrier"):
text += "運んでいる救護の搬送は取り消し、患者の救護をやり直します。"
if roles.has(&"patient"):
text += "この従業員への救護は取り消し、救護係を外します。"
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": "襲撃の防衛に参加中は解雇できません。襲撃が終わってから解雇できます。",
&"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)