420 lines
14 KiB
GDScript
420 lines
14 KiB
GDScript
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
|