P16-01-DISMISS: 一般従業員の通常操作での解雇(退職金を含む。P16-01 の補助の作業。原単位への加算 0)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
+18
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
+82
@@ -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件)。
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 "日勤"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user