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

This commit is contained in:
Agent Control Runner
2026-10-25 14:45:45 +09:00
parent b06fec17c7
commit a073ccb59b
10 changed files with 668 additions and 127 deletions
+119 -29
View File
@@ -6934,12 +6934,14 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
## P16-01-DISMISS: whether a hired employee can be dismissed now, with the severance
## (A14) and the Task the removal would reopen. Nothing is changed.
## (A14), the Task the removal reopens and the rescue Tasks the command interrupts.
## Nothing is changed.
func get_staff_dismissal_preview(staff_id: StringName) -> Dictionary:
var staff: StaffRuntime = staff_service.get_staff(staff_id) if staff_service != null else null
var preview := {
&"staff_id": staff_id, &"can_dismiss": false, &"reason": &"NOT_FOUND",
&"severance": 0, &"unpaid_wages": 0, &"reopened_task_ids": [], &"reopened_task_types": [],
&"interrupted_rescue_task_ids": [], &"interrupted_rescue_task_types": [], &"interrupted_rescue_roles": [],
&"available_funds": FinancePolicy.available_funds(economy_service) if economy_service != null else 0,
&"front_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT) if economy_service != null else 0,
}
@@ -6948,18 +6950,41 @@ func get_staff_dismissal_preview(staff_id: StringName) -> Dictionary:
var severance := FinancePolicy.severance(staff.get_effective_daily_wage())
preview[&"severance"] = severance
preview[&"unpaid_wages"] = staff.unpaid_wages
var task := staff_service.get_task(staff.current_task_id)
if task != null and not task.is_terminal():
preview[&"reopened_task_ids"] = [task.task_id]
preview[&"reopened_task_types"] = [task.task_type]
var reason := _staff_dismissal_block_reason(staff, severance)
var work := _staff_dismissal_work(staff)
for kind: StringName in [&"reopened", &"interrupted_rescue"]:
for task: StaffTask in work[kind]:
(preview[StringName(String(kind) + "_task_ids")] as Array).append(task.task_id)
(preview[StringName(String(kind) + "_task_types")] as Array).append(task.task_type)
for task: StaffTask in work[&"interrupted_rescue"]:
(preview[&"interrupted_rescue_roles"] as Array).append(&"carrier" if task.task_id == staff.current_task_id else &"patient")
# R5: after the game ends the command is refused before acceptance (submit_command).
var ended := _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal()
var reason: StringName = &"BANKRUPT" if ended else _staff_dismissal_block_reason(staff, severance)
preview[&"reason"] = reason
preview[&"can_dismiss"] = reason.is_empty()
return preview
## Every refusal of SPEC 6.3 except the Task Reservation release (R3), which only the
## existing removal contract can decide and which then changes nothing.
## The work a dismissal changes: the held Task the removal contract reopens, and the
## rescue Tasks the command interrupts (the held transport and every rescue of the
## employee as the patient; SPEC 6.2.1).
func _staff_dismissal_work(staff: StaffRuntime) -> Dictionary:
var reopened: Array[StaffTask] = []
var interrupted: Array[StaffTask] = []
var held := staff_service.get_task(staff.current_task_id)
if held != null and not held.is_terminal():
if held.task_type == &"rescue_transport":
interrupted.append(held)
else:
reopened.append(held)
for task: StaffTask in staff_service.get_tasks():
if task.task_type in [&"rescue_pickup", &"rescue_transport"] and not task.is_terminal() \
and task.target_entity_id == staff.staff_id and not interrupted.has(task):
interrupted.append(task)
return {&"reopened": reopened, &"interrupted_rescue": interrupted}
## Every refusal of SPEC 6.3 the executor decides (R1, R2, R3, R7, R8, R9).
func _staff_dismissal_block_reason(staff: StaffRuntime, severance: int) -> StringName:
if staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE:
return &"UNIQUE"
@@ -6967,24 +6992,35 @@ func _staff_dismissal_block_reason(staff: StaffRuntime, severance: int) -> Strin
return &"NOT_GENERAL_EMPLOYEE"
if staff_service.is_externally_driven_task(staff.current_task_id):
return &"EXTERNAL_TASK"
if not _staff_task_reservations_releasable(staff):
return &"RESERVATION_RELEASE_FAILED"
# Root's temporary boundary R9: an unfinished raid's defender stays until the raid ends.
if not _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty():
return &"DEFENSE_LOCKED"
# A rescue pair aboard an elevator stays together until a real landing (the
# existing rule), so either member is dismissed after that landing.
var trip := elevators.get_actor_trip(_staff_path_actor_id(staff.staff_id))
if trip.get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED \
and not StringName(trip.get(&"companion_id", &"")).is_empty():
return &"RESCUE_PAIR_ABOARD"
if severance > FinancePolicy.available_funds(economy_service):
return &"INSUFFICIENT_FUNDS"
return &""
## R3 decided before any change: the removal contract releases every Reservation of the
## held Task, and InventoryService.release_reservation fails for a missing or a
## consumed Reservation (a released or expired one is a replayed success).
func _staff_task_reservations_releasable(staff: StaffRuntime) -> bool:
var task := staff_service.get_task(staff.current_task_id)
if task == null:
return true
for reservation_id: StringName in task.reservation_ids:
var reservation := inventory_service.get_reservation(reservation_id)
if reservation == null or reservation.state == InventoryReservation.State.CONSUMED:
return false
return true
## Dismissal of a general employee (FREE_HIRE). All refusals are decided before any
## change (SPEC 4.1 c). The existing removal contract, the severance paid from the front
## fund with the hidden fund covering the rest (one posting, A14) and the cleanup of
## SPEC 6.2 then commit as one operation and are rolled back together on a failure.
## change (SPEC 4.1 c). The employee's seat in a boarded rescue pair, the existing
## removal contract, the severance paid from the front fund with the hidden fund
## covering the rest (one posting, A14) and the cleanup of SPEC 6.2 then commit as one
## operation and are rolled back together on a failure.
func _execute_dismiss_staff(command: SimulationCommand) -> CommandResult:
var raw_staff_id: Variant = command.payload.get(&"staff_id", command.payload.get("staff_id", null))
if typeof(raw_staff_id) not in [TYPE_STRING, TYPE_STRING_NAME] or String(raw_staff_id).is_empty():
@@ -7001,13 +7037,13 @@ func _execute_dismiss_staff(command: SimulationCommand) -> CommandResult:
var severance := FinancePolicy.severance(staff.get_effective_daily_wage())
var reason := _staff_dismissal_block_reason(staff, severance)
if not reason.is_empty():
# R1 and R2 keep the existing removal contract's code and text.
# R1, R2 and R3 keep the existing removal contract's code and text.
var refusals := {
&"UNIQUE": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Unique Staff cannot be dismissed, sold or permanently removed."],
&"EXTERNAL_TASK": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Finish the employee external operation before removal."],
&"RESERVATION_RELEASE_FAILED": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Staff removal could not release Task Reservations."],
&"NOT_GENERAL_EMPLOYEE": [CommandResult.Code.VALIDATION_FAILED, "Only a general employee can be dismissed."],
&"DEFENSE_LOCKED": [CommandResult.Code.VALIDATION_FAILED, "Staff is defending an unfinished raid."],
&"RESCUE_PAIR_ABOARD": [CommandResult.Code.VALIDATION_FAILED, "A rescue pair aboard an elevator must reach a landing first."],
&"INSUFFICIENT_FUNDS": [CommandResult.Code.VALIDATION_FAILED, "Front and hidden funds cannot pay the severance."],
}
return CommandResult.failed(refusals[reason][0], command.command_id, refusals[reason][1], {
@@ -7027,10 +7063,19 @@ func _execute_dismiss_staff(command: SimulationCommand) -> CommandResult:
&"id_generator": id_generator.create_snapshot(),
}
var held_task_id := staff.current_task_id
# The existing contract refuses an unreleasable Task Reservation (R3) before it
# changes anything; the Inventory release rolls itself back.
var reopened: Array = _staff_dismissal_work(staff)[&"reopened"]
if not _release_dismissed_rescue_seat(staff_id):
_rollback_staff_dismissal(before)
return CommandResult.failed(
CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id,
"Staff dismissal could not release the employee's seat in a boarded rescue pair.",
{&"reason": &"RESCUE_SEAT_RELEASE_FAILED", &"staff_id": staff_id, &"error": elevators.last_error}
)
# The existing contract still refuses an unreleasable Task Reservation (R3, decided
# above) as the last guard; nothing it would change survives the rollback.
var removal := staff_service.remove_staff(staff_id, &"DISMISSED", true, minute)
if not removal.is_success():
_rollback_staff_dismissal(before)
removal.command_id = command.command_id
removal.details[&"reason"] = &"RESERVATION_RELEASE_FAILED"
removal.details[&"staff_id"] = staff_id
@@ -7070,6 +7115,10 @@ func _execute_dismiss_staff(command: SimulationCommand) -> CommandResult:
# settled at the command boundary by the existing rules, not only at the next minute.
_sync_facility_staffing(minute)
event_service.audit_actor_availability(minute)
var reopened_ids: Array[StringName] = []
for task: StaffTask in reopened:
if not task.is_terminal():
reopened_ids.append(task.task_id)
return CommandResult.succeeded(command.command_id, {
&"staff_id": staff_id,
&"severance": severance,
@@ -7078,17 +7127,51 @@ func _execute_dismiss_staff(command: SimulationCommand) -> CommandResult:
&"unpaid_wages_preserved": int(removal.details.get(&"unpaid_wages_preserved", 0)),
&"removed_shift_ids": removal.details.get(&"removed_shift_ids", []),
&"removed_override_days": removal.details.get(&"removed_override_days", []),
&"reopened_task_ids": [held_task_id] if not held_task_id.is_empty() else [],
&"reopened_task_ids": reopened_ids,
&"interrupted_rescue_task_ids": interrupted,
&"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
&"back_cash": economy_service.get_balance(FinancePolicy.BACK_CASH),
})
## SPEC 6.2.1 and 10: a dismissed member of a rescue pair aboard an elevator leaves the
## cabin at the command, as the non-depicted removal; the other member stays aboard, as
## the existing rule keeps a live passenger to a real landing. These are the steps of
## ElevatorService's own cancellation of a single boarded rider (_cancel_single),
## through the public Path and elevator snapshot interfaces: the Path request ends, and
## the employee's trip, cabin seat and pairing are removed from the snapshot that
## ElevatorService.restore_snapshot then checks against the Path state.
func _release_dismissed_rescue_seat(staff_id: StringName) -> bool:
var actor_id := _staff_path_actor_id(staff_id)
var trip := elevators.get_actor_trip(actor_id)
var partner := StringName(trip.get(&"companion_id", &""))
if trip.get(&"state", &"") != PathService.ELEVATOR_REQUEST_BOARDED or partner.is_empty():
return true
if not path_service.complete_elevator_request(StringName(trip[&"connector_id"]), StringName(trip[&"request_id"])):
return false
var snapshot := elevators.create_snapshot()
var trips: Array = []
for value: Dictionary in snapshot[&"trips"]:
var row := value.duplicate(true)
if StringName(row[&"actor_id"]) == actor_id:
continue
if StringName(row[&"actor_id"]) == partner:
row.erase(&"companion_id")
trips.append(row)
snapshot[&"trips"] = trips
for car: Dictionary in snapshot[&"shafts"]:
(car[&"boarded"] as Array).erase(actor_id)
return elevators.restore_snapshot(snapshot)
func _staff_aboard(staff_id: StringName) -> bool:
return elevators.get_actor_trip(_staff_path_actor_id(staff_id)).get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED
## SPEC 6.2.1: no live rescue Task keeps the dismissed employee as its patient or
## carrier. The next minute's rule (_advance_staff_rescue) is applied to those Tasks
## now: each is interrupted and its elevator requests are cancelled, except that a
## carrier already aboard rides on to a real landing. A pickup whose carrier was
## now: each is interrupted and its elevator requests are cancelled, except that a live
## passenger already aboard rides on to a real landing. A pickup whose carrier was
## dismissed was reopened by the removal and stays open for another medic, as that
## rule keeps it. Returns null if a Task could not be interrupted.
func _interrupt_dismissed_staff_rescue(staff_id: StringName, held_task_id: StringName, minute: int) -> Variant:
@@ -7101,13 +7184,12 @@ func _interrupt_dismissed_staff_rescue(staff_id: StringName, held_task_id: Strin
continue
var patient := staff_service.get_staff(task.target_entity_id)
var carrier := staff_service.get_staff(task.assigned_staff_id)
var aboard: bool = carrier != null and elevators.get_actor_trip(_staff_path_actor_id(carrier.staff_id)).get(&"state", &"") \
== PathService.ELEVATOR_REQUEST_BOARDED
if not staff_service.cancel_task(task.task_id, &"RESCUE_INTERRUPTED", minute).is_success():
return null
interrupted.append(task.task_id)
if patient != null: elevators.cancel_actor(_staff_path_actor_id(patient.staff_id))
if carrier != null and not aboard: elevators.cancel_actor(_staff_path_actor_id(carrier.staff_id))
for member: StaffRuntime in [patient, carrier]:
if member != null and not _staff_aboard(member.staff_id):
elevators.cancel_actor(_staff_path_actor_id(member.staff_id))
return interrupted
@@ -9735,6 +9817,14 @@ func _reserve_staff_task_items(task: StaffTask, absolute_minute: int) -> Diction
var before := inventory_service.create_snapshot()
var reservation_ids: Array[StringName] = []
for item_id: StringName in _sorted_name_keys(task.required_items):
# One operation per reservation of the Task: InventoryService never removes a
# Reservation, so the count of the Task's earlier ones for the item makes the next
# reservation after a release (an interruption, a removal or a dismissal) a new
# operation, also within the same minute.
var earlier := 0
for reservation: InventoryReservation in inventory_service.get_reservations():
if reservation.requester_id == task.task_id and reservation.item_id == item_id:
earlier += 1
var result := inventory_service.reserve_item(
inventory_id,
item_id,
@@ -9743,7 +9833,7 @@ func _reserve_staff_task_items(task: StaffTask, absolute_minute: int) -> Diction
&"staff_task",
absolute_minute,
absolute_minute + 480,
StringName("staff.task.reserve.%s.%s" % [task.task_id, item_id])
StringName("staff.task.reserve.%s.%s.%d" % [task.task_id, item_id, earlier])
)
if not bool(result.get(&"ok", false)):
inventory_service.restore_snapshot(before)