P16-01-DISMISS: 一般従業員の通常操作での解雇(退職金を含む。P16-01 の補助の作業。原単位への加算 0)
This commit is contained in:
+119
-29
@@ -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)
|
||||
|
||||
+3
-3
@@ -3997,7 +3997,7 @@
|
||||
"template": "PASS: <checks> checks",
|
||||
"required": true,
|
||||
"fixed_check_count_used": false,
|
||||
"source_sha256_at_schema_audit": "b69a786539d3afd9f80a9857169b8a1687e176a172c4d63bca248dfe0ee316c1",
|
||||
"source_sha256_at_schema_audit": "e325c7ac24f979bc8ac3f4222279c317b2603ef20cae046475647dd90d423123",
|
||||
"authority": {
|
||||
"path": "docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/SPEC.md",
|
||||
"scope": "new P16-01-DISMISS suite marker; native execution pending"
|
||||
@@ -4008,7 +4008,7 @@
|
||||
"template": "PASS: <checks> checks",
|
||||
"required": true,
|
||||
"fixed_check_count_used": false,
|
||||
"source_sha256_at_schema_audit": "05514993cd2fd37c709555a584256ee8233ec2e4b0feca37a54e59f00970457e",
|
||||
"source_sha256_at_schema_audit": "b38745eccb4e51ddf4790051795e2f64faf10f875edc7b754ac2d04db2917a5b",
|
||||
"authority": {
|
||||
"path": "docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/SPEC.md",
|
||||
"scope": "new P16-01-DISMISS F8 restart writer marker; native execution pending"
|
||||
@@ -4019,7 +4019,7 @@
|
||||
"template": "PASS: <checks> checks",
|
||||
"required": true,
|
||||
"fixed_check_count_used": false,
|
||||
"source_sha256_at_schema_audit": "f8ad3cb30c40538131acca962abd8ee1234b1f8b04c9d0edeb0884f979e98b6f",
|
||||
"source_sha256_at_schema_audit": "4e13de2594096fab648fc8c418a1a4aa764ac72fc13fbd68cba1c6f05767fc83",
|
||||
"authority": {
|
||||
"path": "docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/SPEC.md",
|
||||
"scope": "new P16-01-DISMISS F8 restart reader marker; native execution pending"
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"not_expectation_changes": [
|
||||
"tests/economy/test_p05_funds_runtime.gd _test_old_save_migration keeps its expectation: its old save is copied from a current save, so it already holds the severance account and only the five fund accounts are removed.",
|
||||
"No existing suite expected a save with unpaid rank tax or interest after a close to be refused; the restore fix of IMPLEMENTATION_NOTES.md section 5 changes no listed expectation."
|
||||
"No existing suite expected a save with unpaid rank tax or interest after a close to be refused; the restore fix of IMPLEMENTATION_NOTES.md section 6 changes no listed expectation.",
|
||||
"Repair 1: no existing suite reads the operation id of a staff Task's inventory reservation (staff.task.reserve.*); the reservation-count key of IMPLEMENTATION_NOTES.md section 5 changes no listed expectation."
|
||||
]
|
||||
}
|
||||
|
||||
+109
-41
@@ -1,10 +1,21 @@
|
||||
# P16-01-DISMISS 実装の記録(実 Opus の実装担当、2026-10-24)
|
||||
# P16-01-DISMISS 実装の記録(実 Opus の実装担当、2026-10-24、修正 1 は 2026-10-25)
|
||||
|
||||
- 仕様: 同じフォルダの `SPEC.md`(登録版 r04。main `0b59d7c` に固定。Runner の `spec_sha256` は `14efffd9…e6b`)。出典は `PROVENANCE.json`。
|
||||
- 仕様: 同じフォルダの `SPEC.md`(登録版 r04。main `0b59d7c` に固定。ファイルの SHA-256 は `b4cc31c0…5f77`、Runner の `spec_sha256` は `14efffd9…e6b`)。出典は `PROVENANCE.json`。
|
||||
- 原本への加算は 0。原単位 P16-01・P16-03、`AR.staff.dismissal_severance` の package P05 の原 work・原 AC は閉じない(SPEC §8)。
|
||||
- 管理の記録は `artifacts/original-completion-2026-10-02/p16-01-dismiss-control-r01/` にある(git の外)。
|
||||
- 変えた製品のファイル: `autoload/simulation_runtime.gd`・`scripts/economy/finance_policy.gd`・`scripts/ui/staff_management_panel.gd`。`scripts/staff/staff_service.gd` と `scenes/main/main.gd` は変えていない(§1・§4)。
|
||||
|
||||
## 0. 修正 1 の経過(S `b06fec1` → 修正の後の S)
|
||||
|
||||
- S `b06fec1` は、同じ実 reviewer の補足(改訂)の判定で changes_requested になった(high 1・medium 6・low 4。`DESKTOP_RESULT_SUPPLEMENT_R02.json`)。1回目の pass と、その後の補足の判定は、どちらも書き換えずに残している。
|
||||
- 主な所見と直し:
|
||||
- high: S `b06fec1` は、乗ったままの救護の組の解雇を、実装の判断で `RESCUE_PAIR_ABOARD` として拒んでいた。これは SPEC §6.2 の最後の文「直し、拒否に変えない」、F1・F7(許可の全ての行)に合わない。拒否を消し、組を解く後始末を作った(§3)。
|
||||
- medium: 開き直した予約つきの作業を別の職員が取れなかった(`IDEMPOTENCY_CONFLICT` で BLOCKED)。予約の操作 ID を直した(§5)。
|
||||
- medium: preview と結果が、命令の中で取り消す救護の搬送を「開き直す」と書いていた。分けて実際どおりにした(§4)。
|
||||
- medium: preview が R5(運営の終了)と R3(予約を解けない)を判定していなかった。判定を足した(§1)。
|
||||
- medium と low: 条文が明示する確かめ方を代用していた所がある(同じ process の読み込み、通勤の昇降機、拒否の行の画面、bytes の比べ、別の process の境目など)。条文どおりの試験を足した(§7)。
|
||||
- 新しい拒否や、R9 に似た境界は足していない。受入の条件も削っていない。
|
||||
|
||||
## 1. 命令と判定の順(SPEC §4.1)
|
||||
|
||||
- `SimulationRuntime` に通常の command `dismiss_staff`(payload `staff_id`)を足した(`_execute_dismiss_staff`)。
|
||||
@@ -14,69 +25,126 @@
|
||||
2. 職員が居ない → `NOT_FOUND`(R4)
|
||||
3. `UNIQUE` → `UNIQUE`(R1。今の `remove_staff` と同じ code と文)
|
||||
4. `FREE_HIRE` でない(`ADULT_CONTRACT`)→ `NOT_GENERAL_EMPLOYEE`(R7)
|
||||
5. 外部が持つ作業(仕入れの物流)→ `EXTERNAL_TASK`(R2。今の `remove_staff` と同じ code と文)
|
||||
6. 終わっていない襲撃の防衛者 → `DEFENSE_LOCKED`(R9。Root の一時の境界)
|
||||
7. 救護の組が一緒に昇降機に乗っている → `RESCUE_PAIR_ABOARD`(§3 の決め)
|
||||
5. 外部が持つ作業(仕入れの物流の受け取り・配達・返送)→ `EXTERNAL_TASK`(R2。今の `remove_staff` と同じ code と文)
|
||||
6. 持っている作業の予約を解けない → `RESERVATION_RELEASE_FAILED`(R3。今の `remove_staff` と同じ code と文)
|
||||
- 修正 1 で、変える前の判定にした(`_staff_task_reservations_releasable`)。在庫の解放は、予約が無い時と消費済みの時に失敗する(`InventoryService.release_reservation`)。それを、読むだけの `get_reservation` で前もって見る。
|
||||
- `remove_staff` の同じ拒否は、最後の守りとして残す。そこで止まった時も、全体を巻き戻す。
|
||||
7. 終わっていない襲撃の防衛者 → `DEFENSE_LOCKED`(R9。Root の一時の境界)
|
||||
8. 表金と裏金の合計が退職金に足りない → `INSUFFICIENT_FUNDS`(R8)
|
||||
- (d) 全て通れば、次を1つの操作として行う。
|
||||
1. 既存の削除の契約 `StaffService.remove_staff(staff_id, &"DISMISSED", true, 分)`。作業の予約を解けない時(R3)は、この契約が何も変えずに拒む(在庫の解放は自分で巻き戻す)。理由は `RESERVATION_RELEASE_FAILED`、文は今の契約のまま。
|
||||
2. 退職金の記帳(§2)。
|
||||
3. 救護の後始末(§3)、施設の人員の投影(`_sync_facility_staffing`)、出来事の欠けた職員の扱い(`event_service.audit_actor_availability`)。
|
||||
- 1 の後で失敗すれば、命令の前の写し(staff・inventory・path・elevators・移動の残り・出来事・ID・帳簿・Gold の写し)に全て戻して、`TRANSACTION_APPLY_FAILED` を返す。試験は、退職金の記帳の key を先に使っておく fixture でこの経路を通し、5.c の比べ方で何も変わらないことを確かめる。
|
||||
- 画面と試験のための読むだけの関数 `get_staff_dismissal_preview(staff_id)` を足した(解雇できるか・理由・退職金・未払い・開き直す作業・表金・資金の合計)。`get_staff_management_report` の各行に `dismissal` として載る。R3 は前もって分からないので、押した後に命令が拒み、理由を出す。
|
||||
1. 乗ったままの救護の組なら、解雇する人の座席を解く(§3)。
|
||||
2. 既存の削除の契約 `StaffService.remove_staff(staff_id, &"DISMISSED", true, 分)`。
|
||||
3. 退職金の記帳(§2)。
|
||||
4. 救護の後始末(§3)、施設の人員の投影(`_sync_facility_staffing`)、出来事の欠けた職員の扱い(`event_service.audit_actor_availability`)。
|
||||
- (d) の途中で失敗すれば、命令の前の写し(staff・inventory・path・elevators・移動の残り・出来事・ID・帳簿・Gold の写し)に全て戻して、`TRANSACTION_APPLY_FAILED` を返す。理由は次のとおり。
|
||||
- 座席を解けない時 `RESCUE_SEAT_RELEASE_FAILED`
|
||||
- `remove_staff` が予約を解けない時 `RESERVATION_RELEASE_FAILED`(R3 の最後の守り)
|
||||
- 退職金を記帳できない時 `SEVERANCE_POST_FAILED`
|
||||
- 救護の作業を取り消せない時 `RESCUE_CLEANUP_FAILED`
|
||||
- 退職金と救護の2つは、試験で失敗を起こし、5.c の比べ方で何も変わらないことを確かめる。座席と `remove_staff` の2つは、試験で失敗を起こしていない(前もった判定と公開の照合の形で、今の試験の組み立てからは起こせない守り)。
|
||||
- 画面と試験のための読むだけの関数 `get_staff_dismissal_preview(staff_id)`。返すのは、解雇できるか・理由・退職金・未払い・開き直す作業・命令の中で取り消す救護の作業と役(運搬者か患者か)・表金・資金の合計。
|
||||
- 運営の終了の後は、理由を `BANKRUPT` にする(R5。命令は受付の前に拒まれる)。
|
||||
- `get_staff_management_report` の各行に `dismissal` として載る。
|
||||
|
||||
## 2. 退職金(SPEC §3.5・§3.6、A14)
|
||||
|
||||
- `FinancePolicy` に `SEVERANCE_EXPENSE`(口座 `STAFF_SEVERANCE_EXPENSE`)・`SEVERANCE_CATEGORY`(区分 `STAFF_SEVERANCE`)・`SEVERANCE_DAYS`(3)・`severance(日給)` を足した。額は `get_effective_daily_wage() × 3`。
|
||||
- `FinancePolicy` に、`SEVERANCE_EXPENSE`(口座 `STAFF_SEVERANCE_EXPENSE`)・`SEVERANCE_CATEGORY`(区分 `STAFF_SEVERANCE`)・`SEVERANCE_DAYS`(3)・`severance(日給)` を足した。額は `get_effective_daily_wage() × 3`。
|
||||
- 記帳は命令の時刻に、1つの取引(idempotency key `staff.severance.<command の ID>`)で行う。
|
||||
- 表金で足りない分: 裏金 → 表金の補填(区分 `BACK_FUND_TOP_UP`、P05-02 の返済と同じ形)。
|
||||
- 表金で足りない分: 裏金 → 表金の補填(区分 `BACK_FUND_TOP_UP`。P05-02 の返済と同じ形)。
|
||||
- 退職金: 表金 → `STAFF_SEVERANCE_EXPENSE`(区分 `STAFF_SEVERANCE`、source は職員の ID)。
|
||||
- 日次決算の照合では `cost_of_goods`(画面「運営費(仕入れ・契約金等)」)に入れる。写した表は `_build_operations_daily_financials` と `_operations_expected_category_totals` の2つ。施設ごとの表は、施設でない source を読まないので変えていない。補填は fund report v2 の `back_top_up`(その日の全ての補填)に入る。
|
||||
- 旧セーブ: `FinancePolicy.prepare_accounts` の最後で、退職金の口座が無ければ空の口座として足す(A12 の5つの口座とは別に判定する。P05-02 の後・P16 の前のセーブにも足す)。口座があって負を許す設定なら拒む。
|
||||
- 解雇の前に貯まった未払いの給与は、今の契約のまま `_departed_payroll` に保持され、次の 00:00 の締めで払われる(A09 の補填を含む)。解雇の日の日給は今の給与の契約のまま(締めの時に雇用中の職員だけ)。
|
||||
- 日次決算の照合では、`cost_of_goods`(画面「運営費(仕入れ・契約金等)」)に入れる。
|
||||
- 写した表は `_build_operations_daily_financials` と `_operations_expected_category_totals` の2つ。施設ごとの表は、施設でない source を読まないので変えていない。
|
||||
- 補填は、fund report v2 の `back_top_up`(その日の全ての補填)に入る。
|
||||
- 旧セーブ: `FinancePolicy.prepare_accounts` の最後で、退職金の口座が無ければ空の口座として足す。A12 の5つの口座とは別に判定するので、P05-02 の後・P16 の前のセーブにも足す。口座があって負を許す設定なら拒む。
|
||||
- 解雇の前に貯まった未払いの給与は、今の契約のまま `_departed_payroll` に保持し、次の 00:00 の締めで払う(A09 の補填を含む)。解雇の日の日給は、今の給与の契約のまま(締めの時に雇用中の職員だけ)。
|
||||
|
||||
## 3. 後始末(SPEC §6.2・§6.2.1)
|
||||
|
||||
- 勤務・予定・配置は `remove_staff`、経路の actor・昇降機の予約・移動の残り・`STAFF_REMOVED`(reason `DISMISSED`)は今の `_on_staff_removed` が行う。
|
||||
- 施設の人員の投影は命令の中で `_sync_facility_staffing` をすぐ行う。サービスの途中の職員が抜けて人数が `staff_min` を欠けると、次の施設の更新から今の規則で職員を待ち(5 分)、代わりが来なければ失敗する(A4 の試験)。
|
||||
- 勤務・予定・配置は `remove_staff` が行う。経路の actor・昇降機の予約・移動の残り・`STAFF_REMOVED`(reason `DISMISSED`)は、今の `_on_staff_removed` が行う。
|
||||
- 施設の人員の投影は、命令の中で `_sync_facility_staffing` をすぐ行う。サービスの途中の職員が抜けて人数が `staff_min` を欠けると、次の施設の更新から今の規則で職員を待ち(5 分)、代わりが来なければ失敗する(A4 の試験)。
|
||||
- **乗ったままの救護の組(修正 1)**: S `b06fec1` の `RESCUE_PAIR_ABOARD` の拒否を消した。組の段階でも解雇できる。
|
||||
- 解雇する人の座席だけを、命令の時に組から解く(`_release_dismissed_rescue_seat`)。SPEC §10 の「非描写の状態として命令の時点で取り除く」に当たる。
|
||||
- 手順は、`ElevatorService` 自身の乗っている1人の取り消し(`_cancel_single`)と同じ。公開の `path_service.complete_elevator_request` で乗車の要求を終え、昇降機の公開の snapshot から、その人の trip・座席・相方の `companion_id` を外して `restore_snapshot` に渡す。
|
||||
- `ElevatorService` は変えていない。`restore_snapshot` の照合(Path の要求と trip の対応、座席、相方の対応)を通る形なので、命令の直後の保存も照合を通る。
|
||||
- 残る人(患者または運搬者)は、今の規則(`_advance_staff_rescue` の、乗っている間は組を保つ分岐)どおり、1人で乗ったまま本物の階に着く。
|
||||
- 運搬者を解雇した時: 患者は着いた階で倒れたままになり、次の時刻の救護の確認が、迎えを今の規則で作り直す。
|
||||
- 患者を解雇した時: 運搬者は着いた階で手が空く。
|
||||
- 試験は組の段階で運搬者と患者のそれぞれを解雇し、次を確かめる(§7)。
|
||||
- 参照が残らない。
|
||||
- 残る人が本物の階(昇降機の止まる階)で降りる。
|
||||
- 救護が今の規則でやり直される。
|
||||
- 命令の直後の保存を、同じ process(写しの復元・ディスク)と別の process(再開の2段)で読む。
|
||||
- 救護(`_interrupt_dismissed_staff_rescue`): 次の時刻の救護の確認(`_advance_staff_rescue` の最初の段)と同じ規則を、命令の中で、解雇した職員に関わる作業にだけ当てる。
|
||||
- 患者を解雇した時: その患者を目標にした終わっていない救護の作業を `RESCUE_INTERRUPTED` で取り消し、患者と運搬者の昇降機の予約を取り消す。運搬者が1人で昇降機に乗っている時は、今の規則(乗っている間は保つ)どおり予約を取り消さず、運搬者は本物の階に着く。作業は取り消すので、解雇した患者への参照は残らない。
|
||||
- 運搬者を解雇した時: `remove_staff` が開き直した搬送(`rescue_transport`)は運搬者が居ないので取り消す(今の規則と同じ)。迎え(`rescue_pickup`)は開き直ったまま、別の医療職を待つ(今の規則でも有効な作業)。患者は次の時刻に今の規則で救護をやり直す。
|
||||
- **運搬者と患者が一緒に昇降機に乗っている時(決め)**: どちらの解雇も `RESCUE_PAIR_ABOARD` で拒む(5.c)。着くまで保つ今の規則(`ElevatorService.cancel_actor` も乗っている組の取り消しを拒む)と、命令の直後の保存の照合の両方が成り立つのはこの形だけである。決めた範囲(Runtime と `StaffService`)の外の `ElevatorService` を変えずに、乗っている1人だけを消すと、昇降機に持ち主の居ない予約が残る。これは R2 と同じ「着くまで待つ」の一時の拒否で、許可の行の全面の拒否ではない。A6・A7 は他の全ての段階(歩いている・昇降機を待つ・1人で乗っている・組で待つ・降りた後・治療中)で解雇でき、着いた後は同じ職員を解雇できる(試験)。退却や戦闘のような新しい規則は作っていない。
|
||||
- 出来事: 命令の中で公開の `event_service.audit_actor_availability` を呼び、進行中・選択待ち・中断の出来事の欠けた職員を今の `missing_actor_policy` で扱う(CANCEL と CONTINUE_WITHOUT を試験)。予約した費用は今の規則で1回だけ戻る。この audit は他の欠けた actor も今の規則で扱う(次の時刻と同じ処理が早く行われるだけ)。
|
||||
- 命令の直後に保存し、同じ process(写しの復元と、ディスクへの保存と読み込み)と別の process(再開の2段)で読み込んでも、欠けた職員への生きた参照(終わっていない作業の患者と運搬者、昇降機の予約と相方、経路の actor、施設の人員、出来事の拘束と actor)が残らず、保存の照合が通る。
|
||||
- 患者を解雇した時: その患者を目標にした、終わっていない救護の作業を `RESCUE_INTERRUPTED` で取り消し、運搬者を作業から外す。
|
||||
- 運搬者を解雇した時: `remove_staff` が開き直した搬送(`rescue_transport`)は、運搬者が居ないので取り消す(今の規則と同じ)。迎え(`rescue_pickup`)は開き直ったまま、別の医療職を待つ(今の規則でも有効な作業)。
|
||||
- 昇降機の予約の取り消しは、乗っていない生きた人だけに行う。乗っている人は、着くまで乗せたままにする(今の規則)。
|
||||
- 出来事: 命令の中で、公開の `event_service.audit_actor_availability` を呼ぶ。進行中・選択待ち・中断の出来事の欠けた職員を、今の `missing_actor_policy` で扱う(CANCEL と CONTINUE_WITHOUT を試験)。予約した費用は、今の規則で1回だけ戻る。この audit は他の欠けた actor も今の規則で扱う(次の時刻と同じ処理が早く行われるだけ)。
|
||||
- 命令の直後に保存し、同じ process と別の process で読み込んでも、欠けた職員への生きた参照が残らず、保存の照合が通る。生きた参照とは、終わっていない作業の患者と運搬者、昇降機の予約と相方、経路の actor、施設の人員、出来事の拘束と actor のこと。
|
||||
- 履歴として残るもの: 終わった作業の記録の `target_entity_id`、サービスの `staff_ids`(始めた時の記録)、給与の締めの行、雇った候補の記録(同じ候補は二度雇えない今の規則のまま)。
|
||||
|
||||
## 4. 画面(SPEC §4.3)
|
||||
|
||||
- `StaffManagementPanel` に「解雇する…」と確定の欄(「解雇を確定」「やめる」)を足した。判定は Runtime の preview に従う。
|
||||
- `StaffManagementPanel` に、「解雇する…」と確定の欄(「解雇を確定」「やめる」)を足した。判定は Runtime の preview に従う。
|
||||
- 押す前に、退職金の額(日給の3日分)、裏金からの補填、未払いの給与、開き直す作業を出す。
|
||||
- 拒否の行ではボタンを無効にし、理由を出す。`UNIQUE` は今の「ユニーク:雇用・維持費無料 / 解雇・売却不可」、それ以外は「解雇不可:…」。無効のボタンを無理に押しても命令を送らない(5.a)。
|
||||
- 確定の時に名簿を読み直し、読み込みや変化の後の古い確認では送らない。
|
||||
- 時間が止まっている時はすぐに、進んでいる時は次の時刻に命令が処理され、結果(払った退職金・補填・保持した未払い・開き直した作業、または拒否の理由)を出し、名簿と選択を更新する。解雇の受理を待つ間、その職員の配属の変更とドラッグは止める。
|
||||
- 修正 1 で、命令の中で取り消す救護を分けて出す(`interrupted_rescue_roles`)。運搬者なら「運んでいる救護の搬送は取り消し、患者の救護をやり直します。」、患者なら「この従業員への救護は取り消し、救護係を外します。」。
|
||||
- 拒否の行(R1・R2・R3・R5・R7・R8・R9)ではボタンを無効にし、理由を出す。`UNIQUE` は今の「ユニーク:雇用・維持費無料 / 解雇・売却不可」、それ以外は「解雇不可:…」。無効のボタンを無理に押しても、命令を送らない(5.a)。
|
||||
- 確定の時に名簿を読み直す。読み込みや変化の後の古い確認では送らない。
|
||||
- 時間が止まっている時はすぐに、進んでいる時は次の時刻に、命令が処理される。
|
||||
- 結果として、払った退職金・補填・保持した未払い・開き直した作業・取り消した救護、または拒否の理由(押した後に資金が減って拒まれた場合を含む)を出し、名簿と選択を更新する。
|
||||
- 解雇の受理を待つ間は、その職員の配属の変更とドラッグを止める。
|
||||
- `Main` の職員の管理の画面は、今の部品と送り手(`_audio_feedback.submit_command`)のままで使えるので、`scenes/main/main.gd` は変えていない。UI は今の部品のままで、完成の見た目ではない。
|
||||
- 入力の経路(F9 の開示): 試験は本物の Main と Runtime を使う。Main のキーの処理(`_unhandled_key_input`)と、画面のボタンの `pressed` の信号で操作し、他の Main の suite と同じ形で通常の入口を通す。
|
||||
- viewport や OS の入力イベントは通していない。headless の診断では、`push_input` と `Input.parse_input_event` の形を1回ずつ試したが、ボタンは反応しなかった。これは headless の診断の結果で、実機の OS 入力を試して失敗した証拠ではない。
|
||||
- Windows の実機・IME・OS の倍率・同じ候補での実機の受入は、未達のまま残す。
|
||||
|
||||
## 5. 見つけた既存の不具合と直し
|
||||
## 5. 予約の操作 ID(修正 1、A5・F7)
|
||||
|
||||
- 原因: 在庫の予約の操作 ID が、作業と品目だけで決まっていた(`staff.task.reserve.<作業>.<品目>`)。在庫の照合の fingerprint には予約した分が入るので、開き直した作業を別の分に別の職員が取ると、`IDEMPOTENCY_CONFLICT` になって作業が BLOCKED になる(戻す経路は無い)。同じ分なら、閉じた旧い予約の結果が再生される。
|
||||
- 直し(`_reserve_staff_task_items`): 操作 ID に、その作業のその品目のこれまでの予約の数を足す(`staff.task.reserve.<作業>.<品目>.<回数>`)。在庫は予約も照合の記録も消さないので、回数は増える一方で、予約ごとに別の操作になる。分ではなく回数にしたので、同じ分に解雇して取り直しても、旧い予約に当たらない。
|
||||
- 試験(§7):
|
||||
- 元の予約・解雇・別の職員の再予約を同じ分で起こし、別の職員が終えるまで。同じ分の派遣は、StaffService の公開の `dispatch_now` で起こす。
|
||||
- 列で処理される解雇と同じ tick での再予約から、完了まで。
|
||||
- 清掃(`facility_clean`)の作業の開き直しと、別の清掃係による完了。
|
||||
- 既存の試験は、この操作 ID の文字列を見ていない(期待の変更なし)。
|
||||
|
||||
## 6. 見つけた既存の不具合と直し(S `b06fec1` で直したもの、変更なし)
|
||||
|
||||
- 締めの後に税・利息の未払いが残ったゲームの保存が読めなかった(P05-02 からの不具合)。
|
||||
- 保存を読む時の Operations の照合(`_parse_operations_runtime_snapshot`)が、締めた未払いを「給与 + 維持費」だけで数え、実行中の照合(`_operations_closed_arrears_total`)が足している税・利息の未払い(`_finance_arrears_through`)を数えていなかった。
|
||||
- 見つけた経路: 帳簿の日の流れの試験で、退職金の補填で裏金が減り、2日目の締めで世界樹ランク税 70 のうち 30 が未払いになった。その後の保存を復元できなかった(診断の記録は管理のフォルダ)。退職金の無い同じ流れでは税を払いきれるので起きない。
|
||||
- 直し: 読む時の照合を実行中と同じ式(給与 + 維持費 + 税・利息の未払い)にそろえた。照合を緩めたのではなく、実行中の照合と同じにした。
|
||||
- 試験: `test_general_dismissal.gd` の帳簿の日の流れが、税の未払い 30 を確かめてから、締めの後の保存を復元する。再開の2段も締めの後の保存を別の process で読む。
|
||||
|
||||
## 6. 範囲の外として残すもの(主張しない)
|
||||
|
||||
- 予約つきの作業を開き直した後、別の職員がその作業を取る時に、在庫の予約の操作 ID(`staff.task.reserve.<作業>.<品目>`)が前の予約と同じなので `IDEMPOTENCY_CONFLICT` で止まる(BLOCKED)。解雇でも今の `remove_staff` でも同じ結果になる(試験は、解雇の後の作業と在庫が今の `remove_staff` の結果と同じことを確かめる)。開き直しの全般の問題なので、別の単位として提案する。
|
||||
- まだ始まっていない(待ち行列の)出来事の actor の ID は、拘束を持たず、始まる時に今の規則(`start_next_event` の欠けた actor の扱い)で扱われる。命令の中では変えない。
|
||||
- 進行中の襲撃の防衛者の解雇(R9)は、原本の要件として未達のまま(SPEC §6.3・§8)。
|
||||
- 解雇した職員が歩いて出る描写は作らない。UI は今の部品のまま。Windows 実機は後。
|
||||
- 原単位 P16-01・P16-03 の残り(求人募集、奴隷市場・破産客からの獲得、人身売買、勤務・休息・気絶・救護の横断、解雇の経路の外の退職時の参照の除去)は未達のまま(SPEC §8)。
|
||||
- 保存を読む時の Operations の照合(`_parse_operations_runtime_snapshot`)は、締めた未払いを「給与 + 維持費」だけで数えていた。実行中の照合(`_operations_closed_arrears_total`)が足している税・利息の未払い(`_finance_arrears_through`)を、数えていなかった。
|
||||
- 読む時の照合を、実行中と同じ式にそろえた。照合を緩めたのではない。
|
||||
- 試験: 帳簿の日の流れが、税の未払い 30 を確かめてから締めの後の保存を復元する。同じ process(修正 1 で全ての保存点)と別の process でも読む。
|
||||
|
||||
## 7. 試験
|
||||
|
||||
- 新しい suite:
|
||||
- `tests/staff/test_general_dismissal.gd`(F1〜F9。§6 の全ての行。共通の部品は `tests/helpers/p16_dismissal_fixture.gd`)。最後の場面がゲームの Main を使うので、suite-policy で ordinary の音の後片付けの証明(`single`)を持つ。
|
||||
- 新しい suite `tests/staff/test_general_dismissal.gd`(F1〜F9。§6 の全ての行。共通の部品は `tests/helpers/p16_dismissal_fixture.gd`)。最後の場面がゲームの Main を使うので、suite-policy で ordinary の音の後片付けの証明(`single`)を持つ。修正 1 で足した場面:
|
||||
- 乗ったままの組を含む、救護の全ての段階での運搬者と患者の解雇。preview と結果で、開き直すと取り消すを分けて確かめる。
|
||||
- 通勤の職員が上の階の職場へ向かう時に、昇降機を待つ段と乗っている段で解雇する(A2)。
|
||||
- 予約つきの作業の同じ分の再予約と完了、同じ tick の再予約と完了。清掃の作業の開き直しと完了(A5・F7)。
|
||||
- 帳簿の日の流れの全ての保存点を、同じ process でディスクから読み、最後まで続ける(F8)。
|
||||
- 物流の返送の段の拒否(R2)。
|
||||
- 救護の後始末の失敗の巻き戻し(5.c)。
|
||||
- 列で待つ命令の処理を 5.c の増分で比べる(A9)。
|
||||
- その命令は、Runtime 自身の命令の段(`_process_commands`)で、tick の他の処理なしに処理する。
|
||||
- Main の画面で、拒否の行の全て(R1・R2・R3・R5・R7・R8・R9)を確かめる。ボタンが無効で、理由が出て、無理に押しても snapshot の全体の bytes が同じ。
|
||||
- 保持した未払い(本物の 0 時の締めで資金が足りず、日給が未払いになる)も、押す前と結果で確かめる。
|
||||
- 押した後の拒否の理由も確かめる。
|
||||
- 画面の比べは全て `var_to_bytes`。
|
||||
- 再開の2段 `tests/staff/test_general_dismissal_restart_1_write.gd`(唯一の生成ファイルは `artifacts/p16-01-dismiss/restart-handoff.json`)と `..._2_read.gd`。
|
||||
- 帳簿の6つの保存点を読む。
|
||||
- 次の命令の境目の保存を読む。修正 1 で、A6 の迎え・A6 と A7 の乗ったままの組・A8 の CONTINUE_WITHOUT を足した。
|
||||
- A6: 搬送を徒歩で運ぶ段・迎えの段・組で乗っている段
|
||||
- A7: 運搬者が1人で乗っている段・組で乗っている段
|
||||
- A8: CANCEL・CONTINUE_WITHOUT
|
||||
- 局所の確かめで分かった、既存の規則による制約(製品の不具合ではない。試験の組み立てを直した):
|
||||
- 保持した未払いの給与は未払いの費用として数えられ、建設と雇用を止める(`UNPAID_EXPENSES`)。
|
||||
- 締めを通らない未払いを持たせた職員の登録は、運営の照合(`OPERATIONS_CROSSLINK_MISMATCH`)を止める。そのため画面の試験は、本物の締めで未払いを作る。
|
||||
- validation `p16-dismiss`: `tools/run_p16_dismiss_tests.py`。`tools/run_p07_weather_tests.py` から、名前・名簿・再開の段・handoff・protocol 名だけを明示の置き換えで変えた(段の手順・厳密な照合・ordinary と old_bounded の分類は同じ)。実 12 段(新しい suite 1 + 既存 9 + 再開 2)と import・parse。
|
||||
- suite-policy と marker contract に新しい3本の行を足し、今の source の SHA-256 で固定した。
|
||||
- 既存の試験の期待値の変更は `EXPECTATION_CHANGES.json`(kitchen の旧セーブの写しに退職金の口座を足す1件)。
|
||||
|
||||
## 8. 範囲の外として残すもの(主張しない)
|
||||
|
||||
- まだ始まっていない(待ち行列の)出来事の actor の ID は、拘束を持たない。始まる時に、今の規則(`start_next_event` の欠けた actor の扱い)で扱われる。命令の中では変えない。
|
||||
- 進行中の襲撃の防衛者の解雇(R9)は、原本の要件として未達のまま(SPEC §6.3・§8)。
|
||||
- 解雇した職員が歩いて出る描写は作らない(SPEC §10)。UI は今の部品のまま。Windows の実機・IME・OS の倍率・同じ候補の実機の受入は後。
|
||||
- 原単位 P16-01・P16-03 の残りは未達のまま(SPEC §8): 求人募集、奴隷市場・破産客からの獲得、人身売買、勤務・休息・気絶・救護の横断、解雇の経路の外の退職時の参照の除去。
|
||||
|
||||
@@ -402,6 +402,8 @@ func _show_dismissal_result(result: Variant, employee_name: String) -> void:
|
||||
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)
|
||||
|
||||
|
||||
@@ -418,6 +420,12 @@ func _dismissal_summary(row: Dictionary, preview: Dictionary) -> String:
|
||||
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
|
||||
|
||||
|
||||
@@ -434,7 +442,6 @@ func _dismissal_reason_text(reason: StringName, details: Dictionary = {}) -> Str
|
||||
&"NOT_GENERAL_EMPLOYEE": "ギルドで雇った一般従業員だけを解雇できます。",
|
||||
&"EXTERNAL_TASK": "物流の受け取り・配達・返送の途中です。終わってから解雇できます。",
|
||||
&"DEFENSE_LOCKED": "襲撃の防衛に参加中は解雇できません。襲撃が終わってから解雇できます。",
|
||||
&"RESCUE_PAIR_ABOARD": "救護の搬送で昇降機に乗っています。着いてから解雇できます。",
|
||||
&"RESERVATION_RELEASE_FAILED": "作業の予約を解けないため解雇できません。",
|
||||
&"BANKRUPT": "運営終了後は解雇できません。",
|
||||
}
|
||||
|
||||
@@ -209,6 +209,25 @@ func new_event_fixture(seed: int) -> Dictionary:
|
||||
return fixture
|
||||
|
||||
|
||||
## A foreground Event of two employees whose missing actor policy continues without
|
||||
## the missing one (CONTINUE_WITHOUT).
|
||||
func new_event_pair_fixture(seed: int) -> Dictionary:
|
||||
var fixture := new_fixture(seed)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
for staff_id: StringName in [&"event_lead", &"event_partner"]:
|
||||
check(command(fixture, &"register_staff", {&"staff_id": staff_id, &"role_id": &"service", &"options": {&"daily_wage": 80}}).is_success(), "register %s" % staff_id)
|
||||
check(command(fixture, &"register_event_definition", {&"definition": {&"event_id": &"p16.event.pair", &"category": &"RELATIONSHIP", &"priority": 70,
|
||||
&"foreground": true, &"transaction_policy": &"ROLLBACK_ALL", &"missing_actor_policy": &"CONTINUE_WITHOUT", &"content_rating": &"GENERAL",
|
||||
&"choices": [{&"choice_id": &"wait", &"text_key": &"event.p16.pair.wait", &"actions": []}]}}).is_success()
|
||||
and command(fixture, &"enqueue_event", {&"event_id": &"p16.event.pair", &"actor_ids": [&"event_lead", &"event_partner"],
|
||||
&"options": {&"event_instance_id": &"p16.event.pair.instance"}}).is_success(), "a two-actor Event that continues without a missing actor")
|
||||
fixture[&"clock"].set_paused(false)
|
||||
fixture[&"clock"].advance_one_minute()
|
||||
fixture[&"clock"].set_paused(true)
|
||||
check(runtime.event_service.get_actor_locks().has(&"event_partner"), "the Event locks both employees")
|
||||
return fixture
|
||||
|
||||
|
||||
## Live references to a removed employee (SPEC 6.2/6.2.1): live Tasks, elevator
|
||||
## trips, the path actor, facility staffing, Event locks and running Event actors.
|
||||
## Terminal Task records keep their history.
|
||||
|
||||
@@ -33,18 +33,21 @@ func _run() -> void:
|
||||
print("P16-01-DISMISS: general employee dismissal")
|
||||
_scenes = Scenes.new(_expect)
|
||||
_test_ledger_day()
|
||||
_test_ledger_saves_same_process()
|
||||
_test_top_up_and_insufficient_funds()
|
||||
_test_category_refusals()
|
||||
_test_queued_commands_for_the_same_employee()
|
||||
_test_bankrupt_refusal()
|
||||
_test_external_task_refusal()
|
||||
_test_reserved_task()
|
||||
_test_cleaning_task()
|
||||
_test_facility_duty_and_service()
|
||||
_test_walking_commute()
|
||||
_test_rescue_matrix()
|
||||
_test_event_actors()
|
||||
_test_defense_refusal()
|
||||
_test_commit_failure_rolls_back()
|
||||
_test_rescue_cleanup_failure_rolls_back()
|
||||
_test_old_save_gains_the_severance_account()
|
||||
await _test_dismiss_from_main()
|
||||
await process_frame
|
||||
@@ -282,20 +285,52 @@ func _test_queued_commands_for_the_same_employee() -> void:
|
||||
var next_minute: int = fixture[&"clock"].absolute_minute + 1
|
||||
var assignment: CommandResult = runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": &"staff_eri", &"facility_id": &"", &"template_id": &"NIGHT"},
|
||||
&"player", &"staff_eri", 0, next_minute, &"p16.a9.assignment")
|
||||
var second: CommandResult = runtime.submit_command(&"dismiss_staff", {&"staff_id": &"staff_eri"}, &"player", &"staff_eri", 0, next_minute, &"p16.a9.second")
|
||||
_expect(assignment.is_success() and second.is_success() and (runtime.command_queue.create_snapshot()["pending"] as Array).size() == 2, "an assignment and a second dismissal wait for the next minute")
|
||||
var second: CommandResult = runtime.submit_command(&"dismiss_staff", {&"staff_id": &"staff_eri"}, &"player", &"staff_eri", 0, next_minute + 1, &"p16.a9.second")
|
||||
_expect(assignment.is_success() and second.is_success() and (runtime.command_queue.create_snapshot()["pending"] as Array).size() == 2, "an assignment and a second dismissal wait in the queue")
|
||||
_expect(_scenes.dismiss(fixture, &"staff_eri").is_success(), "A9: the dismissal runs before the queued commands")
|
||||
_expect_boundary(fixture, &"staff_eri", "A9")
|
||||
fixture[&"clock"].advance_minutes(1)
|
||||
var later: CommandResult = runtime.command_queue.get_processed_result(assignment.command_id)
|
||||
var again: CommandResult = runtime.command_queue.get_processed_result(second.command_id)
|
||||
_expect(later != null and not later.is_success() and StringName(later.details.get(&"reason", &"")) == &"STAFF_NOT_FOUND"
|
||||
and runtime.staff_service.get_work_assignment(&"staff_eri").is_empty(), "A9: the queued assignment is refused as STAFF_NOT_FOUND and creates nothing")
|
||||
_expect(again != null and not again.is_success() and StringName(again.details.get(&"reason", &"")) == &"NOT_FOUND"
|
||||
and _scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1, "A9: the queued second dismissal is NOT_FOUND and pays nothing")
|
||||
# Each queued command is processed by the Runtime's own command phase (_process_commands),
|
||||
# without the rest of the tick, so that its change alone is compared by SPEC 5.c.
|
||||
for queued: Array in [[assignment, next_minute, &"STAFF_NOT_FOUND", "the queued assignment"], [second, next_minute + 1, &"NOT_FOUND", "the queued second dismissal"]]:
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
runtime.call("_process_commands", queued[1])
|
||||
var after: Dictionary = runtime.create_snapshot()
|
||||
var processed: CommandResult = runtime.command_queue.get_processed_result((queued[0] as CommandResult).command_id)
|
||||
_expect(processed != null and not processed.is_success() and StringName(processed.details.get(&"reason", &"")) == queued[2], "A9: %s is refused as %s" % [queued[3], queued[2]])
|
||||
_expect(processed != null and var_to_bytes(_without_command_record(before)) == var_to_bytes(_without_command_record(after))
|
||||
and _only_queued_command_processed(before, after, processed), "A9: %s changes nothing but its own record (5.c)" % queued[3])
|
||||
_expect(runtime.staff_service.get_work_assignment(&"staff_eri").is_empty() and _scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1,
|
||||
"A9: nothing is assigned and the severance is paid once")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## SPEC 5.c for a command accepted earlier (A9): its processing takes no new command id
|
||||
## and adds only its failed result and one COMMAND_FAILED event.
|
||||
func _only_queued_command_processed(before: Dictionary, after: Dictionary, result: CommandResult) -> bool:
|
||||
var b: Dictionary = before[&"systems"][&"runtime"]
|
||||
var a: Dictionary = after[&"systems"][&"runtime"]
|
||||
var counters: Dictionary = (b[&"id_generator"]["counters"] as Dictionary).duplicate()
|
||||
counters["domain_event"] = int(counters.get("domain_event", 0)) + 1
|
||||
var bq: Dictionary = b[&"command_queue"]
|
||||
var aq: Dictionary = a[&"command_queue"]
|
||||
var pending: Array = (bq["pending"] as Array).filter(func(row: Dictionary) -> bool: return int(row["command_id"]) != result.command_id)
|
||||
var processed: Array = (aq["processed"] as Array).duplicate()
|
||||
var index := processed.find(result.to_dictionary())
|
||||
if index >= 0:
|
||||
processed.remove_at(index)
|
||||
var b_ids: Array = b[&"domain_events"]["published_ids"]
|
||||
var a_ids: Array = a[&"domain_events"]["published_ids"]
|
||||
var prior: Array = b[&"domain_events"]["recent_events"]
|
||||
var recent: Array = a[&"domain_events"]["recent_events"]
|
||||
var added: Dictionary = recent[-1]
|
||||
var kept := recent.slice(0, recent.size() - 1)
|
||||
return int(a[&"next_command_id"]) == int(b[&"next_command_id"]) and a[&"id_generator"]["counters"] == counters \
|
||||
and (bq["pending"] as Array).size() == pending.size() + 1 and aq["pending"] == pending and aq["in_flight"] == bq["in_flight"] \
|
||||
and aq["idempotency"] == bq["idempotency"] and aq["accepted"] == bq["accepted"] and index >= 0 and processed == bq["processed"] \
|
||||
and a_ids.size() == b_ids.size() + 1 and added["event_type"] == "COMMAND_FAILED" and int(added["causation_command_id"]) == result.command_id \
|
||||
and kept == prior.slice(prior.size() - kept.size())
|
||||
|
||||
|
||||
## R5: after the game ends the command is refused before acceptance.
|
||||
func _test_bankrupt_refusal() -> void:
|
||||
var fixture := _scenes.new_fixture(16_050, true)
|
||||
@@ -349,6 +384,20 @@ func _test_external_task_refusal() -> void:
|
||||
clock.advance_minutes(1)
|
||||
_expect(runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.DELIVERING, "the porter carries the loaded cargo")
|
||||
_expect_executor_refusal(fixture, &"staff_gina", &"EXTERNAL_TASK", "R2 loaded cargo")
|
||||
var loaded: Dictionary = runtime.create_snapshot()
|
||||
# The return stage (返送), as tests/supply/test_supply_logistics_bridge.gd reaches it:
|
||||
# disclosed fixture splitting the cargo into two lots, one spoiling next minute.
|
||||
var inventory: Dictionary = runtime.inventory_service.create_snapshot()
|
||||
for owner: Dictionary in inventory[&"inventories"]:
|
||||
if owner[&"owner_inventory_id"] == "cargo:staff_gina":
|
||||
owner[&"stacks"][0][&"quantity"] = 4
|
||||
_expect(runtime.inventory_service.restore_snapshot(inventory), "fixture splits the loaded cargo")
|
||||
runtime.inventory_service.add_stack(&"cargo:staff_gina", SupplyCatalog.RAW_FOOD, 4, 100, clock.absolute_minute + 1, clock.absolute_minute)
|
||||
clock.advance_minutes(1)
|
||||
var porter_task: StaffTask = runtime.staff_service.get_task(runtime.staff_service.get_staff(&"staff_gina").current_task_id)
|
||||
_expect(porter_task != null and porter_task.task_type == SupplyLogisticsBridge.RETURN_TYPE, "the porter returns the surviving cargo to its source")
|
||||
_expect_executor_refusal(fixture, &"staff_gina", &"EXTERNAL_TASK", "R2 return")
|
||||
_expect(runtime.restore_snapshot(loaded), "back to the loaded delivery")
|
||||
for minute: int in 60:
|
||||
if runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.COMPLETED: break
|
||||
clock.advance_minutes(1)
|
||||
@@ -394,6 +443,37 @@ func _test_reserved_task() -> void:
|
||||
_expect(runtime.restore_snapshot(working) and runtime.staff_service.remove_staff(&"tool_worker", &"DISMISSED", true, fixture[&"clock"].absolute_minute).is_success()
|
||||
and runtime.staff_service.get_task(&"tool_task").create_snapshot() == dismissed_task and runtime.inventory_service.create_snapshot() == dismissed_inventory,
|
||||
"A5: the Task and the inventory end as the existing removal contract leaves them")
|
||||
# F7: the reopened reserved Task is taken again by another worker and completed, also when
|
||||
# the first reservation, the dismissal and the new reservation fall in one minute.
|
||||
var first_minute := int(working[&"clock"][&"absolute_minute"])
|
||||
var first_reservation: StringName = reservation_id
|
||||
_expect(runtime.restore_snapshot(working) and int(fixture[&"clock"].absolute_minute) == first_minute, "back to the Task reserved at minute %d" % first_minute)
|
||||
_expect(_scenes.dismiss(fixture, &"tool_worker").is_success()
|
||||
and _scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker_2", &"role_id": &"worker", &"options": worker_options}).is_success(),
|
||||
"dismiss the worker and register another one in the same minute")
|
||||
runtime.staff_service.dispatch_now(first_minute)
|
||||
task = runtime.staff_service.get_task(&"tool_task")
|
||||
var second_reservation: StringName = task.reservation_ids[0] if task.reservation_ids.size() == 1 else &""
|
||||
var reservation: InventoryReservation = runtime.inventory_service.get_reservation(second_reservation)
|
||||
_expect(task.assigned_staff_id == &"tool_worker_2" and second_reservation != first_reservation and reservation != null and reservation.is_active()
|
||||
and reservation.created_at == first_minute and runtime.inventory_service.get_reservation(first_reservation).state == InventoryReservation.State.RELEASED,
|
||||
"F7: in the same minute the other worker reserves the tool anew; the closed reservation is not replayed")
|
||||
fixture[&"clock"].advance_minutes(15)
|
||||
_expect(runtime.staff_service.get_task(&"tool_task").state == StaffTask.State.COMPLETED and runtime.inventory_service.available_quantity(&"staff.supplies", &"tool/snapshot_kit") == 1,
|
||||
"F7: the other worker completes the reopened Task and its tool is released again")
|
||||
_expect(runtime.restore_snapshot(working), "back to the reserved Task once more")
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker_2", &"role_id": &"worker", &"options": worker_options}).is_success(), "another worker is ready")
|
||||
fixture[&"clock"].set_paused(false)
|
||||
var queued: CommandResult = runtime.submit_command(&"dismiss_staff", {&"staff_id": &"tool_worker"}, &"player", &"tool_worker", 0, -1, &"p16.a5.queued")
|
||||
fixture[&"clock"].advance_one_minute()
|
||||
fixture[&"clock"].set_paused(true)
|
||||
task = runtime.staff_service.get_task(&"tool_task")
|
||||
var done: CommandResult = runtime.command_queue.get_processed_result(queued.command_id)
|
||||
_expect(done != null and done.is_success() and task.assigned_staff_id == &"tool_worker_2" and task.reservation_ids.size() == 1
|
||||
and runtime.inventory_service.get_reservation(task.reservation_ids[0]).created_at == int(fixture[&"clock"].absolute_minute),
|
||||
"F7: a queued dismissal and the new reservation fall in the same tick")
|
||||
fixture[&"clock"].advance_minutes(15)
|
||||
_expect(runtime.staff_service.get_task(&"tool_task").state == StaffTask.State.COMPLETED, "F7: that Task is completed too")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
@@ -460,10 +540,48 @@ func _test_walking_commute() -> void:
|
||||
_expect(_scenes.dismiss(fixture, &"staff_akane").is_success(), "A2: the commuting employee is dismissed")
|
||||
_expect_boundary(fixture, &"staff_akane", "A2 walking")
|
||||
_scenes.free_fixture(fixture)
|
||||
_test_elevator_commute()
|
||||
|
||||
|
||||
## A2 at the elevator, A6 the carrier and A7 the patient, through every rescue
|
||||
## phase of the elevator rescue; a pair aboard is kept together until a landing.
|
||||
## A2 at the elevator: a cleaner commuting to the cleaning room upstairs waits for
|
||||
## and rides the service elevator; she is dismissed at each of those phases.
|
||||
func _test_elevator_commute() -> void:
|
||||
var fixture := _scenes.new_fixture(16_091)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
for spec: Array in [[&"service_elevator", Vector3i(0, 0, 1)], [&"cleaning_room", Vector3i(3, 1, 1)]]:
|
||||
_expect(_scenes.command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]}).is_success(), "build %s" % spec[0])
|
||||
clock.advance_minutes(400)
|
||||
var room: StringName = &""
|
||||
for facility: FacilityInstance in runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"cleaning_room": room = facility.instance_id
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success()
|
||||
and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_eri", &"facility_id": room, &"template_id": &"DAY"}).is_success(),
|
||||
"hire staff_eri and send her to the cleaning room upstairs")
|
||||
var checkpoints := {}
|
||||
for minute: int in 60:
|
||||
clock.advance_minutes(1)
|
||||
var worker: StaffRuntime = runtime.staff_service.get_staff(&"staff_eri")
|
||||
var trip: Dictionary = runtime.elevators.get_actor_trip(&"staff:staff_eri")
|
||||
if worker.state == StaffRuntime.State.COMMUTING and not trip.is_empty() and not checkpoints.has(trip[&"state"]):
|
||||
checkpoints[trip[&"state"]] = runtime.create_snapshot()
|
||||
if worker.state != StaffRuntime.State.COMMUTING:
|
||||
break
|
||||
_expect(checkpoints.has(PathService.ELEVATOR_REQUEST_QUEUED) and checkpoints.has(PathService.ELEVATOR_REQUEST_BOARDED), "her commute waits for and rides the elevator %s" % [checkpoints.keys()])
|
||||
for phase: Variant in checkpoints:
|
||||
_expect(runtime.restore_snapshot(checkpoints[phase]), "restore the commute %s" % phase)
|
||||
_expect(runtime.staff_service.get_staff(&"staff_eri").state == StaffRuntime.State.COMMUTING, "she is commuting (%s)" % phase)
|
||||
_expect(_scenes.dismiss(fixture, &"staff_eri").is_success(), "A2: the commuting employee is dismissed at the elevator (%s)" % phase)
|
||||
_expect(runtime.elevators.get_report()[&"trips"].is_empty(), "A2: her elevator request ends with her (%s)" % phase)
|
||||
_expect_boundary(fixture, &"staff_eri", "A2 elevator %s" % phase)
|
||||
clock.advance_minutes(3)
|
||||
_expect(runtime.restore_snapshot(runtime.create_snapshot()), "A2: the elevator keeps working after the dismissal (%s)" % phase)
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## A6 the carrier and A7 the patient, through every rescue phase of the elevator
|
||||
## rescue, the pair aboard included: the dismissed member leaves the cabin at the
|
||||
## command and the other one rides on to a real landing (SPEC 6.2.1).
|
||||
func _test_rescue_matrix() -> void:
|
||||
var fixture := _scenes.new_rescue_fixture()
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
@@ -474,16 +592,17 @@ func _test_rescue_matrix() -> void:
|
||||
if phase in RESCUE_PHASES and not checkpoints.has(phase):
|
||||
checkpoints[phase] = runtime.create_snapshot()
|
||||
_expect(checkpoints.size() == RESCUE_PHASES.size(), "the rescue passes every phase %s" % [checkpoints.keys()])
|
||||
var stops: Array = runtime.elevators.get_report()[&"shafts"][0][&"stops"]
|
||||
for phase: String in RESCUE_PHASES:
|
||||
if not checkpoints.has(phase):
|
||||
continue
|
||||
for staff_id: StringName in [&"medic", &"patient"]:
|
||||
_expect(runtime.restore_snapshot(checkpoints[phase]), "restore %s" % phase)
|
||||
var label := "%s, dismiss the %s" % [phase, staff_id]
|
||||
if phase.ends_with("BOARDED/pair"):
|
||||
_expect_executor_refusal(fixture, staff_id, &"RESCUE_PAIR_ABOARD", label)
|
||||
continue
|
||||
var other: StringName = &"patient" if staff_id == &"medic" else &"medic"
|
||||
var held: StringName = runtime.staff_service.get_staff(&"medic").current_task_id
|
||||
var other_aboard_before := _aboard(runtime, other)
|
||||
var preview: Dictionary = runtime.get_staff_dismissal_preview(staff_id)
|
||||
var result := _scenes.dismiss(fixture, staff_id)
|
||||
_expect(result.is_success(), "%s: dismissed (%s)" % [label, result.message])
|
||||
_expect_boundary(fixture, staff_id, label)
|
||||
@@ -491,28 +610,44 @@ func _test_rescue_matrix() -> void:
|
||||
if staff_id == &"medic" and phase.begins_with("rescue_pickup"):
|
||||
_expect(task.state == StaffTask.State.OPEN and task.assigned_staff_id.is_empty() and runtime.staff_service.get_staff(&"patient").state == StaffRuntime.State.INCAPACITATED,
|
||||
"%s: the pickup is reopened for another medic (A6)" % label)
|
||||
_expect(preview[&"reopened_task_ids"] == [held] and (preview[&"interrupted_rescue_task_ids"] as Array).is_empty() and result.details[&"reopened_task_ids"] == [held],
|
||||
"%s: the preview and the result call the pickup reopened" % label)
|
||||
elif staff_id == &"medic":
|
||||
_expect(task.state == StaffTask.State.CANCELLED and task.state_reason == &"RESCUE_INTERRUPTED" and result.details[&"interrupted_rescue_task_ids"] == [held]
|
||||
and runtime.elevators.get_report()[&"trips"].is_empty(), "%s: the transport is interrupted with its elevator requests (A6)" % label)
|
||||
_expect(task.state == StaffTask.State.CANCELLED and task.state_reason == &"RESCUE_INTERRUPTED" and result.details[&"interrupted_rescue_task_ids"] == [held],
|
||||
"%s: the transport is interrupted (A6)" % label)
|
||||
_expect(preview[&"interrupted_rescue_task_ids"] == [held] and preview[&"interrupted_rescue_roles"] == [&"carrier"] and (preview[&"reopened_task_ids"] as Array).is_empty()
|
||||
and (result.details[&"reopened_task_ids"] as Array).is_empty(), "%s: the preview and the result call the transport interrupted, not reopened" % label)
|
||||
else:
|
||||
_expect(task.state == StaffTask.State.CANCELLED and task.state_reason == &"RESCUE_INTERRUPTED" and runtime.staff_service.get_staff(&"medic").current_task_id.is_empty(),
|
||||
"%s: the rescue is interrupted and the medic released (A7)" % label)
|
||||
_expect(preview[&"interrupted_rescue_task_ids"] == [held] and preview[&"interrupted_rescue_roles"] == [&"patient"], "%s: the preview names the interrupted rescue" % label)
|
||||
# The other one rides on to a real landing when aboard; otherwise no elevator request remains.
|
||||
var other_trip: Dictionary = runtime.elevators.get_actor_trip(StringName("staff:" + String(other)))
|
||||
if other_aboard_before:
|
||||
_expect(other_trip.get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED and not other_trip.has(&"companion_id"),
|
||||
"%s: the %s stays aboard alone" % [label, other])
|
||||
for minute: int in 40:
|
||||
if runtime.elevators.get_actor_trip(StringName("staff:" + String(other))).is_empty():
|
||||
break
|
||||
fixture[&"clock"].advance_minutes(1)
|
||||
_expect(runtime.elevators.get_actor_trip(StringName("staff:" + String(other))).is_empty() and runtime.staff_service.get_staff(other).current_cell in stops,
|
||||
"%s: the %s alights at a real landing" % [label, other])
|
||||
else:
|
||||
_expect(other_trip.is_empty(), "%s: no elevator request remains for the %s" % [label, other])
|
||||
if staff_id == &"medic" and not phase.begins_with("rescue_pickup"):
|
||||
fixture[&"clock"].advance_minutes(1)
|
||||
var restarted: Array = runtime.staff_service.get_tasks().filter(func(t: StaffTask) -> bool: return t.task_type == &"rescue_pickup" and not t.is_terminal() and t.target_entity_id == &"patient")
|
||||
_expect(restarted.size() == 1, "%s: the next minute starts a new rescue for the patient (existing rule)" % label)
|
||||
else:
|
||||
var medic: StaffRuntime = runtime.staff_service.get_staff(&"medic")
|
||||
var aboard: bool = runtime.elevators.get_actor_trip(&"staff:medic").get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED
|
||||
_expect(task.state == StaffTask.State.CANCELLED and task.state_reason == &"RESCUE_INTERRUPTED" and medic.current_task_id.is_empty(),
|
||||
"%s: the rescue is interrupted and the medic released (A7)" % label)
|
||||
_expect(aboard == (phase == "rescue_pickup/MOVING/BOARDED/single"), "%s: a medic riding alone rides on to a landing; otherwise no elevator request remains" % label)
|
||||
fixture[&"clock"].advance_minutes(3)
|
||||
_expect(restarted.size() == 1, "%s: the patient's rescue starts again (existing rule)" % label)
|
||||
fixture[&"clock"].advance_minutes(2)
|
||||
var later: Dictionary = runtime.create_snapshot()
|
||||
_expect(runtime.restore_snapshot(later) and runtime.staff_service.validate_invariants()[&"ok"], "%s: three minutes later the game still saves" % label)
|
||||
_expect(runtime.restore_snapshot(checkpoints.get("rescue_transport/MOVING/BOARDED/pair", runtime.create_snapshot())), "back to the pair aboard")
|
||||
_expect(_scenes.advance_to_rescue_phase(fixture, "rescue_transport/MOVING/-/single") and _scenes.dismiss(fixture, &"medic").is_success(),
|
||||
"the pair refusal is a wait: after the landing the medic is dismissed")
|
||||
_expect_boundary(fixture, &"medic", "after the landing")
|
||||
_expect(runtime.restore_snapshot(later) and runtime.staff_service.validate_invariants()[&"ok"], "%s: later the game still saves" % label)
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
func _aboard(runtime: Variant, staff_id: StringName) -> bool:
|
||||
return runtime.elevators.get_actor_trip(StringName("staff:" + String(staff_id))).get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED
|
||||
|
||||
|
||||
## A8: the Event's existing missing actor policy handles the dismissed actor at
|
||||
## the command boundary (CANCEL; CONTINUE_WITHOUT keeps the other actor).
|
||||
func _test_event_actors() -> void:
|
||||
@@ -608,9 +743,123 @@ func _test_old_save_gains_the_severance_account() -> void:
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## F9: the game's own Runtime and Main. The staff panel shows the severance and
|
||||
## the reopened Task before the press, dismisses through its buttons, refuses the
|
||||
## UNIQUE and ADULT_CONTRACT rows with their reasons and sends nothing for them.
|
||||
## A5 with the SPEC's example: the cleaning Task (facility_clean) a cleaner works is
|
||||
## reopened by the dismissal, and the other cleaner takes it and completes it.
|
||||
func _test_cleaning_task() -> void:
|
||||
var fixture := _scenes.new_fixture(16_075)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
for spec: Array in [[&"cleaning_room", Vector3i(-6, 0, 1)], [&"reception", Vector3i(1, 0, 0)]]:
|
||||
_expect(_scenes.command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]}).is_success(), "build %s" % spec[0])
|
||||
clock.advance_minutes(240)
|
||||
var room: StringName = &""
|
||||
var reception: StringName = &""
|
||||
for facility: FacilityInstance in runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"cleaning_room": room = facility.instance_id
|
||||
if facility.definition_id == &"reception": reception = facility.instance_id
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success()
|
||||
and _scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success()
|
||||
and _scenes.command(fixture, &"register_staff", {&"staff_id": &"spare_cleaner", &"role_id": &"cleaner", &"options": {&"role_tags": [&"cleaner"],
|
||||
&"permitted_task_tags": [&"cleaning", &"maintenance"], &"daily_wage": 90}}).is_success(), "two cleaners and a receptionist")
|
||||
for row: Array in [[&"staff_eri", room], [&"spare_cleaner", room], [&"staff_akane", reception]]:
|
||||
_expect(_scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": row[0], &"facility_id": row[1], &"template_id": &"DAY"}).is_success(), "assign %s" % row[0])
|
||||
clock.advance_minutes(30)
|
||||
# 25 completed guest visits soil the reception to the cleaning threshold (75).
|
||||
var count := 0
|
||||
for batch: int in 4:
|
||||
for index: int in (8 if batch < 3 else 1):
|
||||
var guest := StringName("p16_cleaning_payer_%d" % count)
|
||||
_expect(_scenes.command(fixture, &"register_guest_wallet", {&"guest_id": guest, &"gold": 100}).is_success()
|
||||
and _scenes.command(fixture, &"create_service_request", {&"request_id": StringName("p16_cleaning_visit_%d" % count), &"facility_id": reception,
|
||||
&"guest_ids": [guest], &"service_type": &"check_in"}).is_success(), "a real reception visit")
|
||||
count += 1
|
||||
for minute: int in 25:
|
||||
clock.advance_one_minute()
|
||||
if int(runtime.facility_cleanliness.get_facility(reception)["current"]) == 100 - count: break
|
||||
var task: StaffTask = null
|
||||
for minute: int in 80:
|
||||
for candidate: StaffTask in runtime.staff_service.get_tasks():
|
||||
if candidate.task_type == FacilityCleaningBridge.TASK_TYPE and candidate.target_entity_id == reception and not candidate.is_terminal():
|
||||
task = candidate
|
||||
if task != null and task.state == StaffTask.State.WORKING: break
|
||||
clock.advance_one_minute()
|
||||
_expect(task != null and task.state == StaffTask.State.WORKING, "a cleaner works the reception's cleaning Task")
|
||||
if task == null:
|
||||
_scenes.free_fixture(fixture)
|
||||
return
|
||||
var task_id := task.task_id
|
||||
var worker := task.assigned_staff_id
|
||||
var other: StringName = &"spare_cleaner" if worker == &"staff_eri" else &"staff_eri"
|
||||
var result := _scenes.dismiss(fixture, worker)
|
||||
task = runtime.staff_service.get_task(task_id)
|
||||
_expect(result.is_success() and result.details[&"reopened_task_ids"] == [task_id] and task.state == StaffTask.State.OPEN and task.assigned_staff_id.is_empty()
|
||||
and task.state_reason == &"STAFF_REMOVED", "A5: the cleaning Task is reopened by the dismissal")
|
||||
_expect_boundary(fixture, worker, "A5 cleaning")
|
||||
var taker: StringName = &""
|
||||
for minute: int in 80:
|
||||
clock.advance_one_minute()
|
||||
task = runtime.staff_service.get_task(task_id)
|
||||
if not task.assigned_staff_id.is_empty(): taker = task.assigned_staff_id
|
||||
if task.state == StaffTask.State.COMPLETED: break
|
||||
_expect(taker == other and task.state == StaffTask.State.COMPLETED and int(runtime.facility_cleanliness.get_facility(reception)["current"]) == 100,
|
||||
"A5: the other cleaner takes the reopened cleaning Task and completes it (cleanliness 100)")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## F8 in the same process: every ledger save point (before and right after the
|
||||
## dismissal, a minute later and around the day 2 close) is written to disk, read back
|
||||
## by this process and played to the end, exactly as without the save.
|
||||
func _test_ledger_saves_same_process() -> void:
|
||||
var straight := _scenes.new_ledger_fixture()
|
||||
_scenes.play_ledger(straight, 360, Scenes.LAST_MINUTE, Callable())
|
||||
var straight_economy := _scenes.economy_bytes(straight[&"runtime"])
|
||||
var straight_state := _scenes.state_value(straight[&"runtime"], straight[&"clock"])
|
||||
var saving := _scenes.new_ledger_fixture()
|
||||
var paths := {}
|
||||
_scenes.play_ledger(saving, 360, Scenes.LAST_MINUTE, func(minute: int, phase: String) -> void:
|
||||
if [minute, phase] in Scenes.SAVE_POINTS:
|
||||
var path := "user://tests/p16-same-process-%d-%s" % [minute, phase]
|
||||
_expect(bool(saving[&"runtime"].save_to_path(path).get(&"ok", false)), "same process: save at %d %s" % [minute, phase])
|
||||
paths["%d-%s" % [minute, phase]] = path)
|
||||
for point: Array in Scenes.SAVE_POINTS:
|
||||
var name := "%d-%s" % [point[0], point[1]]
|
||||
var resumed := _scenes.new_ledger_fixture()
|
||||
var runtime: Variant = resumed[&"runtime"]
|
||||
_expect(bool(runtime.load_from_path(paths.get(name, "")).get(&"ok", false)) and int(resumed[&"clock"].absolute_minute) == int(point[0]),
|
||||
"F8 same process %s: the save loads at its minute" % name)
|
||||
_expect(runtime.staff_service.has_staff(Scenes.DISMISSED) == (name == "1500-before"), "F8 same process %s: the employee is there exactly before the command" % name)
|
||||
_scenes.play_ledger(resumed, int(point[0]), Scenes.LAST_MINUTE, Callable())
|
||||
_expect(_scenes.economy_bytes(runtime) == straight_economy and _scenes.state_value(runtime, resumed[&"clock"]) == straight_state,
|
||||
"F8 same process %s: resuming gives exactly the uninterrupted ledger, employees and Tasks" % name)
|
||||
_expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0
|
||||
and _payroll_payments(runtime, 2) == {Scenes.DISMISSED: 120, Scenes.KEPT: 180}, "F8 same process %s: the severance and the held 120 are paid once" % name)
|
||||
_scenes.free_fixture(resumed)
|
||||
SaveService.delete_save(paths.get(name, ""))
|
||||
_scenes.free_fixture(straight)
|
||||
_scenes.free_fixture(saving)
|
||||
|
||||
|
||||
## SPEC 4.1 d: a rescue cleanup that cannot complete rolls the whole dismissal back
|
||||
## (5.c). Fixture: the medic's pickup names a Reservation that does not exist, so the
|
||||
## existing cancellation refuses it.
|
||||
func _test_rescue_cleanup_failure_rolls_back() -> void:
|
||||
var fixture := _scenes.new_rescue_fixture()
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
_expect(_scenes.advance_to_rescue_phase(fixture, "rescue_pickup/MOVING/-/single"), "the medic walks to the patient")
|
||||
var task: StaffTask = runtime.staff_service.get_task(runtime.staff_service.get_staff(&"medic").current_task_id)
|
||||
task.reservation_ids.append(&"p16.fixture.missing_reservation")
|
||||
_expect_executor_refusal(fixture, &"patient", &"RESCUE_CLEANUP_FAILED", "rescue cleanup failure")
|
||||
_expect(runtime.staff_service.has_staff(&"patient") and _scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 0, "the patient stays and no severance is posted")
|
||||
_scenes.free_fixture(fixture)
|
||||
|
||||
|
||||
## F9: the game's own Runtime and Main. The staff panel shows the severance, the
|
||||
## reopened Task and the held wages before the press, dismisses through its buttons,
|
||||
## shows the result and a refusal that arrives after the press, and for every refusal
|
||||
## row (R1, R2, R3, R5, R7, R8, R9) keeps the button disabled with the reason and sends
|
||||
## nothing on a forced press (5.a, whole save bytes). Input route: the Buttons' pressed
|
||||
## signals and Main's key handler, as the other Main suites drive them; no OS or
|
||||
## viewport input is claimed.
|
||||
func _test_dismiss_from_main() -> void:
|
||||
var runtime: Variant = root.get_node("SimulationRuntime")
|
||||
var clock: Variant = root.get_node("GameClock")
|
||||
@@ -626,6 +875,7 @@ func _test_dismiss_from_main() -> void:
|
||||
await process_frame
|
||||
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
clock.set_paused(true)
|
||||
var fixture := {&"runtime": runtime, &"clock": clock, &"state": state}
|
||||
var key := InputEventKey.new()
|
||||
key.keycode = KEY_H
|
||||
key.pressed = true
|
||||
@@ -635,9 +885,9 @@ func _test_dismiss_from_main() -> void:
|
||||
_expect(hire != null, "the hiring panel lists %s" % candidate)
|
||||
if hire != null:
|
||||
hire.pressed.emit()
|
||||
_expect(runtime.submit_command(&"register_staff", {&"staff_id": &"adult_contract", &"role_id": &"attendant",
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"adult_contract", &"role_id": &"attendant",
|
||||
&"options": {&"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 100}}).is_success(), "an ADULT_CONTRACT fixture row")
|
||||
_expect(runtime.submit_command(&"create_staff_task", {&"task_id": &"ui_task", &"task_type": &"inspect", &"priority": 90, &"options": {
|
||||
_expect(_scenes.command(fixture, &"create_staff_task", {&"task_id": &"ui_task", &"task_type": &"inspect", &"priority": 90, &"options": {
|
||||
&"target_cell": runtime.staff_service.get_staff(&"staff_eri").current_cell, &"required_role_tags": [&"cleaner"], &"task_tags": [&"cleaning"], &"estimated_minutes": 120}}).is_success(),
|
||||
"a Task for the cleaner")
|
||||
clock.advance_minutes(1)
|
||||
@@ -648,12 +898,12 @@ func _test_dismiss_from_main() -> void:
|
||||
_expect(panel.visible and panel.roster_buttons.size() == 4, "the hired-staff panel lists four employees")
|
||||
(panel.roster_buttons[&"staff_akane"] as Button).pressed.emit()
|
||||
_expect(not panel.dismiss_button.disabled and panel.dismiss_button.tooltip_text.contains("退職金 G360"), "the receptionist row offers the dismissal with severance G360")
|
||||
var untouched: Dictionary = runtime.create_snapshot()
|
||||
var untouched := var_to_bytes(runtime.create_snapshot())
|
||||
panel.dismiss_button.pressed.emit()
|
||||
_expect(panel.dismiss_confirm_box.visible and panel.dismiss_confirm_label.text.contains("退職金 G360(日給の3日分)") and runtime.create_snapshot() == untouched,
|
||||
"pressing 解雇する… shows the severance and sends nothing (5.a)")
|
||||
_expect(panel.dismiss_confirm_box.visible and panel.dismiss_confirm_label.text.contains("退職金 G360(日給の3日分)") and var_to_bytes(runtime.create_snapshot()) == untouched,
|
||||
"pressing 解雇する… shows the severance and sends nothing (5.a, bytes)")
|
||||
panel.dismiss_cancel_button.pressed.emit()
|
||||
_expect(not panel.dismiss_confirm_box.visible and runtime.create_snapshot() == untouched, "やめる closes the confirmation without a command")
|
||||
_expect(not panel.dismiss_confirm_box.visible and var_to_bytes(runtime.create_snapshot()) == untouched, "やめる closes the confirmation without a command (bytes)")
|
||||
panel.dismiss_button.pressed.emit()
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
_expect(not runtime.staff_service.has_staff(&"staff_akane") and not panel.roster_buttons.has(&"staff_akane") and panel.selected_staff_id != &"staff_akane",
|
||||
@@ -669,12 +919,113 @@ func _test_dismiss_from_main() -> void:
|
||||
clock.advance_minutes(1)
|
||||
clock.set_paused(true)
|
||||
_expect(not runtime.staff_service.has_staff(&"staff_eri") and runtime.staff_service.get_task(&"ui_task").state == StaffTask.State.OPEN
|
||||
and panel.result_label.text.contains("解雇しました"), "the queued dismissal completes and reopens her Task")
|
||||
for row: Array in [[&"heroine_anri", "ユニーク:雇用・維持費無料 / 解雇・売却不可"], [&"adult_contract", "解雇不可:ギルドで雇った一般従業員だけを解雇できます。"]]:
|
||||
(panel.roster_buttons[row[0]] as Button).pressed.emit()
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
_expect(panel.dismiss_button.disabled and panel.staff_detail_label.text.contains(row[1]), "%s: the button is disabled and the reason shown" % row[0])
|
||||
and panel.result_label.text.contains("解雇しました") and panel.result_label.text.contains("持っていた作業は開き直しました"), "the queued dismissal completes and reopens her Task")
|
||||
for row: Array in [[&"heroine_anri", "ユニーク:雇用・維持費無料 / 解雇・売却不可", "R1"], [&"adult_contract", "ギルドで雇った一般従業員だけを解雇できます。", "R7"]]:
|
||||
_expect_refused_row(panel, runtime, row[0], row[1], row[2])
|
||||
# R9: the defender of an unfinished raid.
|
||||
var objective_build := _scenes.command(fixture, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-8, 0, 0)})
|
||||
_expect(objective_build.is_success(), "build the raid objective (%s %s)" % [objective_build.message, objective_build.details])
|
||||
clock.advance_minutes(runtime.definition_registry.get_facility(&"reception").build_minutes)
|
||||
var objective: StringName = &""
|
||||
for facility: FacilityInstance in runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"reception": objective = facility.instance_id
|
||||
_expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_klaus"}).is_success()
|
||||
and runtime.submit_command(&"create_defense_raid", {&"raid_id": &"ui.raid", &"definition": {&"source_id": &"runtime_test", &"entry_points": [&"gate.north"],
|
||||
&"objective_ids": [objective], &"round_minutes": 5, &"max_duration_minutes": 180, &"retreat_policy": &"HOLD", &"defender_retreat_threshold": 0,
|
||||
&"waves": [{&"wave_id": &"wave.one", &"threat_strength": 15, &"retreat_threshold": 0, &"max_rounds": 1, &"entry_points": [&"gate.north"]}]}}, &"defense_controller").is_success()
|
||||
and runtime.submit_command(&"register_defense_combatant", {&"raid_id": &"ui.raid", &"actor_id": &"staff_klaus", &"side": &"DEFENDER", &"combat": 90,
|
||||
&"max_health": 100, &"options": {}}, &"defense_controller").is_success(), "the guard defends an unfinished raid")
|
||||
_expect_refused_row(panel, runtime, &"staff_klaus", "襲撃の防衛に参加中は解雇できません。", "R9")
|
||||
# R2: the porter carrying supply logistics.
|
||||
for spec: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(4, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||||
var built := _scenes.command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]})
|
||||
_expect(built.is_success(), "build %s (%s %s)" % [spec[0], built.message, built.details])
|
||||
clock.advance_minutes(180)
|
||||
var source: StringName = &""
|
||||
var destination: StringName = &""
|
||||
for facility: FacilityInstance in runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"warehouse": source = facility.instance_id
|
||||
if facility.definition_id == &"kitchen": destination = facility.instance_id
|
||||
_expect(not source.is_empty() and not destination.is_empty() and _scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_gina"}).is_success()
|
||||
and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_gina", &"facility_id": source, &"template_id": &"DAY"}).is_success(), "the porter works at the warehouse")
|
||||
if source.is_empty() or destination.is_empty():
|
||||
var early: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
|
||||
_expect(bool(early.get(&"passed", false)), "ordinary Main release with audio cleanup")
|
||||
return
|
||||
for minute: int in 40:
|
||||
if runtime.staff_service.get_staff(&"staff_gina").state == StaffRuntime.State.AVAILABLE: break
|
||||
clock.advance_minutes(1)
|
||||
_expect(_scenes.command(fixture, &"purchase_supplies", {&"warehouse_id": source, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}).is_success()
|
||||
and _scenes.command(fixture, &"create_logistics_request", {&"request_id": &"supply.p16_ui", &"source_inventory_id": runtime.facility_service.get_instance(source).local_inventory_id,
|
||||
&"destination_inventory_id": runtime.facility_service.get_instance(destination).local_inventory_id, &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}}).is_success(), "a supply delivery")
|
||||
for minute: int in 40:
|
||||
if runtime.staff_service.is_externally_driven_task(runtime.staff_service.get_staff(&"staff_gina").current_task_id): break
|
||||
clock.advance_minutes(1)
|
||||
_expect_refused_row(panel, runtime, &"staff_gina", "物流の受け取り・配達・返送の途中です。", "R2")
|
||||
# R3: a Task Reservation that cannot be released (disclosed fixture: the tool is consumed).
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_tool_worker", &"role_id": &"worker", &"options": {&"state": StaffRuntime.State.AVAILABLE,
|
||||
&"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"role_tags": [&"worker"], &"permitted_task_tags": [&"work"], &"daily_wage": 100}}).is_success()
|
||||
and _scenes.command(fixture, &"add_inventory_stack", {&"owner_inventory_id": &"staff.supplies", &"item_id": &"tool/snapshot_kit", &"quantity": 1}).is_success()
|
||||
and _scenes.command(fixture, &"create_staff_task", {&"task_id": &"ui_tool_task", &"task_type": &"inspect", &"priority": 95, &"options": {
|
||||
&"target_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"required_role_tags": [&"worker"], &"required_items": {&"tool/snapshot_kit": 1},
|
||||
&"task_tags": [&"work"], &"estimated_minutes": 600}}).is_success(), "a worker and a Task with a tool")
|
||||
clock.advance_minutes(1)
|
||||
var tool_task: StaffTask = runtime.staff_service.get_task(&"ui_tool_task")
|
||||
var before_consume: Dictionary = runtime.create_snapshot()
|
||||
_expect(tool_task.assigned_staff_id == &"ui_tool_worker" and tool_task.reservation_ids.size() == 1
|
||||
and bool(runtime.inventory_service.consume_reservation(tool_task.reservation_ids[0], &"p16.ui.consume", clock.absolute_minute).get(&"ok", false)), "fixture consumes the reserved tool")
|
||||
_expect_refused_row(panel, runtime, &"ui_tool_worker", "作業の予約を解けないため解雇できません。", "R3")
|
||||
_expect(runtime.restore_snapshot(before_consume), "the fixture's consumption is undone after the check")
|
||||
panel.refresh()
|
||||
# A refusal that arrives after the press: the funds are spent before the queued command runs.
|
||||
_expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"ui_late", &"role_id": &"cleaner", &"options": {&"daily_wage": 100}}).is_success(), "an employee to dismiss")
|
||||
panel.refresh()
|
||||
(panel.roster_buttons[&"ui_late"] as Button).pressed.emit()
|
||||
panel.dismiss_button.pressed.emit()
|
||||
_expect(runtime.create_snapshot() == before and not panel.dismiss_confirm_box.visible and runtime.staff_service.has_staff(row[0]), "%s: a forced press sends nothing (5.a)" % row[0])
|
||||
clock.set_paused(false)
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
_expect(runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("受理しました"), "the dismissal is accepted for the next minute")
|
||||
_expect(_scenes.drain_front(fixture, "ui.late") and runtime.economy_service.get_balance(BACK) < 300, "disclosed fixture: the funds are spent before the command runs")
|
||||
clock.advance_minutes(1)
|
||||
clock.set_paused(true)
|
||||
_expect(runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("退職金 G300 を払えません"), "the result shows the refusal after the press: %s" % panel.result_label.text)
|
||||
_expect_refused_row(panel, runtime, &"ui_late", "退職金 G300 を払えません", "R8")
|
||||
# The held unpaid wages: the first 00:00 close finds no funds, so the day's wage stays unpaid (A09).
|
||||
clock.advance_minutes(1441 - int(clock.absolute_minute))
|
||||
_expect(runtime.staff_service.get_staff(&"ui_late").unpaid_wages == 100, "the first close leaves her daily wage 100 unpaid")
|
||||
for account: StringName in [Scenes.FRONT_SALES, Scenes.PAYER]:
|
||||
runtime.economy_service.register_account(account)
|
||||
_expect(runtime.economy_service.initialize_account(Scenes.PAYER, 100000, clock.absolute_minute, &"p16.ui.payer").is_success()
|
||||
and _scenes.sale(fixture, FRONT, 1000, "ui.sale"), "disclosed fixture: a sale refills the front fund")
|
||||
panel.refresh()
|
||||
(panel.roster_buttons[&"ui_late"] as Button).pressed.emit()
|
||||
panel.dismiss_button.pressed.emit()
|
||||
_expect(panel.dismiss_confirm_label.text.contains("未払い G100 は退職金と別に次の0時に精算します"), "the confirmation shows the held wages: %s" % panel.dismiss_confirm_label.text)
|
||||
panel.dismiss_confirm_button.pressed.emit()
|
||||
_expect(not runtime.staff_service.has_staff(&"ui_late") and panel.result_label.text.contains("未払い G100 は次の0時に精算します"), "the result shows the held wages: %s" % panel.result_label.text)
|
||||
# R5: after the game ends (three actual operating losses with no funds).
|
||||
if not bool(runtime.get(&"_operations_enabled")):
|
||||
_expect(bool(runtime.enable_production_operations().get(&"ok", false)), "production Operations")
|
||||
for minute: int in 6000:
|
||||
if runtime.operations_coordinator.is_terminal(): break
|
||||
if posmod(int(clock.absolute_minute), 1440) == 1439:
|
||||
_scenes.drain_front(fixture, "ui.bankrupt.%d" % clock.absolute_minute)
|
||||
clock.advance_minutes(1)
|
||||
_expect(runtime.operations_coordinator.is_terminal(), "the game ends (BANKRUPT) after three actual operating losses")
|
||||
_expect_refused_row(panel, runtime, &"ui_tool_worker", "運営終了後は解雇できません。", "R5")
|
||||
var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main)
|
||||
_expect(bool(cleanup.get(&"passed", false)), "ordinary Main release with audio cleanup")
|
||||
|
||||
|
||||
func _expect_refused_row(panel: StaffManagementPanel, runtime: Variant, staff_id: StringName, text: String, label: String) -> void:
|
||||
panel.refresh()
|
||||
_expect(panel.roster_buttons.has(staff_id), "%s: the row is listed" % label)
|
||||
if not panel.roster_buttons.has(staff_id):
|
||||
return
|
||||
(panel.roster_buttons[staff_id] as Button).pressed.emit()
|
||||
var before := var_to_bytes(runtime.create_snapshot())
|
||||
_expect(panel.dismiss_button.disabled and panel.staff_detail_label.text.contains(text) and panel.dismiss_button.tooltip_text.contains(text),
|
||||
"%s: the button is disabled and the reason is shown (%s)" % [label, panel.dismiss_button.tooltip_text])
|
||||
panel.dismiss_button.pressed.emit()
|
||||
_expect(var_to_bytes(runtime.create_snapshot()) == before and not panel.dismiss_confirm_box.visible and runtime.staff_service.has_staff(staff_id),
|
||||
"%s: a forced press sends nothing (5.a, whole save bytes)" % label)
|
||||
|
||||
@@ -41,7 +41,9 @@ func _run() -> void:
|
||||
ledger_saves.append(_package(name, path, {"minute": minute, "phase": phase})))
|
||||
_expect(_scenes.economy_bytes(saving[&"runtime"]) == _scenes.economy_bytes(straight_runtime), "writing the save files leaves the ledger as without saving")
|
||||
var boundaries: Array = []
|
||||
for scene: Array in [["A6", "rescue_transport/MOVING/-/single", &"medic"], ["A7", "rescue_pickup/MOVING/BOARDED/single", &"patient"]]:
|
||||
for scene: Array in [["A6", "rescue_transport/MOVING/-/single", &"medic"], ["A7", "rescue_pickup/MOVING/BOARDED/single", &"patient"],
|
||||
["A6-pickup", "rescue_pickup/MOVING/-/single", &"medic"], ["A6-pair-aboard", "rescue_transport/MOVING/BOARDED/pair", &"medic"],
|
||||
["A7-pair-aboard", "rescue_transport/MOVING/BOARDED/pair", &"patient"]]:
|
||||
var fixture := _scenes.new_rescue_fixture()
|
||||
_expect(_scenes.advance_to_rescue_phase(fixture, scene[1]), "%s: the rescue reaches %s" % [scene[0], scene[1]])
|
||||
boundaries.append(_boundary(fixture, scene[0], scene[2]))
|
||||
@@ -49,6 +51,9 @@ func _run() -> void:
|
||||
var event_fixture := _scenes.new_event_fixture(16_100)
|
||||
boundaries.append(_boundary(event_fixture, "A8", &"event_actor"))
|
||||
_scenes.free_fixture(event_fixture)
|
||||
var pair_fixture := _scenes.new_event_pair_fixture(16_101)
|
||||
boundaries.append(_boundary(pair_fixture, "A8-continue-without", &"event_partner"))
|
||||
_scenes.free_fixture(pair_fixture)
|
||||
var handoff := {
|
||||
"schema": Scenes.HANDOFF_SCHEMA,
|
||||
"writer_pid": OS.get_process_id(),
|
||||
|
||||
@@ -100,7 +100,7 @@ func _check_boundaries(handoff: Dictionary) -> void:
|
||||
var labels: Array = []
|
||||
for boundary: Variant in boundaries:
|
||||
labels.append(str((boundary as Dictionary).get("label", "")) if boundary is Dictionary else "")
|
||||
_expect(labels == ["A6", "A7", "A8"], "the writer saved the A6, A7 and A8 command boundaries")
|
||||
_expect(labels == ["A6", "A7", "A6-pickup", "A6-pair-aboard", "A7-pair-aboard", "A8", "A8-continue-without"], "the writer saved the A6, A7 and A8 command boundaries")
|
||||
for boundary: Variant in boundaries:
|
||||
if not boundary is Dictionary:
|
||||
continue
|
||||
|
||||
@@ -461,21 +461,21 @@
|
||||
"new_audio_QA": false
|
||||
},
|
||||
"tests/staff/test_general_dismissal.gd": {
|
||||
"source_sha256": "b69a786539d3afd9f80a9857169b8a1687e176a172c4d63bca248dfe0ee316c1",
|
||||
"source_sha256": "e325c7ac24f979bc8ac3f4222279c317b2603ef20cae046475647dd90d423123",
|
||||
"ordinary_cleanup_prepared": true,
|
||||
"ordinary_control_labels": ["single"],
|
||||
"old_bounded_teardown": false,
|
||||
"new_audio_QA": false
|
||||
},
|
||||
"tests/staff/test_general_dismissal_restart_1_write.gd": {
|
||||
"source_sha256": "05514993cd2fd37c709555a584256ee8233ec2e4b0feca37a54e59f00970457e",
|
||||
"source_sha256": "b38745eccb4e51ddf4790051795e2f64faf10f875edc7b754ac2d04db2917a5b",
|
||||
"ordinary_cleanup_prepared": false,
|
||||
"ordinary_control_labels": [],
|
||||
"old_bounded_teardown": false,
|
||||
"new_audio_QA": false
|
||||
},
|
||||
"tests/staff/test_general_dismissal_restart_2_read.gd": {
|
||||
"source_sha256": "f8ad3cb30c40538131acca962abd8ee1234b1f8b04c9d0edeb0884f979e98b6f",
|
||||
"source_sha256": "4e13de2594096fab648fc8c418a1a4aa764ac72fc13fbd68cba1c6f05767fc83",
|
||||
"ordinary_cleanup_prepared": false,
|
||||
"ordinary_control_labels": [],
|
||||
"old_bounded_teardown": false,
|
||||
|
||||
Reference in New Issue
Block a user