482 lines
18 KiB
GDScript
482 lines
18 KiB
GDScript
class_name LogisticsDispatcher
|
|
extends RefCounted
|
|
|
|
const LogisticsRequestType = preload("res://scripts/inventory/logistics_request.gd")
|
|
|
|
const LEGACY_SNAPSHOT_VERSION := 1
|
|
const SNAPSHOT_VERSION := 2
|
|
const PRIORITY_MULTIPLIER := 10
|
|
const PATH_MINUTE_PENALTY := 3
|
|
const DETOUR_MINUTE_PENALTY := 2
|
|
const DEADLINE_RISK_WINDOW := 120
|
|
|
|
var last_report: Dictionary = {}
|
|
var last_restore_error := ""
|
|
var restored_snapshot_version := SNAPSHOT_VERSION
|
|
|
|
var _carrier_reserved_load: Dictionary = {}
|
|
var _assignment_ownership: Dictionary = {}
|
|
var _assignment_history: Array[Dictionary] = []
|
|
|
|
|
|
func dispatch(
|
|
requests: Array,
|
|
carriers: Dictionary,
|
|
absolute_minute: int = 0
|
|
) -> Array[Dictionary]:
|
|
var pending: Array[LogisticsRequest] = []
|
|
for value: Variant in requests:
|
|
if value is LogisticsRequest and value.is_dispatchable():
|
|
pending.append(value)
|
|
pending.sort_custom(func(a: LogisticsRequest, b: LogisticsRequest) -> bool:
|
|
if a.priority != b.priority:
|
|
return a.priority > b.priority
|
|
var a_deadline := 9223372036854775807 if a.deadline < 0 else a.deadline
|
|
var b_deadline := 9223372036854775807 if b.deadline < 0 else b.deadline
|
|
if a_deadline != b_deadline:
|
|
return a_deadline < b_deadline
|
|
if a.created_at != b.created_at:
|
|
return a.created_at < b.created_at
|
|
return String(a.request_id) < String(b.request_id)
|
|
)
|
|
|
|
var normalized_carriers := _normalize_carriers(carriers)
|
|
# Carrier order is invariant for this dispatch pass. Building and sorting it
|
|
# once avoids repeating the same allocation and lexical sort for every request.
|
|
var ordered_carrier_ids := _sorted_names(normalized_carriers.keys())
|
|
# A carrier can disappear from one scheduler projection while its accepted
|
|
# assignment is still in flight. Keep those reservations until the owner
|
|
# explicitly releases them; otherwise reappearing carriers can be overbooked.
|
|
var reserved_loads: Dictionary = _carrier_reserved_load.duplicate(true)
|
|
var working_loads: Dictionary = {}
|
|
for carrier_id: StringName in ordered_carrier_ids:
|
|
var carrier: Dictionary = normalized_carriers[carrier_id]
|
|
working_loads[carrier_id] = maxi(
|
|
int(carrier[&"current_load"]),
|
|
int(_carrier_reserved_load.get(carrier_id, 0))
|
|
)
|
|
|
|
var assignments: Array[Dictionary] = []
|
|
var unassigned: Array[Dictionary] = []
|
|
for request: LogisticsRequest in pending:
|
|
if _assignment_ownership.has(request.request_id):
|
|
unassigned.append({
|
|
&"request_id": request.request_id,
|
|
&"reason": &"REQUEST_ALREADY_OWNED",
|
|
})
|
|
continue
|
|
var best: Dictionary = {}
|
|
var deadline_score := _deadline_risk(request.deadline, absolute_minute)
|
|
for carrier_id: StringName in ordered_carrier_ids:
|
|
var carrier: Dictionary = normalized_carriers[carrier_id]
|
|
var current_load := int(working_loads[carrier_id])
|
|
if current_load + request.total_weight > int(carrier[&"capacity"]):
|
|
continue
|
|
var path_minutes := _path_minutes_for(carrier, request)
|
|
if path_minutes < 0:
|
|
continue
|
|
# Keep score_candidate as the public single-candidate API, but avoid a
|
|
# second path lookup while evaluating the same pair in a dispatch batch.
|
|
var score := (
|
|
request.priority * PRIORITY_MULTIPLIER
|
|
- path_minutes * PATH_MINUTE_PENALTY
|
|
- int(carrier[&"detour_minutes"]) * DETOUR_MINUTE_PENALTY
|
|
+ _role_fitness(carrier, request)
|
|
+ deadline_score
|
|
)
|
|
if (
|
|
best.is_empty()
|
|
or score > int(best[&"score"])
|
|
or (score == int(best[&"score"]) and String(carrier_id) < String(best[&"carrier_id"]))
|
|
):
|
|
best = {
|
|
&"carrier_id": carrier_id,
|
|
&"score": score,
|
|
&"path_minutes": path_minutes,
|
|
&"position": carrier[&"position"],
|
|
}
|
|
if best.is_empty():
|
|
unassigned.append({
|
|
&"request_id": request.request_id,
|
|
&"reason": &"NO_ELIGIBLE_CARRIER",
|
|
})
|
|
continue
|
|
var selected_carrier_id: StringName = best[&"carrier_id"]
|
|
if not request.assign(selected_carrier_id, absolute_minute):
|
|
unassigned.append({
|
|
&"request_id": request.request_id,
|
|
&"reason": &"STATE_CHANGED",
|
|
})
|
|
continue
|
|
working_loads[selected_carrier_id] = int(working_loads[selected_carrier_id]) + request.total_weight
|
|
reserved_loads[selected_carrier_id] = (
|
|
int(reserved_loads.get(selected_carrier_id, 0)) + request.total_weight
|
|
)
|
|
var assignment := {
|
|
&"request_id": request.request_id,
|
|
&"carrier_id": selected_carrier_id,
|
|
&"score": int(best[&"score"]),
|
|
&"path_minutes": int(best[&"path_minutes"]),
|
|
&"weight": request.total_weight,
|
|
&"assigned_at": absolute_minute,
|
|
}
|
|
assignments.append(assignment)
|
|
_assignment_history.append(assignment.duplicate(true))
|
|
_assignment_ownership[request.request_id] = assignment.duplicate(true)
|
|
|
|
_carrier_reserved_load = reserved_loads
|
|
last_report = {
|
|
&"assigned": assignments.duplicate(true),
|
|
&"unassigned": unassigned,
|
|
&"carrier_loads": _carrier_reserved_load.duplicate(true),
|
|
}
|
|
return assignments
|
|
|
|
|
|
func score_candidate(
|
|
request: LogisticsRequest,
|
|
carrier: Dictionary,
|
|
absolute_minute: int
|
|
) -> int:
|
|
var path_minutes := _path_minutes_for(carrier, request)
|
|
if path_minutes < 0:
|
|
return -2_147_483_648
|
|
return (
|
|
request.priority * PRIORITY_MULTIPLIER
|
|
- path_minutes * PATH_MINUTE_PENALTY
|
|
- int(carrier.get(&"detour_minutes", 0)) * DETOUR_MINUTE_PENALTY
|
|
+ _role_fitness(carrier, request)
|
|
+ _deadline_risk(request.deadline, absolute_minute)
|
|
)
|
|
|
|
|
|
func release_assignment(carrier_id: StringName, weight: int) -> int:
|
|
var candidates: Array[StringName] = []
|
|
for request_id: StringName in _sorted_names(_assignment_ownership.keys()):
|
|
var ownership: Dictionary = _assignment_ownership[request_id]
|
|
if ownership[&"carrier_id"] == carrier_id and int(ownership[&"weight"]) == weight:
|
|
candidates.append(request_id)
|
|
if candidates.size() == 1:
|
|
release_assignment_for_request(candidates[0], carrier_id, weight)
|
|
return get_reserved_load(carrier_id)
|
|
if candidates.size() > 1 or restored_snapshot_version != LEGACY_SNAPSHOT_VERSION:
|
|
return get_reserved_load(carrier_id)
|
|
var next_load := maxi(int(_carrier_reserved_load.get(carrier_id, 0)) - maxi(weight, 0), 0)
|
|
if next_load == 0:
|
|
_carrier_reserved_load.erase(carrier_id)
|
|
else:
|
|
_carrier_reserved_load[carrier_id] = next_load
|
|
return next_load
|
|
|
|
|
|
func release_assignment_for_request(
|
|
request_id: StringName,
|
|
carrier_id: StringName,
|
|
weight: int
|
|
) -> bool:
|
|
if request_id.is_empty() or carrier_id.is_empty() or weight <= 0 \
|
|
or not _assignment_ownership.has(request_id):
|
|
return false
|
|
var ownership: Dictionary = _assignment_ownership[request_id]
|
|
if ownership[&"carrier_id"] != carrier_id or int(ownership[&"weight"]) != weight:
|
|
return false
|
|
var current_load := int(_carrier_reserved_load.get(carrier_id, 0))
|
|
if current_load < weight:
|
|
return false
|
|
var next_load := current_load - weight
|
|
if next_load == 0:
|
|
_carrier_reserved_load.erase(carrier_id)
|
|
else:
|
|
_carrier_reserved_load[carrier_id] = next_load
|
|
_assignment_ownership.erase(request_id)
|
|
return true
|
|
|
|
|
|
func get_reserved_load(carrier_id: StringName) -> int:
|
|
return int(_carrier_reserved_load.get(carrier_id, 0))
|
|
|
|
|
|
func get_assignment_ownership() -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for request_id: StringName in _sorted_names(_assignment_ownership.keys()):
|
|
result[request_id] = (_assignment_ownership[request_id] as Dictionary).duplicate(true)
|
|
return result
|
|
|
|
|
|
func get_assignment_history() -> Array[Dictionary]:
|
|
return _assignment_history.duplicate(true)
|
|
|
|
|
|
func clear_runtime_loads() -> void:
|
|
_carrier_reserved_load.clear()
|
|
_assignment_ownership.clear()
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var carrier_loads: Array[Dictionary] = []
|
|
for carrier_id: StringName in _sorted_names(_carrier_reserved_load.keys()):
|
|
carrier_loads.append({
|
|
&"carrier_id": String(carrier_id),
|
|
&"reserved_load": int(_carrier_reserved_load[carrier_id]),
|
|
})
|
|
var history: Array[Dictionary] = []
|
|
for assignment: Dictionary in _assignment_history:
|
|
var serialized := assignment.duplicate(true)
|
|
serialized[&"request_id"] = String(assignment[&"request_id"])
|
|
serialized[&"carrier_id"] = String(assignment[&"carrier_id"])
|
|
history.append(serialized)
|
|
var ownership: Array[Dictionary] = []
|
|
for request_id: StringName in _sorted_names(_assignment_ownership.keys()):
|
|
var entry: Dictionary = _assignment_ownership[request_id]
|
|
ownership.append({
|
|
&"request_id": String(request_id),
|
|
&"carrier_id": String(entry[&"carrier_id"]),
|
|
&"weight": int(entry[&"weight"]),
|
|
&"assigned_at": int(entry[&"assigned_at"]),
|
|
})
|
|
return {
|
|
&"version": SNAPSHOT_VERSION,
|
|
&"carrier_loads": carrier_loads,
|
|
&"assignment_ownership": ownership,
|
|
&"assignment_history": history,
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
last_restore_error = ""
|
|
var version := int(snapshot.get(&"version", -1))
|
|
if version not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]:
|
|
last_restore_error = "Unsupported logistics dispatcher snapshot version."
|
|
return false
|
|
var raw_loads: Variant = snapshot.get(&"carrier_loads", null)
|
|
var raw_history: Variant = snapshot.get(&"assignment_history", null)
|
|
var raw_ownership: Variant = snapshot.get(&"assignment_ownership", null)
|
|
if not raw_loads is Array or not raw_history is Array \
|
|
or (version == SNAPSHOT_VERSION and not raw_ownership is Array):
|
|
last_restore_error = "Logistics dispatcher snapshot collections are malformed."
|
|
return false
|
|
|
|
var next_loads: Dictionary = {}
|
|
for raw_load: Variant in raw_loads:
|
|
if not raw_load is Dictionary:
|
|
last_restore_error = "Carrier load entry must be a Dictionary."
|
|
return false
|
|
var carrier_id := StringName(raw_load.get(&"carrier_id", ""))
|
|
var reserved_load := int(raw_load.get(&"reserved_load", -1))
|
|
if carrier_id == &"" or reserved_load < 0 or next_loads.has(carrier_id) \
|
|
or (version == SNAPSHOT_VERSION and reserved_load == 0):
|
|
last_restore_error = "Carrier load entry is invalid or duplicated."
|
|
return false
|
|
next_loads[carrier_id] = reserved_load
|
|
|
|
var next_history: Array[Dictionary] = []
|
|
var history_fingerprints: Dictionary = {}
|
|
for raw_assignment: Variant in raw_history:
|
|
if not raw_assignment is Dictionary:
|
|
last_restore_error = "Assignment history entry must be a Dictionary."
|
|
return false
|
|
var request_id := StringName(raw_assignment.get(&"request_id", ""))
|
|
var carrier_id := StringName(raw_assignment.get(&"carrier_id", ""))
|
|
var weight_value: Variant = raw_assignment.get(&"weight", null)
|
|
var assigned_at_value: Variant = raw_assignment.get(&"assigned_at", null)
|
|
if request_id == &"" or carrier_id == &"" or not weight_value is int \
|
|
or int(weight_value) < (1 if version == SNAPSHOT_VERSION else 0) \
|
|
or (version == SNAPSHOT_VERSION and (
|
|
not assigned_at_value is int or int(assigned_at_value) < 0
|
|
)):
|
|
last_restore_error = "Assignment history entry is invalid."
|
|
return false
|
|
var assignment: Dictionary = raw_assignment.duplicate(true)
|
|
assignment[&"request_id"] = request_id
|
|
assignment[&"carrier_id"] = carrier_id
|
|
var fingerprint := "%s|%s|%d|%d" % [
|
|
request_id,
|
|
carrier_id,
|
|
int(weight_value),
|
|
int(assigned_at_value) if assigned_at_value is int else -1,
|
|
]
|
|
if version == SNAPSHOT_VERSION and history_fingerprints.has(fingerprint):
|
|
last_restore_error = "Assignment history contains an exact duplicate."
|
|
return false
|
|
history_fingerprints[fingerprint] = true
|
|
next_history.append(assignment)
|
|
|
|
var next_ownership: Dictionary = {}
|
|
if version == SNAPSHOT_VERSION:
|
|
for raw_entry: Variant in raw_ownership:
|
|
if not raw_entry is Dictionary:
|
|
last_restore_error = "Assignment ownership entry must be a Dictionary."
|
|
return false
|
|
var request_id := StringName(raw_entry.get(&"request_id", ""))
|
|
var carrier_id := StringName(raw_entry.get(&"carrier_id", ""))
|
|
var weight_value: Variant = raw_entry.get(&"weight", null)
|
|
var assigned_at_value: Variant = raw_entry.get(&"assigned_at", null)
|
|
if request_id.is_empty() or carrier_id.is_empty() or next_ownership.has(request_id) \
|
|
or not weight_value is int or int(weight_value) <= 0 \
|
|
or not assigned_at_value is int or int(assigned_at_value) < 0:
|
|
last_restore_error = "Assignment ownership entry is invalid or duplicated."
|
|
return false
|
|
next_ownership[request_id] = {
|
|
&"request_id": request_id,
|
|
&"carrier_id": carrier_id,
|
|
&"weight": int(weight_value),
|
|
&"assigned_at": int(assigned_at_value),
|
|
}
|
|
if not _ownership_matches_loads_and_history(next_ownership, next_loads, next_history):
|
|
last_restore_error = "Assignment ownership does not match reserved loads or history."
|
|
return false
|
|
|
|
_carrier_reserved_load = next_loads
|
|
_assignment_ownership = next_ownership
|
|
_assignment_history = next_history
|
|
restored_snapshot_version = version
|
|
last_restore_error = ""
|
|
return true
|
|
|
|
|
|
func migrate_legacy_assignment_ownership(expected: Dictionary) -> bool:
|
|
if restored_snapshot_version != LEGACY_SNAPSHOT_VERSION or not _assignment_ownership.is_empty():
|
|
return false
|
|
var normalized: Dictionary = {}
|
|
for request_id: StringName in _sorted_names(expected.keys()):
|
|
var raw_entry: Variant = expected[request_id]
|
|
if request_id.is_empty() or not raw_entry is Dictionary:
|
|
return false
|
|
var carrier_id := StringName(raw_entry.get(&"carrier_id", &""))
|
|
var weight := int(raw_entry.get(&"weight", -1))
|
|
var assigned_at := int(raw_entry.get(&"assigned_at", -1))
|
|
if carrier_id.is_empty() or weight <= 0 or assigned_at < 0:
|
|
return false
|
|
normalized[request_id] = {
|
|
&"request_id": request_id,
|
|
&"carrier_id": carrier_id,
|
|
&"weight": weight,
|
|
&"assigned_at": assigned_at,
|
|
}
|
|
var rebuilt_loads: Dictionary = {}
|
|
for request_id: StringName in _sorted_names(normalized.keys()):
|
|
var entry: Dictionary = normalized[request_id]
|
|
var carrier_id: StringName = entry[&"carrier_id"]
|
|
rebuilt_loads[carrier_id] = int(rebuilt_loads.get(carrier_id, 0)) + int(entry[&"weight"])
|
|
if int(_carrier_reserved_load.get(carrier_id, -1)) < int(rebuilt_loads[carrier_id]):
|
|
return false
|
|
# v1 mixed external carrier.current_load into this table. Active Requests plus
|
|
# assignment history are the only durable ownership authority during migration;
|
|
# any excess legacy load is therefore discarded deterministically.
|
|
if not _ownership_matches_loads_and_history(
|
|
normalized,
|
|
rebuilt_loads,
|
|
_assignment_history
|
|
):
|
|
return false
|
|
_carrier_reserved_load = rebuilt_loads
|
|
_assignment_ownership = normalized
|
|
restored_snapshot_version = SNAPSHOT_VERSION
|
|
return true
|
|
|
|
|
|
static func _ownership_matches_loads_and_history(
|
|
ownership: Dictionary,
|
|
loads: Dictionary,
|
|
history: Array[Dictionary]
|
|
) -> bool:
|
|
var expected_loads: Dictionary = {}
|
|
var latest_by_request: Dictionary = {}
|
|
for assignment: Dictionary in history:
|
|
latest_by_request[StringName(assignment[&"request_id"])] = assignment
|
|
for request_id: StringName in _sorted_names(ownership.keys()):
|
|
var entry: Dictionary = ownership[request_id]
|
|
var carrier_id: StringName = entry[&"carrier_id"]
|
|
var weight := int(entry[&"weight"])
|
|
expected_loads[carrier_id] = int(expected_loads.get(carrier_id, 0)) + weight
|
|
if not latest_by_request.has(request_id):
|
|
return false
|
|
var latest: Dictionary = latest_by_request[request_id]
|
|
if StringName(latest[&"carrier_id"]) != carrier_id \
|
|
or int(latest[&"weight"]) != weight \
|
|
or int(latest.get(&"assigned_at", -1)) != int(entry[&"assigned_at"]):
|
|
return false
|
|
if expected_loads.size() != loads.size():
|
|
return false
|
|
for carrier_id: StringName in _sorted_names(expected_loads.keys()):
|
|
if int(loads.get(carrier_id, -1)) != int(expected_loads[carrier_id]):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _normalize_carriers(carriers: Dictionary) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for raw_carrier_id: Variant in carriers:
|
|
if not carriers[raw_carrier_id] is Dictionary:
|
|
continue
|
|
var carrier_id := StringName(raw_carrier_id)
|
|
var source: Dictionary = carriers[raw_carrier_id]
|
|
var capacity := int(source.get(&"capacity", source.get(&"capacity_weight", 0)))
|
|
var current_load := maxi(int(source.get(&"current_load", 0)), 0)
|
|
if carrier_id == &"" or capacity <= 0 or current_load > capacity:
|
|
continue
|
|
result[carrier_id] = {
|
|
&"carrier_id": carrier_id,
|
|
&"position": source.get(&"position", Vector3i.ZERO),
|
|
&"capacity": capacity,
|
|
&"current_load": current_load,
|
|
&"role": StringName(source.get(&"role", &"")),
|
|
&"role_fit": source.get(&"role_fit", 0),
|
|
&"path_minutes": source.get(&"path_minutes", -1),
|
|
&"detour_minutes": maxi(int(source.get(&"detour_minutes", 0)), 0),
|
|
}
|
|
return result
|
|
|
|
|
|
static func _path_minutes_for(carrier: Dictionary, request: LogisticsRequest) -> int:
|
|
var path_source: Variant = carrier.get(&"path_minutes", -1)
|
|
if path_source is int or path_source is float:
|
|
return int(path_source)
|
|
if not path_source is Dictionary:
|
|
return -1
|
|
var paths: Dictionary = path_source
|
|
for key: Variant in [request.request_id, request.source_inventory_id, &"default", "default"]:
|
|
if paths.has(key):
|
|
return int(paths[key])
|
|
return -1
|
|
|
|
|
|
static func _role_fitness(carrier: Dictionary, request: LogisticsRequest) -> int:
|
|
var explicit_fit: Variant = carrier.get(&"role_fit", 0)
|
|
if explicit_fit is Dictionary:
|
|
var fits: Dictionary = explicit_fit
|
|
if fits.has(request.request_id):
|
|
return int(fits[request.request_id])
|
|
if fits.has(&"default"):
|
|
return int(fits[&"default"])
|
|
elif explicit_fit is int or explicit_fit is float:
|
|
var fit := int(explicit_fit)
|
|
if fit != 0:
|
|
return fit
|
|
match String(carrier.get(&"role", &"")).to_lower():
|
|
"logistics":
|
|
return 100
|
|
"porter":
|
|
return 80
|
|
"carrier":
|
|
return 60
|
|
_:
|
|
return 0
|
|
|
|
|
|
static func _deadline_risk(deadline: int, absolute_minute: int) -> int:
|
|
if deadline < 0:
|
|
return 0
|
|
var remaining := deadline - absolute_minute
|
|
if remaining <= 0:
|
|
return 200 + mini(-remaining, 100)
|
|
return maxi(DEADLINE_RISK_WINDOW - remaining, 0)
|
|
|
|
|
|
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
|