Files
TheTree_mock/scripts/inventory/logistics_dispatcher.gd
T

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