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

This commit is contained in:
Agent Control Runner
2026-10-24 12:49:31 +09:00
parent 0b59d7c23c
commit b06fec17c7
17 changed files with 2503 additions and 17 deletions
+206 -1
View File
@@ -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(&"decide_guest", Callable(self, "_execute_decide_guest"))
register_command_executor(&"register_staff", Callable(self, "_execute_register_staff")) 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(&"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(&"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_schedule_override", Callable(self, "_execute_set_staff_schedule_override"))
register_command_executor(&"set_staff_work_assignment", Callable(self, "_execute_set_staff_work_assignment")) 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", &"AUTHORITY_FINE": &"fines",
&"OPERATING_EXPENSE": &"cost_of_goods", &"OPERATING_EXPENSE": &"cost_of_goods",
&"STAFF_SIGNING_COST": &"cost_of_goods", &"STAFF_SIGNING_COST": &"cost_of_goods",
FinancePolicy.SEVERANCE_CATEGORY: &"cost_of_goods",
&"EVENT_ACTION": &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods",
&"EVENT_COST_COMMITTED": &"cost_of_goods", &"EVENT_COST_COMMITTED": &"cost_of_goods",
&"DEFENSE_SKILL_COST": &"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 \ var terminal_shape := deferred_runtime_minute == -1 \
and not failure_latched and restored_coordinator.is_terminal() \ and not failure_latched and restored_coordinator.is_terminal() \
and coordinator_current_matches and bool(restored_clock_paused) 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)) \ if not bool(restored_coordinator.validate_invariants().get(&"ok", false)) \
or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \ or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \
or not (normal_shape or failure_shape or pause_shape or terminal_shape) \ 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_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( or not _operations_payroll_crosslinks_are_valid(
restored_coordinator, restored_staff, restored_economy, restored_upkeep_settlement restored_coordinator, restored_staff, restored_economy, restored_upkeep_settlement
) \ ) \
@@ -3954,6 +3960,7 @@ func _operations_expected_category_totals(
&"CONSTRUCTION_REFUND": &"construction_cash_flow", &"CONSTRUCTION_REFUND": &"construction_cash_flow",
&"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods", &"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods",
&"STAFF_SIGNING_COST": &"cost_of_goods", &"EVENT_ACTION": &"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", &"EVENT_COST_COMMITTED": &"cost_of_goods",
&"DEFENSE_SKILL_COST": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods",
&"MAINTENANCE_ACCRUAL": &"maintenance", &"MAINTENANCE_ACCRUAL": &"maintenance",
@@ -6925,6 +6932,203 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
_runtime_event_minute() _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: func _staff_role_matches_facility(role_id: StringName, definition: FacilityDefinition) -> bool:
if definition.required_roles.is_empty() or role_id in definition.required_roles: if definition.required_roles.is_empty() or role_id in definition.required_roles:
return true return true
@@ -11316,6 +11520,7 @@ func get_staff_management_report() -> Dictionary:
&"rest_status": get_staff_rest_report(staff.staff_id).get(&"label", ""), &"rest_status": get_staff_rest_report(staff.staff_id).get(&"label", ""),
&"can_edit": block_reason.is_empty(), &"can_edit": block_reason.is_empty(),
&"assignment_block_reason": block_reason, &"assignment_block_reason": block_reason,
&"dismissal": get_staff_dismissal_preview(staff.staff_id),
&"unique_contract": _staff_unique_contract_report(staff), &"unique_contract": _staff_unique_contract_report(staff),
&"original_relationship": relationship_service.get_original_relationship(staff.staff_id), &"original_relationship": relationship_service.get_original_relationship(staff.staff_id),
}) })
@@ -3991,6 +3991,39 @@
"path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md", "path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md",
"scope": "new P07-01 F7 restart reader marker; native execution pending" "scope": "new P07-01 F7 restart reader marker; native execution pending"
} }
},
{
"test": "tests/staff/test_general_dismissal.gd",
"template": "PASS: <checks> 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> 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> 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", "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting",
@@ -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."
]
}
@@ -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.<command の ID>`)で行う。
- 表金で足りない分: 裏金 → 表金の補填(区分 `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件)。
+21 -2
View File
@@ -32,6 +32,13 @@ const LOAN_INTEREST_CATEGORY: StringName = &"LOAN_INTEREST"
const LOAN_INTEREST_PAYMENT_CATEGORY: StringName = &"LOAN_INTEREST_PAYMENT" const 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] 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: static func rank_tax(sales: int) -> int:
return maxi(sales, 0) / 100 * RANK_TAX_PERCENT + maxi(sales, 0) % 100 * RANK_TAX_PERCENT / 100 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 + 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: static func is_back_fund_service(tags: Array) -> bool:
for tag: StringName in BACK_FUND_TAGS: for tag: StringName in BACK_FUND_TAGS:
if tag in tags: if tag in tags:
@@ -66,12 +77,20 @@ static func prepare_accounts(economy: EconomyService) -> bool:
for account_id: StringName in expected: for account_id: StringName in expected:
if not economy.register_account(account_id, bool(expected[account_id])): if not economy.register_account(account_id, bool(expected[account_id])):
return false return false
return true return _prepare_severance_account(economy)
for account_id: StringName in expected: for account_id: StringName in expected:
if not economy.has_account(account_id) \ if not economy.has_account(account_id) \
or economy.account_allows_negative(account_id) != bool(expected[account_id]): or economy.account_allows_negative(account_id) != bool(expected[account_id]):
return false 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: static func outstanding_principal(economy: EconomyService) -> int:
+216 -7
View File
@@ -9,12 +9,17 @@ signal status_requested(message: String, success: bool)
const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment" const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment"
const KEY_PREFIX := "ui.staff_assignment." const KEY_PREFIX := "ui.staff_assignment."
const DISMISSAL_COMMAND: StringName = &"dismiss_staff"
const DISMISSAL_KEY_PREFIX := "ui.staff_dismissal."
const ROLE_NAMES := { const ROLE_NAMES := {
&"attendant": "接客", &"bartender": "酒場", &"clerk": "会計", &"attendant": "接客", &"bartender": "酒場", &"clerk": "会計",
&"cleaner": "清掃", &"cook": "調理", &"guard": "警備", &"cleaner": "清掃", &"cook": "調理", &"guard": "警備",
&"maintenance": "保守", &"masseur": "施術", &"medic": "医療", &"maintenance": "保守", &"masseur": "施術", &"medic": "医療",
&"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者", &"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者",
} }
const TASK_NAMES := {
&"facility_clean": "清掃の作業", &"rescue_pickup": "救護の迎え", &"rescue_transport": "救護の搬送",
}
const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"] const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"]
var summary_label: Label var summary_label: Label
@@ -25,6 +30,11 @@ var staff_detail_label: Label
var facility_option: OptionButton var facility_option: OptionButton
var shift_option: OptionButton var shift_option: OptionButton
var apply_button: Button 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 result_label: Label
var selected_staff_id: StringName = &"" var selected_staff_id: StringName = &""
@@ -37,7 +47,10 @@ var _draft_facility_id: StringName = &""
var _draft_template_id: StringName = &"DAY" var _draft_template_id: StringName = &"DAY"
var _draft_dirty := false var _draft_dirty := false
var _sequence := 1 var _sequence := 1
var _dismissal_sequence := 1
var _pending_commands: Dictionary = {} var _pending_commands: Dictionary = {}
var _pending_dismissals: Dictionary = {}
var _confirming_dismissal_id: StringName = &""
var _queue_instance_id := 0 var _queue_instance_id := 0
var _built := false var _built := false
@@ -112,6 +125,42 @@ func _build_interface() -> void:
apply_button.pressed.connect(submit_assignment) apply_button.pressed.connect(submit_assignment)
_style(apply_button) _style(apply_button)
editor_panel.add_child(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 = _label("")
result_label.name = "StaffAssignmentResult" result_label.name = "StaffAssignmentResult"
add_child(result_label) add_child(result_label)
@@ -124,6 +173,7 @@ func refresh() -> void:
if queue != null and int(queue.get_instance_id()) != _queue_instance_id: if queue != null and int(queue.get_instance_id()) != _queue_instance_id:
_queue_instance_id = int(queue.get_instance_id()) _queue_instance_id = int(queue.get_instance_id())
_draft_dirty = false _draft_dirty = false
_confirming_dismissal_id = &""
facility_option.get_popup().hide() facility_option.get_popup().hide()
shift_option.get_popup().hide() shift_option.get_popup().hide()
_synchronize_commands(queue) _synchronize_commands(queue)
@@ -178,6 +228,7 @@ func refresh() -> void:
func clear_selection() -> void: func clear_selection() -> void:
selected_staff_id = &"" selected_staff_id = &""
_draft_dirty = false _draft_dirty = false
_confirming_dismissal_id = &""
if not _built: if not _built:
return return
result_label.text = "" result_label.text = ""
@@ -239,16 +290,157 @@ func _refresh_editor() -> void:
facility_option.select(selected_index) facility_option.select(selected_index)
shift_option.select(1 if _draft_template_id == &"NIGHT" else 0) shift_option.select(1 if _draft_template_id == &"NIGHT" else 0)
var pending := _pending_commands.values().has(selected_staff_id) var pending := _pending_commands.values().has(selected_staff_id)
facility_option.disabled = pending var dismissing := _pending_dismissals.values().has(selected_staff_id)
shift_option.disabled = pending 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)) 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 "配属先と毎日の勤務帯をまとめて変更します。" apply_button.tooltip_text = _assignment_block_text(row) if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
if not can_edit: if not can_edit:
staff_detail_label.text += "\n" + _assignment_block_text(row) staff_detail_label.text += "\n" + _assignment_block_text(row)
apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更" 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 ## Runtime supplies canonical, owned presentation data after every normal
## load. The panel neither infers a unique identity from wages nor edits a save. ## load. The panel neither infers a unique identity from wages nor edits a save.
func _unique_contract_text(row: Dictionary) -> String: func _unique_contract_text(row: Dictionary) -> String:
@@ -373,15 +565,20 @@ func _assignment_reason_text(reason: StringName) -> String:
func _synchronize_commands(queue: Variant) -> void: func _synchronize_commands(queue: Variant) -> void:
_pending_commands.clear() _pending_commands.clear()
_pending_dismissals.clear()
for metadata: Dictionary in queue.get_accepted_command_metadata(): for metadata: Dictionary in queue.get_accepted_command_metadata():
var key := String(metadata.get("idempotency_key", "")) var key := String(metadata.get("idempotency_key", ""))
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int(): 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) _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", []): 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 continue
var payload: Dictionary = command.get("payload", {}) 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: 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: 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)): if not _pending_commands.has(int(result.command_id)):
return return
var staff_id := StringName(_pending_commands[int(result.command_id)]) 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: func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant:
if selected_staff_id != staff_id: if selected_staff_id != staff_id:
select_staff(staff_id) select_staff(staff_id)
if _pending_commands.values().has(staff_id) or not bool(_selected_row().get(&"can_edit", false)): var busy := _pending_commands.values().has(staff_id) or _pending_dismissals.values().has(staff_id)
_set_result("変更を処理中です。" if _pending_commands.values().has(staff_id) else _assignment_block_text(_selected_row()), false) if busy or not bool(_selected_row().get(&"can_edit", false)):
_set_result("変更を処理中です。" if busy else _assignment_block_text(_selected_row()), false)
return null return null
var preview := Label.new() var preview := Label.new()
preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"] preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"]
+19 -7
View File
@@ -302,16 +302,24 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
fields.append("elevators_v1_to_v2") fields.append("elevators_v1_to_v2")
proof["elevators"] = {"only_version_changed": same_otherwise} proof["elevators"] = {"only_version_changed": same_otherwise}
# P05-02 A12: a save from before the funds were split gains the five empty fund # 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. # accounts on load, and P16-01-DISMISS (A14) a save from before the severance gains
var has_back_cash := func(economy: Variant) -> bool: # 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: if not economy is Dictionary:
return false return false
for account: Variant in (economy as Dictionary).get(&"accounts", []): 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 true
return false return false
if not has_back_cash.call(old.get(&"economy")) and has_back_cash.call(now.get(&"economy")): var fund_accounts := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true}
var added := {"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 economy: Dictionary = (old[&"economy"] as Dictionary).duplicate(true)
var accounts: Array = (economy[&"accounts"] as Array).duplicate(true) var accounts: Array = (economy[&"accounts"] as Array).duplicate(true)
for account_id: String in added: 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"]) var only_accounts_added: bool = var_to_bytes(economy) == var_to_bytes(now[&"economy"])
ok = ok and only_accounts_added ok = ok and only_accounts_added
old[&"economy"] = economy old[&"economy"] = economy
fields.append("economy_A12_empty_fund_accounts") if added.has("BACK_CASH"):
proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added} 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 # 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 # 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 # drawn (no past draw), holds no rain extension, and starts the weather
+285
View File
@@ -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()
@@ -0,0 +1 @@
uid://dr2mdns7nvdoj
+680
View File
@@ -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")
@@ -0,0 +1 @@
uid://dbf85ttljo65h
@@ -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)
@@ -0,0 +1 @@
uid://df5vm2ir115eb
@@ -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)
@@ -0,0 +1 @@
uid://bcgouseodo7h6
+24
View File
@@ -459,6 +459,27 @@
"ordinary_control_labels": [], "ordinary_control_labels": [],
"old_bounded_teardown": false, "old_bounded_teardown": false,
"new_audio_QA": 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": { "outside_routes": {
@@ -503,6 +524,9 @@
"artifacts/upkeep-2026-09-29/preclose-snapshot.txt", "artifacts/upkeep-2026-09-29/preclose-snapshot.txt",
"artifacts/upkeep-2026-09-29/closed-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": [ "tests/transport/test_runtime_transport_comparison.gd": [
"artifacts/transport/runtime-comparison.json", "artifacts/transport/runtime-comparison.json",
"artifacts/transport/runtime-comparison-1-trace.json", "artifacts/transport/runtime-comparison-1-trace.json",
+631
View File
@@ -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)