From b06fec17c7c1e2e4cc22116f209c51dec751215c Mon Sep 17 00:00:00 2001 From: Agent Control Runner Date: Sat, 24 Oct 2026 12:49:31 +0900 Subject: [PATCH] =?UTF-8?q?P16-01-DISMISS:=20=E4=B8=80=E8=88=AC=E5=BE=93?= =?UTF-8?q?=E6=A5=AD=E5=93=A1=E3=81=AE=E9=80=9A=E5=B8=B8=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E3=81=A7=E3=81=AE=E8=A7=A3=E9=9B=87=EF=BC=88=E9=80=80=E8=81=B7?= =?UTF-8?q?=E9=87=91=E3=82=92=E5=90=AB=E3=82=80=E3=80=82P16-01=20=E3=81=AE?= =?UTF-8?q?=E8=A3=9C=E5=8A=A9=E3=81=AE=E4=BD=9C=E6=A5=AD=E3=80=82=E5=8E=9F?= =?UTF-8?q?=E5=8D=98=E4=BD=8D=E3=81=B8=E3=81=AE=E5=8A=A0=E7=AE=97=200?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autoload/simulation_runtime.gd | 207 +++++- .../completion_marker_contracts.json | 33 + .../p16-01-dismiss/EXPECTATION_CHANGES.json | 18 + .../p16-01-dismiss/IMPLEMENTATION_NOTES.md | 82 +++ scripts/economy/finance_policy.gd | 23 +- scripts/ui/staff_management_panel.gd | 223 +++++- tests/business/test_required_kitchen.gd | 26 +- tests/helpers/p16_dismissal_fixture.gd | 285 ++++++++ tests/helpers/p16_dismissal_fixture.gd.uid | 1 + tests/staff/test_general_dismissal.gd | 680 ++++++++++++++++++ tests/staff/test_general_dismissal.gd.uid | 1 + .../test_general_dismissal_restart_1_write.gd | 116 +++ ...t_general_dismissal_restart_1_write.gd.uid | 1 + .../test_general_dismissal_restart_2_read.gd | 168 +++++ ...st_general_dismissal_restart_2_read.gd.uid | 1 + tools/regression/suite-policy.json | 24 + tools/run_p16_dismiss_tests.py | 631 ++++++++++++++++ 17 files changed, 2503 insertions(+), 17 deletions(-) create mode 100644 docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/EXPECTATION_CHANGES.json create mode 100644 docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/IMPLEMENTATION_NOTES.md create mode 100644 tests/helpers/p16_dismissal_fixture.gd create mode 100644 tests/helpers/p16_dismissal_fixture.gd.uid create mode 100644 tests/staff/test_general_dismissal.gd create mode 100644 tests/staff/test_general_dismissal.gd.uid create mode 100644 tests/staff/test_general_dismissal_restart_1_write.gd create mode 100644 tests/staff/test_general_dismissal_restart_1_write.gd.uid create mode 100644 tests/staff/test_general_dismissal_restart_2_read.gd create mode 100644 tests/staff/test_general_dismissal_restart_2_read.gd.uid create mode 100644 tools/run_p16_dismiss_tests.py diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index c9a0106..16c4a89 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -336,6 +336,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S register_command_executor(&"decide_guest", Callable(self, "_execute_decide_guest")) register_command_executor(&"register_staff", Callable(self, "_execute_register_staff")) register_command_executor(&"hire_staff_candidate", Callable(self, "_execute_hire_staff_candidate")) + register_command_executor(&"dismiss_staff", Callable(self, "_execute_dismiss_staff")) register_command_executor(&"create_staff_shift", Callable(self, "_execute_create_staff_shift")) register_command_executor(&"set_staff_schedule_override", Callable(self, "_execute_set_staff_schedule_override")) register_command_executor(&"set_staff_work_assignment", Callable(self, "_execute_set_staff_work_assignment")) @@ -2730,6 +2731,7 @@ func _build_operations_daily_financials( &"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods", &"STAFF_SIGNING_COST": &"cost_of_goods", + FinancePolicy.SEVERANCE_CATEGORY: &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods", &"EVENT_COST_COMMITTED": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods", @@ -3410,11 +3412,15 @@ func _parse_operations_runtime_snapshot( var terminal_shape := deferred_runtime_minute == -1 \ and not failure_latched and restored_coordinator.is_terminal() \ and coordinator_current_matches and bool(restored_clock_paused) + # The closed arrears are counted as the live check counts them + # (_operations_closed_arrears_total): wages, upkeep and the unpaid rank tax and interest. + var last_close := restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1) if not bool(restored_coordinator.validate_invariants().get(&"ok", false)) \ or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \ or not (normal_shape or failure_shape or pause_shape or terminal_shape) \ or restored_coordinator.get_population_count() != restored_guests.guest_count() \ - or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1))) \ + or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(last_close) + + _finance_arrears_through(restored_economy, last_close)) \ or not _operations_payroll_crosslinks_are_valid( restored_coordinator, restored_staff, restored_economy, restored_upkeep_settlement ) \ @@ -3954,6 +3960,7 @@ func _operations_expected_category_totals( &"CONSTRUCTION_REFUND": &"construction_cash_flow", &"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods", &"STAFF_SIGNING_COST": &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods", + FinancePolicy.SEVERANCE_CATEGORY: &"cost_of_goods", &"EVENT_COST_COMMITTED": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods", &"MAINTENANCE_ACCRUAL": &"maintenance", @@ -6925,6 +6932,203 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool: _runtime_event_minute() ) + +## 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. +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": [], + &"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, + } + if staff == null: + return preview + 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) + 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. +func _staff_dismissal_block_reason(staff: StaffRuntime, severance: int) -> StringName: + if staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE: + return &"UNIQUE" + if staff.employment_kind != StaffRuntime.EmploymentKind.FREE_HIRE: + return &"NOT_GENERAL_EMPLOYEE" + if staff_service.is_externally_driven_task(staff.current_task_id): + return &"EXTERNAL_TASK" + # 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 &"" + + +## 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. +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(): + return CommandResult.failed( + CommandResult.Code.INVALID_PAYLOAD, command.command_id, "A Staff id is required." + ) + var staff_id := StringName(raw_staff_id) + var staff := staff_service.get_staff(staff_id) + if staff == null: + return CommandResult.failed( + CommandResult.Code.NOT_FOUND, command.command_id, + "Staff was not found: %s" % staff_id, {&"reason": &"NOT_FOUND", &"staff_id": staff_id} + ) + 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. + 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."], + &"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], { + &"reason": reason, &"staff_id": staff_id, &"severance": severance, + &"available_funds": FinancePolicy.available_funds(economy_service), + }) + var minute := _runtime_event_minute() + var before := { + &"economy": economy_service.create_snapshot(), + &"game_state": _state.create_snapshot(), + &"staff": staff_service.create_snapshot(), + &"inventory": inventory_service.create_snapshot(), + &"path": path_service.create_snapshot(), + &"elevators": elevators.create_snapshot(), + &"movement_credits": _movement_credits.duplicate(true), + &"domain_events": domain_events.create_snapshot(), + &"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 removal := staff_service.remove_staff(staff_id, &"DISMISSED", true, minute) + if not removal.is_success(): + removal.command_id = command.command_id + removal.details[&"reason"] = &"RESERVATION_RELEASE_FAILED" + removal.details[&"staff_id"] = staff_id + return removal + var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT) + var top_up := maxi(severance - cash, 0) + var transaction_id: StringName = &"" + if severance > 0: + var entries: Array = [] + if top_up > 0: + entries.append(LedgerEntry.new().configure(&"", minute, PLAYER_CASH_ACCOUNT, FinancePolicy.BACK_CASH, + top_up, FinancePolicy.TOP_UP_CATEGORY, staff_id, &"", &"finance.back_fund.top_up")) + entries.append(LedgerEntry.new().configure(&"", minute, FinancePolicy.SEVERANCE_EXPENSE, PLAYER_CASH_ACCOUNT, + severance, FinancePolicy.SEVERANCE_CATEGORY, staff_id, &"", &"staff.severance")) + economy_service.begin_notification_scope() + var paid := economy_service.post(entries, StringName("staff.severance.%d" % command.command_id)) + if paid.is_success(): + _state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT)) + transaction_id = StringName(paid.details.get(&"transaction_id", &"")) + economy_service.end_notification_scope() + if not paid.is_success(): + _rollback_staff_dismissal(before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, + "Staff dismissal could not commit its severance with the removal.", + {&"reason": &"SEVERANCE_POST_FAILED", &"staff_id": staff_id, &"post": paid.to_dictionary()} + ) + var interrupted: Variant = _interrupt_dismissed_staff_rescue(staff_id, held_task_id, minute) + if interrupted == null: + _rollback_staff_dismissal(before) + return CommandResult.failed( + CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, + "Staff dismissal could not interrupt the rescue it leaves.", + {&"reason": &"RESCUE_CLEANUP_FAILED", &"staff_id": staff_id} + ) + # SPEC 6.2 / 6.2.1: the facility projection and the Events' missing actors are + # 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) + return CommandResult.succeeded(command.command_id, { + &"staff_id": staff_id, + &"severance": severance, + &"severance_top_up": top_up, + &"severance_transaction_id": transaction_id, + &"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 [], + &"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: 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 +## 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: + var interrupted: Array[StringName] = [] + for task: StaffTask in staff_service.get_tasks(): + if task.task_type not in [&"rescue_pickup", &"rescue_transport"] or task.is_terminal(): + continue + var carried := task.task_id == held_task_id and task.task_type == &"rescue_transport" + if task.target_entity_id != staff_id and not carried: + 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)) + return interrupted + + +func _rollback_staff_dismissal(snapshot: Dictionary) -> bool: + var restored := economy_service.restore_snapshot(snapshot[&"economy"]) + restored = _state.restore_snapshot(snapshot[&"game_state"]) and restored + restored = staff_service.restore_snapshot(snapshot[&"staff"]) and restored + restored = inventory_service.restore_snapshot(snapshot[&"inventory"]) and restored + restored = path_service.restore_snapshot(snapshot[&"path"]) and restored + restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored + _movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true) + restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored + restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored + return restored and _validate_runtime_path_crosslinks( + path_service, + guest_service, + staff_service, + _movement_credits, + _runtime_event_minute() + ) + func _staff_role_matches_facility(role_id: StringName, definition: FacilityDefinition) -> bool: if definition.required_roles.is_empty() or role_id in definition.required_roles: return true @@ -11316,6 +11520,7 @@ func get_staff_management_report() -> Dictionary: &"rest_status": get_staff_rest_report(staff.staff_id).get(&"label", ""), &"can_edit": block_reason.is_empty(), &"assignment_block_reason": block_reason, + &"dismissal": get_staff_dismissal_preview(staff.staff_id), &"unique_contract": _staff_unique_contract_report(staff), &"original_relationship": relationship_service.get_original_relationship(staff.staff_id), }) 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 5b146bd..488f824 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 @@ -3991,6 +3991,39 @@ "path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md", "scope": "new P07-01 F7 restart reader marker; native execution pending" } + }, + { + "test": "tests/staff/test_general_dismissal.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "b69a786539d3afd9f80a9857169b8a1687e176a172c4d63bca248dfe0ee316c1", + "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" + } + }, + { + "test": "tests/staff/test_general_dismissal_restart_1_write.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "05514993cd2fd37c709555a584256ee8233ec2e4b0feca37a54e59f00970457e", + "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" + } + }, + { + "test": "tests/staff/test_general_dismissal_restart_2_read.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "f8ad3cb30c40538131acca962abd8ee1234b1f8b04c9d0edeb0884f979e98b6f", + "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" + } } ], "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting", 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 new file mode 100644 index 0000000..cf728d0 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/EXPECTATION_CHANGES.json @@ -0,0 +1,18 @@ +{ + "task": "P16-01-DISMISS", + "spec": "docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/SPEC.md", + "rule": "SPEC F10: every changed expectation of an existing suite is listed with the value before and after and the reason.", + "changes": [ + { + "suite": "tests/business/test_required_kitchen.gd", + "check": "whole typed loaded state unchanged except complete identity/saveID-bound IO1: legacy14-load (and the byte-identical-copy legacy migration control, which uses the same mapped expectation)", + "before": "_approved_runtime_migrations maps A06 (mana reserve fresh start), P14-03 (elevators v1 to v2), P05-02 A12 (the five empty fund accounts) and P07-01 A13 (natural weather) into the old checkpoint before the whole typed comparison", + "after": "the economy mapping also adds the empty STAFF_SEVERANCE_EXPENSE account (allow_negative false) when the old save lacks it and the loaded save has it; the fund accounts and the severance account are added in one step and the whole economy must then be byte-identical, so nothing else may differ", + "reason": "A14 / SPEC section 3.6: a save from before the severance gains its empty expense account on load (FinancePolicy._prepare_severance_account, the A12 form). The genuine legacy14 fixture is such a save, so its loaded economy differs from the old checkpoint exactly in this account." + } + ], + "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." + ] +} 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 new file mode 100644 index 0000000..c60e94b --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/IMPLEMENTATION_NOTES.md @@ -0,0 +1,82 @@ +# P16-01-DISMISS 実装の記録(実 Opus の実装担当、2026-10-24) + +- 仕様: 同じフォルダの `SPEC.md`(登録版 r04。main `0b59d7c` に固定。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)。 + +## 1. 命令と判定の順(SPEC §4.1) + +- `SimulationRuntime` に通常の command `dismiss_staff`(payload `staff_id`)を足した(`_execute_dismiss_staff`)。 +- (a) 運営の終了(`BANKRUPT`)と (b) 同じ key の再送(`DUPLICATE_IDEMPOTENCY_KEY`)は、今の `submit_command` と `CommandQueue.enqueue` のまま受付の前・受付で止まる(R5・R6、5.b)。 +- (c) executor は、状態を変える前に次の順で確かめる。どれかに当たれば、その理由で失敗の結果を返す(5.c)。 + 1. payload に `staff_id` が無い → `INVALID_PAYLOAD` + 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 の決め) + 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 は前もって分からないので、押した後に命令が拒み、理由を出す。 + +## 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`。 +- 記帳は命令の時刻に、1つの取引(idempotency key `staff.severance.`)で行う。 + - 表金で足りない分: 裏金 → 表金の補填(区分 `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 の補填を含む)。解雇の日の日給は今の給与の契約のまま(締めの時に雇用中の職員だけ)。 + +## 3. 後始末(SPEC §6.2・§6.2.1) + +- 勤務・予定・配置は `remove_staff`、経路の actor・昇降機の予約・移動の残り・`STAFF_REMOVED`(reason `DISMISSED`)は今の `_on_staff_removed` が行う。 +- 施設の人員の投影は命令の中で `_sync_facility_staffing` をすぐ行う。サービスの途中の職員が抜けて人数が `staff_min` を欠けると、次の施設の更新から今の規則で職員を待ち(5 分)、代わりが来なければ失敗する(A4 の試験)。 +- 救護(`_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)が残らず、保存の照合が通る。 +- 履歴として残るもの: 終わった作業の記録の `target_entity_id`、サービスの `staff_ids`(始めた時の記録)、給与の締めの行、雇った候補の記録(同じ候補は二度雇えない今の規則のまま)。 + +## 4. 画面(SPEC §4.3) + +- `StaffManagementPanel` に「解雇する…」と確定の欄(「解雇を確定」「やめる」)を足した。判定は Runtime の preview に従う。 + - 押す前に、退職金の額(日給の3日分)、裏金からの補填、未払いの給与、開き直す作業を出す。 + - 拒否の行ではボタンを無効にし、理由を出す。`UNIQUE` は今の「ユニーク:雇用・維持費無料 / 解雇・売却不可」、それ以外は「解雇不可:…」。無効のボタンを無理に押しても命令を送らない(5.a)。 + - 確定の時に名簿を読み直し、読み込みや変化の後の古い確認では送らない。 + - 時間が止まっている時はすぐに、進んでいる時は次の時刻に命令が処理され、結果(払った退職金・補填・保持した未払い・開き直した作業、または拒否の理由)を出し、名簿と選択を更新する。解雇の受理を待つ間、その職員の配属の変更とドラッグは止める。 +- `Main` の職員の管理の画面は、今の部品と送り手(`_audio_feedback.submit_command`)のままで使えるので、`scenes/main/main.gd` は変えていない。UI は今の部品のままで、完成の見た目ではない。 + +## 5. 見つけた既存の不具合と直し + +- 締めの後に税・利息の未払いが残ったゲームの保存が読めなかった(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)。 + +## 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`。 +- 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件)。 diff --git a/scripts/economy/finance_policy.gd b/scripts/economy/finance_policy.gd index 20ab359..154fb3b 100644 --- a/scripts/economy/finance_policy.gd +++ b/scripts/economy/finance_policy.gd @@ -32,6 +32,13 @@ const LOAN_INTEREST_CATEGORY: StringName = &"LOAN_INTEREST" const LOAN_INTEREST_PAYMENT_CATEGORY: StringName = &"LOAN_INTEREST_PAYMENT" const NON_PROFIT_CATEGORIES: Array[StringName] = [TOP_UP_CATEGORY, RANK_TAX_PAYMENT_CATEGORY, LOAN_INTEREST_PAYMENT_CATEGORY] +## A14 (2026-10-23): dismissing a general employee pays three days of the effective daily +## wage, from the front fund with the hidden fund covering the rest. It is booked to its +## own expense account, apart from WAGE, and counted with the operating costs. +const SEVERANCE_EXPENSE: StringName = &"STAFF_SEVERANCE_EXPENSE" +const SEVERANCE_CATEGORY: StringName = &"STAFF_SEVERANCE" +const SEVERANCE_DAYS := 3 + static func rank_tax(sales: int) -> int: return maxi(sales, 0) / 100 * RANK_TAX_PERCENT + maxi(sales, 0) % 100 * RANK_TAX_PERCENT / 100 @@ -42,6 +49,10 @@ static func loan_interest(outstanding_principal: int) -> int: + maxi(outstanding_principal, 0) % 100 * LOAN_INTEREST_PERCENT_PER_DAY / 100 +static func severance(daily_wage: int) -> int: + return maxi(daily_wage, 0) * SEVERANCE_DAYS + + static func is_back_fund_service(tags: Array) -> bool: for tag: StringName in BACK_FUND_TAGS: if tag in tags: @@ -66,12 +77,20 @@ static func prepare_accounts(economy: EconomyService) -> bool: for account_id: StringName in expected: if not economy.register_account(account_id, bool(expected[account_id])): return false - return true + return _prepare_severance_account(economy) for account_id: StringName in expected: if not economy.has_account(account_id) \ or economy.account_allows_negative(account_id) != bool(expected[account_id]): return false - return true + return _prepare_severance_account(economy) + + +## A save from before the severance gains its empty expense account the same way; the +## balances, the other accounts and the history are unchanged. +static func _prepare_severance_account(economy: EconomyService) -> bool: + if economy.has_account(SEVERANCE_EXPENSE): + return not economy.account_allows_negative(SEVERANCE_EXPENSE) + return economy.register_account(SEVERANCE_EXPENSE, false) static func outstanding_principal(economy: EconomyService) -> int: diff --git a/scripts/ui/staff_management_panel.gd b/scripts/ui/staff_management_panel.gd index f317776..89657f6 100644 --- a/scripts/ui/staff_management_panel.gd +++ b/scripts/ui/staff_management_panel.gd @@ -9,12 +9,17 @@ signal status_requested(message: String, success: bool) const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment" const KEY_PREFIX := "ui.staff_assignment." +const DISMISSAL_COMMAND: StringName = &"dismiss_staff" +const DISMISSAL_KEY_PREFIX := "ui.staff_dismissal." const ROLE_NAMES := { &"attendant": "接客", &"bartender": "酒場", &"clerk": "会計", &"cleaner": "清掃", &"cook": "調理", &"guard": "警備", &"maintenance": "保守", &"masseur": "施術", &"medic": "医療", &"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者", } +const TASK_NAMES := { + &"facility_clean": "清掃の作業", &"rescue_pickup": "救護の迎え", &"rescue_transport": "救護の搬送", +} const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"] var summary_label: Label @@ -25,6 +30,11 @@ var staff_detail_label: Label var facility_option: OptionButton var shift_option: OptionButton var apply_button: Button +var dismiss_button: Button +var dismiss_confirm_box: VBoxContainer +var dismiss_confirm_label: Label +var dismiss_confirm_button: Button +var dismiss_cancel_button: Button var result_label: Label var selected_staff_id: StringName = &"" @@ -37,7 +47,10 @@ var _draft_facility_id: StringName = &"" var _draft_template_id: StringName = &"DAY" var _draft_dirty := false var _sequence := 1 +var _dismissal_sequence := 1 var _pending_commands: Dictionary = {} +var _pending_dismissals: Dictionary = {} +var _confirming_dismissal_id: StringName = &"" var _queue_instance_id := 0 var _built := false @@ -112,6 +125,42 @@ func _build_interface() -> void: apply_button.pressed.connect(submit_assignment) _style(apply_button) editor_panel.add_child(apply_button) + # P16-01-DISMISS: the dismissal shows its severance and reopened Task before the + # confirming press; the Runtime command decides and records everything. + dismiss_button = Button.new() + dismiss_button.name = "DismissStaffButton" + dismiss_button.text = "解雇する…" + dismiss_button.custom_minimum_size.y = 32 + dismiss_button.pressed.connect(request_dismissal) + _style(dismiss_button) + editor_panel.add_child(dismiss_button) + dismiss_confirm_box = VBoxContainer.new() + dismiss_confirm_box.name = "DismissStaffConfirm" + dismiss_confirm_box.add_theme_constant_override("separation", 3) + dismiss_confirm_box.visible = false + editor_panel.add_child(dismiss_confirm_box) + dismiss_confirm_label = _label("") + dismiss_confirm_label.name = "DismissStaffConfirmText" + dismiss_confirm_box.add_child(dismiss_confirm_label) + var confirm_row := HBoxContainer.new() + confirm_row.add_theme_constant_override("separation", 5) + dismiss_confirm_box.add_child(confirm_row) + dismiss_confirm_button = Button.new() + dismiss_confirm_button.name = "ConfirmDismissStaff" + dismiss_confirm_button.text = "解雇を確定" + dismiss_confirm_button.custom_minimum_size.y = 30 + dismiss_confirm_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + dismiss_confirm_button.pressed.connect(confirm_dismissal) + _style(dismiss_confirm_button) + confirm_row.add_child(dismiss_confirm_button) + dismiss_cancel_button = Button.new() + dismiss_cancel_button.name = "CancelDismissStaff" + dismiss_cancel_button.text = "やめる" + dismiss_cancel_button.custom_minimum_size.y = 30 + dismiss_cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + dismiss_cancel_button.pressed.connect(cancel_dismissal) + _style(dismiss_cancel_button) + confirm_row.add_child(dismiss_cancel_button) result_label = _label("") result_label.name = "StaffAssignmentResult" add_child(result_label) @@ -124,6 +173,7 @@ func refresh() -> void: if queue != null and int(queue.get_instance_id()) != _queue_instance_id: _queue_instance_id = int(queue.get_instance_id()) _draft_dirty = false + _confirming_dismissal_id = &"" facility_option.get_popup().hide() shift_option.get_popup().hide() _synchronize_commands(queue) @@ -178,6 +228,7 @@ func refresh() -> void: func clear_selection() -> void: selected_staff_id = &"" _draft_dirty = false + _confirming_dismissal_id = &"" if not _built: return result_label.text = "" @@ -239,16 +290,157 @@ func _refresh_editor() -> void: facility_option.select(selected_index) shift_option.select(1 if _draft_template_id == &"NIGHT" else 0) var pending := _pending_commands.values().has(selected_staff_id) - facility_option.disabled = pending - shift_option.disabled = pending + var dismissing := _pending_dismissals.values().has(selected_staff_id) + facility_option.disabled = pending or dismissing + shift_option.disabled = pending or dismissing + _refresh_dismissal(row, dismissing) var can_edit := bool(row.get(&"can_edit", true)) - apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index) + apply_button.disabled = pending or dismissing or not can_edit or facility_option.is_item_disabled(selected_index) apply_button.tooltip_text = _assignment_block_text(row) if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。" if not can_edit: staff_detail_label.text += "\n" + _assignment_block_text(row) apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更" +## P16-01-DISMISS: the Runtime's preview decides whether the dismissal is offered; +## a refused row keeps the button disabled and shows the reason. +func _refresh_dismissal(row: Dictionary, dismissing: bool) -> void: + var preview: Dictionary = row.get(&"dismissal", {}) + var allowed := bool(preview.get(&"can_dismiss", false)) + dismiss_button.disabled = dismissing or not allowed + dismiss_button.text = "解雇を受理済み…" if dismissing else "解雇する…" + if allowed: + dismiss_button.tooltip_text = _dismissal_summary(row, preview) + else: + var reason := StringName(preview.get(&"reason", &"")) + var reason_text := _dismissal_reason_text(reason, preview) + dismiss_button.tooltip_text = reason_text + if not dismissing and not reason_text.is_empty() and not staff_detail_label.text.contains(reason_text): + # UNIQUE keeps its existing contract line; the other reasons are labelled. + var line := reason_text if reason == &"UNIQUE" else "解雇不可:" + reason_text + staff_detail_label.text += "\n" + line + if _confirming_dismissal_id != selected_staff_id or dismissing or not allowed: + _confirming_dismissal_id = &"" + dismiss_confirm_box.visible = not _confirming_dismissal_id.is_empty() + if dismiss_confirm_box.visible: + dismiss_confirm_label.text = _dismissal_summary(row, preview) + + +func request_dismissal() -> void: + if _runtime == null or selected_staff_id.is_empty(): + return + refresh() + var row := _selected_row() + var preview: Dictionary = row.get(&"dismissal", {}) + if row.is_empty() or not bool(preview.get(&"can_dismiss", false)) or _pending_dismissals.values().has(selected_staff_id): + var reason_text := "この従業員の解雇はすでに受理されています。" if _pending_dismissals.values().has(selected_staff_id) \ + else _dismissal_reason_text(StringName(preview.get(&"reason", &"")), preview) + _set_result(reason_text if not reason_text.is_empty() else "この従業員は解雇できません。", false) + return + _confirming_dismissal_id = selected_staff_id + refresh() + + +func cancel_dismissal() -> void: + _confirming_dismissal_id = &"" + refresh() + + +func confirm_dismissal() -> void: + if _runtime == null or selected_staff_id.is_empty(): + return + var armed := _confirming_dismissal_id + # Re-read the roster first: a load or a changed employee never sends a stale request. + refresh() + var row := _selected_row() + var preview: Dictionary = row.get(&"dismissal", {}) + var employee_name := String(row.get(&"display_name", selected_staff_id)) + if not armed.is_empty() and armed == selected_staff_id and not row.is_empty() \ + and not bool(preview.get(&"can_dismiss", false)): + _confirming_dismissal_id = &"" + _set_result("%s:%s" % [employee_name, _dismissal_reason_text(StringName(preview.get(&"reason", &"")), preview)], false) + refresh() + return + if armed.is_empty() or armed != selected_staff_id or _confirming_dismissal_id != armed: + _set_result("解雇の確認は取り消されました。もう一度「解雇する…」を押してください。", false) + return + var staff_id := selected_staff_id + _confirming_dismissal_id = &"" + var key := StringName("%s%08d" % [DISMISSAL_KEY_PREFIX, _dismissal_sequence]) + _dismissal_sequence += 1 + var result: Variant = _submit_ui_command(DISMISSAL_COMMAND, {&"staff_id": staff_id}, &"player", staff_id, 75, -1, key) + if result == null or not result.has_method(&"is_success"): + _set_result("解雇に応答がありません。", false) + refresh() + return + var pending := false + var queue: Variant = _runtime.get(&"command_queue") + if queue != null: + for command: Dictionary in queue.create_snapshot().get("pending", []): + if int(command.get("command_id", 0)) == int(result.command_id): + pending = true + break + if result.is_success() and pending: + _pending_dismissals[int(result.command_id)] = staff_id + _set_result("%s の解雇を受理しました。次の時間更新で反映します。" % employee_name, true) + else: + _show_dismissal_result(result, employee_name) + refresh() + + +func _show_dismissal_result(result: Variant, employee_name: String) -> void: + var details: Dictionary = result.details if result.details is Dictionary else {} + if not result.is_success(): + var translated := _dismissal_reason_text(StringName(details.get(&"reason", details.get("reason", &""))), details) + _set_result("%s:%s" % [employee_name, translated if not translated.is_empty() else "解雇できませんでした:%s" % String(result.message)], false) + return + var text := "%s を解雇しました。退職金 G%d を払いました" % [employee_name, int(details.get(&"severance", 0))] + if int(details.get(&"severance_top_up", 0)) > 0: + text += "(不足 G%d は裏金から補填)" % int(details[&"severance_top_up"]) + text += "。" + if int(details.get(&"unpaid_wages_preserved", 0)) > 0: + text += "未払い G%d は次の0時に精算します。" % int(details[&"unpaid_wages_preserved"]) + if not (details.get(&"reopened_task_ids", []) as Array).is_empty(): + text += "持っていた作業は開き直しました。" + _set_result(text, true) + + +func _dismissal_summary(row: Dictionary, preview: Dictionary) -> String: + var severance := int(preview.get(&"severance", 0)) + var top_up := maxi(severance - int(preview.get(&"front_cash", 0)), 0) + var text := "%sを解雇します。退職金 G%d(日給の3日分)を表金から払います。" % [row.get(&"display_name", selected_staff_id), severance] + if top_up > 0: + text += "不足 G%d は裏金から補います。" % top_up + if int(preview.get(&"unpaid_wages", 0)) > 0: + text += "未払い G%d は退職金と別に次の0時に精算します。" % int(preview[&"unpaid_wages"]) + var task_names: Array[String] = [] + for task_type: Variant in preview.get(&"reopened_task_types", []): + task_names.append(String(TASK_NAMES.get(StringName(task_type), "作業"))) + if not task_names.is_empty(): + text += "持っている%sは開き直して、ほかの従業員に回します。" % "・".join(task_names) + return text + + +func _dismissal_reason_text(reason: StringName, details: Dictionary = {}) -> String: + match reason: + &"": + return "" + &"UNIQUE": + return "ユニーク:雇用・維持費無料 / 解雇・売却不可" + &"INSUFFICIENT_FUNDS": + return "退職金 G%d を払えません(表金と裏金の合計 G%d)。" % [int(details.get(&"severance", 0)), int(details.get(&"available_funds", 0))] + var reasons := { + &"NOT_FOUND": "従業員が見つかりません。", + &"NOT_GENERAL_EMPLOYEE": "ギルドで雇った一般従業員だけを解雇できます。", + &"EXTERNAL_TASK": "物流の受け取り・配達・返送の途中です。終わってから解雇できます。", + &"DEFENSE_LOCKED": "襲撃の防衛に参加中は解雇できません。襲撃が終わってから解雇できます。", + &"RESCUE_PAIR_ABOARD": "救護の搬送で昇降機に乗っています。着いてから解雇できます。", + &"RESERVATION_RELEASE_FAILED": "作業の予約を解けないため解雇できません。", + &"BANKRUPT": "運営終了後は解雇できません。", + } + return String(reasons.get(reason, "")) + + ## Runtime supplies canonical, owned presentation data after every normal ## load. The panel neither infers a unique identity from wages nor edits a save. func _unique_contract_text(row: Dictionary) -> String: @@ -373,15 +565,20 @@ func _assignment_reason_text(reason: StringName) -> String: func _synchronize_commands(queue: Variant) -> void: _pending_commands.clear() + _pending_dismissals.clear() for metadata: Dictionary in queue.get_accepted_command_metadata(): var key := String(metadata.get("idempotency_key", "")) if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int(): _sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1) + if key.begins_with(DISMISSAL_KEY_PREFIX) and key.trim_prefix(DISMISSAL_KEY_PREFIX).is_valid_int(): + _dismissal_sequence = maxi(_dismissal_sequence, int(key.trim_prefix(DISMISSAL_KEY_PREFIX)) + 1) for command: Dictionary in queue.create_snapshot().get("pending", []): - if StringName(command.get("command_type", &"")) != ASSIGNMENT_COMMAND: + var command_type := StringName(command.get("command_type", &"")) + if command_type not in [ASSIGNMENT_COMMAND, DISMISSAL_COMMAND]: continue var payload: Dictionary = command.get("payload", {}) - _pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &""))) + var pending := _pending_commands if command_type == ASSIGNMENT_COMMAND else _pending_dismissals + pending[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &""))) func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: @@ -390,6 +587,17 @@ func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> func _on_command_completed(result: Variant) -> void: + if _pending_dismissals.has(int(result.command_id)): + var dismissed_id := StringName(_pending_dismissals[int(result.command_id)]) + _pending_dismissals.erase(int(result.command_id)) + var employee_name := String(dismissed_id) + for row: Dictionary in _staff_rows: + if StringName(row.get(&"staff_id", &"")) == dismissed_id: + employee_name = String(row.get(&"display_name", dismissed_id)) + break + _show_dismissal_result(result, employee_name) + refresh() + return if not _pending_commands.has(int(result.command_id)): return var staff_id := StringName(_pending_commands[int(result.command_id)]) @@ -478,8 +686,9 @@ func _style(button: Button) -> void: func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant: if selected_staff_id != staff_id: select_staff(staff_id) - if _pending_commands.values().has(staff_id) or not bool(_selected_row().get(&"can_edit", false)): - _set_result("変更を処理中です。" if _pending_commands.values().has(staff_id) else _assignment_block_text(_selected_row()), false) + var busy := _pending_commands.values().has(staff_id) or _pending_dismissals.values().has(staff_id) + if busy or not bool(_selected_row().get(&"can_edit", false)): + _set_result("変更を処理中です。" if busy else _assignment_block_text(_selected_row()), false) return null var preview := Label.new() preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"] diff --git a/tests/business/test_required_kitchen.gd b/tests/business/test_required_kitchen.gd index e15525b..a082e06 100644 --- a/tests/business/test_required_kitchen.gd +++ b/tests/business/test_required_kitchen.gd @@ -302,16 +302,24 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D fields.append("elevators_v1_to_v2") proof["elevators"] = {"only_version_changed": same_otherwise} # P05-02 A12: a save from before the funds were split gains the five empty fund - # accounts on load; every other economy field stays byte-identical. - var has_back_cash := func(economy: Variant) -> bool: + # accounts on load, and P16-01-DISMISS (A14) a save from before the severance gains + # its empty expense account; every other economy field stays byte-identical. + var has_account := func(economy: Variant, account_id: String) -> bool: if not economy is Dictionary: return false for account: Variant in (economy as Dictionary).get(&"accounts", []): - if account is Dictionary and String((account as Dictionary).get(&"account_id", "")) == "BACK_CASH": + if account is Dictionary and String((account as Dictionary).get(&"account_id", "")) == account_id: return true return false - if not has_back_cash.call(old.get(&"economy")) and has_back_cash.call(now.get(&"economy")): - var added := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true} + var fund_accounts := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true} + var added := {} + if not has_account.call(old.get(&"economy"), "BACK_CASH") and has_account.call(now.get(&"economy"), "BACK_CASH"): + added.merge(fund_accounts) + var severance_added: bool = not has_account.call(old.get(&"economy"), "STAFF_SEVERANCE_EXPENSE") \ + and has_account.call(now.get(&"economy"), "STAFF_SEVERANCE_EXPENSE") + if severance_added: + added["STAFF_SEVERANCE_EXPENSE"] = false + if not added.is_empty(): var economy: Dictionary = (old[&"economy"] as Dictionary).duplicate(true) var accounts: Array = (economy[&"accounts"] as Array).duplicate(true) for account_id: String in added: @@ -321,8 +329,12 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D var only_accounts_added: bool = var_to_bytes(economy) == var_to_bytes(now[&"economy"]) ok = ok and only_accounts_added old[&"economy"] = economy - fields.append("economy_A12_empty_fund_accounts") - proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added} + if added.has("BACK_CASH"): + fields.append("economy_A12_empty_fund_accounts") + proof["economy_A12"] = {"added_accounts": fund_accounts.keys(), "only_accounts_added": only_accounts_added} + if severance_added: + fields.append("economy_A14_empty_severance_account") + proof["economy_A14"] = {"added_accounts": ["STAFF_SEVERANCE_EXPENSE"], "only_accounts_added": only_accounts_added} # P07-01 A13 (SPEC section 5.2): a save from before natural weather keeps its # weather and pending schedule, counts every day whose 06:00 has passed as # drawn (no past draw), holds no rain extension, and starts the weather diff --git a/tests/helpers/p16_dismissal_fixture.gd b/tests/helpers/p16_dismissal_fixture.gd new file mode 100644 index 0000000..657cc3d --- /dev/null +++ b/tests/helpers/p16_dismissal_fixture.gd @@ -0,0 +1,285 @@ +extends RefCounted + +## P16-01-DISMISS shared scenes for tests/staff/test_general_dismissal.gd and its +## restart writer and reader. Every scene runs the production Runtime; a fixture +## only places employees, rescue states, Events and balances, never the dismissal. + +const FRONT := &"PLAYER_CASH" +const BACK := &"BACK_CASH" +const FRONT_SALES := &"P16_FIXTURE_FRONT_SALES" +const BACK_SALES := &"P16_FIXTURE_BACK_SALES" +const PAYER := &"P16_FIXTURE_PAYER" + +## The ledger day script (F4/F8): two hired general employees, a 00:00 close +## without funds (their first day's wages stay unpaid), sales in both funds, +## the dismissal of staff_akane at 01:00 of day 2 (severance 360 = 3 x 120: front +## 300, hidden 60) and the day 2 close that pays staff_eri and the departed +## obligation of staff_akane (120) once, the front fund topped up from the hidden one. +const LEDGER_SEED := 16_016 +const DISMISSED := &"staff_akane" +const KEPT := &"staff_eri" +const DISMISS_MINUTE := 1500 +const LAST_MINUTE := 2900 +const SAVE_POINTS := [[1500, "before"], [1500, "after"], [1501, "before"], [2879, "before"], [2880, "before"], [2881, "before"]] + +const RESCUE_SEED := 712 +const HANDOFF_SCHEMA := "p16-01-dismiss-restart-v1" +const HANDOFF_PATH := "res://artifacts/p16-01-dismiss/restart-handoff.json" +const WRITER_SOURCE := "res://tests/staff/test_general_dismissal_restart_1_write.gd" +const WRITER_SAVE := "user://tests/p16-dismiss-writer-%s" +const READER_SAVE := "user://tests/p16-dismiss-reader-%s" + +var _expect: Callable + + +func _init(expect: Callable) -> void: + _expect = expect + + +func check(condition: bool, message: String) -> void: + _expect.call(condition, message) + + +func new_fixture(seed: int, operations := false) -> Dictionary: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + check(bool(runtime.initialize(clock, state, seed).get(&"valid", false)), "fixture Runtime initializes (seed %d)" % seed) + clock.set_paused(true) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + if operations: + check(bool(runtime.enable_production_operations().get(&"ok", false)), "fixture enables production Operations") + for account: StringName in [FRONT_SALES, BACK_SALES, PAYER]: + runtime.economy_service.register_account(account) + check(runtime.economy_service.initialize_account(PAYER, 100000, clock.absolute_minute, &"p16.fixture.payer").is_success(), "fixture payer funded") + return {&"clock": clock, &"state": state, &"runtime": runtime} + + +func free_fixture(fixture: Dictionary) -> void: + for key: StringName in [&"runtime", &"clock", &"state"]: + if fixture.get(key) != null: + fixture[key].free() + + +func command(fixture: Dictionary, type: StringName, payload: Dictionary, key: StringName = &"") -> CommandResult: + return fixture[&"runtime"].submit_command(type, payload, &"player", &"", 0, -1, key) + + +func dismiss(fixture: Dictionary, staff_id: StringName, key: StringName = &"") -> CommandResult: + return command(fixture, &"dismiss_staff", {&"staff_id": staff_id}, key) + + +## A disclosed balance fixture: a sale swept into the front or the hidden fund. +func sale(fixture: Dictionary, fund: StringName, amount: int, key: String) -> bool: + var runtime: Variant = fixture[&"runtime"] + var minute: int = fixture[&"clock"].absolute_minute + var revenue := FRONT_SALES if fund == FRONT else BACK_SALES + var entries: Array = [ + LedgerEntry.new().configure(&"", minute, revenue, PAYER, amount, &"SERVICE_SALE", &"p16.fixture"), + LedgerEntry.new().configure(&"", minute, fund, revenue, amount, &"SERVICE_CASH_SWEEP", &"p16.fixture"), + ] + return runtime.economy_service.post(entries, StringName("p16.sale." + key)).is_success() + + +## A disclosed balance fixture: an operating cost that empties the front fund. +func drain_front(fixture: Dictionary, key: String) -> bool: + var runtime: Variant = fixture[&"runtime"] + var cash: int = runtime.economy_service.get_balance(FRONT) + if cash <= 0: + return true + return runtime.economy_service.transfer(FRONT, &"OPERATING_EXPENSE", cash, fixture[&"clock"].absolute_minute, + &"OPERATING_EXPENSE", &"p16.fixture", StringName("p16.drain." + key)).is_success() + + +func posted(fixture: Dictionary, key: String) -> bool: + return not fixture[&"runtime"].economy_service.get_transaction_id_for_idempotency_key(StringName(key)).is_empty() + + +## The ledger day script from `from_minute` to `to_minute`. `on_point` is called +## with (minute, "before") ahead of the minute's scripted step and (minute, "after") +## behind it. Each step is skipped when its effect is already in the loaded game. +func play_ledger(fixture: Dictionary, from_minute: int, to_minute: int, on_point: Callable) -> void: + var clock: Variant = fixture[&"clock"] + var runtime: Variant = fixture[&"runtime"] + if int(clock.absolute_minute) != from_minute: + check(false, "ledger script starts at minute %d (clock %d)" % [from_minute, clock.absolute_minute]) + return + while int(clock.absolute_minute) < to_minute: + var minute := int(clock.absolute_minute) + if on_point.is_valid(): + on_point.call(minute, "before") + if minute == 362 and not runtime.staff_service.has_staff(DISMISSED): + for candidate: StringName in [DISMISSED, KEPT]: + check(command(fixture, &"hire_staff_candidate", {&"candidate_id": candidate}).is_success(), "hire %s through the ordinary command" % candidate) + if minute == 1430 and not posted(fixture, "p16.drain.close1"): + check(drain_front(fixture, "close1"), "the front fund is empty before the first close") + if minute == 1470 and not posted(fixture, "p16.sale.front2"): + check(sale(fixture, FRONT, 300, "front2") and sale(fixture, BACK, 400, "back2"), "day 2 sales: front 300, hidden 400") + if minute == DISMISS_MINUTE and runtime.staff_service.has_staff(DISMISSED): + var result := dismiss(fixture, DISMISSED, &"p16.ledger.dismiss") + check(result.is_success() and int(result.details.get(&"severance", -1)) == 360 and int(result.details.get(&"severance_top_up", -1)) == 60, + "the scripted dismissal pays 360, 60 of it from the hidden fund (%s %s)" % [result.message, result.details]) + if on_point.is_valid(): + on_point.call(minute, "after") + clock.advance_minutes(1) + + +func new_ledger_fixture() -> Dictionary: + return new_fixture(LEDGER_SEED, true) + + +func count_category(runtime: Variant, category: StringName, source: StringName = &"") -> int: + var count := 0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if entry.category == category and (source.is_empty() or entry.source_entity_id == source): + count += 1 + return count + + +func economy_bytes(runtime: Variant) -> PackedByteArray: + return var_to_bytes(runtime.economy_service.create_snapshot()) + + +## The rescue elevator world of tests/staff/test_rescue_elevator.gd: an infirmary, +## a service elevator, a collapsed medic upstairs (patient) and a medic below. +## Both are ordinary FREE_HIRE employees with a daily wage of 100 (severance 300). +func new_rescue_fixture(seed := RESCUE_SEED) -> Dictionary: + var fixture := new_fixture(seed) + for spec: Array in [[&"infirmary", Vector3i(-6, 0, 1)], [&"service_elevator", Vector3i(0, 0, 1)]]: + check(command(fixture, &"build_facility", {&"definition_id": spec[0], &"origin": spec[1]}).is_success(), "ordinary construction: %s" % spec[0]) + fixture[&"clock"].advance_minutes(400) + for id: StringName in [&"patient", &"medic"]: + var options := { + &"current_cell": Vector3i(0, 1, 1) if id == &"patient" else Vector3i(5, 0, 1), + &"health": 0 if id == &"patient" else 100, &"state": StaffRuntime.State.AVAILABLE, + &"role_tags": [&"medic"], &"permitted_task_tags": [&"rescue"], &"daily_wage": 100, + } + check(command(fixture, &"register_staff", {&"staff_id": id, &"role_id": &"medic", &"options": options}).is_success(), "register rescue employee %s" % id) + return fixture + + +## The medic's rescue phase: task type, task state, elevator state and pairing. +func rescue_phase(runtime: Variant) -> String: + var medic: StaffRuntime = runtime.staff_service.get_staff(&"medic") + if medic == null: + return "gone" + var task: StaffTask = runtime.staff_service.get_task(medic.current_task_id) + var trip: Dictionary = runtime.elevators.get_actor_trip(&"staff:medic") + return "%s/%s/%s/%s" % [task.task_type if task != null else "-", StaffTask.State.keys()[task.state] if task != null else "-", + trip.get(&"state", "-"), "pair" if trip.has(&"companion_id") else "single"] + + +## Advances the rescue world minute by minute to the first minute of `phase`. +func advance_to_rescue_phase(fixture: Dictionary, phase: String, limit := 160) -> bool: + for tick: int in limit: + if rescue_phase(fixture[&"runtime"]) == phase: + return true + fixture[&"clock"].advance_minutes(1) + return rescue_phase(fixture[&"runtime"]) == phase + + +func register_event_definition(fixture: Dictionary, event_id: StringName, gold_cost: int) -> bool: + return command(fixture, &"register_event_definition", {&"definition": { + &"event_id": event_id, &"category": &"RELATIONSHIP", &"priority": 70, &"foreground": true, + &"transaction_policy": &"ROLLBACK_ALL", &"missing_actor_policy": &"CANCEL", &"content_rating": &"GENERAL", + &"choices": [{&"choice_id": &"purchase", &"text_key": StringName("event.%s.purchase" % event_id), &"cost": {&"gold": gold_cost}, + &"actions": [{&"action_id": &"paid_choice_flag", &"type": &"SET_FLAG", &"payload": {&"flag_id": &"paid_choice_completed"}}]}], + }}).is_success() + + +## The Event scene of tests/s4/test_runtime_relationship_events.gd: a foreground +## Event that locks the employee and holds 80 Gold of reserved choice cost. +func new_event_fixture(seed: int) -> Dictionary: + var fixture := new_fixture(seed) + var runtime: Variant = fixture[&"runtime"] + check(command(fixture, &"register_staff", {&"staff_id": &"event_actor", &"role_id": &"service", &"options": {&"daily_wage": 80}}).is_success(), "register the Event actor") + check(register_event_definition(fixture, &"p16.event.actor", 80), "register the paid Event definition") + check(command(fixture, &"enqueue_event", {&"event_id": &"p16.event.actor", &"actor_ids": [&"event_actor"], + &"options": {&"event_instance_id": &"p16.event.instance"}}).is_success(), "enqueue the Event with the employee as its actor") + fixture[&"clock"].set_paused(false) + fixture[&"clock"].advance_one_minute() + var foreground: Variant = runtime.event_service.get_foreground_instance() + check(foreground != null and runtime.event_service.get_actor_locks().has(&"event_actor"), "the foreground Event locks the employee") + if foreground != null: + check(command(fixture, &"reserve_event_choice_cost", {&"event_instance_id": foreground.event_instance_id, &"choice_id": &"purchase"}).is_success(), + "the Event reserves its choice cost in escrow") + fixture[&"clock"].set_paused(true) + 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. +func dangling_references(runtime: Variant, staff_id: StringName) -> Array[String]: + var found: Array[String] = [] + if runtime.staff_service.has_staff(staff_id): + found.append("staff") + for task: StaffTask in runtime.staff_service.get_tasks(): + if not task.is_terminal() and (task.target_entity_id == staff_id or task.assigned_staff_id == staff_id): + found.append("task " + String(task.task_id)) + var actor := StringName("staff:" + String(staff_id)) + if not runtime.elevators.get_actor_trip(actor).is_empty(): + found.append("elevator trip") + for trip: Dictionary in runtime.elevators.get_report()[&"trips"]: + if StringName(trip.get(&"companion_id", &"")) == actor: + found.append("elevator companion") + if runtime.path_service.has_actor_projection(actor): + found.append("path actor") + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if staff_id in facility.active_staff_ids: + found.append("facility " + String(facility.instance_id)) + if runtime.event_service.get_actor_locks().has(staff_id): + found.append("event lock") + for instance: EventInstance in runtime.event_service.get_instances(): + if instance.state in [EventInstance.State.RUNNING, EventInstance.State.WAITING_CHOICE, EventInstance.State.INTERRUPTED] \ + and staff_id in instance.actor_ids: + found.append("event actor " + String(instance.event_instance_id)) + return found + + +## A transport-safe value of the state a dismissal touches, compared across +## processes (String keys, sorted, no StringName ordering). +func state_value(runtime: Variant, clock: Variant) -> Dictionary: + var staff := {} + for worker: StaffRuntime in runtime.staff_service.get_staff_members(): + staff[String(worker.staff_id)] = [int(worker.state), var_to_str(worker.current_cell), String(worker.current_task_id), worker.unpaid_wages, worker.health] + var tasks := {} + for task: StaffTask in runtime.staff_service.get_tasks(): + tasks[String(task.task_id)] = [String(task.task_type), int(task.state), String(task.state_reason), String(task.target_entity_id), String(task.assigned_staff_id), task.reservation_ids.size()] + var trips := {} + for trip: Dictionary in runtime.elevators.get_report()[&"trips"]: + trips[String(trip.get(&"actor_id", ""))] = [String(trip.get(&"state", "")), String(trip.get(&"companion_id", ""))] + var balances := {} + for account: Dictionary in runtime.economy_service.get_accounts(): + balances[String(account[&"account_id"])] = int(account[&"balance"]) + var departed := {} + for staff_id: StringName in runtime.staff_service.get_departed_staff_ids(): + departed[String(staff_id)] = runtime.staff_service.get_departed_unpaid_wages(staff_id) + var locks := [] + for actor_id: Variant in runtime.event_service.get_actor_locks(): + locks.append(String(actor_id)) + locks.sort() + var events := {} + for instance: EventInstance in runtime.event_service.get_instances(): + events[String(instance.event_instance_id)] = [int(instance.state), instance.actor_ids.size()] + return {"minute": int(clock.absolute_minute), "staff": _sorted(staff), "tasks": _sorted(tasks), "trips": _sorted(trips), + "balances": _sorted(balances), "departed": _sorted(departed), "locks": locks, "events": _sorted(events), + "severance_entries": count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY), "wage_entries": count_category(runtime, &"WAGE")} + + +func _sorted(value: Dictionary) -> Array: + var keys := value.keys() + keys.sort() + var rows := [] + for key: Variant in keys: + rows.append([key, value[key]]) + return rows + + +static func sha256(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(bytes) + return context.finish().hex_encode() diff --git a/tests/helpers/p16_dismissal_fixture.gd.uid b/tests/helpers/p16_dismissal_fixture.gd.uid new file mode 100644 index 0000000..e71dfa4 --- /dev/null +++ b/tests/helpers/p16_dismissal_fixture.gd.uid @@ -0,0 +1 @@ +uid://dr2mdns7nvdoj diff --git a/tests/staff/test_general_dismissal.gd b/tests/staff/test_general_dismissal.gd new file mode 100644 index 0000000..ad98e7e --- /dev/null +++ b/tests/staff/test_general_dismissal.gd @@ -0,0 +1,680 @@ +extends SceneTree +const FixtureCleanup := preload("res://tests/helpers/ui_audio_fixture_cleanup.gd") +const Scenes := preload("res://tests/helpers/p16_dismissal_fixture.gd") + +## P16-01-DISMISS: dismissal of a general employee (FREE_HIRE) by the ordinary +## dismiss_staff command, with the A14 severance (three days of the effective daily +## wage from the front fund, the hidden fund covering the rest), the existing +## removal contract, the refusals R1-R9 and the cleanup of SPEC 6.2/6.2.1 at the +## command boundary. Every scene runs the production Runtime and its own rules; +## fixtures only place employees, Tasks, rescue states, Events, raids and balances, +## never the dismissal under test. The last scene dismisses from the game's own +## Main staff panel (F9), so the runner gives this suite the ordinary Main audio +## cleanup proof (tools/regression/suite-policy.json). + +const FRONT := Scenes.FRONT +const BACK := Scenes.BACK +const RESCUE_PHASES := [ + "rescue_pickup/MOVING/-/single", "rescue_pickup/MOVING/QUEUED/single", "rescue_pickup/MOVING/BOARDED/single", + "rescue_transport/MOVING/QUEUED/pair", "rescue_transport/MOVING/BOARDED/pair", + "rescue_transport/MOVING/-/single", "rescue_transport/WORKING/-/single", +] + +var _checks := 0 +var _failures := 0 +var _scenes: Scenes + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P16-01-DISMISS: general employee dismissal") + _scenes = Scenes.new(_expect) + _test_ledger_day() + _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_facility_duty_and_service() + _test_walking_commute() + _test_rescue_matrix() + _test_event_actors() + _test_defense_refusal() + _test_commit_failure_rolls_back() + _test_old_save_gains_the_severance_account() + await _test_dismiss_from_main() + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) + + +# --- SPEC 5: what a refusal may change ------------------------------------------- + +func _without(snapshot: Dictionary, keys: Array) -> Dictionary: + var copy := snapshot.duplicate(true) + var runtime_data: Dictionary = copy[&"systems"][&"runtime"] + for key: Variant in keys: + runtime_data.erase(key) + return copy + + +func _without_command_record(snapshot: Dictionary) -> Dictionary: + return _without(snapshot, [&"next_command_id", &"command_queue", &"id_generator", &"domain_events"]) + + +## SPEC 5.c/5.d: the four command sections hold exactly the refused command's +## record; every other section is byte-identical. +func _only_command_record_added(before: Dictionary, after: Dictionary, command_id: int, result: CommandResult, key: StringName) -> bool: + var b: Dictionary = before[&"systems"][&"runtime"] + var a: Dictionary = after[&"systems"][&"runtime"] + if int(a[&"next_command_id"]) != command_id + 1 or int(b[&"next_command_id"]) != command_id: + return false + var counters: Dictionary = (b[&"id_generator"]["counters"] as Dictionary).duplicate() + counters["domain_event"] = int(counters.get("domain_event", 0)) + 1 + if a[&"id_generator"]["counters"] != counters: + return false + var bq: Dictionary = b[&"command_queue"] + var aq: Dictionary = a[&"command_queue"] + var processed: Array = aq["processed"] + var accepted: Array = aq["accepted"] + var idempotency: Dictionary = (bq["idempotency"] as Dictionary).duplicate() + if not key.is_empty(): + idempotency[String(key)] = command_id + if aq["pending"] != bq["pending"] or aq["in_flight"] != bq["in_flight"] or aq["idempotency"] != idempotency \ + or processed.size() != (bq["processed"] as Array).size() + 1 or processed.slice(0, processed.size() - 1) != bq["processed"] \ + or processed[-1] != result.to_dictionary() or accepted.size() != (bq["accepted"] as Array).size() + 1 \ + or accepted.slice(0, accepted.size() - 1) != bq["accepted"] or int(accepted[-1]["command_id"]) != command_id: + return false + 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] + # The stream keeps its newest 256 events; the oldest one may leave it. + var kept := recent.slice(0, recent.size() - 1) + return a_ids.size() == b_ids.size() + 1 and a_ids.has(added["event_id"]) and not b_ids.has(added["event_id"]) \ + and added["event_type"] == "COMMAND_FAILED" and int(added["causation_command_id"]) == command_id \ + and kept == prior.slice(prior.size() - kept.size()) + + +func _expect_executor_refusal(fixture: Dictionary, staff_id: StringName, reason: StringName, label: String, key: StringName = &"") -> CommandResult: + var runtime: Variant = fixture[&"runtime"] + var before: Dictionary = runtime.create_snapshot() + var command_id := int(before[&"systems"][&"runtime"][&"next_command_id"]) + var result := _scenes.dismiss(fixture, staff_id, key) + var after: Dictionary = runtime.create_snapshot() + _expect(not result.is_success() and result.command_id == command_id and StringName(result.details.get(&"reason", &"")) == reason, + "%s: refused with %s (%s %s)" % [label, reason, result.message, result.details]) + _expect(var_to_bytes(_without_command_record(before)) == var_to_bytes(_without_command_record(after)), "%s: every other section of the save is byte-identical (5.c)" % label) + _expect(_only_command_record_added(before, after, command_id, result, key), "%s: only the command id, its failed result and one COMMAND_FAILED event were added (5.d)" % label) + return result + + +## SPEC 5.b: refused before the command is accepted; the whole save is byte-identical. +func _expect_admission_refusal(fixture: Dictionary, staff_id: StringName, key: StringName, label: String) -> CommandResult: + var runtime: Variant = fixture[&"runtime"] + var before := var_to_bytes(runtime.create_snapshot()) + var result := _scenes.dismiss(fixture, staff_id, key) + _expect(not result.is_success() and var_to_bytes(runtime.create_snapshot()) == before, "%s: refused before acceptance, the whole save is byte-identical (5.b)" % label) + return result + + +# --- SPEC 6.2: the cleanup at the command boundary -------------------------------- + +func _count_removed(runtime: Variant, staff_id: StringName) -> int: + var count := 0 + for event in runtime.domain_events.recent_events(): + if event.event_type == &"STAFF_REMOVED" and staff_id in event.entity_ids: + count += 1 if StringName(event.payload.get(&"reason", &"")) == &"DISMISSED" else 100 + return count + + +func _expect_boundary(fixture: Dictionary, staff_id: StringName, label: String) -> void: + var runtime: Variant = fixture[&"runtime"] + var dangling: Array[String] = _scenes.dangling_references(runtime, staff_id) + _expect(dangling.is_empty(), "%s: no live reference to the dismissed employee remains %s" % [label, dangling]) + _expect(_count_removed(runtime, staff_id) == 1, "%s: exactly one STAFF_REMOVED with reason DISMISSED" % label) + _expect(runtime.staff_service.validate_invariants()[&"ok"], "%s: Staff and Task ownership stays consistent" % label) + var orphans: Dictionary = runtime.staff_service.audit_orphan_tasks() + _expect((orphans[&"reopened_task_ids"] as Array).is_empty() and (orphans[&"released_staff_ids"] as Array).is_empty() + and (orphans[&"release_failed_task_ids"] as Array).is_empty(), "%s: audit_orphan_tasks finds no orphan" % label) + _expect(runtime.economy_service.validate_ledger()[&"ok"], "%s: the ledger stays balanced" % label) + var saved: Dictionary = runtime.create_snapshot() + _expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "%s: a save right after the command restores exactly" % label) + var slot := "user://tests/p16-dismiss-boundary-%d" % OS.get_process_id() + var written: bool = runtime.save_to_path(slot).get(&"ok", false) + var loaded: bool = runtime.load_from_path(slot).get(&"ok", false) + SaveService.delete_save(slot) + # An ordinary load publishes its own load event; nothing else differs. + var events_only := [&"id_generator", &"domain_events"] + _expect(written and loaded and var_to_bytes(_without(runtime.create_snapshot(), events_only)) == var_to_bytes(_without(saved, events_only)), + "%s: the same save written to disk and loaded again is the same game" % label) + + +# --- Scenes ------------------------------------------------------------------------- + +## A1, A10, F1, F3, F4, F5: the ledger day script with its 00:00 closes. +func _test_ledger_day() -> void: + var fixture := _scenes.new_ledger_fixture() + var runtime: Variant = fixture[&"runtime"] + _scenes.play_ledger(fixture, 360, Scenes.LAST_MINUTE, func(minute: int, phase: String) -> void: + if minute != Scenes.DISMISS_MINUTE: + return + if phase == "before": + var worker: StaffRuntime = runtime.staff_service.get_staff(Scenes.DISMISSED) + _expect(worker != null and worker.employment_kind == StaffRuntime.EmploymentKind.FREE_HIRE and worker.current_task_id.is_empty() + and worker.state == StaffRuntime.State.OFF_DUTY, "A1: the general employee is off duty with no Task before the dismissal") + _expect(worker != null and worker.unpaid_wages == 120 and worker.get_effective_daily_wage() == 120, "A10: the first close left her daily wage 120 unpaid") + var preview: Dictionary = runtime.get_staff_dismissal_preview(Scenes.DISMISSED) + _expect(bool(preview[&"can_dismiss"]) and int(preview[&"severance"]) == 360 and int(preview[&"unpaid_wages"]) == 120 + and int(preview[&"front_cash"]) == 300 and int(preview[&"available_funds"]) == 700, "the preview shows severance 360, unpaid 120 and funds 300 + 400") + return + var cash_entries: Array = runtime.economy_service.get_entries().filter(func(entry: LedgerEntry) -> bool: + return entry.source_entity_id == Scenes.DISMISSED and entry.absolute_minute == minute) + _expect(cash_entries.size() == 2 and cash_entries[0].transaction_id == cash_entries[1].transaction_id, "F3: the top-up and the severance are one transaction") + var categories := {} + for entry: LedgerEntry in cash_entries: + categories[entry.category] = [entry.debit_account, entry.credit_account, entry.amount, entry.absolute_minute] + _expect(categories.get(FinancePolicy.TOP_UP_CATEGORY) == [FRONT, BACK, 60, minute] + and categories.get(FinancePolicy.SEVERANCE_CATEGORY) == [FinancePolicy.SEVERANCE_EXPENSE, FRONT, 360, minute], + "F3: hidden 60 tops up the front fund, which pays 360 to the severance expense at the command minute") + _expect(runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 340 + and runtime.economy_service.get_balance(FinancePolicy.SEVERANCE_EXPENSE) == 360 and int(fixture[&"state"].get_resource(&"gold")) == 0, + "F3: front 0, hidden 340, severance expense 360 and the Gold mirror agree") + _expect(not runtime.staff_service.has_staff(Scenes.DISMISSED) and runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 120, + "F1/F4: she leaves the roster; her unpaid 120 is held apart from the severance") + var first: CommandResult = runtime.command_queue.get_result_by_idempotency_key(&"p16.ledger.dismiss") + _expect(first != null and first.is_success() and int(first.details[&"unpaid_wages_preserved"]) == 120 and (first.details[&"removed_shift_ids"] as Array).size() == 1, + "4.2: the result lists the severance, the held wages and the removed Shift") + _expect_boundary(fixture, Scenes.DISMISSED, "A1") + var replay := _expect_admission_refusal(fixture, Scenes.DISMISSED, &"p16.ledger.dismiss", "F5 same key") + _expect(replay.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY and (replay.details.get("original_result", {}) as Dictionary).get("details", {}).get(&"severance") == 360, + "F5: the resent key returns DUPLICATE_IDEMPOTENCY_KEY with the first result") + _expect_executor_refusal(fixture, Scenes.DISMISSED, &"NOT_FOUND", "R4 dismissed employee again", &"p16.ledger.dismiss.again") + _expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1, "F5: the severance is paid once")) + var day2: Dictionary = runtime.operations_coordinator.get_day_summary(2) + _expect(not day2.is_empty() and int(day2[&"category_totals"][&"cost_of_goods"]) == 360 and int(day2[&"category_totals"][&"wages"]) == 90, + "F3/F4: the day 2 close counts the severance as an operating cost and the kept employee's wage only (%s)" % [day2.get(&"category_totals", {})]) + _expect(runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0 and runtime.staff_service.get_departed_staff_ids().is_empty(), + "F4: the day 2 00:00 close pays the held wages") + var first_close := _payroll_payments(runtime, 1) + var second_close := _payroll_payments(runtime, 2) + _expect(int(first_close.get(Scenes.DISMISSED, 0)) == 0 and int(first_close.get(Scenes.KEPT, 0)) == 0 + and second_close == {Scenes.DISMISSED: 120, Scenes.KEPT: 180}, "F4: nothing is paid at the first close; the departed 120 is paid once, at the day 2 close (%s %s)" % [first_close, second_close]) + _expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and runtime.economy_service.validate_ledger()[&"ok"], + "F3: one severance posting; the ledger is balanced after both closes") + _expect(int(day2[&"settlement"][&"funds"][&"rank_tax_arrears"]) == 30 and runtime.operations_coordinator.get_arrears_total() == 30, + "the hidden fund spent on the severance leaves 30 of the day 2 rank tax unpaid") + var saved: Dictionary = runtime.create_snapshot() + _expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "the game after the close, with the unpaid tax, restores exactly") + _scenes.free_fixture(fixture) + + +func _payroll_payments(runtime: Variant, day: int) -> Dictionary: + var paid := {} + var settlement: Dictionary = runtime.staff_service.get_payroll_settlement(StringName("payroll-day-%08d" % day)) + for payment: Dictionary in settlement.get(&"input_payments", []): + paid[StringName(payment[&"staff_id"])] = int(payment[&"amount"]) + return paid + + +## F3 / R8: the hidden fund covers what the front fund cannot; a total one Gold +## short refuses the dismissal and changes nothing. +func _test_top_up_and_insufficient_funds() -> void: + var fixture := _scenes.new_fixture(16_020, true) + var runtime: Variant = fixture[&"runtime"] + _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_marc"}).is_success(), "hire staff_marc (daily wage 170)") + _expect(_scenes.drain_front(fixture, "r8") and _scenes.sale(fixture, FRONT, 100, "r8.front"), "front 100, hidden 0") + _expect_executor_refusal(fixture, &"staff_marc", &"INSUFFICIENT_FUNDS", "R8 total 100 of 510") + _expect(_scenes.sale(fixture, BACK, 409, "r8.back"), "hidden 409") + var refused := _expect_executor_refusal(fixture, &"staff_marc", &"INSUFFICIENT_FUNDS", "R8 total 509 of 510") + _expect(int(refused.details[&"severance"]) == 510 and int(refused.details[&"available_funds"]) == 509, "R8: the refusal names the severance and the funds") + _expect(_scenes.sale(fixture, BACK, 1, "r8.back.last"), "hidden 410") + var result := _scenes.dismiss(fixture, &"staff_marc") + _expect(result.is_success() and int(result.details[&"severance"]) == 510 and int(result.details[&"severance_top_up"]) == 410 + and runtime.economy_service.get_balance(FRONT) == 0 and runtime.economy_service.get_balance(BACK) == 0, + "F3: a total of exactly 510 pays it, 410 from the hidden fund") + _expect_boundary(fixture, &"staff_marc", "F3 top-up") + _scenes.free_fixture(fixture) + + +## R1 UNIQUE, R7 ADULT_CONTRACT, R4 NOT_FOUND and a malformed payload. +func _test_category_refusals() -> void: + var fixture := _scenes.new_fixture(16_030) + var runtime: Variant = fixture[&"runtime"] + var anri := _scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"heroine_anri"}) + _expect(anri.is_success() and runtime.staff_service.get_staff(&"heroine_anri").employment_kind == StaffRuntime.EmploymentKind.UNIQUE, "Anri joins as the approved UNIQUE employee") + var unique := _expect_executor_refusal(fixture, &"heroine_anri", &"UNIQUE", "R1 UNIQUE") + _expect(unique.message == "Unique Staff cannot be dismissed, sold or permanently removed.", "R1: the existing removal contract's text") + _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"adult_contract", &"role_id": &"attendant", + &"options": {&"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 100}}).is_success(), "register an ADULT_CONTRACT fixture") + _expect_executor_refusal(fixture, &"adult_contract", &"NOT_GENERAL_EMPLOYEE", "R7 ADULT_CONTRACT") + _expect_executor_refusal(fixture, &"nobody", &"NOT_FOUND", "R4 unknown employee") + var before: Dictionary = runtime.create_snapshot() + var malformed := _scenes.command(fixture, &"dismiss_staff", {}) + _expect(not malformed.is_success() and malformed.code == CommandResult.Code.INVALID_PAYLOAD + and var_to_bytes(_without_command_record(runtime.create_snapshot())) == var_to_bytes(_without_command_record(before)), "a dismissal without a Staff id is refused (5.c)") + _expect(not bool(runtime.get_staff_dismissal_preview(&"heroine_anri")[&"can_dismiss"]) + and runtime.get_staff_dismissal_preview(&"adult_contract")[&"reason"] == &"NOT_GENERAL_EMPLOYEE", "the previews refuse the same rows") + _scenes.free_fixture(fixture) + + +## A9: another command queued for the dismissed employee is refused after the +## dismissal and changes nothing; a second queued dismissal pays nothing. +func _test_queued_commands_for_the_same_employee() -> void: + var fixture := _scenes.new_fixture(16_040) + var runtime: Variant = fixture[&"runtime"] + _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success(), "hire staff_eri") + 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") + _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") + _scenes.free_fixture(fixture) + + +## R5: after the game ends the command is refused before acceptance. +func _test_bankrupt_refusal() -> void: + var fixture := _scenes.new_fixture(16_050, true) + var runtime: Variant = fixture[&"runtime"] + _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_eri"}).is_success(), "hire staff_eri") + fixture[&"clock"].advance_minutes(1439 - int(fixture[&"clock"].absolute_minute)) + _expect(_scenes.drain_front(fixture, "r5"), "disclosed spend leaves no funds before the first close") + fixture[&"clock"].advance_minutes(4320 - int(fixture[&"clock"].absolute_minute)) + _expect(runtime.operations_coordinator.is_terminal(), "three actual operating losses end the game (BANKRUPT)") + var refused := _expect_admission_refusal(fixture, &"staff_eri", &"", "R5 BANKRUPT") + _expect(StringName(refused.details.get(&"reason", &"")) == &"BANKRUPT" and runtime.staff_service.has_staff(&"staff_eri"), "R5: the existing BANKRUPT refusal keeps the employee") + _scenes.free_fixture(fixture) + + +## R2: an employee carrying supply logistics (an external operation) is kept until +## the operation ends; then the same command dismisses her. +func _test_external_task_refusal() -> void: + var fixture := _scenes.new_fixture(16_060) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + for row: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]: + _expect(_scenes.command(fixture, &"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "build %s" % row[0]) + 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(_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(), + "hire the porter and assign her to the warehouse") + 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_dismiss", &"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(), + "purchase stock and request its delivery") + var external := false + for minute: int in 40: + clock.advance_minutes(1) + var worker: StaffRuntime = runtime.staff_service.get_staff(&"staff_gina") + if runtime.staff_service.is_externally_driven_task(worker.current_task_id): + external = true + break + _expect(external, "the porter takes the supply job, an external operation") + var refused := _expect_executor_refusal(fixture, &"staff_gina", &"EXTERNAL_TASK", "R2 external operation") + _expect(refused.message == "Finish the employee external operation before removal.", "R2: the existing removal contract's text") + for minute: int in 60: + if runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.DELIVERING: break + 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") + for minute: int in 60: + if runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.COMPLETED: break + clock.advance_minutes(1) + _expect(runtime.logistics_requests[&"supply.p16_dismiss"].state == LogisticsRequest.State.COMPLETED, "the delivery completes") + _expect(_scenes.dismiss(fixture, &"staff_gina").is_success(), "R2 is a wait: after the delivery the porter is dismissed") + _expect_boundary(fixture, &"staff_gina", "after the external operation") + _scenes.free_fixture(fixture) + + +## A5: a working Task with a tool Reservation is reopened and its Reservation +## released; R3: a Reservation that cannot be released refuses the dismissal. +func _test_reserved_task() -> void: + var fixture := _scenes.new_fixture(16_070) + var runtime: Variant = fixture[&"runtime"] + var worker_options := {&"state": StaffRuntime.State.AVAILABLE, &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"role_tags": [&"worker"], &"permitted_task_tags": [&"work"], &"daily_wage": 100} + _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": &"tool_worker", &"role_id": &"worker", &"options": worker_options}).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": &"tool_task", &"task_type": &"inspect", &"priority": 90, &"options": { + &"target_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"required_role_tags": [&"worker"], &"required_items": {&"tool/snapshot_kit": 1}, + &"task_tags": [&"work"], &"estimated_minutes": 10}}).is_success(), "a worker, a tool and a Task that needs it") + fixture[&"clock"].advance_one_minute() + var task: StaffTask = runtime.staff_service.get_task(&"tool_task") + _expect(task.state == StaffTask.State.WORKING and task.assigned_staff_id == &"tool_worker" and task.reservation_ids.size() == 1, "the worker works the Task holding the tool Reservation") + var working: Dictionary = runtime.create_snapshot() + var preview: Dictionary = runtime.get_staff_dismissal_preview(&"tool_worker") + _expect(bool(preview[&"can_dismiss"]) and preview[&"reopened_task_ids"] == [&"tool_task"], "the preview names the Task the dismissal reopens") + var reservation_id: StringName = task.reservation_ids[0] + # Disclosed fixture: the tool is consumed while the Task still names its Reservation. + _expect(bool(runtime.inventory_service.consume_reservation(reservation_id, &"p16.fixture.consume", fixture[&"clock"].absolute_minute).get(&"ok", false)), "fixture consumes the reserved tool") + var refused := _expect_executor_refusal(fixture, &"tool_worker", &"RESERVATION_RELEASE_FAILED", "R3 unreleasable Reservation") + _expect(refused.message == "Staff removal could not release Task Reservations.", "R3: the existing removal contract's text") + _expect(runtime.restore_snapshot(working), "back to the working Task") + var result := _scenes.dismiss(fixture, &"tool_worker") + task = runtime.staff_service.get_task(&"tool_task") + _expect(result.is_success() and result.details[&"reopened_task_ids"] == [&"tool_task"] and task.state == StaffTask.State.OPEN + and task.assigned_staff_id.is_empty() and task.reservation_ids.is_empty() and task.state_reason == &"STAFF_REMOVED", "A5: the Task is reopened by the removal contract") + _expect(runtime.inventory_service.available_quantity(&"staff.supplies", &"tool/snapshot_kit") == 1, "A5: its tool Reservation is released") + var dismissed_task: Dictionary = task.create_snapshot() + var dismissed_inventory: Dictionary = runtime.inventory_service.create_snapshot() + _expect_boundary(fixture, &"tool_worker", "A5") + # The reopened Task follows the existing removal contract exactly. + _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") + _scenes.free_fixture(fixture) + + +## A3 on duty and A4 mid-service at a staffed cafeteria. +func _test_facility_duty_and_service() -> void: + var fixture := _scenes.new_fixture(16_080) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + _expect(_scenes.command(fixture, &"build_facility", {&"definition_id": &"cafeteria", &"origin": Vector3i(-5, 0, WorldService.LAYER_FRONT)}).is_success(), "build a cafeteria") + clock.advance_minutes(180) + var facility: FacilityInstance = runtime.facility_service.get_instances()[0] + _expect(bool(load("res://tests/fixtures/required_kitchen_setup.gd").ensure(runtime, clock, facility.instance_id, true).get(&"ok", false)), "the cafeteria's paid kitchen link with its physical kitchen worker") + var start_minute: int = posmod(clock.absolute_minute, 1440) + for staff_id: StringName in [&"duty_cook", &"duty_server"]: + _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": staff_id, &"role_id": &"worker", &"options": {&"state": StaffRuntime.State.OFF_DUTY, + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), &"daily_wage": 100}}).is_success() + and _scenes.command(fixture, &"create_staff_shift", {&"shift_id": StringName("shift_%s" % staff_id), &"staff_id": staff_id, &"template_id": &"FLEX", + &"start_day": int(clock.absolute_minute) / 1440, &"start_minute_of_day": start_minute, &"end_minute_of_day": start_minute + 300, &"facility_id": facility.instance_id}).is_success(), + "%s works a Shift at the cafeteria" % staff_id) + clock.advance_one_minute() + _expect(facility.active_staff_ids == [&"duty_cook", &"duty_server"] and facility.state == FacilityInstance.State.OPEN, "both employees staff the open cafeteria") + var on_duty: Dictionary = runtime.create_snapshot() + _expect(_scenes.dismiss(fixture, &"duty_server").is_success(), "A3: the idle employee on duty is dismissed") + _expect(facility.active_staff_ids == [&"duty_cook"], "A3: the facility staffing loses her at the command, not at the next minute") + _expect_boundary(fixture, &"duty_server", "A3") + _expect(runtime.restore_snapshot(on_duty), "back to both on duty") + facility = runtime.facility_service.get_instance(facility.instance_id) + _expect(_scenes.command(fixture, &"add_inventory_stack", {&"owner_inventory_id": facility.local_inventory_id, &"item_id": &"ingredient/basic_meal", &"quantity": 1}).is_success() + and _scenes.command(fixture, &"register_guest_wallet", {&"guest_id": &"p16_guest", &"gold": 100}).is_success() + and _scenes.command(fixture, &"create_service_request", {&"request_id": &"p16_service", &"facility_id": facility.instance_id, &"guest_ids": [&"p16_guest"], + &"service_type": &"default", &"options": {&"quoted_price": 35}}).is_success(), "a guest requests a meal") + clock.advance_one_minute() + var active: Array = runtime.facility_service.get_active_services(facility.instance_id) + _expect(active.size() == 1 and &"duty_server" in (active[0] as ServiceInstance).staff_ids, "A4: the service runs with her") + if active.is_empty(): + _scenes.free_fixture(fixture) + return + var service_id: StringName = (active[0] as ServiceInstance).service_id + _expect(_scenes.dismiss(fixture, &"duty_server").is_success(), "A4: the employee in the service is dismissed") + _expect_boundary(fixture, &"duty_server", "A4") + clock.advance_one_minute() + var service: ServiceInstance = runtime.facility_service.get_service(service_id) + _expect(service != null and service.state == ServiceInstance.State.WAITING_FOR_STAFF + and service.staff_grace_deadline == clock.absolute_minute + FacilityService.STAFF_REPLACEMENT_GRACE_MINUTES, "A4: the service waits for replacement staff (existing rule)") + clock.advance_minutes(FacilityService.STAFF_REPLACEMENT_GRACE_MINUTES) + service = runtime.facility_service.get_service(service_id) + _expect(service != null and service.state == ServiceInstance.State.FAILED and service.failure_reason == FacilityService.INTERRUPTION_STAFF_UNAVAILABLE, + "A4: without a replacement the service fails as staff unavailable") + _scenes.free_fixture(fixture) + + +## A2 on foot: an employee walking to her workplace is dismissed. +func _test_walking_commute() -> void: + var fixture := _scenes.new_fixture(16_090) + var runtime: Variant = fixture[&"runtime"] + _expect(_scenes.command(fixture, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-8, 0, 0)}).is_success(), "build a reception") + fixture[&"clock"].advance_minutes(180) + var reception: StringName = runtime.facility_service.get_instances()[0].instance_id + _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success() + and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_akane", &"facility_id": reception, &"template_id": &"DAY"}).is_success(), + "hire staff_akane and send her to the reception") + fixture[&"clock"].advance_minutes(1) + _expect(runtime.staff_service.get_staff(&"staff_akane").state == StaffRuntime.State.COMMUTING and runtime.path_service.has_actor_projection(&"staff:staff_akane"), "she is walking to work") + _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) + + +## 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. +func _test_rescue_matrix() -> void: + var fixture := _scenes.new_rescue_fixture() + var runtime: Variant = fixture[&"runtime"] + var checkpoints := {} + for tick: int in 160: + fixture[&"clock"].advance_minutes(1) + var phase := _scenes.rescue_phase(runtime) + 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()]) + 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 held: StringName = runtime.staff_service.get_staff(&"medic").current_task_id + var result := _scenes.dismiss(fixture, staff_id) + _expect(result.is_success(), "%s: dismissed (%s)" % [label, result.message]) + _expect_boundary(fixture, staff_id, label) + var task: StaffTask = runtime.staff_service.get_task(held) + 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) + 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) + 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") + _scenes.free_fixture(fixture) + + +## 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: + var fixture := _scenes.new_event_fixture(16_100) + var runtime: Variant = fixture[&"runtime"] + var cash_before: int = runtime.economy_service.get_balance(FRONT) + 80 + var result := _scenes.dismiss(fixture, &"event_actor") + _expect(result.is_success() and int(result.details[&"severance"]) == 240, "A8: the locked Event actor is dismissed (severance 240)") + var instance: EventInstance = runtime.event_service.get_instance(&"p16.event.instance") + _expect(instance != null and instance.state == EventInstance.State.CANCELLED and runtime.event_service.get_foreground_instance() == null, + "A8: the CANCEL policy cancels the Event at the command") + _expect(runtime.economy_service.get_balance(&"EVENT_ESCROW:p16.event.instance") == 0 and runtime.economy_service.get_balance(FRONT) == cash_before - 240, + "A8: the escrow is refunded once and the severance paid") + _expect_boundary(fixture, &"event_actor", "A8 CANCEL") + _scenes.free_fixture(fixture) + fixture = _scenes.new_fixture(16_101) + runtime = fixture[&"runtime"] + for staff_id: StringName in [&"event_lead", &"event_partner"]: + _expect(_scenes.command(fixture, &"register_staff", {&"staff_id": staff_id, &"role_id": &"service", &"options": {&"daily_wage": 80}}).is_success(), "register %s" % staff_id) + _expect(_scenes.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 _scenes.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) + _expect(runtime.event_service.get_actor_locks().has(&"event_partner"), "the Event locks both employees") + _expect(_scenes.dismiss(fixture, &"event_partner").is_success(), "A8: one actor of the continuing Event is dismissed") + var pair: EventInstance = runtime.event_service.get_instance(&"p16.event.pair.instance") + _expect(pair != null and pair.actor_ids == [&"event_lead"] and runtime.event_service.get_actor_locks().has(&"event_lead") + and pair.state in [EventInstance.State.RUNNING, EventInstance.State.WAITING_CHOICE], "A8: CONTINUE_WITHOUT keeps the Event with the other actor") + _expect_boundary(fixture, &"event_partner", "A8 CONTINUE_WITHOUT") + _scenes.free_fixture(fixture) + + +## R9 (Root's temporary boundary): the defender of an unfinished raid is kept; +## after the raid ends the same employee is dismissed. +func _test_defense_refusal() -> void: + var fixture := _scenes.new_fixture(16_110) + var runtime: Variant = fixture[&"runtime"] + _expect(_scenes.command(fixture, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-8, 0, 0)}).is_success(), "build the raid objective") + fixture[&"clock"].advance_minutes(180) + var objective: StringName = runtime.facility_service.get_instances()[0].instance_id + _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_klaus"}).is_success(), "hire the guard staff_klaus") + var created: CommandResult = runtime.submit_command(&"create_defense_raid", {&"raid_id": &"p16.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") + var defender: CommandResult = runtime.submit_command(&"register_defense_combatant", {&"raid_id": &"p16.raid", &"actor_id": &"staff_klaus", + &"side": &"DEFENDER", &"combat": 90, &"max_health": 100, &"options": {}}, &"defense_controller") + _expect(created.is_success() and defender.is_success(), "the guard defends an unfinished raid (%s %s)" % [created.message, defender.message]) + _expect_executor_refusal(fixture, &"staff_klaus", &"DEFENSE_LOCKED", "R9 defender of an unfinished raid") + _expect(runtime.submit_command(&"cancel_defense_raid", {&"raid_id": &"p16.raid"}, &"defense_controller").is_success(), "the raid ends") + _expect(_scenes.dismiss(fixture, &"staff_klaus").is_success(), "after the raid the former defender is dismissed") + _expect_boundary(fixture, &"staff_klaus", "after the raid") + _scenes.free_fixture(fixture) + + +## SPEC 4.1 d: if the severance cannot be posted after the removal, the whole +## dismissal is rolled back (5.c). Fixture: its posting key is already used. +func _test_commit_failure_rolls_back() -> void: + var fixture := _scenes.new_fixture(16_120) + var runtime: Variant = fixture[&"runtime"] + _expect(_scenes.command(fixture, &"hire_staff_candidate", {&"candidate_id": &"staff_rika"}).is_success() + and _scenes.command(fixture, &"set_staff_work_assignment", {&"staff_id": &"staff_rika", &"facility_id": &"", &"template_id": &"DAY"}).is_success(), "hire staff_rika") + var next_id := int(runtime.create_snapshot()[&"systems"][&"runtime"][&"next_command_id"]) + _expect(runtime.economy_service.transfer(Scenes.PAYER, Scenes.FRONT_SALES, 1, fixture[&"clock"].absolute_minute, &"TRANSFER", &"p16.fixture", + StringName("staff.severance.%d" % next_id)).is_success(), "fixture occupies the dismissal's posting key") + var refused := _expect_executor_refusal(fixture, &"staff_rika", &"SEVERANCE_POST_FAILED", "commit failure") + _expect(runtime.staff_service.has_staff(&"staff_rika") and refused.code == CommandResult.Code.TRANSACTION_APPLY_FAILED, "the employee, her Shift and path actor are back") + _scenes.free_fixture(fixture) + + +## A save from before the severance gains its empty expense account; an +## incompatible account is refused and changes nothing. +func _test_old_save_gains_the_severance_account() -> void: + var fixture := _scenes.new_fixture(16_130, true) + var runtime: Variant = fixture[&"runtime"] + fixture[&"clock"].advance_minutes(60) + var current: Dictionary = runtime.create_snapshot() + var old := current.duplicate(true) + var accounts: Array = old[&"systems"][&"runtime"][&"economy"][&"accounts"] + var kept: Array = accounts.filter(func(account: Dictionary) -> bool: return String(account[&"account_id"]) != String(FinancePolicy.SEVERANCE_EXPENSE)) + _expect(kept.size() == accounts.size() - 1, "the current save holds the severance expense account") + old[&"systems"][&"runtime"][&"economy"][&"accounts"] = kept + _expect(runtime.restore_snapshot(old) and var_to_bytes(runtime.create_snapshot()) == var_to_bytes(current), "an older save gains only the empty severance account") + var wrong := current.duplicate(true) + for account: Dictionary in wrong[&"systems"][&"runtime"][&"economy"][&"accounts"]: + if String(account[&"account_id"]) == String(FinancePolicy.SEVERANCE_EXPENSE): + account[&"allow_negative"] = true + _expect(not runtime.restore_snapshot(wrong) and var_to_bytes(runtime.create_snapshot()) == var_to_bytes(current), "an incompatible severance account is refused and changes nothing") + _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. +func _test_dismiss_from_main() -> void: + var runtime: Variant = root.get_node("SimulationRuntime") + var clock: Variant = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + clock.reset(1, 6, 0) + state.reset() + runtime.initialize(clock, state, 16_140) + clock.set_paused(true) + root.size = Vector2i(1280, 720) + var main: Control = load("res://scenes/main/main.tscn").instantiate() + root.add_child(main) + await process_frame + await process_frame + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + clock.set_paused(true) + var key := InputEventKey.new() + key.keycode = KEY_H + key.pressed = true + main.call("_unhandled_key_input", key) + for candidate: StringName in [&"staff_akane", &"staff_eri", &"heroine_anri"]: + var hire := (main.get("staff_candidate_buttons") as Dictionary).get(candidate) as Button + _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", + &"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": { + &"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) + var eri_task: StringName = runtime.staff_service.get_staff(&"staff_eri").current_task_id + _expect(eri_task == &"ui_task", "the cleaner holds the Task (%s)" % eri_task) + (main.get("staff_management_toggle_button") as Button).pressed.emit() + var panel: StaffManagementPanel = main.get("staff_management_panel") + _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() + 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)") + panel.dismiss_cancel_button.pressed.emit() + _expect(not panel.dismiss_confirm_box.visible and runtime.create_snapshot() == untouched, "やめる closes the confirmation without a command") + 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", + "解雇を確定 dismisses her; the roster and the selection follow") + _expect(panel.result_label.text.contains("解雇しました") and panel.result_label.text.contains("G360"), "the result shows the paid severance: %s" % panel.result_label.text) + _expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY, &"staff_akane") == 1, "the panel's command paid the severance once") + (panel.roster_buttons[&"staff_eri"] as Button).pressed.emit() + panel.dismiss_button.pressed.emit() + _expect(panel.dismiss_confirm_label.text.contains("持っている作業は開き直して") and panel.dismiss_confirm_label.text.contains("G270"), "the cleaner's confirmation names the reopened Task and G270") + clock.set_paused(false) + panel.dismiss_confirm_button.pressed.emit() + _expect(runtime.staff_service.has_staff(&"staff_eri") and panel.dismiss_button.disabled and panel.result_label.text.contains("受理しました"), "with the clock running the dismissal waits for the next minute") + 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]) + var cleanup: Dictionary = await FixtureCleanup.free_owner_and_wait(self, main) + _expect(bool(cleanup.get(&"passed", false)), "ordinary Main release with audio cleanup") diff --git a/tests/staff/test_general_dismissal.gd.uid b/tests/staff/test_general_dismissal.gd.uid new file mode 100644 index 0000000..58b7147 --- /dev/null +++ b/tests/staff/test_general_dismissal.gd.uid @@ -0,0 +1 @@ +uid://dbf85ttljo65h diff --git a/tests/staff/test_general_dismissal_restart_1_write.gd b/tests/staff/test_general_dismissal_restart_1_write.gd new file mode 100644 index 0000000..d50268f --- /dev/null +++ b/tests/staff/test_general_dismissal_restart_1_write.gd @@ -0,0 +1,116 @@ +extends SceneTree + +## P16-01-DISMISS F7/F8, writer phase. Plays the ledger day script and writes +## ordinary save files before and right after the dismissal command (before the +## next minute), a minute later and around the day 2 00:00 close; and saves the +## command boundary of A6 (the medic dismissed while carrying the patient on +## foot), A7 (the patient dismissed while the medic rides the elevator alone) and +## A8 (the actor of a foreground Event with reserved cost dismissed). The reader +## suite (test_general_dismissal_restart_2_read.gd) loads them in a separate +## engine process. The handoff is the declared generated report +## res://artifacts/p16-01-dismiss/restart-handoff.json +## (tools/regression/suite-policy.json fixed_generated_report_routes). + +const Scenes := preload("res://tests/helpers/p16_dismissal_fixture.gd") +const CONTINUE_MINUTES := 5 + +var _checks := 0 +var _failures := 0 +var _scenes: Scenes + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P16-01-DISMISS: restart writer") + _scenes = Scenes.new(_expect) + var straight := _scenes.new_ledger_fixture() + _scenes.play_ledger(straight, 360, Scenes.LAST_MINUTE, Callable()) + var straight_runtime: Variant = straight[&"runtime"] + _expect(_scenes.count_category(straight_runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and straight_runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0, + "uninterrupted script: one severance and the held wages paid at the day 2 close") + var saving := _scenes.new_ledger_fixture() + var ledger_saves: Array = [] + _scenes.play_ledger(saving, 360, Scenes.LAST_MINUTE, func(minute: int, phase: String) -> void: + if [minute, phase] in Scenes.SAVE_POINTS: + var name := "ledger-%d-%s" % [minute, phase] + var path := Scenes.WRITER_SAVE % name + _expect(bool(saving[&"runtime"].save_to_path(path).get(&"ok", false)), "save file %s" % name) + 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"]]: + 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])) + _scenes.free_fixture(fixture) + var event_fixture := _scenes.new_event_fixture(16_100) + boundaries.append(_boundary(event_fixture, "A8", &"event_actor")) + _scenes.free_fixture(event_fixture) + var handoff := { + "schema": Scenes.HANDOFF_SCHEMA, + "writer_pid": OS.get_process_id(), + "writer_source_sha256": FileAccess.get_sha256(Scenes.WRITER_SOURCE), + "ledger_seed": Scenes.LEDGER_SEED, + "last_minute": Scenes.LAST_MINUTE, + "straight_economy_sha256": Scenes.sha256(_scenes.economy_bytes(straight_runtime)), + "straight_state_base64": Marshalls.raw_to_base64(var_to_bytes(_scenes.state_value(straight_runtime, straight[&"clock"]))), + "ledger_saves": ledger_saves, + "boundaries": boundaries, + } + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(Scenes.HANDOFF_PATH).get_base_dir()) + var file := FileAccess.open(Scenes.HANDOFF_PATH, FileAccess.WRITE) + _expect(file != null, "handoff file opens for writing") + if file != null: + file.store_string(JSON.stringify(handoff, "\t") + "\n") + file.close() + print("P16_RESTART_WRITER pid=%d handoff_sha256=%s" % [OS.get_process_id(), FileAccess.get_sha256(Scenes.HANDOFF_PATH)]) + _scenes.free_fixture(straight) + _scenes.free_fixture(saving) + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +## Dismisses the employee, saves at once (the command boundary, before the next +## minute), then records the state then and CONTINUE_MINUTES later. +func _boundary(fixture: Dictionary, label: String, staff_id: StringName) -> Dictionary: + var runtime: Variant = fixture[&"runtime"] + var result := _scenes.dismiss(fixture, staff_id) + _expect(result.is_success(), "%s: the employee is dismissed (%s)" % [label, result.message]) + var dangling: Array[String] = _scenes.dangling_references(runtime, staff_id) + _expect(dangling.is_empty(), "%s: no live reference remains at the command boundary %s" % [label, dangling]) + var name := "boundary-%s" % label + var path := Scenes.WRITER_SAVE % name + _expect(bool(runtime.save_to_path(path).get(&"ok", false)), "%s: save right after the command" % label) + var at_command := _scenes.state_value(runtime, fixture[&"clock"]) + fixture[&"clock"].advance_minutes(CONTINUE_MINUTES) + var later := _scenes.state_value(runtime, fixture[&"clock"]) + return _package(name, path, {"label": label, "staff_id": String(staff_id), "continue_minutes": CONTINUE_MINUTES, + "at_command_base64": Marshalls.raw_to_base64(var_to_bytes(at_command)), "later_base64": Marshalls.raw_to_base64(var_to_bytes(later))}) + + +func _package(name: String, path: String, extra: Dictionary) -> Dictionary: + var directory := ProjectSettings.globalize_path(path) + _expect(DirAccess.dir_exists_absolute(directory) and DirAccess.get_directories_at(directory).is_empty(), "save package %s holds only files" % name) + var files := {} + for file_name: String in DirAccess.get_files_at(directory): + var bytes := FileAccess.get_file_as_bytes(directory.path_join(file_name)) + files[file_name] = {"sha256": Scenes.sha256(bytes), "bytes": bytes.size(), "base64": Marshalls.raw_to_base64(bytes)} + _expect(files.has("manifest.json") and files.has("world.bin"), "save package %s has its manifest and world" % name) + var package := extra.duplicate(true) + package["name"] = name + package["files"] = files + return package + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/staff/test_general_dismissal_restart_1_write.gd.uid b/tests/staff/test_general_dismissal_restart_1_write.gd.uid new file mode 100644 index 0000000..3ada29b --- /dev/null +++ b/tests/staff/test_general_dismissal_restart_1_write.gd.uid @@ -0,0 +1 @@ +uid://df5vm2ir115eb diff --git a/tests/staff/test_general_dismissal_restart_2_read.gd b/tests/staff/test_general_dismissal_restart_2_read.gd new file mode 100644 index 0000000..1226805 --- /dev/null +++ b/tests/staff/test_general_dismissal_restart_2_read.gd @@ -0,0 +1,168 @@ +extends SceneTree + +## P16-01-DISMISS F7/F8, reader phase. Runs after the writer suite +## (test_general_dismissal_restart_1_write.gd) as a separate engine process. It +## loads every ledger save, continues the day script and requires exactly the +## uninterrupted ledger: one severance and one payment of the held wages, +## whether the save was made before or right after the dismissal command or +## around the day 2 00:00 close. It loads the A6, A7 and A8 command boundary +## saves, requires the writer's state and no live reference to the dismissed +## employee, and continues them to the writer's later state. A changed package +## is refused. + +const Scenes := preload("res://tests/helpers/p16_dismissal_fixture.gd") + +var _checks := 0 +var _failures := 0 +var _scenes: Scenes + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P16-01-DISMISS: restart reader") + _scenes = Scenes.new(_expect) + var handoff := _read_handoff() + if not handoff.is_empty(): + _check_ledger(handoff) + _check_boundaries(handoff) + _check_tampered(handoff) + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _read_handoff() -> Dictionary: + _expect(FileAccess.file_exists(Scenes.HANDOFF_PATH), "the writer phase left its handoff (run test_general_dismissal_restart_1_write.gd first)") + if not FileAccess.file_exists(Scenes.HANDOFF_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(Scenes.HANDOFF_PATH)) + _expect(parsed is Dictionary and str((parsed as Dictionary).get("schema", "")) == Scenes.HANDOFF_SCHEMA, "handoff schema") + if not parsed is Dictionary or str((parsed as Dictionary).get("schema", "")) != Scenes.HANDOFF_SCHEMA: + return {} + var handoff: Dictionary = parsed + var writer_pid := int(handoff.get("writer_pid", 0)) + _expect(writer_pid > 0 and writer_pid != OS.get_process_id(), "a separate engine process wrote the saves") + _expect(str(handoff.get("writer_source_sha256", "")) == FileAccess.get_sha256(Scenes.WRITER_SOURCE), "the handoff comes from the current writer suite") + _expect(int(handoff.get("ledger_seed", -1)) == Scenes.LEDGER_SEED and int(handoff.get("last_minute", -1)) == Scenes.LAST_MINUTE, "same fixture and day script") + print("P16_RESTART_READER pid=%d writer_pid=%d handoff_sha256=%s" % [OS.get_process_id(), writer_pid, FileAccess.get_sha256(Scenes.HANDOFF_PATH)]) + return handoff + + +func _decode(text: Variant) -> Variant: + return bytes_to_var(Marshalls.base64_to_raw(str(text))) + + +func _check_ledger(handoff: Dictionary) -> void: + var straight := _scenes.new_ledger_fixture() + _scenes.play_ledger(straight, 360, Scenes.LAST_MINUTE, Callable()) + var straight_runtime: Variant = straight[&"runtime"] + var straight_economy := _scenes.economy_bytes(straight_runtime) + var straight_state := _scenes.state_value(straight_runtime, straight[&"clock"]) + _expect(Scenes.sha256(straight_economy) == str(handoff.get("straight_economy_sha256", "")), "the uninterrupted day script gives the same ledger in both processes") + _expect(straight_state == _decode(handoff.get("straight_state_base64", "")), "the uninterrupted day script gives the same employees, Tasks and balances in both processes") + var saves: Array = handoff.get("ledger_saves", []) + var points: Array = [] + for save: Variant in saves: + points.append([int((save as Dictionary).get("minute", -1)), str((save as Dictionary).get("phase", ""))] if save is Dictionary else []) + _expect(points == Scenes.SAVE_POINTS, "the writer saved before and right after the dismissal, a minute later and around the day 2 close") + for save: Variant in saves: + if not save is Dictionary: + continue + var minute := int((save as Dictionary)["minute"]) + var name := str((save as Dictionary)["name"]) + var path := Scenes.READER_SAVE % name + if not _materialize(path, (save as Dictionary).get("files", {}), false): + continue + var resumed := _scenes.new_ledger_fixture() + var runtime: Variant = resumed[&"runtime"] + _expect(bool(runtime.load_from_path(path).get(&"ok", false)) and int(resumed[&"clock"].absolute_minute) == minute, "%s: this process loads the save at minute %d" % [name, minute]) + var dismissed_before: bool = not runtime.staff_service.has_staff(Scenes.DISMISSED) + _expect(dismissed_before == (name != "ledger-1500-before"), "%s: the save holds the employee exactly when it was made before the command" % name) + _scenes.play_ledger(resumed, minute, Scenes.LAST_MINUTE, Callable()) + _expect(_scenes.economy_bytes(runtime) == straight_economy, "%s: resuming gives exactly the uninterrupted ledger" % name) + _expect(_scenes.state_value(runtime, resumed[&"clock"]) == straight_state, "%s: resuming gives the same employees, Tasks and balances" % name) + _expect(_scenes.count_category(runtime, FinancePolicy.SEVERANCE_CATEGORY) == 1 and runtime.economy_service.get_balance(FinancePolicy.SEVERANCE_EXPENSE) == 360, + "%s: the severance is paid once" % name) + _expect(runtime.staff_service.get_departed_unpaid_wages(Scenes.DISMISSED) == 0 + and int(runtime.operations_coordinator.get_day_summary(2)[&"settlement"][&"input_payments"][0][&"amount"]) == 120, "%s: the held 120 is paid once, at the day 2 close" % name) + _scenes.free_fixture(resumed) + _scenes.free_fixture(straight) + + +func _check_boundaries(handoff: Dictionary) -> void: + var boundaries: Array = handoff.get("boundaries", []) + 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") + for boundary: Variant in boundaries: + if not boundary is Dictionary: + continue + var name := str((boundary as Dictionary)["name"]) + var staff_id := StringName(str((boundary as Dictionary)["staff_id"])) + var path := Scenes.READER_SAVE % name + if not _materialize(path, (boundary as Dictionary).get("files", {}), false): + continue + var fixture := _scenes.new_fixture(1) + var runtime: Variant = fixture[&"runtime"] + _expect(bool(runtime.load_from_path(path).get(&"ok", false)), "%s: this process loads the save made right after the command" % name) + var dangling: Array[String] = _scenes.dangling_references(runtime, staff_id) + _expect(dangling.is_empty(), "%s: no live reference to the dismissed employee after the load %s" % [name, dangling]) + _expect(runtime.staff_service.validate_invariants()[&"ok"] and runtime.economy_service.validate_ledger()[&"ok"], "%s: ownership and the ledger are consistent after the load" % name) + _expect(_scenes.state_value(runtime, fixture[&"clock"]) == _decode((boundary as Dictionary)["at_command_base64"]), "%s: the loaded game is the writer's game at the command" % name) + var saved: Dictionary = runtime.create_snapshot() + _expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "%s: the loaded game saves and restores exactly" % name) + fixture[&"clock"].advance_minutes(int((boundary as Dictionary)["continue_minutes"])) + _expect(_scenes.state_value(runtime, fixture[&"clock"]) == _decode((boundary as Dictionary)["later_base64"]), "%s: it continues exactly as the writer's game" % name) + _scenes.free_fixture(fixture) + + +func _check_tampered(handoff: Dictionary) -> void: + var saves: Array = handoff.get("ledger_saves", []) + if saves.is_empty() or not saves[0] is Dictionary: + _expect(false, "a save package to change") + return + var path := Scenes.READER_SAVE % "tampered" + if not _materialize(path, (saves[0] as Dictionary).get("files", {}), true): + return + var fixture := _scenes.new_ledger_fixture() + var runtime: Variant = fixture[&"runtime"] + var before := _scenes.economy_bytes(runtime) + _expect(not bool(runtime.load_from_path(path).get(&"ok", true)), "a changed save package is refused") + _expect(_scenes.economy_bytes(runtime) == before and int(fixture[&"clock"].absolute_minute) == 360, "a refused load changes no Gold and no time") + _scenes.free_fixture(fixture) + + +# Writes the writer's exact package bytes into this process's own profile. +func _materialize(path: String, files: Variant, change_world: bool) -> bool: + var directory := ProjectSettings.globalize_path(path) + _expect(files is Dictionary and (files as Dictionary).has("manifest.json") and (files as Dictionary).has("world.bin"), "handoff package %s is complete" % path) + if not files is Dictionary: + return false + _expect(DirAccess.make_dir_recursive_absolute(directory) == OK, "package directory %s" % path) + for name: String in (files as Dictionary): + var pin: Dictionary = (files as Dictionary)[name] + var bytes := Marshalls.base64_to_raw(str(pin.get("base64", ""))) + _expect(Scenes.sha256(bytes) == str(pin.get("sha256", "")) and bytes.size() == int(pin.get("bytes", -1)), "handoff bytes of %s are exact" % name) + if change_world and name == "world.bin" and not bytes.is_empty(): + bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0xff + var file := FileAccess.open(directory.path_join(name), FileAccess.WRITE) + _expect(file != null, "write %s into %s" % [name, path]) + if file == null: + return false + file.store_buffer(bytes) + file.close() + return true + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/staff/test_general_dismissal_restart_2_read.gd.uid b/tests/staff/test_general_dismissal_restart_2_read.gd.uid new file mode 100644 index 0000000..4d3e6b2 --- /dev/null +++ b/tests/staff/test_general_dismissal_restart_2_read.gd.uid @@ -0,0 +1 @@ +uid://bcgouseodo7h6 diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index befcf95..348127f 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -459,6 +459,27 @@ "ordinary_control_labels": [], "old_bounded_teardown": false, "new_audio_QA": false + }, + "tests/staff/test_general_dismissal.gd": { + "source_sha256": "b69a786539d3afd9f80a9857169b8a1687e176a172c4d63bca248dfe0ee316c1", + "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", + "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", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": false, + "new_audio_QA": false } }, "outside_routes": { @@ -503,6 +524,9 @@ "artifacts/upkeep-2026-09-29/preclose-snapshot.txt", "artifacts/upkeep-2026-09-29/closed-snapshot.txt" ], + "tests/staff/test_general_dismissal_restart_1_write.gd": [ + "artifacts/p16-01-dismiss/restart-handoff.json" + ], "tests/transport/test_runtime_transport_comparison.gd": [ "artifacts/transport/runtime-comparison.json", "artifacts/transport/runtime-comparison-1-trace.json", diff --git a/tools/run_p16_dismiss_tests.py b/tools/run_p16_dismiss_tests.py new file mode 100644 index 0000000..971ccf9 --- /dev/null +++ b/tools/run_p16_dismiss_tests.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +"""Root-owned narrow P16-01-DISMISS (general employee dismissal with the A14 severance) collection using the +unchanged guarded phase protocol and strict verifier of tools/run_p07_weather_tests.py (itself the protocol of +tools/run_p05_funds_tests.py and tools/run_p18_relationship_tests.py). After the roster, the F8 restart writer and +reader suites run as two more phases (separate engine processes); the writer's only declared generated report is +the save handoff the reader loads.""" +import argparse +import hashlib +import json +import os +import re +import stat +from pathlib import Path +import shutil +import sys +import types +import uuid + +sys.dont_write_bytecode = True +import run_all_tests as shared + +ROSTER = [ + 'tests/staff/test_general_dismissal.gd', + 'tests/ui/test_staff_management.gd', + 'tests/ui/test_selection_departure.gd', + 'tests/s3/test_runtime_staff_assignment.gd', + 'tests/s3/test_staff_service.gd', + 'tests/ui/test_anri_recruitment.gd', + 'tests/ui/test_approved_free_unique_arrears_ui.gd', + 'tests/s6/test_runtime_save_lifecycle.gd', + 'tests/s6/test_runtime_snapshot_serialization.gd', + 'tests/mvp/test_runtime_operations_integration.gd', +] +# P16-01-DISMISS F8 (SPEC section 9): the writer saves, the reader in the next phase's engine resumes. +RESTART = [ + 'tests/staff/test_general_dismissal_restart_1_write.gd', + 'tests/staff/test_general_dismissal_restart_2_read.gd', +] +HANDOFF = 'artifacts/p16-01-dismiss/restart-handoff.json' +SUITES = ROSTER + RESTART +WRITER_LINE = r'(?m)^P16_RESTART_WRITER pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$' +READER_LINE = r'(?m)^P16_RESTART_READER pid=([0-9]+) writer_pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$' + + +def collect(args, root, out, refs, markers, contracts, policy, contract_path, contract_sha, parse_paths): + source_bytes = sum(pin['bytes'] for pin in refs.values()) + free = shutil.disk_usage(out.parent).free + needed = source_bytes + shared.MIN_SPACE + shared.require(free >= needed, 'fresh source and phase disk admission') + outer = shared.module('p16_owned_outer', shared.SUPPORT / 'owned_outer.py') + shared.require(not outer.native.helper['live_engines'](), 'another Godot owns CPU') + engine = shutil.which('godot') + shared.require(engine and Path(engine).is_file(), 'native launcher exists') + engine = str(Path(engine).resolve()) + out.mkdir(parents=True, exist_ok=False) + stage = out / 'stage' + stage.mkdir() + (out / 'starters').mkdir() + (out / 'bootstrap').mkdir() + for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items(): + data = pin['text'].encode('utf-8') + shared.require(hashlib.sha256(data).hexdigest() == pin['sha256'], 'unchanged external bootstrap bytes') + (out / 'bootstrap' / name).write_bytes(data) + protected = shared.default_player(root) + expected = {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} for name, pin in refs.items()} + selected = {'root_before': refs, 'map': {name: pin['sha256'] for name, pin in refs.items()}, 'timeout': args.timeout} + for name, pin in refs.items(): + target = stage / name + target.parent.mkdir(parents=True, exist_ok=True) + shared.copy_source(Path(pin['path']), target) + shared.require(shared.inventory(stage, stage=True) == expected, 'complete private source copy') + public = (stage / 'project.godot').read_bytes() + shared.write(out / 'parse-paths.json', parse_paths) + run_id = uuid.uuid4().hex + shared.write(out / 'runner-input.json', dict(protocol='guarded-p16-01-dismiss-v1', run_id=run_id, + owner_runner_pid=os.getpid(), project_root=str(root), stage=str(stage), roster=ROSTER, + restart_phases=RESTART, restart_handoff=HANDOFF, + selected_source_map=selected['map'], marker_contract_sha256=contract_sha, + runner_sha256=shared.sha(Path(__file__)), shared_runner_sha256=shared.sha(shared.HERE / 'run_all_tests.py'), + marker_module_sha256=shared.sha(shared.HERE / 'strict_test_markers.py'), + engine=engine, engine_sha256=shared.sha(engine), default_player_before=protected, + formal_acceptance=False, original_unit_complete=False)) + shared.write(out / 'source-start.json', refs) + diagnostics = shared.module('p16_diagnostics', shared.SUPPORT / 'diagnostic_gate.py') + owners = shared.module('p16_owners', shared.SUPPORT / 'ordinary_proof_gate.py') + audio = shared.module('p16_audio', shared.SUPPORT / 'audio_evidence_gate.py') + phases, rows, previous = [], [], {} + error = None + try: + for phase, test in [('import', None), ('parse', None)] + [(str(i).zfill(3), name) for i, name in enumerate(SUITES)]: + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged') + row = shared.execute_phase(engine, stage, out, phase, test, selected, expected, previous, + policy, contracts, parse_paths, public, protected, root, outer, markers, diagnostics, owners, audio) + archive_profiles(out, row) + phases.append(row) + if test: + row.update(sequence=len(rows), godot=engine) + rows.append(row) + (out / 'all-tests.json').write_text(json.dumps(rows, ensure_ascii=False, indent=2) + '\n') + print(('PASS ' if row['passed'] else 'FAIL ') + test, flush=True) + shared.require(row['passed'], 'first failed phase stops without retry: ' + phase) + except BaseException as caught: + error = type(caught).__name__ + ': ' + str(caught) + finally: + cleanup, cleanup_errors = outer.finish_captured(outer.OWNED) + guard_errors = [] + try: + shared.require(shared.source_refs(root) == refs, 'whole source unchanged') + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged at end') + shared.require(shared.default_player(root) == protected, 'default profile untouched') + shared.require(shared.inventory(stage, stage=True) == dict(expected, **previous), 'whole stage exact') + except BaseException as caught: + guard_errors.append(type(caught).__name__ + ': ' + str(caught)) + ended = bool(outer.OWNED) and all(row['engine_ended'] is True for row in cleanup) + passed = error is None and not cleanup_errors and not guard_errors and ended and len(phases) == len(SUITES) + 2 and all(row['passed'] for row in phases) + context = shared.totals(rows, SUITES) + shared.write(out / 'runner-ended.json', dict(context, protocol='guarded-p16-01-dismiss-v1', run_id=run_id, + owner_runner_pid=os.getpid(), passed=passed, engine_ended=ended, + actual_captured_PIDs=[process.pid for process in outer.OWNED], final_owned_cleanup=cleanup, + cleanup_errors=cleanup_errors, guard_errors=guard_errors, error=error, phases=phases, + all_tests_sha256=shared.sha(out / 'all-tests.json') if (out / 'all-tests.json').exists() else None, + default_player_after=shared.default_player(root), formal_acceptance=False, + original_unit_complete=False, independent_review='pending', native_Windows='pending')) + if passed: + seal(out, refs, engine, contract_sha) + # Root also exercises precisely the verifier that ordinary Runner uses. + verify(out, refs, markers, contracts, policy, contract_sha, parse_paths) + print(json.dumps(dict(context, passed=passed, error=error, cleanup_errors=cleanup_errors, guard_errors=guard_errors)), flush=True) + return 0 if passed else 1 + + + +# Receipt file names are always relative; absolute paths in native receipts are +# historical identity strings only and are never opened by the verifier. +def require(ok, reason): + shared.require(ok, reason) + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False).encode()).hexdigest() + + +def content_map(refs): + return {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} + for name, pin in refs.items()} + + +def plain_path(path): + path = Path(path) + require('..' not in path.parts, 'parent traversal refused') + absolute = Path(os.path.abspath(path)) + for part in [absolute, *absolute.parents]: + require(not part.is_symlink(), 'symlink path refused: ' + str(part)) + return absolute + + +def confined(out, name): + require(type(name) is str and name and '\\' not in name, 'ordinary relative evidence name') + path = Path(name) + require(not path.is_absolute() and '..' not in path.parts + and path.as_posix() == name and name != '.', 'evidence path escape or alias') + result = plain_path(out / path) + require(result.is_relative_to(out), 'evidence confined to output') + return result + + +def inventory(out): + result = {} + require(out.is_dir(), 'output directory required') + for directory, dirs, files in os.walk(out, followlinks=False): + for name in dirs + files: + p = Path(directory) / name + mode = p.lstat().st_mode + require(stat.S_ISREG(mode) or stat.S_ISDIR(mode), 'nonordinary evidence refused') + if stat.S_ISREG(mode): + require(p.stat().st_nlink == 1, 'hardlinked evidence refused') + result[p.relative_to(out).as_posix()] = { + 'bytes': p.stat().st_size, 'sha256': shared.sha(p)} + return result + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'duplicate JSON key') + result[key] = value + return result + + +def read_json(path): + return json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=unique_object, + parse_constant=lambda token: require(False, 'nonfinite JSON ' + token)) + + +def archive_profiles(out, row): + for label in ('startup', 'runtime'): + value = row.get('whole_' + label + '_profile_after') + if value is None: + continue + dest = out / 'profiles' / row['phase'] / label + dest.mkdir(parents=True, exist_ok=False) + source = plain_path(value['path']) + require(shared.inventory(source) == value['files'], 'whole owned profile before archive') + for name in value['files']: + target = confined(out, str((dest / name).relative_to(out))) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / name, target) + require(shared.inventory(dest) == value['files'] == shared.inventory(source), + 'whole owned profile archive exact') + + +def seal(out, refs, engine, contract_sha): + # Last file written: an interrupted collection has no complete receipt. + shutil.copyfile(engine, out / 'engine.bin') + require(shared.sha(out / 'engine.bin') == shared.sha(engine), 'engine archive exact') + source = content_map(refs) + shared.write(out / 'receipt.json', { + 'schema': 'guarded-p16-01-dismiss-v1', 'source_map': source, + 'parse_checks': len([name for name in refs if name.endswith('.gd')]), + 'source_map_sha256': digest(source), + 'project_settings_sha256': source['project.godot']['sha256'], + 'marker_contract_sha256': contract_sha, + 'engine_sha256': shared.sha(engine), + 'files': inventory(out), + }) + + +def cleanup_gate(value): + require(value['owned_child_created'] is True and value['engine_ended'] is True + and value['actual_process_exit_code'] == 0 and value['errors'] == [], + 'successful actual owned cleanup') + attempts = value['attempts'] + require(1 <= len(attempts) <= 3, 'bounded cleanup attempts') + for index, row in enumerate(attempts): + require(row['attempt'] == index + 1 and row['errors'] == [] + and row['wait_timeout_seconds'] == 5 and row['wait_completed'] is True + and row['wait_returned_code'] == 0 and row['poll_after'] == 0, + 'complete bounded owned wait') + + +def outer_cleanup(rows, pids): + require([r['pid'] for r in rows] == pids, 'exact captured cleanup PID order') + for row in rows: + require(row['engine_ended'] is True and row['outer_cleanup_errors'] == [] + and row['foreign_process_or_group_signals'] == 0 + and row['outer_maximum_helper_invocations'] == 3 + and row['helper_maximum_waits'] == 3 and row['per_wait_timeout_seconds'] == 5, + 'unchanged outer cleanup contract') + cleanup_gate(row['cleanup']) + + +def verify(out, refs, markers, contracts, policy, contract_sha, parse_paths): + receipt_path = confined(out, 'receipt.json') + require(receipt_path.is_file(), 'regular final receipt required') + manifest = read_json(receipt_path) + require(manifest['schema'] == 'guarded-p16-01-dismiss-v1', 'current receipt schema required') + files = manifest['files'] + require(type(files) is dict and files, 'complete raw manifest') + for name, pin in files.items(): + p = confined(out, name) + require(p.is_file() and set(pin) == {'bytes', 'sha256'} + and type(pin['bytes']) is int and pin['bytes'] >= 0 + and re.fullmatch('[0-9a-f]{64}', pin['sha256']) is not None, + 'ordinary artifact pin') + actual = inventory(out) + receipt_pin = actual.pop('receipt.json') + require(actual == files, 'complete raw file set and SHA-256 equality') + source = content_map(refs) + require(manifest['source_map'] == source and manifest['source_map_sha256'] == digest(source), + 'current whole source content map independent of checkout path') + require(manifest['project_settings_sha256'] == source['project.godot']['sha256'] + and manifest['marker_contract_sha256'] == contract_sha, 'current project/marker pins') + + def read(name): + require(name in files, 'required raw file missing: ' + name) + return read_json(confined(out, name)) + + def text(name): + require(name in files, 'required raw transport missing: ' + name) + return confined(out, name).read_text(encoding='utf-8') + + start, end = read('runner-input.json'), read('runner-ended.json') + require(start['protocol'] == end['protocol'] == 'guarded-p16-01-dismiss-v1' + and start['run_id'] == end['run_id'] + and re.fullmatch('[0-9a-f]{32}', start['run_id']) is not None, + 'same current run') + require(type(start['owner_runner_pid']) is int and start['owner_runner_pid'] > 0 + and start['owner_runner_pid'] == end['owner_runner_pid'], 'same collector owner') + require(start['roster'] == ROSTER and start['restart_phases'] == RESTART + and start['restart_handoff'] == HANDOFF and len(set(SUITES)) == len(SUITES), + 'exact unique roster and restart tests') + require(start['selected_source_map'] == {n: p['sha256'] for n, p in source.items()} + and content_map(read('source-start.json')) == source, 'whole selected source pins') + for field, name in [('runner_sha256', 'tools/run_p16_dismiss_tests.py'), + ('shared_runner_sha256', 'tools/run_all_tests.py'), + ('marker_module_sha256', 'tools/strict_test_markers.py')]: + require(start[field] == source[name]['sha256'], 'current collector/shared/marker pin') + require(start['marker_contract_sha256'] == contract_sha, 'marker contract pin') + # Resolve only the locally configured launcher, never the receipt's engine path. + engine = shutil.which('godot') + require(engine and shared.sha(engine) == start['engine_sha256'] + == manifest['engine_sha256'] == files['engine.bin']['sha256'], + 'current native engine and retained bytes pinned') + derived_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')] + require(read('parse-paths.json') == parse_paths == derived_paths + and manifest['parse_checks'] == len(derived_paths), + 'all exact source parse paths and receipt count') + require(end['passed'] is True and end['engine_ended'] is True and end['error'] is None + and end['cleanup_errors'] == [] and end['guard_errors'] == [], + 'finished successful collection without guard/cleanup error') + for receipt in (start, end): + require(receipt['formal_acceptance'] is False and receipt['original_unit_complete'] is False, + 'backend evidence never completes original unit') + before = start['default_player_before'] + require(before == end['default_player_after'] and before['logs_and_locks_included'] is True + and type(before['existed']) is bool and type(before['files']) is dict, + 'complete default player before/after equality') + for name, pin in before['files'].items(): + confined(out, name) # Validate the inventory name; never open the player. + require(set(pin) == {'bytes', 'sha256'} and type(pin['bytes']) is int + and pin['bytes'] >= 0 and re.fullmatch('[0-9a-f]{64}', pin['sha256']), + 'whole default player file pin') + require(before['existed'] or before['files'] == {}, 'absent player has no files') + + native = shared.module('p16_verify_native', shared.SUPPORT / 'owned_diagnostic_process.py') + diagnostics = shared.module('p16_verify_diagnostics', shared.SUPPORT / 'diagnostic_gate.py') + owners = shared.module('p16_verify_owners', shared.SUPPORT / 'ordinary_proof_gate.py') + audio = shared.module('p16_verify_audio', shared.SUPPORT / 'audio_evidence_gate.py') + # Helpers are loaded for their pure gates only. No run/probe/cleanup is called. + origin = Path(start['stage']).parent + require(Path(start['stage']) == origin / 'stage' and origin.is_absolute() + and '..' not in origin.parts, 'original output identity') + + def relocated(value): + if isinstance(value, dict): + return {relocated(k): relocated(v) for k, v in value.items()} + if isinstance(value, list): + return [relocated(v) for v in value] + if isinstance(value, str) and value.startswith(str(origin) + '/'): + return str(confined(out, value[len(str(origin)) + 1:])) + return value + + def proof_read(path): + path = plain_path(path) + require(path.is_relative_to(out), 'proof path stays in output') + return relocated(read(path.relative_to(out).as_posix())) + + owners.read = proof_read + # The bounded teardown gate parses its owner proof itself and pins the snapshots by their recorded + # absolute paths; this private module instance reads them relocated, as the ordinary gate does. + audio.json = types.SimpleNamespace(loads=lambda data: relocated(json.loads(data))) + phases = end['phases'] + require(len(phases) == len(SUITES) + 2, 'exact import, parse, roster and restart phases') + tests = read('all-tests.json') + stage_files = shared.inventory(confined(out, 'stage'), stage=True) + require(set(stage_files) == set(source) | {HANDOFF}, 'retained stage is the source and the restart handoff') + handoff = stage_files[HANDOFF] + require(len(tests) == len(SUITES) and end['all_tests_sha256'] == files['all-tests.json']['sha256'], + 'complete test rows') + pids = [] + for index, (phase, test) in enumerate([('import', None), ('parse', None)] + + [(str(i).zfill(3), n) for i, n in enumerate(SUITES)]): + row = read(phase + '-receipt.json') + expected_row = dict(row) + if test: + expected_row.update(sequence=index - 2, godot=start['engine']) + require(tests[index - 2] == expected_row, 'test row matches raw phase receipt') + require(phases[index] == expected_row and row['phase'] == phase and row['test'] == test + and row['passed'] is True and row['failed'] is False + and row['validation_error'] is None and not row.get('guard_error') + and row['default_player_entire_profile_unchanged'] is True, + 'exact successful phase') + # The same proof classification as the shared collector (tools/run_all_tests.py). + p = shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) if test else None + ordinary = bool(p) and p['ordinary_cleanup_prepared'] is True + bounded = bool(p) and p['old_bounded_teardown'] is True + pid = row['actual_pid'] + require(type(pid) is int and pid > 0 and pid not in pids, 'distinct actual PIDs per phase') + pids.append(pid) + process = read(phase + '/process.json') + require(process == row['native_process'] and process['pid'] == pid + and process['owned_child_process_helper_sha256'] == native.HELPER_SHA + == source['tools/regression/p09_owned_engine_process.py']['sha256'], + 'actual phase/process/helper agreement') + stdout, stderr, logger = (text(phase + '/' + n) for n in + ('stdout.log', 'stderr.log', 'engine-native.log')) + raw = stdout + stderr + require(text(row['log']) == raw and row['log_sha256'] == files[row['log']]['sha256'], + 'literal complete raw stream') + require(process['stdout_sha256'] == files[phase + '/stdout.log']['sha256'] + and process['stderr_sha256'] == files[phase + '/stderr.log']['sha256'], + 'process transport SHA') + require(row['raw_native_log'] == dict(files[phase + '/engine-native.log'], + path=str(origin / phase / 'engine-native.log')), 'native logger pin') + review = diagnostics.review_streams(native, { + 'stdout': stdout, 'stderr': stderr, 'native_file': logger}, test) + require(row['finite_original_diagnostic_review'] == review + and diagnostics.diagnostic_result_gate(process, review) + and shared.process_gate(process, review['allowed_e31_error_lines']), + 'strict diagnostics/process gates') + require(native.native_diagnostics(stdout, stderr, row['command']) + == process['strict_native_diagnostics'], 'raw banner/diagnostics recomputed') + command = [start['engine'], '--headless', '--path', start['stage']] + if phase == 'import': + command += ['--editor', '--import', '--quit', '--verbose'] + else: + script = ('bootstrap/parse_full_dependencies.gd' if phase == 'parse' + else 'starters/' + phase + '.gd') + command += ['--script', str(origin / script), '--verbose'] + require(row['starter_sha256'] == files[script]['sha256'], 'exact starter pin') + if test: + expected_starter = ('extends ' + json.dumps('res://' + test) + '\nconst Guard := preload(' + + json.dumps(str(origin / 'bootstrap/profile_guard.gd')) + + ')\nfunc _initialize() -> void:\n\tif not Guard.activate():\n\t\tquit(97)\n\t\treturn\n\tsuper._initialize()\n') + require(text(script) == expected_starter, 'unchanged guarded starter bytes') + command += ['--log-file', str(origin / phase / 'engine-native.log')] + require(row['command'] == process['argv'] == command, 'exact native argv/logger') + for filename, field, event in [('started.json', 'actual_start_binding', 'ENGINE_STARTED'), + ('ended.json', 'actual_end_binding', 'ENGINE_ENDED')]: + binding = read(phase + '/' + filename) + require(binding == process[field] and binding['event'] == event + and binding['actual_pid'] == binding['pid'] == pid + and binding['owner_pid'] == start['owner_runner_pid'] + and binding['command'] == binding['argv'] == command, 'actual START/END ownership') + binding = process['actual_start_binding'] + identity = binding['current_process_identity'] + if identity is None: + require(binding['tracked_child_ended_before_initial_probe'] is True, 'early ended tracked child') + else: + require(binding['live_identity_exact'] is True and identity == { + 'pid': pid, 'ppid': start['owner_runner_pid'], 'pgid': pid, 'args': ' '.join(command)}, + 'recorded live child PID/PPID/PGID/argv') + finish = process['actual_end_binding'] + require(finish['engine_ended'] is True and finish['timed_out'] is False + and finish['lifecycle_error'] is None and finish['exit_code'] == 0 + and finish['actual_process_exit_code'] == 0 + and finish['start_receipt_written'] is True + and finish['monotonic_ns'] >= binding['monotonic_ns'], 'complete native END') + cleanup = read(phase + '/adapter-owned-cleanup.json') + require(cleanup == process['adapter_owned_cleanup'], 'raw adapter cleanup') + cleanup_gate(cleanup) + capture = row['outer_actual_capture'] + require(read(phase + '/outer-owned-finally.json') == { + k: v for k, v in capture.items() + if k not in ('outer_receipt_written', 'outer_receipt_error', 'passed')}, + 'whole raw outer receipt') + require(capture['actual_captured_pids'] == [pid] and capture['passed'] is True + and capture['all_captured_ended'] is True and capture['adapter_exception'] is None + and capture['outer_cleanup_errors'] == [] and capture['outer_receipt_error'] is None + and capture['outer_receipt_written'] is True + and capture['all_cleanup_before_outer_receipt'] is True + and capture['foreign_process_or_group_signals'] == 0 + and row['engine_ended'] is True and row['exit_code'] == 0, 'owned outer capture') + outer_cleanup(capture['cleanup'], [pid]) + startup = read(phase + '-startup-profile-before.json') + require(startup == {'path': row['whole_startup_profile_after']['path'], 'existed': False, + 'files': {}, 'before_native_spawn': True}, 'fresh startup profile') + require(row['whole_startup_profile_after']['fresh_before_engine'] is True, + 'startup fresh before engine') + name = row['profile_startup_name'] + require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-startup-' + phase, name) + and Path(startup['path']).name == name and row['private_project_fields'] == 2 + and row['private_project_sha256'] == hashlib.sha256(shared.private_project( + (shared.HERE.parent / 'project.godot').read_bytes(), name)).hexdigest(), + 'exact two-field startup isolation') + for label in ('startup', 'runtime'): + if label == 'runtime' and phase == 'import': + continue + whole = row['whole_' + label + '_profile_after'] + archive = confined(out, 'profiles/' + phase + '/' + label) + require(archive.is_dir() and shared.inventory(archive) + == whole['files'], 'complete retained private profile') + if phase != 'import': + guard = read(phase + '-profile.json') + runtime = Path(row['whole_runtime_profile_after']['path']) + name = guard['custom_user_dir_name'] + require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-runtime-' + phase, name) + and runtime.name == name and guard['verified'] is True + and guard['conditions'] == [True] * 5 and guard['conditions_checked'] == 5 + and guard['fresh_before_parent_initialize'] is True + and guard['profile_writes_before_guard'] == 0 + and guard['actual_user_data_dir'] == guard['expected_user_data_dir'] == str(runtime) + and guard['globalized_probe'] == str(runtime / 'guard-probe') + and guard['project_settings_persisted'] is False, 'all unchanged Guard conditions') + require(row['actual_private_profile'] == { + 'report': guard, 'report_sha256': files[phase + '-profile.json']['sha256'], + 'whole_files': row['whole_runtime_profile_after']['files']}, 'whole raw Guard report') + require(row['controlled_environment'] == { + 'THE_TREE_PACK_PROFILE_NAME': name, 'THE_TREE_PACK_PROFILE_EXPECTED': str(runtime), + 'THE_TREE_PACK_PROFILE_REPORT': str(origin / (phase + '-profile.json')), + 'THE_TREE_FULL_PARSE_PATHS': str(origin / 'parse-paths.json'), + **({'THE_TREE_AUDIO_TEARDOWN_PROOF_DIR': str(origin / (phase + '-teardown/owned-audio')), + 'THE_TREE_AUDIO_FIXTURE_CONTROL_PROOF': str(origin / (phase + '-teardown/control.json'))} + if ordinary else {'THE_TREE_AUDIO_TEARDOWN_PROOF_DIR': str(origin / (phase + '-teardown'))} + if bounded else {})}, + 'exact controlled Guard/proof environment') + else: + require(row['controlled_environment'] == {}, 'fresh import environment') + if phase == 'parse': + require(len(re.findall(r'(?m)^FULL_DEPENDENCY_PARSE\b', raw)) == 1 + and re.findall(r'(?m)^FULL_DEPENDENCY_PARSE checks=([0-9]+) failures=([0-9]+)\n', raw) + == [(str(len(parse_paths)), '0')] and row['actual_parse_checks'] == len(parse_paths), 'unique all-script parse marker') + if test: + parsed = markers.parse_raw(test, raw, contracts, source[test]['sha256']) + require(parsed['passed'] and row['completion'] == parsed + and row['reported_checks'] == parsed['checks'] + and row['reported_failures'] == parsed['failures'] + and row['completion_marker'] == parsed['marker'] and row['collector_errors'] == [], + 'source-pinned raw completion marker') + require(row['source_sha256'] == row['source_sha256_end'] == source[test]['sha256'], + 'exact test source before/after') + require(row['policy'] == p, 'current exact suite policy') + if ordinary: + proof = owners.ordinary_gate(confined(out, phase + '-teardown'), pid, out, + p['ordinary_control_labels']) + require(proof == relocated(row['ordinary_owner_pairs']), 'full ordinary/audio ownership proof') + if bounded: + proof = audio.teardown_gate(confined(out, phase + '-teardown'), pid, out) + require(proof == relocated(row['old_bounded_proof']), 'full bounded audio teardown proof') + require(('ordinary_owner_pairs' in row) == ordinary and ('old_bounded_proof' in row) == bounded + and any(name.startswith(phase + '-teardown/') for name in files) == (ordinary or bounded), + 'teardown proofs exactly for the classified suites') + if test == RESTART[0]: + require(row['generated_reports'] == {HANDOFF: dict(handoff, actual_engine_pid=pid, + producer_test=test, producer_source_sha256=source[test]['sha256'], payload_modified=False)}, + 'the writer generates exactly the retained restart handoff') + require(re.findall(WRITER_LINE, raw) == [(str(pid), handoff['sha256'])] + and read_json(confined(out, 'stage/' + HANDOFF))['writer_pid'] == pid, + 'the writer engine printed and recorded its own handoff') + writer_pid = pid + else: + require(row['generated_reports'] == {}, 'only the restart writer generates a report') + if test == RESTART[1]: + require(re.findall(READER_LINE, raw) == [(str(pid), str(writer_pid), handoff['sha256'])] + and pid != writer_pid, 'a separate reader engine read the exact writer handoff') + require(end['actual_captured_PIDs'] == pids, 'all captured engines') + outer_cleanup(end['final_owned_cleanup'], pids) + require(all(end[k] == v for k, v in shared.totals(tests, SUITES).items()), 'recomputed exact totals') + require(shared.inventory(confined(out, 'stage'), stage=True) == dict(source, **{HANDOFF: handoff}), + 'entire retained source/settings and handoff exact') + for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items(): + require(text('bootstrap/' + name).encode() == pin['text'].encode() + and files['bootstrap/' + name]['sha256'] == pin['sha256'], 'unchanged bootstrap/Guard pins') + require(inventory(out) == dict(files, **{'receipt.json': receipt_pin}), + 'whole raw evidence unchanged during verification') + require(content_map(shared.source_refs(shared.HERE.parent)) == source, + 'whole current source unchanged during verification') + print(json.dumps({'verified': True, 'actual_suites': len(SUITES), 'parse_checks': len(parse_paths), + 'actual_checks': end['actual_all_roster_checks'], 'engines_started': 0, + 'formal_acceptance': False, 'original_unit_complete': False})) + return 0 + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + # No argparse abbreviation, repeated flags, = aliases or positional tokens. + parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + parser.add_argument('--output', required=True) + modes = parser.add_mutually_exclusive_group() + modes.add_argument('--collect', action='store_true', help='Root only; fresh native collection') + modes.add_argument('--prepare-only', action='store_true') + seen = set() + i = 0 + while i < len(argv): + flag = argv[i] + require(flag in ('--output', '--collect', '--prepare-only', '--help') + and flag not in seen, 'unknown, duplicate or malformed flag') + seen.add(flag) + if flag == '--output': + i += 1 + require(i < len(argv) and argv[i] and not argv[i].startswith('--'), 'output value required') + i += 1 + args = parser.parse_args(argv) + args.timeout = 1200 + root = shared.HERE.parent + output = args.output + # The registered validation passes the literal '{build_dir}/p16-dismiss-evidence'. The current Runner + # replaces only {python} in validation argv and gives the task's build directory as BUILD_OUTPUT_DIR. + if output.startswith('{build_dir}/'): + build = os.environ.get('BUILD_OUTPUT_DIR', '') + require(build and Path(build).is_absolute() and Path(build).is_dir() and not Path(build).is_symlink(), + 'registered build directory from BUILD_OUTPUT_DIR') + output = build + output[len('{build_dir}'):] + out = plain_path(output) + refs = shared.source_refs(root) + require(set(SUITES).issubset(shared.roster(root)), 'normal discovery includes narrow roster and restart tests') + discovered = shared.roster(root) + require(discovered.index(RESTART[0]) < discovered.index(RESTART[1]), 'the full roster also runs the writer first') + markers = shared.module('p16_exact_markers', shared.HERE / 'strict_test_markers.py') + contract_path = root / 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json' + contract_sha = shared.sha(contract_path) + contracts = markers.load_contracts(contract_path) + require(all(name in contracts for name in SUITES), 'complete marker roster') + policy = shared.read(shared.SUPPORT / 'suite-policy.json') + shared.policy_coverage(SUITES, refs, policy) + for test in SUITES: + shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) + require(test not in policy['outside_routes'], 'no outside report routes in the narrow run') + require(policy['fixed_generated_report_routes'].get(test, []) == ([HANDOFF] if test == RESTART[0] else []), + 'only the restart writer declares one generated report') + for test in ROSTER[:1] + RESTART: + require(contracts[test]['source_sha256_at_schema_audit'] == refs[test]['sha256'], + 'new strict source-hashed marker') + parse_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')] + if args.prepare_only: + ancestor = out.parent + while not ancestor.exists(): + ancestor = ancestor.parent + free = shutil.disk_usage(ancestor).free + size = sum(p['bytes'] for p in refs.values()) + print(json.dumps({'roster': ROSTER, 'source_files': len(refs), 'source_bytes': size, + 'free_bytes': free, 'conservative_required_bytes': size + shared.MIN_SPACE, + 'capacity_available': free >= size + shared.MIN_SPACE, 'parse_paths': len(parse_paths), + 'restart_phases': RESTART, 'future_native_phases': len(SUITES) + 2, 'actual_checks': None, 'engines': 0, 'stages': 0, + 'formal_acceptance': False})) + return 0 + if args.collect: + require(not out.exists() and out.parent.is_dir(), 'fresh output with existing parent required') + require(not out.is_relative_to(root), 'private collection outside source tree') + return collect(args, root, out, refs, markers, contracts, policy, + contract_path, contract_sha, parse_paths) + return verify(out, refs, markers, contracts, policy, contract_sha, parse_paths) + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (AssertionError, OSError, ValueError, KeyError, TypeError) as error: + print(type(error).__name__ + ': ' + str(error), file=sys.stderr) + raise SystemExit(1)