Files
TheTree_mock/scripts/staff/task_dispatcher.gd

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