Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
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
|
||||
Reference in New Issue
Block a user