class_name TaskDispatcher extends RefCounted ## Deterministic employee/task matcher. Inventory reservation is injected so ## this layer can keep the Task/Staff pair atomic without owning stock. const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd") const StaffTaskType = preload("res://scripts/staff/staff_task.gd") const SNAPSHOT_VERSION := 1 const DISPATCH_INTERVAL_MINUTES := 5 const HIGH_PRIORITY_THRESHOLD := 90 const PRIORITY_MULTIPLIER := 10 const SKILL_FIT_MULTIPLIER := 4 const ROLE_FIT_MULTIPLIER := 100 const TRAVEL_MINUTE_PENALTY := 3 const FATIGUE_PENALTY := 2 const OVERTIME_PENALTY := 200 const INTERRUPT_COST_LEVEL_1 := 300 const INTERRUPT_COST_LEVEL_2 := 150 const INTERRUPT_COST_LEVEL_3 := 25 const MIN_SCORE := -2_147_483_648 var last_dispatch_minute := -1 var last_report: Dictionary = {} var last_restore_error := "" var _item_reserver := Callable() var _item_releaser := Callable() var _travel_estimator := Callable() var _assignment_history: Array[Dictionary] = [] func configure_callbacks( item_reserver: Callable = Callable(), item_releaser: Callable = Callable(), travel_estimator: Callable = Callable() ) -> TaskDispatcher: _item_reserver = item_reserver _item_releaser = item_releaser _travel_estimator = travel_estimator return self func should_dispatch(absolute_minute: int, has_high_priority_task := false) -> bool: if has_high_priority_task: return true if last_dispatch_minute < 0: return true return absolute_minute - last_dispatch_minute >= DISPATCH_INTERVAL_MINUTES func dispatch( tasks: Array, staff_by_id: Dictionary, absolute_minute: int ) -> Dictionary: var task_by_id := _task_index(tasks) var pending := _sorted_open_tasks(tasks) var assignments: Array[Dictionary] = [] var blocked: Array[Dictionary] = [] var unassigned: Array[Dictionary] = [] var interruptions: Array[Dictionary] = [] var assigned_this_pass: Dictionary = {} for task: StaffTask in pending: var selected := _select_candidate( task, staff_by_id, task_by_id, assigned_this_pass, absolute_minute ) if selected.is_empty(): unassigned.append({ &"task_id": task.task_id, &"reason": &"NO_ELIGIBLE_STAFF", }) continue var staff_id: StringName = selected[&"staff_id"] var staff: StaffRuntime = staff_by_id[staff_id] var previous_task: StaffTask = selected.get(&"current_task") as StaffTask var result := _reserve_pair(task, staff, previous_task, absolute_minute) if not bool(result.get(&"ok", false)): var reason := StringName(result.get(&"reason", &"RESERVATION_FAILED")) if bool(result.get(&"blocked", false)): blocked.append({&"task_id": task.task_id, &"reason": reason}) else: unassigned.append({&"task_id": task.task_id, &"reason": reason}) continue if previous_task != null: interruptions.append({ &"staff_id": staff_id, &"interrupted_task_id": previous_task.task_id, &"replacement_task_id": task.task_id, }) var assignment := { &"task_id": task.task_id, &"staff_id": staff_id, &"score": int(selected[&"score"]), &"travel_minutes": int(selected[&"travel_minutes"]), &"assigned_at": absolute_minute, } assignments.append(assignment) _assignment_history.append(assignment.duplicate(true)) assigned_this_pass[staff_id] = true last_dispatch_minute = absolute_minute last_report = { &"assigned": assignments, &"blocked": blocked, &"unassigned": unassigned, &"interruptions": interruptions, } return last_report.duplicate(true) func score_candidate( task: StaffTask, staff: StaffRuntime, travel_minutes: int, current_task: StaffTask = null ) -> int: if task == null or staff == null or travel_minutes < 0: return MIN_SCORE var interrupt_cost := 0 if current_task != null: match current_task.interrupt_level: 1: interrupt_cost = INTERRUPT_COST_LEVEL_1 2: interrupt_cost = INTERRUPT_COST_LEVEL_2 _: interrupt_cost = INTERRUPT_COST_LEVEL_3 return ( task.priority * PRIORITY_MULTIPLIER + staff.skill_fit_for(task) * SKILL_FIT_MULTIPLIER + staff.role_fit_for(task) * ROLE_FIT_MULTIPLIER - travel_minutes * TRAVEL_MINUTE_PENALTY - staff.fatigue * FATIGUE_PENALTY - interrupt_cost - (OVERTIME_PENALTY if staff.is_overtime else 0) ) func create_snapshot() -> Dictionary: var history: Array[Dictionary] = [] for assignment: Dictionary in _assignment_history: var serialized := assignment.duplicate(true) serialized[&"task_id"] = String(assignment[&"task_id"]) serialized[&"staff_id"] = String(assignment[&"staff_id"]) history.append(serialized) return { &"version": SNAPSHOT_VERSION, &"last_dispatch_minute": last_dispatch_minute, &"assignment_history": history, } func restore_snapshot(snapshot: Dictionary) -> bool: last_restore_error = "" if int(snapshot.get(&"version", -1)) != SNAPSHOT_VERSION: last_restore_error = "Unsupported task dispatcher snapshot version." return false var raw_history: Variant = snapshot.get(&"assignment_history", null) if not raw_history is Array: last_restore_error = "Task dispatcher history is malformed." return false var next_history: Array[Dictionary] = [] for raw_assignment: Variant in raw_history: if not raw_assignment is Dictionary: last_restore_error = "Task dispatcher history entry must be a Dictionary." return false var task_id := StringName(raw_assignment.get(&"task_id", "")) var staff_id := StringName(raw_assignment.get(&"staff_id", "")) if task_id.is_empty() or staff_id.is_empty(): last_restore_error = "Task dispatcher history has an empty identifier." return false var assignment: Dictionary = raw_assignment.duplicate(true) assignment[&"task_id"] = task_id assignment[&"staff_id"] = staff_id next_history.append(assignment) var next_last_minute := int(snapshot.get(&"last_dispatch_minute", -1)) if next_last_minute < -1: last_restore_error = "Task dispatcher minute is invalid." return false _assignment_history = next_history last_dispatch_minute = next_last_minute last_report = {} return true func assignment_history() -> Array[Dictionary]: return _assignment_history.duplicate(true) func _select_candidate( task: StaffTask, staff_by_id: Dictionary, task_by_id: Dictionary, excluded_staff_ids: Dictionary, absolute_minute: int ) -> Dictionary: var best: Dictionary = {} for staff_id: StringName in _sorted_names(staff_by_id.keys()): if excluded_staff_ids.has(staff_id): continue var staff: StaffRuntime = staff_by_id.get(staff_id) as StaffRuntime if staff == null or not staff.meets_task_requirements(task): continue var current_task: StaffTask = null if staff.current_task_id.is_empty(): if not staff.is_available_for_dispatch(): continue else: current_task = task_by_id.get(staff.current_task_id) as StaffTask if current_task == null or not current_task.can_be_interrupted_by(task): continue var travel_minutes := _travel_minutes(staff, task, absolute_minute) if travel_minutes < 0: continue var score := score_candidate(task, staff, travel_minutes, current_task) if ( best.is_empty() or score > int(best[&"score"]) or (score == int(best[&"score"]) and String(staff_id) < String(best[&"staff_id"])) ): best = { &"staff_id": staff_id, &"staff": staff, &"current_task": current_task, &"travel_minutes": travel_minutes, &"score": score, } return best func _reserve_pair( task: StaffTask, staff: StaffRuntime, previous_task: StaffTask, absolute_minute: int ) -> Dictionary: if not task.is_dispatchable() or staff == null: return {&"ok": false, &"reason": &"STATE_CHANGED", &"blocked": false} if previous_task == null and not staff.is_available_for_dispatch(): return {&"ok": false, &"reason": &"STAFF_STATE_CHANGED", &"blocked": false} if previous_task != null: if staff.current_task_id != previous_task.task_id: return {&"ok": false, &"reason": &"STAFF_TASK_MISMATCH", &"blocked": false} if previous_task.assigned_staff_id != staff.staff_id: return {&"ok": false, &"reason": &"TASK_STAFF_MISMATCH", &"blocked": false} if not previous_task.can_be_interrupted_by(task): return {&"ok": false, &"reason": &"INTERRUPT_DENIED", &"blocked": false} var item_result := _reserve_items(task, absolute_minute) if not bool(item_result.get(&"ok", false)): var item_reason := StringName(item_result.get(&"reason", &"ITEM_RESERVATION_FAILED")) task.block(item_reason, absolute_minute) return {&"ok": false, &"reason": item_reason, &"blocked": true} var reservation_ids: Array[StringName] = _name_array(item_result.get(&"reservation_ids", [])) var staff_before := { &"current_task_id": staff.current_task_id, &"state": staff.state, &"state_reason": staff.state_reason, &"state_changed_at": staff.state_changed_at, } var task_before := { &"state": task.state, &"assigned_staff_id": task.assigned_staff_id, &"reservation_ids": task.reservation_ids.duplicate(), &"state_reason": task.state_reason, &"state_changed_at": task.state_changed_at, } var previous_before: Dictionary = {} if previous_task != null: previous_before = { &"state": previous_task.state, &"assigned_staff_id": previous_task.assigned_staff_id, &"reservation_ids": previous_task.reservation_ids.duplicate(), &"progress_minutes": previous_task.progress_minutes, &"state_reason": previous_task.state_reason, &"state_changed_at": previous_task.state_changed_at, } previous_task.reopen( &"INTERRUPTED", absolute_minute, previous_task.preserve_progress_on_interrupt ) staff.release_task(absolute_minute, &"TASK_INTERRUPTED") var task_reserved := task.reserve(staff.staff_id, reservation_ids, absolute_minute) var staff_reserved := task_reserved and staff.reserve_task(task.task_id, absolute_minute) if not task_reserved or not staff_reserved: _restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before) _release_items(reservation_ids, task, absolute_minute) return {&"ok": false, &"reason": &"ATOMIC_PAIR_RESERVATION_FAILED", &"blocked": false} if previous_task != null: var old_ids: Array[StringName] = _name_array(previous_before[&"reservation_ids"]) if not _release_items(old_ids, previous_task, absolute_minute): _restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before) _release_items(reservation_ids, task, absolute_minute) return {&"ok": false, &"reason": &"INTERRUPT_RELEASE_FAILED", &"blocked": false} return {&"ok": true, &"reservation_ids": reservation_ids} func _reserve_items(task: StaffTask, absolute_minute: int) -> Dictionary: if task.required_items.is_empty(): return {&"ok": true, &"reservation_ids": []} if not _item_reserver.is_valid(): return {&"ok": false, &"reason": &"ITEM_RESERVATION_UNAVAILABLE"} var response: Variant = _item_reserver.call(task, absolute_minute) if not response is Dictionary: return {&"ok": false, &"reason": &"ITEM_RESERVATION_INVALID_RESPONSE"} var result: Dictionary = response if not bool(result.get(&"ok", false)): return { &"ok": false, &"reason": StringName(result.get(&"reason", result.get(&"code", &"ITEM_RESERVATION_FAILED"))), } var reservation_ids := _name_array(result.get(&"reservation_ids", [])) if reservation_ids.is_empty(): return {&"ok": false, &"reason": &"ITEM_RESERVATION_IDS_MISSING"} return {&"ok": true, &"reservation_ids": reservation_ids} func _release_items( reservation_ids: Array[StringName], task: StaffTask, absolute_minute: int ) -> bool: if reservation_ids.is_empty(): return true if not _item_releaser.is_valid(): return false var response: Variant = _item_releaser.call(reservation_ids, task, absolute_minute) if response is Dictionary: return bool(response.get(&"ok", false)) return bool(response) func _travel_minutes(staff: StaffRuntime, task: StaffTask, absolute_minute: int) -> int: if _travel_estimator.is_valid(): return int(_travel_estimator.call(staff, task, absolute_minute)) var delta := staff.current_cell - task.target_cell return absi(delta.x) + absi(delta.y) + absi(delta.z) static func _restore_pair_state( task: StaffTask, staff: StaffRuntime, previous_task: StaffTask, task_before: Dictionary, staff_before: Dictionary, previous_before: Dictionary ) -> void: task.state = int(task_before[&"state"]) as StaffTask.State task.assigned_staff_id = task_before[&"assigned_staff_id"] task.reservation_ids.assign(task_before[&"reservation_ids"]) task.state_reason = task_before[&"state_reason"] task.state_changed_at = int(task_before[&"state_changed_at"]) staff.current_task_id = staff_before[&"current_task_id"] staff.state = int(staff_before[&"state"]) as StaffRuntime.State staff.state_reason = staff_before[&"state_reason"] staff.state_changed_at = int(staff_before[&"state_changed_at"]) if previous_task != null: previous_task.state = int(previous_before[&"state"]) as StaffTask.State previous_task.assigned_staff_id = previous_before[&"assigned_staff_id"] previous_task.reservation_ids.assign(previous_before[&"reservation_ids"]) previous_task.progress_minutes = int(previous_before[&"progress_minutes"]) previous_task.state_reason = previous_before[&"state_reason"] previous_task.state_changed_at = int(previous_before[&"state_changed_at"]) static func _task_index(tasks: Array) -> Dictionary: var result: Dictionary = {} for value: Variant in tasks: if value is StaffTask: result[value.task_id] = value return result static func _sorted_open_tasks(tasks: Array) -> Array[StaffTask]: var result: Array[StaffTask] = [] for value: Variant in tasks: if value is StaffTask and value.is_dispatchable(): result.append(value) result.sort_custom(func(a: StaffTask, b: StaffTask) -> bool: if a.priority != b.priority: return a.priority > b.priority var a_deadline := 9_223_372_036_854_775_807 if a.deadline_at < 0 else a.deadline_at var b_deadline := 9_223_372_036_854_775_807 if b.deadline_at < 0 else b.deadline_at if a_deadline != b_deadline: return a_deadline < b_deadline return String(a.task_id) < String(b.task_id) ) return result static func _sorted_names(values: Variant) -> Array[StringName]: var result: Array[StringName] = [] for value: Variant in values: result.append(StringName(value)) result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) return result static func _name_array(source: Variant) -> Array[StringName]: var result: Array[StringName] = [] if not source is Array: return result for value: Variant in source: var name := StringName(value) if not name.is_empty() and not result.has(name): result.append(name) result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) return result