Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
class_name InventoryReservation
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
ACTIVE,
|
||||
CONSUMED,
|
||||
RELEASED,
|
||||
EXPIRED,
|
||||
}
|
||||
|
||||
var reservation_id: StringName = &""
|
||||
var stack_id: StringName = &""
|
||||
var owner_inventory_id: StringName = &""
|
||||
var item_id: StringName = &""
|
||||
var quantity := 0
|
||||
var requester_id: StringName = &""
|
||||
var purpose: StringName = &""
|
||||
var created_at := 0
|
||||
var expires_at := -1
|
||||
var state: State = State.ACTIVE
|
||||
var terminal_at := -1
|
||||
|
||||
|
||||
func configure(
|
||||
p_reservation_id: StringName,
|
||||
p_stack_id: StringName,
|
||||
p_owner_inventory_id: StringName,
|
||||
p_item_id: StringName,
|
||||
p_quantity: int,
|
||||
p_requester_id: StringName,
|
||||
p_purpose: StringName,
|
||||
p_created_at: int,
|
||||
p_expires_at: int = -1
|
||||
) -> InventoryReservation:
|
||||
reservation_id = p_reservation_id
|
||||
stack_id = p_stack_id
|
||||
owner_inventory_id = p_owner_inventory_id
|
||||
item_id = p_item_id
|
||||
quantity = maxi(p_quantity, 0)
|
||||
requester_id = p_requester_id
|
||||
purpose = p_purpose
|
||||
created_at = maxi(p_created_at, 0)
|
||||
expires_at = p_expires_at
|
||||
return self
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return state == State.ACTIVE
|
||||
|
||||
|
||||
func is_expired(absolute_minute: int) -> bool:
|
||||
return expires_at >= 0 and absolute_minute >= expires_at
|
||||
|
||||
|
||||
func mark_consumed(absolute_minute: int) -> bool:
|
||||
if state != State.ACTIVE:
|
||||
return false
|
||||
state = State.CONSUMED
|
||||
terminal_at = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func mark_released(absolute_minute: int) -> bool:
|
||||
if state != State.ACTIVE:
|
||||
return false
|
||||
state = State.RELEASED
|
||||
terminal_at = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func mark_expired(absolute_minute: int) -> bool:
|
||||
if state != State.ACTIVE:
|
||||
return false
|
||||
state = State.EXPIRED
|
||||
terminal_at = absolute_minute
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"reservation_id": String(reservation_id),
|
||||
&"stack_id": String(stack_id),
|
||||
&"owner_inventory_id": String(owner_inventory_id),
|
||||
&"item_id": String(item_id),
|
||||
&"quantity": quantity,
|
||||
&"requester_id": String(requester_id),
|
||||
&"purpose": String(purpose),
|
||||
&"created_at": created_at,
|
||||
&"expires_at": expires_at,
|
||||
&"state": State.keys()[state],
|
||||
&"terminal_at": terminal_at,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> InventoryReservation:
|
||||
if not _is_valid_snapshot(snapshot):
|
||||
return null
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "ACTIVE")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
var reservation := InventoryReservation.new().configure(
|
||||
StringName(snapshot.get(&"reservation_id", "")),
|
||||
StringName(snapshot.get(&"stack_id", "")),
|
||||
StringName(snapshot.get(&"owner_inventory_id", "")),
|
||||
StringName(snapshot.get(&"item_id", "")),
|
||||
int(snapshot.get(&"quantity", 0)),
|
||||
StringName(snapshot.get(&"requester_id", "")),
|
||||
StringName(snapshot.get(&"purpose", "")),
|
||||
int(snapshot.get(&"created_at", 0)),
|
||||
int(snapshot.get(&"expires_at", -1))
|
||||
)
|
||||
reservation.state = state_index as State
|
||||
reservation.terminal_at = int(snapshot.get(&"terminal_at", -1))
|
||||
return reservation
|
||||
|
||||
|
||||
static func _is_valid_snapshot(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
not String(snapshot.get(&"reservation_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"stack_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"owner_inventory_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"item_id", "")).is_empty()
|
||||
and int(snapshot.get(&"quantity", 0)) > 0
|
||||
and int(snapshot.get(&"created_at", -1)) >= 0
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci4av5wp5tmid
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://kuhl1ypidibh
|
||||
@@ -0,0 +1,481 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bakay5aor6q03
|
||||
@@ -0,0 +1,222 @@
|
||||
class_name LogisticsRequest
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
OPEN,
|
||||
RESERVED,
|
||||
ASSIGNED,
|
||||
PICKING_UP,
|
||||
DELIVERING,
|
||||
COMPLETED,
|
||||
BLOCKED,
|
||||
CANCELLED,
|
||||
}
|
||||
|
||||
const MAX_PRIORITY := 100
|
||||
|
||||
var request_id: StringName = &""
|
||||
var source_inventory_id: StringName = &""
|
||||
var destination_inventory_id: StringName = &""
|
||||
var item_requirements: Dictionary = {}
|
||||
var priority := 0
|
||||
var created_at := 0
|
||||
var deadline := -1
|
||||
var total_weight := 0
|
||||
var reserved_stack_ids: Array[StringName] = []
|
||||
var reservation_ids: Array[StringName] = []
|
||||
var assigned_carrier_id: StringName = &""
|
||||
var state: State = State.OPEN
|
||||
var retry_count := 0
|
||||
var state_reason := ""
|
||||
var state_changed_at := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_request_id: StringName,
|
||||
p_source_inventory_id: StringName,
|
||||
p_destination_inventory_id: StringName,
|
||||
p_item_requirements: Dictionary,
|
||||
p_priority: int,
|
||||
p_created_at: int,
|
||||
p_deadline: int,
|
||||
p_total_weight: int
|
||||
) -> LogisticsRequest:
|
||||
request_id = p_request_id
|
||||
source_inventory_id = p_source_inventory_id
|
||||
destination_inventory_id = p_destination_inventory_id
|
||||
item_requirements = _normalize_requirements(p_item_requirements)
|
||||
priority = clampi(p_priority, 0, MAX_PRIORITY)
|
||||
created_at = maxi(p_created_at, 0)
|
||||
deadline = p_deadline
|
||||
total_weight = maxi(p_total_weight, 0)
|
||||
state_changed_at = created_at
|
||||
return self
|
||||
|
||||
|
||||
func mark_reserved(
|
||||
p_reservation_ids: Array[StringName],
|
||||
p_reserved_stack_ids: Array[StringName] = [],
|
||||
absolute_minute: int = -1
|
||||
) -> bool:
|
||||
if state != State.OPEN and state != State.BLOCKED:
|
||||
return false
|
||||
if p_reservation_ids.is_empty():
|
||||
return false
|
||||
reservation_ids.assign(p_reservation_ids)
|
||||
reserved_stack_ids.assign(p_reserved_stack_ids)
|
||||
assigned_carrier_id = &""
|
||||
return transition_to(State.RESERVED, "", absolute_minute)
|
||||
|
||||
|
||||
func assign(carrier_id: StringName, absolute_minute: int = -1) -> bool:
|
||||
if carrier_id == &"" or state != State.RESERVED:
|
||||
return false
|
||||
assigned_carrier_id = carrier_id
|
||||
if transition_to(State.ASSIGNED, "", absolute_minute):
|
||||
return true
|
||||
assigned_carrier_id = &""
|
||||
return false
|
||||
|
||||
|
||||
func unassign(absolute_minute: int = -1) -> bool:
|
||||
if state != State.ASSIGNED:
|
||||
return false
|
||||
assigned_carrier_id = &""
|
||||
return transition_to(State.RESERVED, "", absolute_minute)
|
||||
|
||||
|
||||
func mark_blocked(reason: String, absolute_minute: int = -1) -> bool:
|
||||
if state == State.COMPLETED or state == State.CANCELLED:
|
||||
return false
|
||||
retry_count += 1
|
||||
assigned_carrier_id = &""
|
||||
return transition_to(State.BLOCKED, reason, absolute_minute)
|
||||
|
||||
|
||||
func cancel(reason: String = "", absolute_minute: int = -1) -> bool:
|
||||
return transition_to(State.CANCELLED, reason, absolute_minute)
|
||||
|
||||
|
||||
func transition_to(next_state: State, reason: String = "", absolute_minute: int = -1) -> bool:
|
||||
if state == next_state:
|
||||
return true
|
||||
if not _can_transition(state, next_state):
|
||||
return false
|
||||
state = next_state
|
||||
state_reason = reason
|
||||
state_changed_at = created_at if absolute_minute < 0 else absolute_minute
|
||||
if state == State.COMPLETED or state == State.CANCELLED:
|
||||
assigned_carrier_id = &""
|
||||
return true
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return state == State.COMPLETED or state == State.CANCELLED
|
||||
|
||||
|
||||
func is_dispatchable() -> bool:
|
||||
return state == State.RESERVED and assigned_carrier_id == &"" and total_weight > 0
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"request_id": String(request_id),
|
||||
&"source_inventory_id": String(source_inventory_id),
|
||||
&"destination_inventory_id": String(destination_inventory_id),
|
||||
&"item_requirements": item_requirements.duplicate(true),
|
||||
&"priority": priority,
|
||||
&"created_at": created_at,
|
||||
&"deadline": deadline,
|
||||
&"total_weight": total_weight,
|
||||
&"reserved_stack_ids": _names_to_strings(reserved_stack_ids),
|
||||
&"reservation_ids": _names_to_strings(reservation_ids),
|
||||
&"assigned_carrier_id": String(assigned_carrier_id),
|
||||
&"state": State.keys()[state],
|
||||
&"retry_count": retry_count,
|
||||
&"state_reason": state_reason,
|
||||
&"state_changed_at": state_changed_at,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> LogisticsRequest:
|
||||
if not _is_valid_snapshot(snapshot):
|
||||
return null
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "OPEN")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
var request := LogisticsRequest.new().configure(
|
||||
StringName(snapshot.get(&"request_id", "")),
|
||||
StringName(snapshot.get(&"source_inventory_id", "")),
|
||||
StringName(snapshot.get(&"destination_inventory_id", "")),
|
||||
snapshot.get(&"item_requirements", {}),
|
||||
int(snapshot.get(&"priority", 0)),
|
||||
int(snapshot.get(&"created_at", 0)),
|
||||
int(snapshot.get(&"deadline", -1)),
|
||||
int(snapshot.get(&"total_weight", 0))
|
||||
)
|
||||
request.reserved_stack_ids = _parse_names(snapshot.get(&"reserved_stack_ids", []))
|
||||
request.reservation_ids = _parse_names(snapshot.get(&"reservation_ids", []))
|
||||
request.assigned_carrier_id = StringName(snapshot.get(&"assigned_carrier_id", ""))
|
||||
request.state = state_index as State
|
||||
request.retry_count = maxi(int(snapshot.get(&"retry_count", 0)), 0)
|
||||
request.state_reason = String(snapshot.get(&"state_reason", ""))
|
||||
request.state_changed_at = int(snapshot.get(&"state_changed_at", request.created_at))
|
||||
if request.state == State.ASSIGNED and request.assigned_carrier_id == &"":
|
||||
return null
|
||||
return request
|
||||
|
||||
|
||||
static func _can_transition(from_state: State, to_state: State) -> bool:
|
||||
match from_state:
|
||||
State.OPEN:
|
||||
return to_state in [State.RESERVED, State.BLOCKED, State.CANCELLED]
|
||||
State.RESERVED:
|
||||
return to_state in [State.OPEN, State.ASSIGNED, State.BLOCKED, State.CANCELLED]
|
||||
State.ASSIGNED:
|
||||
return to_state in [State.RESERVED, State.PICKING_UP, State.BLOCKED, State.CANCELLED]
|
||||
State.PICKING_UP:
|
||||
return to_state in [State.DELIVERING, State.BLOCKED, State.CANCELLED]
|
||||
State.DELIVERING:
|
||||
return to_state in [State.COMPLETED, State.BLOCKED, State.CANCELLED]
|
||||
State.BLOCKED:
|
||||
return to_state in [State.OPEN, State.RESERVED, State.CANCELLED]
|
||||
_:
|
||||
return false
|
||||
|
||||
|
||||
static func _normalize_requirements(source: Variant) -> Dictionary:
|
||||
var normalized: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return normalized
|
||||
for raw_item_id: Variant in source:
|
||||
var item_id := StringName(raw_item_id)
|
||||
var quantity := int(source[raw_item_id])
|
||||
if item_id != &"" and quantity > 0:
|
||||
normalized[item_id] = quantity
|
||||
return normalized
|
||||
|
||||
|
||||
static func _is_valid_snapshot(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
not String(snapshot.get(&"request_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"source_inventory_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"destination_inventory_id", "")).is_empty()
|
||||
and snapshot.get(&"item_requirements", null) is Dictionary
|
||||
and int(snapshot.get(&"total_weight", -1)) >= 0
|
||||
)
|
||||
|
||||
|
||||
static func _parse_names(source: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for value: Variant in source:
|
||||
result.append(StringName(value))
|
||||
return result
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for value: StringName in source:
|
||||
result.append(String(value))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bny06mspvcg8u
|
||||
Reference in New Issue
Block a user