diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 16c4a89..9052abb 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -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) diff --git a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json index 488f824..0761eac 100644 --- a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json +++ b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json @@ -3997,7 +3997,7 @@ "template": "PASS: 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", "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", "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" diff --git a/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/EXPECTATION_CHANGES.json b/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/EXPECTATION_CHANGES.json index cf728d0..3554899 100644 --- a/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/EXPECTATION_CHANGES.json +++ b/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/EXPECTATION_CHANGES.json @@ -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." ] } diff --git a/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/IMPLEMENTATION_NOTES.md b/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/IMPLEMENTATION_NOTES.md index c60e94b..32bda49 100644 --- a/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/IMPLEMENTATION_NOTES.md +++ b/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/IMPLEMENTATION_NOTES.md @@ -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.`)で行う。 - - 表金で足りない分: 裏金 → 表金の補填(区分 `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`)を持つ。 - - 再開の2段 `tests/staff/test_general_dismissal_restart_1_write.gd`(唯一の生成ファイルは `artifacts/p16-01-dismiss/restart-handoff.json`)と `..._2_read.gd`。 +- 新しい 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): 求人募集、奴隷市場・破産客からの獲得、人身売買、勤務・休息・気絶・救護の横断、解雇の経路の外の退職時の参照の除去。 diff --git a/scripts/ui/staff_management_panel.gd b/scripts/ui/staff_management_panel.gd index 89657f6..74f89a6 100644 --- a/scripts/ui/staff_management_panel.gd +++ b/scripts/ui/staff_management_panel.gd @@ -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": "運営終了後は解雇できません。", } diff --git a/tests/helpers/p16_dismissal_fixture.gd b/tests/helpers/p16_dismissal_fixture.gd index 657cc3d..abd55e8 100644 --- a/tests/helpers/p16_dismissal_fixture.gd +++ b/tests/helpers/p16_dismissal_fixture.gd @@ -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. diff --git a/tests/staff/test_general_dismissal.gd b/tests/staff/test_general_dismissal.gd index ad98e7e..46e8290 100644 --- a/tests/staff/test_general_dismissal.gd +++ b/tests/staff/test_general_dismissal.gd @@ -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) - 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(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: 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]) - 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]) + 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() + 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) diff --git a/tests/staff/test_general_dismissal_restart_1_write.gd b/tests/staff/test_general_dismissal_restart_1_write.gd index d50268f..9816c2f 100644 --- a/tests/staff/test_general_dismissal_restart_1_write.gd +++ b/tests/staff/test_general_dismissal_restart_1_write.gd @@ -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(), diff --git a/tests/staff/test_general_dismissal_restart_2_read.gd b/tests/staff/test_general_dismissal_restart_2_read.gd index 1226805..7f45626 100644 --- a/tests/staff/test_general_dismissal_restart_2_read.gd +++ b/tests/staff/test_general_dismissal_restart_2_read.gd @@ -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 diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index 348127f..861eda0 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -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,