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