304 lines
19 KiB
GDScript
304 lines
19 KiB
GDScript
extends SceneTree
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
var _clock: Variant
|
|
var _state: Variant
|
|
var _runtime: Variant
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("P1: Staff reassignment, repeating shifts, and save compatibility")
|
|
_test_service_shift_history_and_recurrence()
|
|
_test_night_boundary_and_unassignment()
|
|
_test_service_snapshot_guards()
|
|
_test_runtime_assignment_boundary()
|
|
_cleanup()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d / %d checks" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _worker(service: StaffService, id: StringName = &"worker") -> StaffRuntime:
|
|
_expect(service.create_staff(id, &"receptionist", {&"daily_wage": 120}).is_success(), "test employee registers")
|
|
return service.get_staff(id)
|
|
|
|
|
|
func _test_service_shift_history_and_recurrence() -> void:
|
|
var service := StaffService.new()
|
|
var worker := _worker(service)
|
|
service.advance_one_minute(360)
|
|
_expect(service.set_work_assignment(worker.staff_id, &"west", &"DAY").is_success(), "first assignment starts a recurring DAY shift")
|
|
var first_id := worker.shift_id
|
|
for minute: int in range(361, 600):
|
|
service.advance_one_minute(minute)
|
|
service.advance_one_minute(600)
|
|
var total_before := service.get_shift(first_id).worked_minutes
|
|
var result := service.set_work_assignment(worker.staff_id, &"east", &"DAY")
|
|
_expect(result.is_success() and int(result.details[&"effective_from"]) == 600, "daytime reassignment takes effect now")
|
|
var first := service.get_shift(first_id)
|
|
var second := service.get_shift(worker.shift_id)
|
|
_expect(first.assigned_facility_id == &"west" and first.start_at == 360 and first.end_at == 600 and first.actual_ended_at == 600, "elapsed work retains its original facility and exact half-open interval")
|
|
_expect(second.assigned_facility_id == &"east" and second.start_at == 600 and second.end_at == 1080, "replacement starts exactly at the split boundary")
|
|
_expect(first.worked_minutes + second.worked_minutes == total_before, "reassignment neither duplicates nor loses the current duty minute")
|
|
_expect(worker.current_cell == Vector3i.ZERO, "assignment never teleports the employee")
|
|
var stable := service.create_snapshot()
|
|
result = service.set_work_assignment(worker.staff_id, &"east", &"DAY")
|
|
_expect(result.details[&"reason"] == &"ASSIGNMENT_UNCHANGED" and service.create_snapshot() == stable, "same plan replay is an exact no-op")
|
|
for minute: int in range(601, 1081):
|
|
service.advance_one_minute(minute)
|
|
_expect(worker.state == StaffRuntime.State.OFF_DUTY and worker.assigned_facility_id.is_empty(), "DAY ends at 18:00 and clears active facility")
|
|
_expect(first.worked_minutes + second.worked_minutes == 720, "all 720 daytime minutes remain attributed exactly once")
|
|
var historical := first.create_snapshot()
|
|
for minute: int in range(1081, 3241):
|
|
service.advance_one_minute(minute)
|
|
_expect(worker.state == StaffRuntime.State.AVAILABLE and worker.assigned_facility_id == &"east", "the same assignment works on the third day without another command")
|
|
_expect(service.get_shift(first_id).create_snapshot() == historical, "later daily shifts leave historical work unchanged")
|
|
_expect(service.validate_invariants()[&"ok"], "repeating shifts preserve the Staff graph")
|
|
var allocation := _wage_allocation(service, 0, 1440)
|
|
_expect(int(allocation[&"allocated_total"]) == 120 and int(allocation[&"unallocated_total"]) == 0, "split daily wages are allocated once with no lost amount")
|
|
_expect(allocation[&"allocated_by_facility"] == {&"west": 40, &"east": 80}, "historical wages follow the 240/480 minute split after later days run")
|
|
|
|
|
|
func _test_night_boundary_and_unassignment() -> void:
|
|
var service := StaffService.new()
|
|
var worker := _worker(service)
|
|
service.advance_one_minute(1410)
|
|
_expect(service.set_work_assignment(worker.staff_id, &"west", &"NIGHT").is_success(), "late-night assignment begins immediately")
|
|
var first_id := worker.shift_id
|
|
for minute: int in range(1411, 1451):
|
|
service.advance_one_minute(minute)
|
|
_expect(service.set_work_assignment(worker.staff_id, &"east", &"NIGHT").is_success(), "NIGHT reassignment works after midnight")
|
|
var first := service.get_shift(first_id)
|
|
var next := service.get_shift(worker.shift_id)
|
|
_expect(first.start_at == 1410 and first.end_at == 1450 and next.start_at == 1450 and next.end_at == 1800, "midnight split keeps one exact historical interval and the remaining night")
|
|
_expect(first.worked_minutes + next.worked_minutes == 41, "cross-midnight reassignment preserves recorded duty total")
|
|
_expect(_wage_allocation(service, 0, 1440)[&"allocated_by_facility"] == {&"west": 120}, "reassignment after midnight does not move the prior day's wage expense")
|
|
var restored := StaffService.new()
|
|
_expect(restored.restore_snapshot(service.create_snapshot()), "a split NIGHT snapshot restores")
|
|
for minute: int in range(1451, 2521):
|
|
service.advance_one_minute(minute)
|
|
restored.advance_one_minute(minute)
|
|
_expect(service.create_snapshot() == restored.create_snapshot(), "save replay reaches the next NIGHT deterministically")
|
|
_expect(worker.state == StaffRuntime.State.AVAILABLE and worker.assigned_facility_id == &"east", "next evening starts a new NIGHT automatically")
|
|
var day_result := service.set_work_assignment(worker.staff_id, &"east", &"DAY")
|
|
_expect(int(day_result.details[&"effective_from"]) == 3240 and worker.state == StaffRuntime.State.OFF_DUTY, "changing night to DAY waits for the next 06:00")
|
|
_expect(service.set_work_assignment(worker.staff_id, &"", &"DAY").is_success(), "empty facility explicitly unassigns")
|
|
service.advance_one_minute(4680)
|
|
_expect(worker.assigned_facility_id.is_empty() and worker.shift_id.is_empty(), "unassigned Staff never return to an old future shift")
|
|
_expect(service.get_daily_payroll_total() == 120, "unassigning does not silently dismiss Staff or remove their wage")
|
|
_expect(service.remove_staff(worker.staff_id).is_success() and service.get_work_assignments().is_empty(), "staff removal cleans the recurring plan")
|
|
|
|
|
|
func _wage_allocation(service: StaffService, start: int, end: int) -> Dictionary:
|
|
var allocator = load("res://autoload/simulation_runtime.gd").new()
|
|
allocator.staff_service = service
|
|
for id: StringName in [&"west", &"east"]:
|
|
allocator.facility_service.create_instance({&"instance_id": id, &"definition_id": &"reception"})
|
|
var result: Dictionary = allocator._allocate_operations_wages_to_facilities(start, end, 120, [{&"staff_id": &"worker", &"new_wages": 120}], {})
|
|
allocator.free()
|
|
return result
|
|
|
|
|
|
func _test_service_snapshot_guards() -> void:
|
|
var service := StaffService.new()
|
|
_worker(service)
|
|
service.advance_one_minute(400)
|
|
service.set_work_assignment(&"worker", &"west", &"DAY")
|
|
var current := service.create_snapshot()
|
|
var restored := StaffService.new()
|
|
_expect(restored.restore_snapshot(current) and restored.create_snapshot() == current, "current recurring-plan snapshot round trip is exact")
|
|
var legacy := current.duplicate(true)
|
|
legacy[&"version"] = 3
|
|
legacy.erase(&"work_assignments")
|
|
_expect(restored.restore_snapshot(legacy), "pre-roster v3 snapshot migrates")
|
|
_expect(restored.get_work_assignments().is_empty() and restored.get_shifts().size() == service.get_shifts().size(), "legacy migration preserves concrete shifts without inventing a plan")
|
|
var pristine := restored.create_snapshot()
|
|
for change: Dictionary in [
|
|
{&"staff_id": "missing"}, {&"template_id": "FLEX"},
|
|
{&"effective_from": 1}, {&"next_start_at": 1}, {&"changed_at": 99999},
|
|
{&"next_start_at": 1800 + 1440 * 10},
|
|
{&"facility_id": 17}, {&"unexpected": true},
|
|
]:
|
|
var bad := current.duplicate(true)
|
|
for key: Variant in change:
|
|
bad[&"work_assignments"][0][key] = change[key]
|
|
_expect(not restored.restore_snapshot(bad) and restored.create_snapshot() == pristine, "malformed plan rejects atomically: %s" % change)
|
|
var downgrade := current.duplicate(true)
|
|
downgrade[&"version"] = 3
|
|
_expect(not restored.restore_snapshot(downgrade), "outer-only downgrade cannot hide a roster")
|
|
|
|
|
|
func _test_runtime_assignment_boundary() -> void:
|
|
_clock = load("res://autoload/game_clock.gd").new()
|
|
_state = load("res://autoload/game_state.gd").new()
|
|
_runtime = load("res://autoload/simulation_runtime.gd").new()
|
|
_clock.reset(1, 6, 0)
|
|
_state.reset()
|
|
_expect(_runtime.initialize(_clock, _state, 91003)[&"valid"], "normal Runtime initializes")
|
|
_clock.set_paused(true)
|
|
for origin: Vector3i in [Vector3i(-8, 0, 0), Vector3i(2, 0, 0)]:
|
|
_expect(_runtime.submit_command(&"build_facility", {&"definition_id": &"reception", &"origin": origin}).is_success(), "reception is built through the normal command")
|
|
_clock.advance_minutes(120)
|
|
var buildings: Array = _runtime.facility_service.get_instances()
|
|
_expect(buildings.size() == 2, "both receptions complete")
|
|
if buildings.size() != 2:
|
|
return
|
|
var west: StringName = buildings[0].instance_id
|
|
var east: StringName = buildings[1].instance_id
|
|
for id: StringName in [&"staff_akane", &"staff_bardo", &"staff_chika"]:
|
|
_expect(_runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": id}).is_success(), "catalog employee is hired: %s" % id)
|
|
var money_before: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT)
|
|
_expect(_assign(&"staff_akane", west, &"DAY").is_success(), "new command assigns hired receptionist")
|
|
_expect(buildings[0].active_staff_ids.is_empty() and _runtime.staff_service.get_staff(&"staff_akane").state == StaffRuntime.State.COMMUTING, "paused assignment waits for the receptionist to physically arrive")
|
|
var old_cell: Vector3i = _runtime.staff_service.get_staff(&"staff_akane").current_cell
|
|
var before: Dictionary = _runtime.staff_service.create_snapshot()
|
|
for row: Array in [
|
|
[&"absent", west, &"DAY", &"STAFF_NOT_FOUND"],
|
|
[&"staff_akane", &"absent", &"DAY", &"FACILITY_NOT_FOUND"],
|
|
[&"staff_chika", west, &"DAY", &"ROLE_MISMATCH"],
|
|
[&"staff_akane", west, &"FLEX", &"INVALID_TEMPLATE"],
|
|
]:
|
|
var result = _assign(row[0], row[1], row[2])
|
|
_expect(not result.is_success() and result.details.get(&"reason") == row[3], "invalid assignment explains: %s" % row[3])
|
|
_expect(_runtime.staff_service.create_snapshot() == before, "rejected command leaves Staff and schedule unchanged")
|
|
_runtime.path_service.set_cell_temporary_block(old_cell, true)
|
|
var blocked = _assign(&"staff_akane", east, &"DAY")
|
|
_expect(not blocked.is_success() and blocked.details.get(&"reason") == &"NO_ROUTE", "inaccessible assignment is rejected")
|
|
_expect(_runtime.staff_service.create_snapshot() == before, "route failure preserves the old assignment")
|
|
_runtime.path_service.set_cell_temporary_block(old_cell, false)
|
|
_clock.advance_minutes(20)
|
|
_expect(buildings[0].active_staff_ids == [&"staff_akane"] and buildings[0].state == FacilityInstance.State.OPEN, "real commute arrival opens the first reception")
|
|
old_cell = _runtime.staff_service.get_staff(&"staff_akane").current_cell
|
|
_expect(_assign(&"staff_akane", east, &"DAY").is_success(), "employee can move to another compatible facility")
|
|
_expect(buildings[0].active_staff_ids.is_empty() and buildings[1].active_staff_ids.is_empty(), "reassignment removes old staffing while the new commute is in progress")
|
|
_expect(_runtime.staff_service.get_staff(&"staff_akane").current_cell == old_cell, "Runtime assignment leaves Path position intact")
|
|
_expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == money_before, "assignment and shift changes charge no second signing fee")
|
|
var report: Dictionary = _runtime.get_staff_management_report()
|
|
_expect(report[&"staff"][0][&"recurring"] and report[&"staff"][0][&"assigned_facility_id"] == east, "management report exposes the committed recurring plan")
|
|
report[&"staff"][0][&"assigned_facility_id"] = &"tampered"
|
|
_expect(_runtime.staff_service.get_work_assignment(&"staff_akane")[&"facility_id"] == String(east), "management report is a defensive copy")
|
|
_expect(_assign(&"staff_bardo", east, &"NIGHT").is_success(), "another employee can cover the opposite shift")
|
|
var snapshot: Dictionary = _runtime.create_snapshot()
|
|
_expect(_runtime.restore_snapshot(snapshot), "Runtime save validates repeating assignments and split historical work")
|
|
_clock.advance_minutes(1440)
|
|
var replay_expected: Dictionary = _runtime.create_snapshot()
|
|
_expect(_runtime.restore_snapshot(snapshot), "Runtime rewinds to recurring-plan checkpoint")
|
|
_clock.advance_minutes(1440)
|
|
_expect(_runtime.create_snapshot() == replay_expected, "next-day scheduling and actor projection replay exactly after restore")
|
|
var saved_staff: Dictionary = _runtime.staff_service.create_snapshot()
|
|
var bad_snapshot: Dictionary = _runtime.create_snapshot().duplicate(true)
|
|
bad_snapshot[&"systems"][&"runtime"][&"staff"][&"work_assignments"][0][&"facility_id"] = "missing"
|
|
_expect(not _runtime.restore_snapshot(bad_snapshot), "Runtime rejects a plan targeting a missing facility")
|
|
_expect(_runtime.staff_service.create_snapshot() == saved_staff, "invalid Runtime roster restore is atomic")
|
|
var remove = _runtime.submit_command(&"demolish_facility", {&"instance_id": east})
|
|
_expect(not remove.is_success() and remove.details.get(&"reason") == &"STAFF_ASSIGNED", "demolition requires releasing saved staff assignments")
|
|
var queued_at: int = _clock.absolute_minute
|
|
_clock.set_paused(false)
|
|
var queued = _assign(&"staff_akane", west, &"DAY")
|
|
_expect(queued.is_success(), "running game accepts the assignment into its command queue")
|
|
_clock.advance_one_minute()
|
|
_clock.set_paused(true)
|
|
var applied: Dictionary = _runtime.staff_service.get_work_assignment(&"staff_akane")
|
|
var applied_shift: ShiftInstance = _runtime.staff_service.get_shift(_runtime.staff_service.get_staff(&"staff_akane").shift_id)
|
|
_expect(int(applied[&"changed_at"]) == queued_at + 1 and int(applied[&"effective_from"]) == queued_at + 1, "queued assignment uses the execution minute, not the previous Staff tick")
|
|
_expect(applied_shift.worked_minutes == 0, "queued reassignment cannot record work before arriving at its new workplace")
|
|
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "queued assignment leaves a coherent save at the end of its tick")
|
|
_test_active_service_reassignment(west, east)
|
|
_test_busy_staff_and_capacity(west)
|
|
|
|
|
|
func _test_active_service_reassignment(west: StringName, east: StringName) -> void:
|
|
_expect(_assign(&"staff_akane", east, &"DAY").is_success(), "service test keeps receptionist on DAY")
|
|
_wait_for_attendance(&"staff_akane", east)
|
|
var spawned = _runtime.submit_command(&"spawn_guest", {
|
|
&"guest_id": &"reassignment_guest", &"guest_type_id": &"traveler", &"gold": 100,
|
|
&"options": {&"state": GuestRuntime.State.DECIDING, &"current_cell": Vector3i.ZERO},
|
|
})
|
|
_expect(spawned.is_success(), "a real Guest and wallet enter the service test")
|
|
var request = _runtime.submit_command(&"create_service_request", {
|
|
&"facility_id": east, &"guest_ids": [&"reassignment_guest"], &"service_type": &"check_in",
|
|
&"options": {&"service_minutes": 30},
|
|
})
|
|
_expect(request.is_success(), "Guest enters reception through normal request ownership")
|
|
_clock.advance_one_minute()
|
|
var active: Array = _runtime.facility_service.get_active_services(east)
|
|
_expect(active.size() == 1, "reception begins the Guest service")
|
|
if active.size() != 1:
|
|
return
|
|
var service: ServiceInstance = active[0]
|
|
var balance_before: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT)
|
|
_expect(_assign(&"staff_akane", west, &"DAY").is_success(), "reassignment during facility service is allowed")
|
|
_clock.advance_one_minute()
|
|
_expect(service.state == ServiceInstance.State.WAITING_FOR_STAFF, "existing service enters the normal staff-replacement grace period")
|
|
_expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == balance_before, "waiting service posts no phantom revenue")
|
|
_expect(_assign(&"staff_akane", east, &"DAY").is_success(), "replacement can return during grace period")
|
|
_wait_for_attendance(&"staff_akane", east)
|
|
_clock.advance_one_minute()
|
|
_expect(service.state == ServiceInstance.State.ACTIVE, "staff return resumes the same service instead of duplicating it")
|
|
_expect(_assign(&"staff_akane", &"", &"DAY").is_success(), "employee can be unassigned during service")
|
|
_clock.advance_minutes(6)
|
|
_expect(service.state == ServiceInstance.State.FAILED, "uncovered service fails at the existing five-minute grace deadline")
|
|
_expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == balance_before, "failed uncovered service never posts completed-sale revenue")
|
|
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "Guest, service, staff, and repeating plan save consistently after interruption")
|
|
|
|
|
|
func _test_busy_staff_and_capacity(west: StringName) -> void:
|
|
_expect(_assign(&"staff_akane", west, &"DAY").is_success(), "busy-task test starts with a working employee")
|
|
_wait_for_attendance(&"staff_akane", west)
|
|
var task = _runtime.submit_command(&"create_staff_task", {
|
|
&"task_id": &"assignment_guard_task", &"task_type": &"reception", &"priority": 99,
|
|
&"options": {&"target_cell": Vector3i.ZERO, &"required_role_tags": [&"receptionist"],
|
|
&"task_tags": [&"reception"], &"estimated_minutes": 20},
|
|
})
|
|
_expect(task.is_success(), "staff task enters normal dispatcher")
|
|
_clock.advance_one_minute()
|
|
var before: Dictionary = _runtime.staff_service.create_snapshot()
|
|
var busy = _assign(&"staff_akane", &"", &"DAY")
|
|
_expect(not busy.is_success() and busy.details.get(&"reason") == &"STAFF_BUSY", "active task blocks a schedule edit with an explicit reason")
|
|
_expect(_runtime.staff_service.create_snapshot() == before, "busy rejection preserves Task ownership and progress")
|
|
_expect(_runtime.staff_service.cancel_task(&"assignment_guard_task", &"TEST_COMPLETE", _clock.absolute_minute).is_success(), "task releases its employee through the service boundary")
|
|
_expect(_assign(&"staff_bardo", west, &"DAY").is_success(), "second receptionist shares the day shift")
|
|
for id: StringName in [&"capacity_extra", &"capacity_overflow"]:
|
|
_expect(_runtime.submit_command(&"register_staff", {&"staff_id": id, &"role_id": &"receptionist"}).is_success(), "additional employee registers for the capacity boundary")
|
|
_expect(_assign(&"capacity_extra", west, &"DAY").is_success(), "third receptionist reaches the exact staffing limit")
|
|
before = _runtime.staff_service.create_snapshot()
|
|
var overflow = _assign(&"capacity_overflow", west, &"DAY")
|
|
_expect(not overflow.is_success() and overflow.details.get(&"reason") == &"FACILITY_FULL", "fourth simultaneous receptionist is rejected")
|
|
_expect(_runtime.staff_service.create_snapshot() == before, "capacity rejection leaves all assignments unchanged")
|
|
_expect(_assign(&"capacity_overflow", west, &"NIGHT").is_success(), "opposite shift has its own staffing capacity")
|
|
|
|
|
|
func _wait_for_attendance(staff_id: StringName, facility_id: StringName) -> void:
|
|
for minute in range(40):
|
|
if _runtime.facility_service.get_instance(facility_id).active_staff_ids.has(staff_id):
|
|
return
|
|
_clock.advance_one_minute()
|
|
_expect(false, "employee reaches the assigned workplace through the real route")
|
|
|
|
|
|
func _assign(staff_id: StringName, facility_id: StringName, template_id: StringName) -> CommandResult:
|
|
return _runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": staff_id, &"facility_id": facility_id, &"template_id": template_id})
|
|
|
|
|
|
func _cleanup() -> void:
|
|
for node: Variant in [_runtime, _clock, _state]:
|
|
if node != null:
|
|
node.free()
|
|
_runtime = null
|
|
_clock = null
|
|
_state = null
|
|
|
|
|
|
func _expect(condition: bool, description: String) -> void:
|
|
_checks += 1
|
|
if not condition:
|
|
_failures += 1
|
|
push_error(description)
|