Connect physical elevators and staff commutes to construction, saves, and cutaway UI
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
class_name StaffCommuteService
|
||||
extends RefCounted
|
||||
|
||||
## Commutes derive from the saved work plan, Shift and current actor position.
|
||||
## Runtime owns movement credit, Path projection, elevator rides and events.
|
||||
## This adapter deliberately owns no additional snapshot payload.
|
||||
const ROUTE_BLOCKED := &"COMMUTE_ROUTE_BLOCKED"
|
||||
const WORKPLACE_MISSING := &"COMMUTE_WORKPLACE_MISSING"
|
||||
const WAITING_SHIFT := &"WAITING_FOR_SHIFT"
|
||||
const MOVING_TO_WORK := &"MOVING_TO_WORK"
|
||||
|
||||
var _runtime: Variant
|
||||
|
||||
|
||||
func configure(runtime: Variant) -> StaffCommuteService:
|
||||
_runtime = runtime
|
||||
_runtime.staff_service.configure_attendance_driver(Callable(self, "can_attend_shift"))
|
||||
return self
|
||||
|
||||
|
||||
## A porter checks in once at the warehouse, then remains dispatchable at the
|
||||
## end of a delivery. Fixed-post workers return before resuming desk service.
|
||||
func can_attend_shift(staff: StaffRuntime, shift: ShiftInstance, _minute: int) -> bool:
|
||||
if _runtime == null or staff == null or shift == null:
|
||||
return false
|
||||
if _in_transit(staff.staff_id) and (staff.current_task_id.is_empty() or shift.actual_started_at < 0):
|
||||
return false
|
||||
if shift.actual_started_at >= 0 and (staff.role_id == &"porter" or not staff.current_task_id.is_empty()):
|
||||
return true
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(shift.assigned_facility_id)
|
||||
return is_at_facility(staff, facility)
|
||||
|
||||
|
||||
## Physical attendance is separate from dispatch availability. A porter away
|
||||
## with goods does not provide staff capacity to an empty warehouse.
|
||||
func is_present(staff: StaffRuntime, facility: FacilityInstance) -> bool:
|
||||
if _runtime == null or staff == null or facility == null \
|
||||
or staff.assigned_facility_id != facility.instance_id \
|
||||
or staff.state != StaffRuntime.State.AVAILABLE or not staff.can_take_shift():
|
||||
return false
|
||||
if _in_transit(staff.staff_id):
|
||||
return false
|
||||
if not _runtime.staff_service.requires_attendance(staff.staff_id):
|
||||
return true
|
||||
return is_at_facility(staff, facility)
|
||||
|
||||
|
||||
func tick(absolute_minute: int) -> Dictionary:
|
||||
if _runtime == null:
|
||||
return {&"ok": false, &"reason": &"NOT_CONFIGURED"}
|
||||
var moved: Array[StringName] = []
|
||||
var waiting: Array[StringName] = []
|
||||
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
if not _runtime.staff_service.requires_attendance(staff.staff_id) \
|
||||
or staff.state != StaffRuntime.State.COMMUTING \
|
||||
or not staff.can_take_shift() or not staff.current_task_id.is_empty():
|
||||
continue
|
||||
var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id)
|
||||
if shift == null or (not shift.contains_minute(absolute_minute) and not shift.is_commute_window(absolute_minute)):
|
||||
continue
|
||||
if _in_transit(staff.staff_id):
|
||||
_wait(staff, absolute_minute, &"ELEVATOR_WAIT")
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(shift.assigned_facility_id)
|
||||
if facility == null:
|
||||
_wait(staff, absolute_minute, WORKPLACE_MISSING)
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
if is_at_facility(staff, facility):
|
||||
staff.absence_reason = &""
|
||||
_wait(staff, absolute_minute, WAITING_SHIFT if shift.is_commute_window(absolute_minute) else &"COMMUTE_ARRIVED")
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
var access := find_facility_access_path(staff.current_cell, facility)
|
||||
if not bool(access.get(&"reachable", false)):
|
||||
_wait(staff, absolute_minute, ROUTE_BLOCKED)
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
staff.absence_reason = &""
|
||||
var result: Dictionary = _runtime.advance_staff_commute(staff.staff_id, access[&"cell"], absolute_minute)
|
||||
if bool(result.get(&"moved", false)):
|
||||
moved.append(staff.staff_id)
|
||||
else:
|
||||
waiting.append(staff.staff_id)
|
||||
return {&"ok": true, &"moved_staff_ids": moved, &"waiting_staff_ids": waiting}
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
var rows: Array[Dictionary] = []
|
||||
if _runtime == null:
|
||||
return {&"staff": rows}
|
||||
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
if not _runtime.staff_service.requires_attendance(staff.staff_id):
|
||||
continue
|
||||
var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id)
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(staff.assigned_facility_id)
|
||||
var reason := staff.absence_reason
|
||||
var target := staff.current_cell
|
||||
if _in_transit(staff.staff_id):
|
||||
reason = &"ELEVATOR_WAIT"
|
||||
if facility != null:
|
||||
var access := find_facility_access_path(staff.current_cell, facility)
|
||||
target = access.get(&"cell", facility.origin)
|
||||
if staff.state == StaffRuntime.State.COMMUTING and reason.is_empty():
|
||||
reason = WAITING_SHIFT if is_at_facility(staff, facility) else MOVING_TO_WORK
|
||||
rows.append({
|
||||
&"staff_id": staff.staff_id, &"state": StaffRuntime.State.keys()[staff.state],
|
||||
&"reason": reason, &"facility_id": staff.assigned_facility_id,
|
||||
&"current_cell": staff.current_cell, &"target_cell": target,
|
||||
&"present": is_present(staff, facility),
|
||||
&"late_minutes": shift.late_minutes if shift != null else 0,
|
||||
})
|
||||
return {&"staff": rows}
|
||||
|
||||
|
||||
func find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
||||
if _runtime == null or facility == null:
|
||||
return {&"reachable": false}
|
||||
var candidates: Dictionary = {}
|
||||
var occupied := facility.occupied_cells.duplicate()
|
||||
if occupied.is_empty():
|
||||
occupied.append(facility.origin)
|
||||
for cell: Vector3i in occupied:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]:
|
||||
if _runtime.path_service.has_cell(cell + delta):
|
||||
candidates[cell + delta] = true
|
||||
var ordered: Array = candidates.keys()
|
||||
ordered.sort_custom(func(a: Vector3i, b: Vector3i) -> bool:
|
||||
if a.z != b.z: return a.z < b.z
|
||||
if a.y != b.y: return a.y < b.y
|
||||
return a.x < b.x)
|
||||
var best: Array[Vector3i] = []
|
||||
var best_cost := 9223372036854775807
|
||||
for cell: Vector3i in ordered:
|
||||
var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell,
|
||||
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"])
|
||||
var cost: int = _runtime.path_service.calculate_path_cost(path, [&"staff"]) if not path.is_empty() else -1
|
||||
if cost >= 0 and (best.is_empty() or cost < best_cost):
|
||||
best = path
|
||||
best_cost = cost
|
||||
return {&"reachable": not best.is_empty(), &"path": best,
|
||||
&"cell": best[-1] if not best.is_empty() else facility.origin,
|
||||
&"steps": maxi(0, best.size() - 1)}
|
||||
|
||||
|
||||
static func is_at_facility(staff: StaffRuntime, facility: FacilityInstance) -> bool:
|
||||
if staff == null or facility == null:
|
||||
return false
|
||||
var occupied := facility.occupied_cells.duplicate()
|
||||
if occupied.is_empty():
|
||||
occupied.append(facility.origin)
|
||||
for cell: Vector3i in occupied:
|
||||
if cell.z == staff.current_cell.z and cell.y == staff.current_cell.y \
|
||||
and absi(cell.x - staff.current_cell.x) <= 1:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _wait(staff: StaffRuntime, minute: int, reason: StringName) -> void:
|
||||
if reason in [ROUTE_BLOCKED, WORKPLACE_MISSING]:
|
||||
staff.absence_reason = reason
|
||||
_runtime.advance_staff_commute(staff.staff_id, staff.current_cell, minute, reason)
|
||||
|
||||
|
||||
func _in_transit(staff_id: StringName) -> bool:
|
||||
return _runtime != null and _runtime.has_method("is_staff_in_transit") \
|
||||
and bool(_runtime.is_staff_in_transit(staff_id))
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddcf3c4qyyysc
|
||||
@@ -49,6 +49,7 @@ var _item_reserver := Callable()
|
||||
var _item_releaser := Callable()
|
||||
var _travel_estimator := Callable()
|
||||
var _external_task_driver := Callable()
|
||||
var _attendance_driver := Callable()
|
||||
|
||||
|
||||
func configure_callbacks(
|
||||
@@ -69,6 +70,17 @@ func configure_external_task_driver(driver: Callable = Callable()) -> void:
|
||||
_external_task_driver = driver
|
||||
|
||||
|
||||
## Runtime attendance uses the employee's real position. Component consumers
|
||||
## and legacy one-off shifts retain their existing scheduling contract.
|
||||
func configure_attendance_driver(driver: Callable = Callable()) -> void:
|
||||
_attendance_driver = driver
|
||||
|
||||
|
||||
func requires_attendance(staff_id: StringName) -> bool:
|
||||
return _attendance_driver.is_valid() and _work_assignments.has(staff_id) \
|
||||
and not String(_work_assignments[staff_id][&"facility_id"]).is_empty()
|
||||
|
||||
|
||||
func is_externally_driven_task(task_id: StringName) -> bool:
|
||||
return _external_task_driver.is_valid() and bool(_external_task_driver.call(task_id))
|
||||
|
||||
@@ -461,6 +473,17 @@ func set_work_assignment(staff_id: StringName, facility_id: StringName, template
|
||||
if shift.cancelled or shift.end_at <= minute:
|
||||
continue
|
||||
var replacement := ShiftInstanceType.from_snapshot(shift.create_snapshot())
|
||||
# Attendance can contain gaps, so clamping by scheduled duration alone
|
||||
# cannot remove an already recorded paused-edit boundary minute. The
|
||||
# current AVAILABLE employee recorded that minute; its replacement plan
|
||||
# records it only if the employee is physically at the new workplace.
|
||||
if requires_attendance(staff_id) and minute == current_minute \
|
||||
and staff.state == StaffRuntimeType.State.AVAILABLE \
|
||||
and staff.shift_id == shift.shift_instance_id \
|
||||
and shift.contains_minute(minute) and shift.worked_minutes > 0:
|
||||
replacement.worked_minutes -= 1
|
||||
if replacement.worked_minutes == 0 and replacement.overtime_minutes == 0:
|
||||
replacement.actual_started_at = -1
|
||||
if replacement.start_at >= minute:
|
||||
replacement.cancelled = true
|
||||
else:
|
||||
@@ -496,6 +519,8 @@ func set_work_assignment(staff_id: StringName, facility_id: StringName, template
|
||||
staff.shift_id = &""
|
||||
staff.assigned_facility_id = &""
|
||||
staff.is_overtime = false
|
||||
if String(staff.absence_reason).begins_with("COMMUTE_"):
|
||||
staff.absence_reason = &""
|
||||
staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"WORK_ASSIGNMENT_CHANGED", minute)
|
||||
if new_shift != null:
|
||||
_shifts[new_shift.shift_instance_id] = new_shift
|
||||
@@ -1379,6 +1404,8 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re
|
||||
task_reopened.emit(current_task.task_id, &"SHIFT_ENDED")
|
||||
staff.current_task_id = &""
|
||||
staff.is_overtime = false
|
||||
if String(staff.absence_reason).begins_with("COMMUTE_"):
|
||||
staff.absence_reason = &""
|
||||
staff.shift_id = &""
|
||||
if staff.assigned_facility_id == shift.assigned_facility_id:
|
||||
staff.assigned_facility_id = &""
|
||||
@@ -1410,16 +1437,26 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re
|
||||
continue
|
||||
if shift.contains_minute(absolute_minute):
|
||||
staff.is_overtime = false
|
||||
var attendance_required := requires_attendance(staff.staff_id)
|
||||
var attended := not attendance_required or bool(_attendance_driver.call(staff, shift, absolute_minute))
|
||||
if attendance_required and attended and String(staff.absence_reason).begins_with("COMMUTE_"):
|
||||
staff.absence_reason = &""
|
||||
if staff.current_task_id.is_empty() and staff.state in [
|
||||
StaffRuntimeType.State.OFF_DUTY,
|
||||
StaffRuntimeType.State.COMMUTING,
|
||||
StaffRuntimeType.State.AVAILABLE,
|
||||
]:
|
||||
staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute)
|
||||
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
|
||||
if record_duty:
|
||||
var next_state := StaffRuntimeType.State.AVAILABLE if attended else StaffRuntimeType.State.COMMUTING
|
||||
if staff.state != next_state:
|
||||
var reason := &"SHIFT_STARTED" if attended else (&"RETURN_TO_POST" if shift.actual_started_at >= 0 else &"SHIFT_COMMUTE")
|
||||
staff.transition_to(next_state, reason, absolute_minute)
|
||||
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
|
||||
if record_duty and attended:
|
||||
shift.record_duty_minute(absolute_minute)
|
||||
if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING:
|
||||
shift.mark_late(absolute_minute, DEFAULT_LATE_REASON)
|
||||
if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING \
|
||||
and (not attendance_required or shift.actual_started_at < 0):
|
||||
var late_reason := staff.absence_reason if attendance_required and not staff.absence_reason.is_empty() else DEFAULT_LATE_REASON
|
||||
shift.mark_late(absolute_minute, late_reason)
|
||||
|
||||
|
||||
func _find_relevant_shift(staff_id: StringName, absolute_minute: int) -> ShiftInstance:
|
||||
|
||||
Reference in New Issue
Block a user