P16-01-DISMISS: 一般従業員の通常操作での解雇(退職金を含む。P16-01 の補助の作業。原単位への加算 0)

This commit is contained in:
Agent Control Runner
2026-10-24 12:49:31 +09:00
parent 0b59d7c23c
commit b06fec17c7
17 changed files with 2503 additions and 17 deletions
+21 -2
View File
@@ -32,6 +32,13 @@ const LOAN_INTEREST_CATEGORY: StringName = &"LOAN_INTEREST"
const LOAN_INTEREST_PAYMENT_CATEGORY: StringName = &"LOAN_INTEREST_PAYMENT"
const NON_PROFIT_CATEGORIES: Array[StringName] = [TOP_UP_CATEGORY, RANK_TAX_PAYMENT_CATEGORY, LOAN_INTEREST_PAYMENT_CATEGORY]
## A14 (2026-10-23): dismissing a general employee pays three days of the effective daily
## wage, from the front fund with the hidden fund covering the rest. It is booked to its
## own expense account, apart from WAGE, and counted with the operating costs.
const SEVERANCE_EXPENSE: StringName = &"STAFF_SEVERANCE_EXPENSE"
const SEVERANCE_CATEGORY: StringName = &"STAFF_SEVERANCE"
const SEVERANCE_DAYS := 3
static func rank_tax(sales: int) -> int:
return maxi(sales, 0) / 100 * RANK_TAX_PERCENT + maxi(sales, 0) % 100 * RANK_TAX_PERCENT / 100
@@ -42,6 +49,10 @@ static func loan_interest(outstanding_principal: int) -> int:
+ maxi(outstanding_principal, 0) % 100 * LOAN_INTEREST_PERCENT_PER_DAY / 100
static func severance(daily_wage: int) -> int:
return maxi(daily_wage, 0) * SEVERANCE_DAYS
static func is_back_fund_service(tags: Array) -> bool:
for tag: StringName in BACK_FUND_TAGS:
if tag in tags:
@@ -66,12 +77,20 @@ static func prepare_accounts(economy: EconomyService) -> bool:
for account_id: StringName in expected:
if not economy.register_account(account_id, bool(expected[account_id])):
return false
return true
return _prepare_severance_account(economy)
for account_id: StringName in expected:
if not economy.has_account(account_id) \
or economy.account_allows_negative(account_id) != bool(expected[account_id]):
return false
return true
return _prepare_severance_account(economy)
## A save from before the severance gains its empty expense account the same way; the
## balances, the other accounts and the history are unchanged.
static func _prepare_severance_account(economy: EconomyService) -> bool:
if economy.has_account(SEVERANCE_EXPENSE):
return not economy.account_allows_negative(SEVERANCE_EXPENSE)
return economy.register_account(SEVERANCE_EXPENSE, false)
static func outstanding_principal(economy: EconomyService) -> int:
+216 -7
View File
@@ -9,12 +9,17 @@ 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
@@ -25,6 +30,11 @@ 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 = &""
@@ -37,7 +47,10 @@ 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
@@ -112,6 +125,42 @@ func _build_interface() -> void:
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)
@@ -124,6 +173,7 @@ func refresh() -> void:
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)
@@ -178,6 +228,7 @@ func refresh() -> void:
func clear_selection() -> void:
selected_staff_id = &""
_draft_dirty = false
_confirming_dismissal_id = &""
if not _built:
return
result_label.text = ""
@@ -239,16 +290,157 @@ func _refresh_editor() -> void:
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)
facility_option.disabled = pending
shift_option.disabled = pending
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 not can_edit or facility_option.is_item_disabled(selected_index)
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:
@@ -373,15 +565,20 @@ func _assignment_reason_text(reason: StringName) -> String:
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", []):
if StringName(command.get("command_type", &"")) != ASSIGNMENT_COMMAND:
var command_type := StringName(command.get("command_type", &""))
if command_type not in [ASSIGNMENT_COMMAND, DISMISSAL_COMMAND]:
continue
var payload: Dictionary = command.get("payload", {})
_pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &"")))
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:
@@ -390,6 +587,17 @@ func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") ->
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)])
@@ -478,8 +686,9 @@ func _style(button: Button) -> void:
func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant:
if selected_staff_id != staff_id:
select_staff(staff_id)
if _pending_commands.values().has(staff_id) or not bool(_selected_row().get(&"can_edit", false)):
_set_result("変更を処理中です。" if _pending_commands.values().has(staff_id) else _assignment_block_text(_selected_row()), false)
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 "日勤"]