Merge commit 'f5fe2196740466a673879cb2950d96446fd99d6e' into HEAD
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(&"register_staff", Callable(self, "_execute_register_staff"))
|
||||
register_command_executor(&"hire_staff_candidate", Callable(self, "_execute_hire_staff_candidate"))
|
||||
register_command_executor(&"dismiss_staff", Callable(self, "_execute_dismiss_staff"))
|
||||
register_command_executor(&"create_staff_shift", Callable(self, "_execute_create_staff_shift"))
|
||||
register_command_executor(&"set_staff_schedule_override", Callable(self, "_execute_set_staff_schedule_override"))
|
||||
register_command_executor(&"set_staff_work_assignment", Callable(self, "_execute_set_staff_work_assignment"))
|
||||
@@ -2730,6 +2731,7 @@ func _build_operations_daily_financials(
|
||||
&"AUTHORITY_FINE": &"fines",
|
||||
&"OPERATING_EXPENSE": &"cost_of_goods",
|
||||
&"STAFF_SIGNING_COST": &"cost_of_goods",
|
||||
FinancePolicy.SEVERANCE_CATEGORY: &"cost_of_goods",
|
||||
&"EVENT_ACTION": &"cost_of_goods",
|
||||
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
||||
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
||||
@@ -3410,11 +3412,15 @@ func _parse_operations_runtime_snapshot(
|
||||
var terminal_shape := deferred_runtime_minute == -1 \
|
||||
and not failure_latched and restored_coordinator.is_terminal() \
|
||||
and coordinator_current_matches and bool(restored_clock_paused)
|
||||
# The closed arrears are counted as the live check counts them
|
||||
# (_operations_closed_arrears_total): wages, upkeep and the unpaid rank tax and interest.
|
||||
var last_close := restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1)
|
||||
if not bool(restored_coordinator.validate_invariants().get(&"ok", false)) \
|
||||
or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \
|
||||
or not (normal_shape or failure_shape or pause_shape or terminal_shape) \
|
||||
or restored_coordinator.get_population_count() != restored_guests.guest_count() \
|
||||
or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1))) \
|
||||
or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(last_close)
|
||||
+ _finance_arrears_through(restored_economy, last_close)) \
|
||||
or not _operations_payroll_crosslinks_are_valid(
|
||||
restored_coordinator, restored_staff, restored_economy, restored_upkeep_settlement
|
||||
) \
|
||||
@@ -3954,6 +3960,7 @@ func _operations_expected_category_totals(
|
||||
&"CONSTRUCTION_REFUND": &"construction_cash_flow",
|
||||
&"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods",
|
||||
&"STAFF_SIGNING_COST": &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods",
|
||||
FinancePolicy.SEVERANCE_CATEGORY: &"cost_of_goods",
|
||||
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
||||
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
||||
&"MAINTENANCE_ACCRUAL": &"maintenance",
|
||||
@@ -6925,6 +6932,285 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
|
||||
_runtime_event_minute()
|
||||
)
|
||||
|
||||
|
||||
## P16-01-DISMISS: whether a hired employee can be dismissed now, with the severance
|
||||
## (A14), the Task the removal reopens and the rescue Tasks the command interrupts.
|
||||
## Nothing is changed.
|
||||
func get_staff_dismissal_preview(staff_id: StringName) -> Dictionary:
|
||||
var staff: StaffRuntime = staff_service.get_staff(staff_id) if staff_service != null else null
|
||||
var preview := {
|
||||
&"staff_id": staff_id, &"can_dismiss": false, &"reason": &"NOT_FOUND",
|
||||
&"severance": 0, &"unpaid_wages": 0, &"reopened_task_ids": [], &"reopened_task_types": [],
|
||||
&"interrupted_rescue_task_ids": [], &"interrupted_rescue_task_types": [], &"interrupted_rescue_roles": [],
|
||||
&"available_funds": FinancePolicy.available_funds(economy_service) if economy_service != null else 0,
|
||||
&"front_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT) if economy_service != null else 0,
|
||||
}
|
||||
if staff == null:
|
||||
return preview
|
||||
var severance := FinancePolicy.severance(staff.get_effective_daily_wage())
|
||||
preview[&"severance"] = severance
|
||||
preview[&"unpaid_wages"] = staff.unpaid_wages
|
||||
var work := _staff_dismissal_work(staff)
|
||||
for kind: StringName in [&"reopened", &"interrupted_rescue"]:
|
||||
for task: StaffTask in work[kind]:
|
||||
(preview[StringName(String(kind) + "_task_ids")] as Array).append(task.task_id)
|
||||
(preview[StringName(String(kind) + "_task_types")] as Array).append(task.task_type)
|
||||
for task: StaffTask in work[&"interrupted_rescue"]:
|
||||
(preview[&"interrupted_rescue_roles"] as Array).append(&"carrier" if task.task_id == staff.current_task_id else &"patient")
|
||||
# R5: after the game ends the command is refused before acceptance (submit_command).
|
||||
var ended := _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal()
|
||||
var reason: StringName = &"BANKRUPT" if ended else _staff_dismissal_block_reason(staff, severance)
|
||||
preview[&"reason"] = reason
|
||||
preview[&"can_dismiss"] = reason.is_empty()
|
||||
return preview
|
||||
|
||||
|
||||
## The work a dismissal changes: the held Task the removal contract reopens, and the
|
||||
## rescue Tasks the command interrupts (the held transport and every rescue of the
|
||||
## employee as the patient; SPEC 6.2.1).
|
||||
func _staff_dismissal_work(staff: StaffRuntime) -> Dictionary:
|
||||
var reopened: Array[StaffTask] = []
|
||||
var interrupted: Array[StaffTask] = []
|
||||
var held := staff_service.get_task(staff.current_task_id)
|
||||
if held != null and not held.is_terminal():
|
||||
if held.task_type == &"rescue_transport":
|
||||
interrupted.append(held)
|
||||
else:
|
||||
reopened.append(held)
|
||||
for task: StaffTask in staff_service.get_tasks():
|
||||
if task.task_type in [&"rescue_pickup", &"rescue_transport"] and not task.is_terminal() \
|
||||
and task.target_entity_id == staff.staff_id and not interrupted.has(task):
|
||||
interrupted.append(task)
|
||||
return {&"reopened": reopened, &"interrupted_rescue": interrupted}
|
||||
|
||||
|
||||
## Every refusal of SPEC 6.3 the executor decides (R1, R2, R3, R7, R8, R9).
|
||||
func _staff_dismissal_block_reason(staff: StaffRuntime, severance: int) -> StringName:
|
||||
if staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE:
|
||||
return &"UNIQUE"
|
||||
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"
|
||||
if not _staff_task_reservations_releasable(staff):
|
||||
return &"RESERVATION_RELEASE_FAILED"
|
||||
# Root's temporary boundary R9: an unfinished raid's defender stays until the raid ends.
|
||||
if not _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty():
|
||||
return &"DEFENSE_LOCKED"
|
||||
if severance > FinancePolicy.available_funds(economy_service):
|
||||
return &"INSUFFICIENT_FUNDS"
|
||||
return &""
|
||||
|
||||
|
||||
## R3 decided before any change: the removal contract releases every Reservation of the
|
||||
## held Task, and InventoryService.release_reservation fails for a missing or a
|
||||
## consumed Reservation (a released or expired one is a replayed success).
|
||||
func _staff_task_reservations_releasable(staff: StaffRuntime) -> bool:
|
||||
var task := staff_service.get_task(staff.current_task_id)
|
||||
if task == null:
|
||||
return true
|
||||
for reservation_id: StringName in task.reservation_ids:
|
||||
var reservation := inventory_service.get_reservation(reservation_id)
|
||||
if reservation == null or reservation.state == InventoryReservation.State.CONSUMED:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Dismissal of a general employee (FREE_HIRE). All refusals are decided before any
|
||||
## change (SPEC 4.1 c). The employee's seat in a boarded rescue pair, the existing
|
||||
## removal contract, the severance paid from the front fund with the hidden fund
|
||||
## covering the rest (one posting, A14) and the cleanup of SPEC 6.2 then commit as one
|
||||
## operation and are rolled back together on a failure.
|
||||
func _execute_dismiss_staff(command: SimulationCommand) -> CommandResult:
|
||||
var raw_staff_id: Variant = command.payload.get(&"staff_id", command.payload.get("staff_id", null))
|
||||
if typeof(raw_staff_id) not in [TYPE_STRING, TYPE_STRING_NAME] or String(raw_staff_id).is_empty():
|
||||
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, R2 and R3 keep the existing removal contract's code and text.
|
||||
var refusals := {
|
||||
&"UNIQUE": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Unique Staff cannot be dismissed, sold or permanently removed."],
|
||||
&"EXTERNAL_TASK": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Finish the employee external operation before removal."],
|
||||
&"RESERVATION_RELEASE_FAILED": [CommandResult.Code.TRANSACTION_APPLY_FAILED, "Staff removal could not release Task Reservations."],
|
||||
&"NOT_GENERAL_EMPLOYEE": [CommandResult.Code.VALIDATION_FAILED, "Only a general employee can be dismissed."],
|
||||
&"DEFENSE_LOCKED": [CommandResult.Code.VALIDATION_FAILED, "Staff is defending an unfinished raid."],
|
||||
&"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
|
||||
var reopened: Array = _staff_dismissal_work(staff)[&"reopened"]
|
||||
if not _release_dismissed_rescue_seat(staff_id):
|
||||
_rollback_staff_dismissal(before)
|
||||
return CommandResult.failed(
|
||||
CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id,
|
||||
"Staff dismissal could not release the employee's seat in a boarded rescue pair.",
|
||||
{&"reason": &"RESCUE_SEAT_RELEASE_FAILED", &"staff_id": staff_id, &"error": elevators.last_error}
|
||||
)
|
||||
# The existing contract still refuses an unreleasable Task Reservation (R3, decided
|
||||
# above) as the last guard; nothing it would change survives the rollback.
|
||||
var removal := staff_service.remove_staff(staff_id, &"DISMISSED", true, minute)
|
||||
if not removal.is_success():
|
||||
_rollback_staff_dismissal(before)
|
||||
removal.command_id = command.command_id
|
||||
removal.details[&"reason"] = &"RESERVATION_RELEASE_FAILED"
|
||||
removal.details[&"staff_id"] = staff_id
|
||||
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)
|
||||
var reopened_ids: Array[StringName] = []
|
||||
for task: StaffTask in reopened:
|
||||
if not task.is_terminal():
|
||||
reopened_ids.append(task.task_id)
|
||||
return CommandResult.succeeded(command.command_id, {
|
||||
&"staff_id": staff_id,
|
||||
&"severance": severance,
|
||||
&"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": reopened_ids,
|
||||
&"interrupted_rescue_task_ids": interrupted,
|
||||
&"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
||||
&"back_cash": economy_service.get_balance(FinancePolicy.BACK_CASH),
|
||||
})
|
||||
|
||||
|
||||
## SPEC 6.2.1 and 10: a dismissed member of a rescue pair aboard an elevator leaves the
|
||||
## cabin at the command, as the non-depicted removal; the other member stays aboard, as
|
||||
## the existing rule keeps a live passenger to a real landing. These are the steps of
|
||||
## ElevatorService's own cancellation of a single boarded rider (_cancel_single),
|
||||
## through the public Path and elevator snapshot interfaces: the Path request ends, and
|
||||
## the employee's trip, cabin seat and pairing are removed from the snapshot that
|
||||
## ElevatorService.restore_snapshot then checks against the Path state.
|
||||
func _release_dismissed_rescue_seat(staff_id: StringName) -> bool:
|
||||
var actor_id := _staff_path_actor_id(staff_id)
|
||||
var trip := elevators.get_actor_trip(actor_id)
|
||||
var partner := StringName(trip.get(&"companion_id", &""))
|
||||
if trip.get(&"state", &"") != PathService.ELEVATOR_REQUEST_BOARDED or partner.is_empty():
|
||||
return true
|
||||
if not path_service.complete_elevator_request(StringName(trip[&"connector_id"]), StringName(trip[&"request_id"])):
|
||||
return false
|
||||
var snapshot := elevators.create_snapshot()
|
||||
var trips: Array = []
|
||||
for value: Dictionary in snapshot[&"trips"]:
|
||||
var row := value.duplicate(true)
|
||||
if StringName(row[&"actor_id"]) == actor_id:
|
||||
continue
|
||||
if StringName(row[&"actor_id"]) == partner:
|
||||
row.erase(&"companion_id")
|
||||
trips.append(row)
|
||||
snapshot[&"trips"] = trips
|
||||
for car: Dictionary in snapshot[&"shafts"]:
|
||||
(car[&"boarded"] as Array).erase(actor_id)
|
||||
return elevators.restore_snapshot(snapshot)
|
||||
|
||||
|
||||
func _staff_aboard(staff_id: StringName) -> bool:
|
||||
return elevators.get_actor_trip(_staff_path_actor_id(staff_id)).get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED
|
||||
|
||||
|
||||
## SPEC 6.2.1: no live rescue Task keeps the dismissed employee as its patient or
|
||||
## carrier. The next minute's rule (_advance_staff_rescue) is applied to those Tasks
|
||||
## now: each is interrupted and its elevator requests are cancelled, except that a live
|
||||
## passenger already aboard rides on to a real landing. A pickup whose carrier was
|
||||
## dismissed was reopened by the removal and stays open for another medic, as that
|
||||
## rule keeps it. Returns null if a Task could not be interrupted.
|
||||
func _interrupt_dismissed_staff_rescue(staff_id: StringName, held_task_id: StringName, minute: int) -> Variant:
|
||||
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)
|
||||
if not staff_service.cancel_task(task.task_id, &"RESCUE_INTERRUPTED", minute).is_success():
|
||||
return null
|
||||
interrupted.append(task.task_id)
|
||||
for member: StaffRuntime in [patient, carrier]:
|
||||
if member != null and not _staff_aboard(member.staff_id):
|
||||
elevators.cancel_actor(_staff_path_actor_id(member.staff_id))
|
||||
return interrupted
|
||||
|
||||
|
||||
func _rollback_staff_dismissal(snapshot: Dictionary) -> bool:
|
||||
var restored := economy_service.restore_snapshot(snapshot[&"economy"])
|
||||
restored = _state.restore_snapshot(snapshot[&"game_state"]) and restored
|
||||
restored = staff_service.restore_snapshot(snapshot[&"staff"]) and restored
|
||||
restored = inventory_service.restore_snapshot(snapshot[&"inventory"]) and restored
|
||||
restored = path_service.restore_snapshot(snapshot[&"path"]) and restored
|
||||
restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored
|
||||
_movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true)
|
||||
restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored
|
||||
restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored
|
||||
return restored and _validate_runtime_path_crosslinks(
|
||||
path_service,
|
||||
guest_service,
|
||||
staff_service,
|
||||
_movement_credits,
|
||||
_runtime_event_minute()
|
||||
)
|
||||
|
||||
func _staff_role_matches_facility(role_id: StringName, definition: FacilityDefinition) -> bool:
|
||||
if definition.required_roles.is_empty() or role_id in definition.required_roles:
|
||||
return true
|
||||
@@ -9531,6 +9817,14 @@ func _reserve_staff_task_items(task: StaffTask, absolute_minute: int) -> Diction
|
||||
var before := inventory_service.create_snapshot()
|
||||
var reservation_ids: Array[StringName] = []
|
||||
for item_id: StringName in _sorted_name_keys(task.required_items):
|
||||
# One operation per reservation of the Task: InventoryService never removes a
|
||||
# Reservation, so the count of the Task's earlier ones for the item makes the next
|
||||
# reservation after a release (an interruption, a removal or a dismissal) a new
|
||||
# operation, also within the same minute.
|
||||
var earlier := 0
|
||||
for reservation: InventoryReservation in inventory_service.get_reservations():
|
||||
if reservation.requester_id == task.task_id and reservation.item_id == item_id:
|
||||
earlier += 1
|
||||
var result := inventory_service.reserve_item(
|
||||
inventory_id,
|
||||
item_id,
|
||||
@@ -9539,7 +9833,7 @@ func _reserve_staff_task_items(task: StaffTask, absolute_minute: int) -> Diction
|
||||
&"staff_task",
|
||||
absolute_minute,
|
||||
absolute_minute + 480,
|
||||
StringName("staff.task.reserve.%s.%s" % [task.task_id, item_id])
|
||||
StringName("staff.task.reserve.%s.%s.%d" % [task.task_id, item_id, earlier])
|
||||
)
|
||||
if not bool(result.get(&"ok", false)):
|
||||
inventory_service.restore_snapshot(before)
|
||||
@@ -11316,6 +11610,7 @@ func get_staff_management_report() -> Dictionary:
|
||||
&"rest_status": get_staff_rest_report(staff.staff_id).get(&"label", ""),
|
||||
&"can_edit": block_reason.is_empty(),
|
||||
&"assignment_block_reason": block_reason,
|
||||
&"dismissal": get_staff_dismissal_preview(staff.staff_id),
|
||||
&"unique_contract": _staff_unique_contract_report(staff),
|
||||
&"original_relationship": relationship_service.get_original_relationship(staff.staff_id),
|
||||
})
|
||||
|
||||
@@ -3991,6 +3991,39 @@
|
||||
"path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md",
|
||||
"scope": "new P07-01 F7 restart reader marker; native execution pending"
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "tests/staff/test_general_dismissal.gd",
|
||||
"template": "PASS: <checks> checks",
|
||||
"required": true,
|
||||
"fixed_check_count_used": false,
|
||||
"source_sha256_at_schema_audit": "f1f77bfd6320a3c8e0851915b150e95fc40f4a352876358393572a6d3f7c79ce",
|
||||
"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": "b38745eccb4e51ddf4790051795e2f64faf10f875edc7b754ac2d04db2917a5b",
|
||||
"authority": {
|
||||
"path": "docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/SPEC.md",
|
||||
"scope": "new P16-01-DISMISS F8 restart writer marker; native execution pending"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "4e13de2594096fab648fc8c418a1a4aa764ac72fc13fbd68cba1c6f05767fc83",
|
||||
"authority": {
|
||||
"path": "docs/plans/original-completion-2026-10-02/execution/p16-01-dismiss/SPEC.md",
|
||||
"scope": "new P16-01-DISMISS F8 restart reader marker; native execution pending"
|
||||
}
|
||||
}
|
||||
],
|
||||
"unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting",
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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 6 changes no listed expectation.",
|
||||
"Repair 1: no existing suite reads the operation id of a staff Task's inventory reservation (staff.task.reserve.*); the reservation-count key of IMPLEMENTATION_NOTES.md section 5 changes no listed expectation."
|
||||
]
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
# P16-01-DISMISS 実装の記録(実 Opus の実装担当、2026-10-24、修正 1 と修正 2 は 2026-10-25)
|
||||
|
||||
- 仕様: 同じフォルダの `SPEC.md`(登録版 r04。main `0b59d7c` に固定。ファイルの SHA-256 は `b4cc31c0…5f77`、Runner の `spec_sha256` は `14efffd9…e6b`)。出典は `PROVENANCE.json`。
|
||||
- 原本への加算は 0。原単位 P16-01・P16-03、`AR.staff.dismissal_severance` の package P05 の原 work・原 AC は閉じない(SPEC §8)。
|
||||
- 管理の記録は `artifacts/original-completion-2026-10-02/p16-01-dismiss-control-r01/` にある(git の外)。
|
||||
- 変えた製品のファイル: `autoload/simulation_runtime.gd`・`scripts/economy/finance_policy.gd`・`scripts/ui/staff_management_panel.gd`。`scripts/staff/staff_service.gd` と `scenes/main/main.gd` は変えていない(§1・§4)。
|
||||
|
||||
## 0. 修正 1 の経過(S `b06fec1` → 修正の後の S)
|
||||
|
||||
- S `b06fec1` は、同じ実 reviewer の補足(改訂)の判定で changes_requested になった(high 1・medium 6・low 4。`DESKTOP_RESULT_SUPPLEMENT_R02.json`)。1回目の pass と、その後の補足の判定は、どちらも書き換えずに残している。
|
||||
- 主な所見と直し:
|
||||
- high: S `b06fec1` は、乗ったままの救護の組の解雇を、実装の判断で `RESCUE_PAIR_ABOARD` として拒んでいた。これは SPEC §6.2 の最後の文「直し、拒否に変えない」、F1・F7(許可の全ての行)に合わない。拒否を消し、組を解く後始末を作った(§3)。
|
||||
- medium: 開き直した予約つきの作業を別の職員が取れなかった(`IDEMPOTENCY_CONFLICT` で BLOCKED)。予約の操作 ID を直した(§5)。
|
||||
- medium: preview と結果が、命令の中で取り消す救護の搬送を「開き直す」と書いていた。分けて実際どおりにした(§4)。
|
||||
- medium: preview が R5(運営の終了)と R3(予約を解けない)を判定していなかった。判定を足した(§1)。
|
||||
- medium と low: 条文が明示する確かめ方を代用していた所がある(同じ process の読み込み、通勤の昇降機、拒否の行の画面、bytes の比べ、別の process の境目など)。条文どおりの試験を足した(§7)。
|
||||
- 新しい拒否や、R9 に似た境界は足していない。受入の条件も削っていない。
|
||||
|
||||
### 修正 2 の経過(S `a073ccb` → 修正の後の S)
|
||||
|
||||
- S `a073ccb` は、新しい fresh な別の実 reviewer の判定で changes_requested になった(medium 1・low 3。`p16-01-dismiss-independent-review-a073ccb-r01/DESKTOP_RESULT.json`)。F1〜F8 と F10 は満たし、F9 を満たさないという判定。結果・配送・transcript は書き換えずに残している。
|
||||
- 所見と直し(製品のファイルは変えていない。試験と記録だけ):
|
||||
- medium(F9・§4.3 の通常の入力): S `a073ccb` の画面の試験は、Main のキーの処理を直接呼び、ボタンの `pressed` の信号を直接出していた。viewport の入力の経路で、解雇と確定のボタンに届くことを確かめていなかった。画面の試験を viewport の入力に置き換えた(§4・§7)。
|
||||
- low(§4.1 (d)): 試験で失敗を起こしていなかった2つの巻き戻しの枝を、開示した fixture で起こした。記録の「起こせない」という理由は、`remove_staff` の枝について正しくなかったので直した(§1)。
|
||||
- low(§4.3): 裏金からの補填の文と、命令の中で取り消す救護の文を、画面で確かめた(§7)。
|
||||
- low(後続のスクリプトの1回目の元の bytes の欠落): 管理の記録の欠落として残す。この W の中で直すものは無い。
|
||||
|
||||
## 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. 持っている作業の予約を解けない → `RESERVATION_RELEASE_FAILED`(R3。今の `remove_staff` と同じ code と文)
|
||||
- 修正 1 で、変える前の判定にした(`_staff_task_reservations_releasable`)。在庫の解放は、予約が無い時と消費済みの時に失敗する(`InventoryService.release_reservation`)。それを、読むだけの `get_reservation` で前もって見る。
|
||||
- `remove_staff` の同じ拒否は、最後の守りとして残す。そこで止まった時も、全体を巻き戻す。
|
||||
7. 終わっていない襲撃の防衛者 → `DEFENSE_LOCKED`(R9。Root の一時の境界)
|
||||
8. 表金と裏金の合計が退職金に足りない → `INSUFFICIENT_FUNDS`(R8)
|
||||
- (d) 全て通れば、次を1つの操作として行う。
|
||||
1. 乗ったままの救護の組なら、解雇する人の座席を解く(§3)。
|
||||
2. 既存の削除の契約 `StaffService.remove_staff(staff_id, &"DISMISSED", true, 分)`。
|
||||
3. 退職金の記帳(§2)。
|
||||
4. 救護の後始末(§3)、施設の人員の投影(`_sync_facility_staffing`)、出来事の欠けた職員の扱い(`event_service.audit_actor_availability`)。
|
||||
- (d) の途中で失敗すれば、命令の前の写し(staff・inventory・path・elevators・移動の残り・出来事・ID・帳簿・Gold の写し)に全て戻して、`TRANSACTION_APPLY_FAILED` を返す。理由は次のとおり。
|
||||
- 座席を解けない時 `RESCUE_SEAT_RELEASE_FAILED`
|
||||
- `remove_staff` が予約を解けない時 `RESERVATION_RELEASE_FAILED`(R3 の最後の守り)
|
||||
- 退職金を記帳できない時 `SEVERANCE_POST_FAILED`
|
||||
- 救護の作業を取り消せない時 `RESCUE_CLEANUP_FAILED`
|
||||
- 4つとも、試験で失敗を起こし、5.c の比べ方で何も変わらないことを確かめる(修正 2 で座席と `remove_staff` の2つを足した。§7)。
|
||||
- 退職金: 記帳の key を先に使う(開示した fixture)。乗ったままの組の段でも起こし、座席を解いた後の path と昇降機の巻き戻しを確かめる。
|
||||
- 救護の後始末: 迎えの作業に無い予約の ID を持たせる(開示した fixture)。
|
||||
- `remove_staff`: 在庫の解放の操作 ID(`staff.task.release.<作業>.<予約>`)を、先に別の予約の操作で束縛する(開示した fixture)。前もった判定(予約が無い・消費済み)は通るが、在庫が `IDEMPOTENCY_CONFLICT` で解放を拒む。修正 1 の記録の「試験の組み立てからは起こせない」は、この枝について誤りだった。
|
||||
- 座席: 運搬者の Path の乗車の要求を、公開の `PathService.complete_elevator_request` で先に終える(開示した fixture)。この状態は保存の照合が拒む形で、通常のゲームの状態からは起きない。
|
||||
- 巻き戻しの関数 `_rollback_staff_dismissal` の戻り値は使っていない。既存の雇用の巻き戻し(`_rollback_staff_candidate_hire`)と同じ形のまま。
|
||||
- 画面と試験のための読むだけの関数 `get_staff_dismissal_preview(staff_id)`。返すのは、解雇できるか・理由・退職金・未払い・開き直す作業・命令の中で取り消す救護の作業と役(運搬者か患者か)・表金・資金の合計。
|
||||
- 運営の終了の後は、理由を `BANKRUPT` にする(R5。命令は受付の前に拒まれる)。
|
||||
- `get_staff_management_report` の各行に `dismissal` として載る。
|
||||
|
||||
## 2. 退職金(SPEC §3.5・§3.6、A14)
|
||||
|
||||
- `FinancePolicy` に、`SEVERANCE_EXPENSE`(口座 `STAFF_SEVERANCE_EXPENSE`)・`SEVERANCE_CATEGORY`(区分 `STAFF_SEVERANCE`)・`SEVERANCE_DAYS`(3)・`severance(日給)` を足した。額は `get_effective_daily_wage() × 3`。
|
||||
- 記帳は命令の時刻に、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 の試験)。
|
||||
- **乗ったままの救護の組(修正 1)**: S `b06fec1` の `RESCUE_PAIR_ABOARD` の拒否を消した。組の段階でも解雇できる。
|
||||
- 解雇する人の座席だけを、命令の時に組から解く(`_release_dismissed_rescue_seat`)。SPEC §10 の「非描写の状態として命令の時点で取り除く」に当たる。
|
||||
- 手順は、`ElevatorService` 自身の乗っている1人の取り消し(`_cancel_single`)と同じ。公開の `path_service.complete_elevator_request` で乗車の要求を終え、昇降機の公開の snapshot から、その人の trip・座席・相方の `companion_id` を外して `restore_snapshot` に渡す。
|
||||
- `ElevatorService` は変えていない。`restore_snapshot` の照合(Path の要求と trip の対応、座席、相方の対応)を通る形なので、命令の直後の保存も照合を通る。
|
||||
- 残る人(患者または運搬者)は、今の規則(`_advance_staff_rescue` の、乗っている間は組を保つ分岐)どおり、1人で乗ったまま本物の階に着く。
|
||||
- 運搬者を解雇した時: 患者は着いた階で倒れたままになり、次の時刻の救護の確認が、迎えを今の規則で作り直す。
|
||||
- 患者を解雇した時: 運搬者は着いた階で手が空く。
|
||||
- 試験は組の段階で運搬者と患者のそれぞれを解雇し、次を確かめる(§7)。
|
||||
- 参照が残らない。
|
||||
- 残る人が本物の階(昇降機の止まる階)で降りる。
|
||||
- 救護が今の規則でやり直される。
|
||||
- 命令の直後の保存を、同じ process(写しの復元・ディスク)と別の process(再開の2段)で読む。
|
||||
- 救護(`_interrupt_dismissed_staff_rescue`): 次の時刻の救護の確認(`_advance_staff_rescue` の最初の段)と同じ規則を、命令の中で、解雇した職員に関わる作業にだけ当てる。
|
||||
- 患者を解雇した時: その患者を目標にした、終わっていない救護の作業を `RESCUE_INTERRUPTED` で取り消し、運搬者を作業から外す。
|
||||
- 運搬者を解雇した時: `remove_staff` が開き直した搬送(`rescue_transport`)は、運搬者が居ないので取り消す(今の規則と同じ)。迎え(`rescue_pickup`)は開き直ったまま、別の医療職を待つ(今の規則でも有効な作業)。
|
||||
- 昇降機の予約の取り消しは、乗っていない生きた人だけに行う。乗っている人は、着くまで乗せたままにする(今の規則)。
|
||||
- 出来事: 命令の中で、公開の `event_service.audit_actor_availability` を呼ぶ。進行中・選択待ち・中断の出来事の欠けた職員を、今の `missing_actor_policy` で扱う(CANCEL と CONTINUE_WITHOUT を試験)。予約した費用は、今の規則で1回だけ戻る。この audit は他の欠けた actor も今の規則で扱う(次の時刻と同じ処理が早く行われるだけ)。
|
||||
- 命令の直後に保存し、同じ process と別の process で読み込んでも、欠けた職員への生きた参照が残らず、保存の照合が通る。生きた参照とは、終わっていない作業の患者と運搬者、昇降機の予約と相方、経路の actor、施設の人員、出来事の拘束と actor のこと。
|
||||
- 履歴として残るもの: 終わった作業の記録の `target_entity_id`、サービスの `staff_ids`(始めた時の記録)、給与の締めの行、雇った候補の記録(同じ候補は二度雇えない今の規則のまま)。
|
||||
|
||||
## 4. 画面(SPEC §4.3)
|
||||
|
||||
- `StaffManagementPanel` に、「解雇する…」と確定の欄(「解雇を確定」「やめる」)を足した。判定は Runtime の preview に従う。
|
||||
- 押す前に、退職金の額(日給の3日分)、裏金からの補填、未払いの給与、開き直す作業を出す。
|
||||
- 修正 1 で、命令の中で取り消す救護を分けて出す(`interrupted_rescue_roles`)。運搬者なら「運んでいる救護の搬送は取り消し、患者の救護をやり直します。」、患者なら「この従業員への救護は取り消し、救護係を外します。」。
|
||||
- 拒否の行(R1・R2・R3・R5・R7・R8・R9)ではボタンを無効にし、理由を出す。`UNIQUE` は今の「ユニーク:雇用・維持費無料 / 解雇・売却不可」、それ以外は「解雇不可:…」。無効のボタンを無理に押しても、命令を送らない(5.a)。
|
||||
- 確定の時に名簿を読み直す。読み込みや変化の後の古い確認では送らない。
|
||||
- 時間が止まっている時はすぐに、進んでいる時は次の時刻に、命令が処理される。
|
||||
- 結果として、払った退職金・補填・保持した未払い・開き直した作業・取り消した救護、または拒否の理由(押した後に資金が減って拒まれた場合を含む)を出し、名簿と選択を更新する。
|
||||
- 解雇の受理を待つ間は、その職員の配属の変更とドラッグを止める。
|
||||
- `Main` の職員の管理の画面は、今の部品と送り手(`_audio_feedback.submit_command`)のままで使えるので、`scenes/main/main.gd` は変えていない。UI は今の部品のままで、完成の見た目ではない。
|
||||
- 入力の経路(F9、修正 2): 試験は本物の Main と Runtime を使い、viewport の入力で操作する。
|
||||
- キー(雇用の候補を開く H)は `root.push_input` の `InputEventKey`。
|
||||
- ボタン(候補の雇用・「雇用済み」・名簿の行・「解雇する…」・「やめる」・「解雇を確定」・締めの後に戻る「従業員」)は、全 roster で PASS している `tests/ui/test_business_management.gd` の `_click_principal_button` と同じ形で押す。ScrollContainer で見える位置へ送り、マウスの移動、押しと離しを viewport の local の座標で送り、各フレームを待つ。
|
||||
- 拒否の行では、無効のボタンを viewport の入力で押しても命令を送らず、snapshot の全体の bytes が同じことを確かめる。
|
||||
- 時計が動いている時の押し方: 離す瞬間だけ時計を動かし、すぐ止める。命令は次の分の処理になり、フレームが分を進めない(1分は実時間 1/6 秒なので、フレームを待つ間に時計を動かしたままにすると、確かめる前に分が進む)。
|
||||
- 修正 1 の headless の診断でボタンが反応しなかった原因(修正 2 の局所の診断で確かめた。正式の証拠ではない):
|
||||
- Main は基準の大きさ 1440×810 から縮めて表示される(窓 1280×720 で倍率 0.889)。修正 1 の診断は `push_input` に local の座標を指定せず(もう1つの形の `Input.parse_input_event` も窓の座標)、押した位置が約 1.125 倍ずれてボタンから外れていた。
|
||||
- 名簿の行は `refresh` で作り直される。そのフレームに足した部品では `ScrollContainer.ensure_control_visible` がスクロールしないので、1フレーム待ってから見える位置へ送る。
|
||||
- 0 時の締めの後、Main は側面の panel を帳簿に切り替え、職員の画面を含む部分を隠す(今の `_on_snapshot_published` の動き)。試験は、プレイヤーと同じく「従業員」のボタンで戻る。
|
||||
- どれも試験の押し方の問題で、画面の部品や配置は変えていない。
|
||||
- これは viewport の入力で、OS の入力ではない。Windows の実機・IME・OS の倍率・同じ候補での実機の受入は、SPEC §10「Windows 実機は後」の後続の条件として、実施しておらず未達のまま残す。
|
||||
|
||||
## 5. 予約の操作 ID(修正 1、A5・F7)
|
||||
|
||||
- 原因: 在庫の予約の操作 ID が、作業と品目だけで決まっていた(`staff.task.reserve.<作業>.<品目>`)。在庫の照合の fingerprint には予約した分が入るので、開き直した作業を別の分に別の職員が取ると、`IDEMPOTENCY_CONFLICT` になって作業が BLOCKED になる(戻す経路は無い)。同じ分なら、閉じた旧い予約の結果が再生される。
|
||||
- 直し(`_reserve_staff_task_items`): 操作 ID に、その作業のその品目のこれまでの予約の数を足す(`staff.task.reserve.<作業>.<品目>.<回数>`)。在庫は予約も照合の記録も消さないので、回数は増える一方で、予約ごとに別の操作になる。分ではなく回数にしたので、同じ分に解雇して取り直しても、旧い予約に当たらない。
|
||||
- 試験(§7):
|
||||
- 元の予約・解雇・別の職員の再予約を同じ分で起こし、別の職員が終えるまで。同じ分の派遣は、StaffService の公開の `dispatch_now` で起こす。
|
||||
- 列で処理される解雇と同じ tick での再予約から、完了まで。
|
||||
- 清掃(`facility_clean`)の作業の開き直しと、別の清掃係による完了。
|
||||
- 既存の試験は、この操作 ID の文字列を見ていない(期待の変更なし)。
|
||||
|
||||
## 6. 見つけた既存の不具合と直し(S `b06fec1` で直したもの、変更なし)
|
||||
|
||||
- 締めの後に税・利息の未払いが残ったゲームの保存が読めなかった(P05-02 からの不具合)。
|
||||
- 保存を読む時の Operations の照合(`_parse_operations_runtime_snapshot`)は、締めた未払いを「給与 + 維持費」だけで数えていた。実行中の照合(`_operations_closed_arrears_total`)が足している税・利息の未払い(`_finance_arrears_through`)を、数えていなかった。
|
||||
- 読む時の照合を、実行中と同じ式にそろえた。照合を緩めたのではない。
|
||||
- 試験: 帳簿の日の流れが、税の未払い 30 を確かめてから締めの後の保存を復元する。同じ process(修正 1 で全ての保存点)と別の process でも読む。
|
||||
|
||||
## 7. 試験
|
||||
|
||||
- 新しい suite `tests/staff/test_general_dismissal.gd`(F1〜F9。§6 の全ての行。共通の部品は `tests/helpers/p16_dismissal_fixture.gd`)。最後の場面がゲームの Main を使うので、suite-policy で ordinary の音の後片付けの証明(`single`)を持つ。修正 1 で足した場面:
|
||||
- 乗ったままの組を含む、救護の全ての段階での運搬者と患者の解雇。preview と結果で、開き直すと取り消すを分けて確かめる。
|
||||
- 通勤の職員が上の階の職場へ向かう時に、昇降機を待つ段と乗っている段で解雇する(A2)。
|
||||
- 予約つきの作業の同じ分の再予約と完了、同じ tick の再予約と完了。清掃の作業の開き直しと完了(A5・F7)。
|
||||
- 帳簿の日の流れの全ての保存点を、同じ process でディスクから読み、最後まで続ける(F8)。
|
||||
- 物流の返送の段の拒否(R2)。
|
||||
- 救護の後始末の失敗の巻き戻し(5.c)。
|
||||
- 列で待つ命令の処理を 5.c の増分で比べる(A9)。
|
||||
- その命令は、Runtime 自身の命令の段(`_process_commands`)で、tick の他の処理なしに処理する。
|
||||
- Main の画面で、拒否の行の全て(R1・R2・R3・R5・R7・R8・R9)を確かめる。ボタンが無効で、理由が出て、無理に押しても snapshot の全体の bytes が同じ。
|
||||
- 保持した未払い(本物の 0 時の締めで資金が足りず、日給が未払いになる)も、押す前と結果で確かめる。
|
||||
- 押した後の拒否の理由も確かめる。
|
||||
- 画面の比べは全て `var_to_bytes`。
|
||||
- 修正 2 で変えた・足した場面:
|
||||
- Main の画面の場面を、viewport の入力で操作する形に置き換えた(§4)。`Button.pressed.emit()` と `_unhandled_key_input` の直接の呼び出しは、この suite から無くした。
|
||||
- Main の画面で、裏金からの補填(表金 G100・退職金 G300・補填 G200)の押す前と結果の文を確かめる。資金の fixture は、解雇の後に元の残高に戻る形にした。
|
||||
- Main の画面で、救護室へ歩いて運ぶ搬送の段に、患者と運搬者の押す前の文と、運搬者の結果の文(取り消した救護)を確かめる。
|
||||
- 巻き戻しの枝(`_test_rollback_branches`): `remove_staff` の予約の解放の失敗、乗ったままの組の座席の解放の失敗、乗ったままの組で座席を解いた後の退職金の記帳の失敗。どれも 5.c と 5.d の比べ方で、組が乗ったままであることも確かめる。
|
||||
- 再開の2段 `tests/staff/test_general_dismissal_restart_1_write.gd`(唯一の生成ファイルは `artifacts/p16-01-dismiss/restart-handoff.json`)と `..._2_read.gd`。
|
||||
- 帳簿の6つの保存点を読む。
|
||||
- 次の命令の境目の保存を読む。修正 1 で、A6 の迎え・A6 と A7 の乗ったままの組・A8 の CONTINUE_WITHOUT を足した。
|
||||
- A6: 搬送を徒歩で運ぶ段・迎えの段・組で乗っている段
|
||||
- A7: 運搬者が1人で乗っている段・組で乗っている段
|
||||
- A8: CANCEL・CONTINUE_WITHOUT
|
||||
- 局所の確かめで分かった、既存の規則による制約(製品の不具合ではない。試験の組み立てを直した):
|
||||
- 保持した未払いの給与は未払いの費用として数えられ、建設と雇用を止める(`UNPAID_EXPENSES`)。
|
||||
- 締めを通らない未払いを持たせた職員の登録は、運営の照合(`OPERATIONS_CROSSLINK_MISMATCH`)を止める。そのため画面の試験は、本物の締めで未払いを作る。
|
||||
- validation `p16-dismiss`: `tools/run_p16_dismiss_tests.py`。`tools/run_p07_weather_tests.py` から、名前・名簿・再開の段・handoff・protocol 名だけを明示の置き換えで変えた(段の手順・厳密な照合・ordinary と old_bounded の分類は同じ)。実 12 段(新しい suite 1 + 既存 9 + 再開 2)と import・parse。
|
||||
- suite-policy と marker contract に新しい3本の行を足し、今の source の SHA-256 で固定した。
|
||||
- 既存の試験の期待値の変更は `EXPECTATION_CHANGES.json`(kitchen の旧セーブの写しに退職金の口座を足す1件)。
|
||||
|
||||
## 8. 範囲の外として残すもの(主張しない)
|
||||
|
||||
- まだ始まっていない(待ち行列の)出来事の actor の ID は、拘束を持たない。始まる時に、今の規則(`start_next_event` の欠けた actor の扱い)で扱われる。命令の中では変えない。
|
||||
- 進行中の襲撃の防衛者の解雇(R9)は、原本の要件として未達のまま(SPEC §6.3・§8)。
|
||||
- 解雇した職員が歩いて出る描写は作らない(SPEC §10)。UI は今の部品のまま。Windows の実機・IME・OS の倍率・同じ候補の実機の受入は後。
|
||||
- 原単位 P16-01・P16-03 の残りは未達のまま(SPEC §8): 求人募集、奴隷市場・破産客からの獲得、人身売買、勤務・休息・気絶・救護の横断、解雇の経路の外の退職時の参照の除去。
|
||||
@@ -32,6 +32,13 @@ const LOAN_INTEREST_CATEGORY: StringName = &"LOAN_INTEREST"
|
||||
const LOAN_INTEREST_PAYMENT_CATEGORY: StringName = &"LOAN_INTEREST_PAYMENT"
|
||||
const NON_PROFIT_CATEGORIES: Array[StringName] = [TOP_UP_CATEGORY, RANK_TAX_PAYMENT_CATEGORY, LOAN_INTEREST_PAYMENT_CATEGORY]
|
||||
|
||||
## A14 (2026-10-23): dismissing a general employee pays three days of the effective daily
|
||||
## wage, from the front fund with the hidden fund covering the rest. It is booked to its
|
||||
## own expense account, apart from WAGE, and counted with the operating costs.
|
||||
const SEVERANCE_EXPENSE: StringName = &"STAFF_SEVERANCE_EXPENSE"
|
||||
const SEVERANCE_CATEGORY: StringName = &"STAFF_SEVERANCE"
|
||||
const SEVERANCE_DAYS := 3
|
||||
|
||||
|
||||
static func rank_tax(sales: int) -> int:
|
||||
return maxi(sales, 0) / 100 * RANK_TAX_PERCENT + maxi(sales, 0) % 100 * RANK_TAX_PERCENT / 100
|
||||
@@ -42,6 +49,10 @@ static func loan_interest(outstanding_principal: int) -> int:
|
||||
+ maxi(outstanding_principal, 0) % 100 * LOAN_INTEREST_PERCENT_PER_DAY / 100
|
||||
|
||||
|
||||
static func severance(daily_wage: int) -> int:
|
||||
return maxi(daily_wage, 0) * SEVERANCE_DAYS
|
||||
|
||||
|
||||
static func is_back_fund_service(tags: Array) -> bool:
|
||||
for tag: StringName in BACK_FUND_TAGS:
|
||||
if tag in tags:
|
||||
@@ -66,12 +77,20 @@ static func prepare_accounts(economy: EconomyService) -> bool:
|
||||
for account_id: StringName in expected:
|
||||
if not economy.register_account(account_id, bool(expected[account_id])):
|
||||
return false
|
||||
return true
|
||||
return _prepare_severance_account(economy)
|
||||
for account_id: StringName in expected:
|
||||
if not economy.has_account(account_id) \
|
||||
or economy.account_allows_negative(account_id) != bool(expected[account_id]):
|
||||
return false
|
||||
return true
|
||||
return _prepare_severance_account(economy)
|
||||
|
||||
|
||||
## A save from before the severance gains its empty expense account the same way; the
|
||||
## balances, the other accounts and the history are unchanged.
|
||||
static func _prepare_severance_account(economy: EconomyService) -> bool:
|
||||
if economy.has_account(SEVERANCE_EXPENSE):
|
||||
return not economy.account_allows_negative(SEVERANCE_EXPENSE)
|
||||
return economy.register_account(SEVERANCE_EXPENSE, false)
|
||||
|
||||
|
||||
static func outstanding_principal(economy: EconomyService) -> int:
|
||||
|
||||
@@ -9,12 +9,17 @@ signal status_requested(message: String, success: bool)
|
||||
|
||||
const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment"
|
||||
const KEY_PREFIX := "ui.staff_assignment."
|
||||
const DISMISSAL_COMMAND: StringName = &"dismiss_staff"
|
||||
const DISMISSAL_KEY_PREFIX := "ui.staff_dismissal."
|
||||
const ROLE_NAMES := {
|
||||
&"attendant": "接客", &"bartender": "酒場", &"clerk": "会計",
|
||||
&"cleaner": "清掃", &"cook": "調理", &"guard": "警備",
|
||||
&"maintenance": "保守", &"masseur": "施術", &"medic": "医療",
|
||||
&"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者",
|
||||
}
|
||||
const TASK_NAMES := {
|
||||
&"facility_clean": "清掃の作業", &"rescue_pickup": "救護の迎え", &"rescue_transport": "救護の搬送",
|
||||
}
|
||||
const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"]
|
||||
|
||||
var summary_label: Label
|
||||
@@ -25,6 +30,11 @@ var staff_detail_label: Label
|
||||
var facility_option: OptionButton
|
||||
var shift_option: OptionButton
|
||||
var apply_button: Button
|
||||
var dismiss_button: Button
|
||||
var dismiss_confirm_box: VBoxContainer
|
||||
var dismiss_confirm_label: Label
|
||||
var dismiss_confirm_button: Button
|
||||
var dismiss_cancel_button: Button
|
||||
var result_label: Label
|
||||
var selected_staff_id: StringName = &""
|
||||
|
||||
@@ -37,7 +47,10 @@ var _draft_facility_id: StringName = &""
|
||||
var _draft_template_id: StringName = &"DAY"
|
||||
var _draft_dirty := false
|
||||
var _sequence := 1
|
||||
var _dismissal_sequence := 1
|
||||
var _pending_commands: Dictionary = {}
|
||||
var _pending_dismissals: Dictionary = {}
|
||||
var _confirming_dismissal_id: StringName = &""
|
||||
var _queue_instance_id := 0
|
||||
var _built := false
|
||||
|
||||
@@ -112,6 +125,42 @@ func _build_interface() -> void:
|
||||
apply_button.pressed.connect(submit_assignment)
|
||||
_style(apply_button)
|
||||
editor_panel.add_child(apply_button)
|
||||
# P16-01-DISMISS: the dismissal shows its severance and reopened Task before the
|
||||
# confirming press; the Runtime command decides and records everything.
|
||||
dismiss_button = Button.new()
|
||||
dismiss_button.name = "DismissStaffButton"
|
||||
dismiss_button.text = "解雇する…"
|
||||
dismiss_button.custom_minimum_size.y = 32
|
||||
dismiss_button.pressed.connect(request_dismissal)
|
||||
_style(dismiss_button)
|
||||
editor_panel.add_child(dismiss_button)
|
||||
dismiss_confirm_box = VBoxContainer.new()
|
||||
dismiss_confirm_box.name = "DismissStaffConfirm"
|
||||
dismiss_confirm_box.add_theme_constant_override("separation", 3)
|
||||
dismiss_confirm_box.visible = false
|
||||
editor_panel.add_child(dismiss_confirm_box)
|
||||
dismiss_confirm_label = _label("")
|
||||
dismiss_confirm_label.name = "DismissStaffConfirmText"
|
||||
dismiss_confirm_box.add_child(dismiss_confirm_label)
|
||||
var confirm_row := HBoxContainer.new()
|
||||
confirm_row.add_theme_constant_override("separation", 5)
|
||||
dismiss_confirm_box.add_child(confirm_row)
|
||||
dismiss_confirm_button = Button.new()
|
||||
dismiss_confirm_button.name = "ConfirmDismissStaff"
|
||||
dismiss_confirm_button.text = "解雇を確定"
|
||||
dismiss_confirm_button.custom_minimum_size.y = 30
|
||||
dismiss_confirm_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
dismiss_confirm_button.pressed.connect(confirm_dismissal)
|
||||
_style(dismiss_confirm_button)
|
||||
confirm_row.add_child(dismiss_confirm_button)
|
||||
dismiss_cancel_button = Button.new()
|
||||
dismiss_cancel_button.name = "CancelDismissStaff"
|
||||
dismiss_cancel_button.text = "やめる"
|
||||
dismiss_cancel_button.custom_minimum_size.y = 30
|
||||
dismiss_cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
dismiss_cancel_button.pressed.connect(cancel_dismissal)
|
||||
_style(dismiss_cancel_button)
|
||||
confirm_row.add_child(dismiss_cancel_button)
|
||||
result_label = _label("")
|
||||
result_label.name = "StaffAssignmentResult"
|
||||
add_child(result_label)
|
||||
@@ -124,6 +173,7 @@ func refresh() -> void:
|
||||
if queue != null and int(queue.get_instance_id()) != _queue_instance_id:
|
||||
_queue_instance_id = int(queue.get_instance_id())
|
||||
_draft_dirty = false
|
||||
_confirming_dismissal_id = &""
|
||||
facility_option.get_popup().hide()
|
||||
shift_option.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
@@ -178,6 +228,7 @@ func refresh() -> void:
|
||||
func clear_selection() -> void:
|
||||
selected_staff_id = &""
|
||||
_draft_dirty = false
|
||||
_confirming_dismissal_id = &""
|
||||
if not _built:
|
||||
return
|
||||
result_label.text = ""
|
||||
@@ -239,16 +290,164 @@ func _refresh_editor() -> void:
|
||||
facility_option.select(selected_index)
|
||||
shift_option.select(1 if _draft_template_id == &"NIGHT" else 0)
|
||||
var pending := _pending_commands.values().has(selected_staff_id)
|
||||
facility_option.disabled = pending
|
||||
shift_option.disabled = pending
|
||||
var dismissing := _pending_dismissals.values().has(selected_staff_id)
|
||||
facility_option.disabled = pending or dismissing
|
||||
shift_option.disabled = pending or dismissing
|
||||
_refresh_dismissal(row, dismissing)
|
||||
var can_edit := bool(row.get(&"can_edit", true))
|
||||
apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index)
|
||||
apply_button.disabled = pending or dismissing or not can_edit or facility_option.is_item_disabled(selected_index)
|
||||
apply_button.tooltip_text = _assignment_block_text(row) if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
|
||||
if not can_edit:
|
||||
staff_detail_label.text += "\n" + _assignment_block_text(row)
|
||||
apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更"
|
||||
|
||||
|
||||
## P16-01-DISMISS: the Runtime's preview decides whether the dismissal is offered;
|
||||
## a refused row keeps the button disabled and shows the reason.
|
||||
func _refresh_dismissal(row: Dictionary, dismissing: bool) -> void:
|
||||
var preview: Dictionary = row.get(&"dismissal", {})
|
||||
var allowed := bool(preview.get(&"can_dismiss", false))
|
||||
dismiss_button.disabled = dismissing or not allowed
|
||||
dismiss_button.text = "解雇を受理済み…" if dismissing else "解雇する…"
|
||||
if allowed:
|
||||
dismiss_button.tooltip_text = _dismissal_summary(row, preview)
|
||||
else:
|
||||
var reason := StringName(preview.get(&"reason", &""))
|
||||
var reason_text := _dismissal_reason_text(reason, preview)
|
||||
dismiss_button.tooltip_text = reason_text
|
||||
if not dismissing and not reason_text.is_empty() and not staff_detail_label.text.contains(reason_text):
|
||||
# UNIQUE keeps its existing contract line; the other reasons are labelled.
|
||||
var line := reason_text if reason == &"UNIQUE" else "解雇不可:" + reason_text
|
||||
staff_detail_label.text += "\n" + line
|
||||
if _confirming_dismissal_id != selected_staff_id or dismissing or not allowed:
|
||||
_confirming_dismissal_id = &""
|
||||
dismiss_confirm_box.visible = not _confirming_dismissal_id.is_empty()
|
||||
if dismiss_confirm_box.visible:
|
||||
dismiss_confirm_label.text = _dismissal_summary(row, preview)
|
||||
|
||||
|
||||
func request_dismissal() -> void:
|
||||
if _runtime == null or selected_staff_id.is_empty():
|
||||
return
|
||||
refresh()
|
||||
var row := _selected_row()
|
||||
var preview: Dictionary = row.get(&"dismissal", {})
|
||||
if row.is_empty() or not bool(preview.get(&"can_dismiss", false)) or _pending_dismissals.values().has(selected_staff_id):
|
||||
var reason_text := "この従業員の解雇はすでに受理されています。" if _pending_dismissals.values().has(selected_staff_id) \
|
||||
else _dismissal_reason_text(StringName(preview.get(&"reason", &"")), preview)
|
||||
_set_result(reason_text if not reason_text.is_empty() else "この従業員は解雇できません。", false)
|
||||
return
|
||||
_confirming_dismissal_id = selected_staff_id
|
||||
refresh()
|
||||
|
||||
|
||||
func cancel_dismissal() -> void:
|
||||
_confirming_dismissal_id = &""
|
||||
refresh()
|
||||
|
||||
|
||||
func confirm_dismissal() -> void:
|
||||
if _runtime == null or selected_staff_id.is_empty():
|
||||
return
|
||||
var armed := _confirming_dismissal_id
|
||||
# Re-read the roster first: a load or a changed employee never sends a stale request.
|
||||
refresh()
|
||||
var row := _selected_row()
|
||||
var preview: Dictionary = row.get(&"dismissal", {})
|
||||
var employee_name := String(row.get(&"display_name", selected_staff_id))
|
||||
if not armed.is_empty() and armed == selected_staff_id and not row.is_empty() \
|
||||
and not bool(preview.get(&"can_dismiss", false)):
|
||||
_confirming_dismissal_id = &""
|
||||
_set_result("%s:%s" % [employee_name, _dismissal_reason_text(StringName(preview.get(&"reason", &"")), preview)], false)
|
||||
refresh()
|
||||
return
|
||||
if armed.is_empty() or armed != selected_staff_id or _confirming_dismissal_id != armed:
|
||||
_set_result("解雇の確認は取り消されました。もう一度「解雇する…」を押してください。", false)
|
||||
return
|
||||
var staff_id := selected_staff_id
|
||||
_confirming_dismissal_id = &""
|
||||
var key := StringName("%s%08d" % [DISMISSAL_KEY_PREFIX, _dismissal_sequence])
|
||||
_dismissal_sequence += 1
|
||||
var result: Variant = _submit_ui_command(DISMISSAL_COMMAND, {&"staff_id": staff_id}, &"player", staff_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("解雇に応答がありません。", false)
|
||||
refresh()
|
||||
return
|
||||
var pending := false
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if queue != null:
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
if int(command.get("command_id", 0)) == int(result.command_id):
|
||||
pending = true
|
||||
break
|
||||
if result.is_success() and pending:
|
||||
_pending_dismissals[int(result.command_id)] = staff_id
|
||||
_set_result("%s の解雇を受理しました。次の時間更新で反映します。" % employee_name, true)
|
||||
else:
|
||||
_show_dismissal_result(result, employee_name)
|
||||
refresh()
|
||||
|
||||
|
||||
func _show_dismissal_result(result: Variant, employee_name: String) -> void:
|
||||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||||
if not result.is_success():
|
||||
var translated := _dismissal_reason_text(StringName(details.get(&"reason", details.get("reason", &""))), details)
|
||||
_set_result("%s:%s" % [employee_name, translated if not translated.is_empty() else "解雇できませんでした:%s" % String(result.message)], false)
|
||||
return
|
||||
var text := "%s を解雇しました。退職金 G%d を払いました" % [employee_name, int(details.get(&"severance", 0))]
|
||||
if int(details.get(&"severance_top_up", 0)) > 0:
|
||||
text += "(不足 G%d は裏金から補填)" % int(details[&"severance_top_up"])
|
||||
text += "。"
|
||||
if int(details.get(&"unpaid_wages_preserved", 0)) > 0:
|
||||
text += "未払い G%d は次の0時に精算します。" % int(details[&"unpaid_wages_preserved"])
|
||||
if not (details.get(&"reopened_task_ids", []) as Array).is_empty():
|
||||
text += "持っていた作業は開き直しました。"
|
||||
if not (details.get(&"interrupted_rescue_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)
|
||||
# A rescue the command interrupts is cancelled, not reopened (SPEC 6.2.1).
|
||||
var roles: Array = preview.get(&"interrupted_rescue_roles", [])
|
||||
if roles.has(&"carrier"):
|
||||
text += "運んでいる救護の搬送は取り消し、患者の救護をやり直します。"
|
||||
if roles.has(&"patient"):
|
||||
text += "この従業員への救護は取り消し、救護係を外します。"
|
||||
return text
|
||||
|
||||
|
||||
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": "襲撃の防衛に参加中は解雇できません。襲撃が終わってから解雇できます。",
|
||||
&"RESERVATION_RELEASE_FAILED": "作業の予約を解けないため解雇できません。",
|
||||
&"BANKRUPT": "運営終了後は解雇できません。",
|
||||
}
|
||||
return String(reasons.get(reason, ""))
|
||||
|
||||
|
||||
## Runtime supplies canonical, owned presentation data after every normal
|
||||
## load. The panel neither infers a unique identity from wages nor edits a save.
|
||||
func _unique_contract_text(row: Dictionary) -> String:
|
||||
@@ -373,15 +572,20 @@ func _assignment_reason_text(reason: StringName) -> String:
|
||||
|
||||
func _synchronize_commands(queue: Variant) -> void:
|
||||
_pending_commands.clear()
|
||||
_pending_dismissals.clear()
|
||||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||||
var key := String(metadata.get("idempotency_key", ""))
|
||||
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||||
if key.begins_with(DISMISSAL_KEY_PREFIX) and key.trim_prefix(DISMISSAL_KEY_PREFIX).is_valid_int():
|
||||
_dismissal_sequence = maxi(_dismissal_sequence, int(key.trim_prefix(DISMISSAL_KEY_PREFIX)) + 1)
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
if StringName(command.get("command_type", &"")) != ASSIGNMENT_COMMAND:
|
||||
var command_type := StringName(command.get("command_type", &""))
|
||||
if command_type not in [ASSIGNMENT_COMMAND, DISMISSAL_COMMAND]:
|
||||
continue
|
||||
var payload: Dictionary = command.get("payload", {})
|
||||
_pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &"")))
|
||||
var pending := _pending_commands if command_type == ASSIGNMENT_COMMAND else _pending_dismissals
|
||||
pending[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &"")))
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
|
||||
@@ -390,6 +594,17 @@ func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") ->
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if _pending_dismissals.has(int(result.command_id)):
|
||||
var dismissed_id := StringName(_pending_dismissals[int(result.command_id)])
|
||||
_pending_dismissals.erase(int(result.command_id))
|
||||
var employee_name := String(dismissed_id)
|
||||
for row: Dictionary in _staff_rows:
|
||||
if StringName(row.get(&"staff_id", &"")) == dismissed_id:
|
||||
employee_name = String(row.get(&"display_name", dismissed_id))
|
||||
break
|
||||
_show_dismissal_result(result, employee_name)
|
||||
refresh()
|
||||
return
|
||||
if not _pending_commands.has(int(result.command_id)):
|
||||
return
|
||||
var staff_id := StringName(_pending_commands[int(result.command_id)])
|
||||
@@ -478,8 +693,9 @@ func _style(button: Button) -> void:
|
||||
func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant:
|
||||
if selected_staff_id != staff_id:
|
||||
select_staff(staff_id)
|
||||
if _pending_commands.values().has(staff_id) or not bool(_selected_row().get(&"can_edit", false)):
|
||||
_set_result("変更を処理中です。" if _pending_commands.values().has(staff_id) else _assignment_block_text(_selected_row()), false)
|
||||
var busy := _pending_commands.values().has(staff_id) or _pending_dismissals.values().has(staff_id)
|
||||
if busy or not bool(_selected_row().get(&"can_edit", false)):
|
||||
_set_result("変更を処理中です。" if busy else _assignment_block_text(_selected_row()), false)
|
||||
return null
|
||||
var preview := Label.new()
|
||||
preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"]
|
||||
|
||||
@@ -302,16 +302,24 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
|
||||
fields.append("elevators_v1_to_v2")
|
||||
proof["elevators"] = {"only_version_changed": same_otherwise}
|
||||
# P05-02 A12: a save from before the funds were split gains the five empty fund
|
||||
# accounts on load; every other economy field stays byte-identical.
|
||||
var has_back_cash := func(economy: Variant) -> bool:
|
||||
# accounts on load, and P16-01-DISMISS (A14) a save from before the severance gains
|
||||
# its empty expense account; every other economy field stays byte-identical.
|
||||
var has_account := func(economy: Variant, account_id: String) -> bool:
|
||||
if not economy is Dictionary:
|
||||
return false
|
||||
for account: Variant in (economy as Dictionary).get(&"accounts", []):
|
||||
if account is Dictionary and String((account as Dictionary).get(&"account_id", "")) == "BACK_CASH":
|
||||
if account is Dictionary and String((account as Dictionary).get(&"account_id", "")) == account_id:
|
||||
return true
|
||||
return false
|
||||
if not has_back_cash.call(old.get(&"economy")) and has_back_cash.call(now.get(&"economy")):
|
||||
var added := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true}
|
||||
var fund_accounts := {"BACK_CASH": false, "LOAN_INTEREST_EXPENSE": false, "LOAN_INTEREST_PAYABLE": true, "RANK_TAX_EXPENSE": false, "RANK_TAX_PAYABLE": true}
|
||||
var added := {}
|
||||
if not has_account.call(old.get(&"economy"), "BACK_CASH") and has_account.call(now.get(&"economy"), "BACK_CASH"):
|
||||
added.merge(fund_accounts)
|
||||
var severance_added: bool = not has_account.call(old.get(&"economy"), "STAFF_SEVERANCE_EXPENSE") \
|
||||
and has_account.call(now.get(&"economy"), "STAFF_SEVERANCE_EXPENSE")
|
||||
if severance_added:
|
||||
added["STAFF_SEVERANCE_EXPENSE"] = false
|
||||
if not added.is_empty():
|
||||
var economy: Dictionary = (old[&"economy"] as Dictionary).duplicate(true)
|
||||
var accounts: Array = (economy[&"accounts"] as Array).duplicate(true)
|
||||
for account_id: String in added:
|
||||
@@ -321,8 +329,12 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D
|
||||
var only_accounts_added: bool = var_to_bytes(economy) == var_to_bytes(now[&"economy"])
|
||||
ok = ok and only_accounts_added
|
||||
old[&"economy"] = economy
|
||||
fields.append("economy_A12_empty_fund_accounts")
|
||||
proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added}
|
||||
if added.has("BACK_CASH"):
|
||||
fields.append("economy_A12_empty_fund_accounts")
|
||||
proof["economy_A12"] = {"added_accounts": fund_accounts.keys(), "only_accounts_added": only_accounts_added}
|
||||
if severance_added:
|
||||
fields.append("economy_A14_empty_severance_account")
|
||||
proof["economy_A14"] = {"added_accounts": ["STAFF_SEVERANCE_EXPENSE"], "only_accounts_added": only_accounts_added}
|
||||
# P07-01 A13 (SPEC section 5.2): a save from before natural weather keeps its
|
||||
# weather and pending schedule, counts every day whose 06:00 has passed as
|
||||
# drawn (no past draw), holds no rain extension, and starts the weather
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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
|
||||
|
||||
|
||||
## A foreground Event of two employees whose missing actor policy continues without
|
||||
## the missing one (CONTINUE_WITHOUT).
|
||||
func new_event_pair_fixture(seed: int) -> Dictionary:
|
||||
var fixture := new_fixture(seed)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
for staff_id: StringName in [&"event_lead", &"event_partner"]:
|
||||
check(command(fixture, &"register_staff", {&"staff_id": staff_id, &"role_id": &"service", &"options": {&"daily_wage": 80}}).is_success(), "register %s" % staff_id)
|
||||
check(command(fixture, &"register_event_definition", {&"definition": {&"event_id": &"p16.event.pair", &"category": &"RELATIONSHIP", &"priority": 70,
|
||||
&"foreground": true, &"transaction_policy": &"ROLLBACK_ALL", &"missing_actor_policy": &"CONTINUE_WITHOUT", &"content_rating": &"GENERAL",
|
||||
&"choices": [{&"choice_id": &"wait", &"text_key": &"event.p16.pair.wait", &"actions": []}]}}).is_success()
|
||||
and command(fixture, &"enqueue_event", {&"event_id": &"p16.event.pair", &"actor_ids": [&"event_lead", &"event_partner"],
|
||||
&"options": {&"event_instance_id": &"p16.event.pair.instance"}}).is_success(), "a two-actor Event that continues without a missing actor")
|
||||
fixture[&"clock"].set_paused(false)
|
||||
fixture[&"clock"].advance_one_minute()
|
||||
fixture[&"clock"].set_paused(true)
|
||||
check(runtime.event_service.get_actor_locks().has(&"event_partner"), "the Event locks both employees")
|
||||
return fixture
|
||||
|
||||
|
||||
## Live references to a removed employee (SPEC 6.2/6.2.1): live Tasks, elevator
|
||||
## trips, the path actor, facility staffing, Event locks and running Event actors.
|
||||
## Terminal Task records keep their history.
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://dbf85ttljo65h
|
||||
@@ -0,0 +1,121 @@
|
||||
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"],
|
||||
["A6-pickup", "rescue_pickup/MOVING/-/single", &"medic"], ["A6-pair-aboard", "rescue_transport/MOVING/BOARDED/pair", &"medic"],
|
||||
["A7-pair-aboard", "rescue_transport/MOVING/BOARDED/pair", &"patient"]]:
|
||||
var fixture := _scenes.new_rescue_fixture()
|
||||
_expect(_scenes.advance_to_rescue_phase(fixture, scene[1]), "%s: the rescue reaches %s" % [scene[0], scene[1]])
|
||||
boundaries.append(_boundary(fixture, scene[0], scene[2]))
|
||||
_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 pair_fixture := _scenes.new_event_pair_fixture(16_101)
|
||||
boundaries.append(_boundary(pair_fixture, "A8-continue-without", &"event_partner"))
|
||||
_scenes.free_fixture(pair_fixture)
|
||||
var handoff := {
|
||||
"schema": Scenes.HANDOFF_SCHEMA,
|
||||
"writer_pid": OS.get_process_id(),
|
||||
"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", "A6-pickup", "A6-pair-aboard", "A7-pair-aboard", "A8", "A8-continue-without"], "the writer saved the A6, A7 and A8 command boundaries")
|
||||
for boundary: Variant in boundaries:
|
||||
if not boundary is Dictionary:
|
||||
continue
|
||||
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": [],
|
||||
"old_bounded_teardown": false,
|
||||
"new_audio_QA": false
|
||||
},
|
||||
"tests/staff/test_general_dismissal.gd": {
|
||||
"source_sha256": "f1f77bfd6320a3c8e0851915b150e95fc40f4a352876358393572a6d3f7c79ce",
|
||||
"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": "b38745eccb4e51ddf4790051795e2f64faf10f875edc7b754ac2d04db2917a5b",
|
||||
"ordinary_cleanup_prepared": false,
|
||||
"ordinary_control_labels": [],
|
||||
"old_bounded_teardown": false,
|
||||
"new_audio_QA": false
|
||||
},
|
||||
"tests/staff/test_general_dismissal_restart_2_read.gd": {
|
||||
"source_sha256": "4e13de2594096fab648fc8c418a1a4aa764ac72fc13fbd68cba1c6f05767fc83",
|
||||
"ordinary_cleanup_prepared": false,
|
||||
"ordinary_control_labels": [],
|
||||
"old_bounded_teardown": false,
|
||||
"new_audio_QA": false
|
||||
}
|
||||
},
|
||||
"outside_routes": {
|
||||
@@ -503,6 +524,9 @@
|
||||
"artifacts/upkeep-2026-09-29/preclose-snapshot.txt",
|
||||
"artifacts/upkeep-2026-09-29/closed-snapshot.txt"
|
||||
],
|
||||
"tests/staff/test_general_dismissal_restart_1_write.gd": [
|
||||
"artifacts/p16-01-dismiss/restart-handoff.json"
|
||||
],
|
||||
"tests/transport/test_runtime_transport_comparison.gd": [
|
||||
"artifacts/transport/runtime-comparison.json",
|
||||
"artifacts/transport/runtime-comparison-1-trace.json",
|
||||
|
||||
@@ -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