561 lines
27 KiB
GDScript
561 lines
27 KiB
GDScript
class_name SupplyLogisticsBridge
|
|
extends RefCounted
|
|
|
|
## Execution state lives in LogisticsRequest, StaffTask, and InventoryService.
|
|
## This adapter owns no additional save payload and never moves an actor itself.
|
|
const REQUEST_PREFIX := "supply."
|
|
const PICKUP_TYPE := &"supply_pickup"
|
|
const DELIVERY_TYPE := &"supply_delivery"
|
|
const RETURN_TYPE := &"supply_return"
|
|
const RETRY_MINUTES := 5
|
|
const RETURNING := "RETURNING_EXPIRED_CARGO"
|
|
|
|
var _runtime: Variant
|
|
var last_report: Dictionary = {}
|
|
|
|
|
|
func configure(runtime: Variant) -> SupplyLogisticsBridge:
|
|
_runtime = runtime
|
|
_runtime.staff_service.configure_external_task_driver(Callable(self, "owns_task"))
|
|
return self
|
|
|
|
|
|
func owns_task(task_id: StringName) -> bool:
|
|
if _runtime == null:
|
|
return false
|
|
var task: StaffTask = _runtime.staff_service.get_task(task_id)
|
|
return task != null and task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] \
|
|
and String(task.target_entity_id).begins_with(REQUEST_PREFIX)
|
|
|
|
|
|
func can_move_task(task_id: StringName) -> bool:
|
|
if not owns_task(task_id):
|
|
return true
|
|
var task: StaffTask = _runtime.staff_service.get_task(task_id)
|
|
var staff: StaffRuntime = _runtime.staff_service.get_staff(task.assigned_staff_id)
|
|
if staff == null or not staff.can_take_shift():
|
|
return false
|
|
# Partial spoilage is a return obligation. Do not keep walking to the
|
|
# customer while the bridge is still searching for a safe return route.
|
|
return task.task_type != DELIVERY_TYPE or not is_returning(task.target_entity_id)
|
|
|
|
|
|
func tick(absolute_minute: int) -> Dictionary:
|
|
if _runtime == null:
|
|
return {&"ok": false, &"reason": &"NOT_CONFIGURED"}
|
|
var actions: Array[Dictionary] = []
|
|
for request_id: StringName in _request_ids(_runtime.logistics_requests):
|
|
var request: LogisticsRequest = _runtime.logistics_requests[request_id]
|
|
if request.is_terminal():
|
|
continue
|
|
if request.state == LogisticsRequest.State.BLOCKED:
|
|
if absolute_minute >= request.state_changed_at + RETRY_MINUTES:
|
|
_rearm(request, absolute_minute)
|
|
continue
|
|
if request.state == LogisticsRequest.State.RESERVED:
|
|
if not _reserved_stock_valid(request):
|
|
_block_before_pickup(request, "SOURCE_STOCK_EXPIRED", absolute_minute)
|
|
else:
|
|
_dispatch(request, absolute_minute)
|
|
continue
|
|
var task := _active_task(_runtime.staff_service, request.request_id)
|
|
var staff: StaffRuntime = _runtime.staff_service.get_staff(request.assigned_carrier_id)
|
|
if task == null or staff == null or staff.current_task_id != task.task_id:
|
|
request.state_reason = "CARRIER_TASK_MISSING"
|
|
continue
|
|
if request.state == LogisticsRequest.State.DELIVERING and _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)).is_empty():
|
|
_cancel_empty_cargo(request, staff, task, "CARGO_EXPIRED", absolute_minute)
|
|
continue
|
|
if not staff.can_take_shift():
|
|
if request.state in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP]:
|
|
_block_before_pickup(request, "CARRIER_UNAVAILABLE", absolute_minute)
|
|
else:
|
|
request.state_reason = "CARRIER_UNAVAILABLE"
|
|
continue
|
|
if request.state in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP]:
|
|
if not _reserved_stock_valid(request):
|
|
_block_before_pickup(request, "SOURCE_STOCK_EXPIRED", absolute_minute)
|
|
elif _arrived_for_work(staff, task, absolute_minute):
|
|
if _pickup(request, staff, task, absolute_minute):
|
|
actions.append({&"request_id": request_id, &"action": &"PICKED_UP"})
|
|
elif request.state == LogisticsRequest.State.DELIVERING:
|
|
var cargo := _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id))
|
|
if cargo.is_empty():
|
|
_cancel_empty_cargo(request, staff, task, "CARGO_EXPIRED", absolute_minute)
|
|
continue
|
|
if not _quantities_cover(cargo, request.item_requirements) and task.task_type != RETURN_TYPE:
|
|
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
|
var route := _access_path(staff.current_cell, source)
|
|
if not bool(route.get(&"reachable", false)):
|
|
request.state_reason = "EXPIRED_CARGO_RETURN_BLOCKED"
|
|
continue
|
|
if not _runtime.staff_service.retarget_external_task(task.task_id, route[&"cell"], absolute_minute).is_success():
|
|
continue
|
|
task.task_type = RETURN_TYPE
|
|
request.state_reason = RETURNING
|
|
continue
|
|
if _arrived_for_work(staff, task, absolute_minute):
|
|
if _deliver(request, staff, task, absolute_minute):
|
|
actions.append({&"request_id": request_id, &"action": &"DELIVERED"})
|
|
last_report = {&"ok": true, &"actions": actions}
|
|
return last_report.duplicate(true)
|
|
|
|
|
|
func handle_path_stuck(task_id: StringName, absolute_minute: int) -> Dictionary:
|
|
if not owns_task(task_id):
|
|
return {&"ok": false, &"action": &"NOT_SUPPLY_TASK"}
|
|
var task: StaffTask = _runtime.staff_service.get_task(task_id)
|
|
var request: LogisticsRequest = _runtime.logistics_requests.get(task.target_entity_id)
|
|
if request == null:
|
|
return {&"ok": false, &"action": &"REQUEST_MISSING"}
|
|
if request.state == LogisticsRequest.State.DELIVERING:
|
|
# Loaded cargo stays with its actor, even when no safe return route exists.
|
|
# The normal Path loop retries after topology is repaired.
|
|
request.retry_count += 1
|
|
if task.task_type != RETURN_TYPE:
|
|
request.state_reason = "ROUTE_BLOCKED"
|
|
task.state_reason = &"ROUTE_BLOCKED"
|
|
return {&"ok": true, &"action": &"SUPPLY_CARGO_HELD"}
|
|
return {&"ok": _block_before_pickup(request, "ROUTE_BLOCKED", absolute_minute), &"action": &"SUPPLY_PICKUP_RETRY"}
|
|
|
|
|
|
func get_report() -> Dictionary:
|
|
var rows: Array[Dictionary] = []
|
|
if _runtime == null:
|
|
return {&"requests": rows, &"deliveries": rows.duplicate(true)}
|
|
for request_id: StringName in _request_ids(_runtime.logistics_requests):
|
|
var request: LogisticsRequest = _runtime.logistics_requests[request_id]
|
|
var task := _active_task(_runtime.staff_service, request_id)
|
|
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
|
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
|
var worker: StaffRuntime = _runtime.staff_service.get_staff(request.assigned_carrier_id)
|
|
rows.append({
|
|
&"source_facility_id": source.instance_id if source != null else &"",
|
|
&"destination_facility_id": destination.instance_id if destination != null else &"",
|
|
&"carrier_name": String(_runtime.mvp_content_catalog.get_entry(&"staff_candidates", worker.staff_id).get(&"display_name", worker.staff_id)) if worker != null else "",
|
|
&"reason_codes": [] if request.state_reason.is_empty() else [StringName(request.state_reason)],
|
|
&"request_id": request_id, &"state": LogisticsRequest.State.keys()[request.state],
|
|
&"reason": request.state_reason, &"carrier_id": request.assigned_carrier_id,
|
|
&"source_inventory_id": request.source_inventory_id,
|
|
&"destination_inventory_id": request.destination_inventory_id,
|
|
&"item_requirements": request.item_requirements.duplicate(true), &"weight": request.total_weight,
|
|
&"retry_count": request.retry_count, &"task_id": task.task_id if task != null else &"",
|
|
})
|
|
var active: Array[Dictionary] = []
|
|
var recent: Array[Dictionary] = []
|
|
for row: Dictionary in rows:
|
|
var request: LogisticsRequest = _runtime.logistics_requests[row[&"request_id"]]
|
|
if request.is_terminal(): recent.append(row)
|
|
else: active.append(row)
|
|
recent.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
var left: LogisticsRequest = _runtime.logistics_requests[a[&"request_id"]]
|
|
var right: LogisticsRequest = _runtime.logistics_requests[b[&"request_id"]]
|
|
if left.state_changed_at != right.state_changed_at: return left.state_changed_at > right.state_changed_at
|
|
return String(left.request_id) > String(right.request_id))
|
|
active.append_array(recent.slice(0, 10))
|
|
return {&"requests": rows, &"deliveries": active}
|
|
|
|
|
|
func is_returning(request_id: StringName) -> bool:
|
|
if _runtime == null:
|
|
return false
|
|
var request: LogisticsRequest = _runtime.logistics_requests.get(request_id)
|
|
if request == null or request.state != LogisticsRequest.State.DELIVERING:
|
|
return false
|
|
var task := _active_task(_runtime.staff_service, request_id)
|
|
if task != null and task.task_type == RETURN_TYPE:
|
|
return true
|
|
return not _quantities_cover(_cargo_quantities(_runtime.inventory_service, _cargo_id(request.assigned_carrier_id)), request.item_requirements)
|
|
|
|
|
|
func _dispatch(request: LogisticsRequest, minute: int) -> void:
|
|
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
|
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
|
if source == null or destination == null or not source.operator_open or not destination.operator_open:
|
|
request.state_reason = "FACILITY_UNAVAILABLE"
|
|
return
|
|
if not _has_storage_space(destination, request.total_weight):
|
|
request.state_reason = "DESTINATION_FULL"
|
|
return
|
|
var carriers: Dictionary = {}
|
|
var source_cells: Dictionary = {}
|
|
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
|
var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id)
|
|
if staff.role_id != &"porter" or not staff.is_available_for_dispatch() \
|
|
or shift == null or not shift.contains_minute(minute) \
|
|
or _runtime.logistics_dispatcher.get_reserved_load(staff.staff_id) > 0 \
|
|
or not _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)).is_empty():
|
|
continue
|
|
var access := _access_path(staff.current_cell, source)
|
|
if not bool(access.get(&"reachable", false)):
|
|
continue
|
|
var onward := _access_path(access[&"cell"], destination)
|
|
if not bool(onward.get(&"reachable", false)):
|
|
continue
|
|
carriers[staff.staff_id] = {&"capacity": mini(20, 10 + int(staff.skills.get(&"operation", 0)) / 10),
|
|
&"current_load": 0, &"path_minutes": int(access[&"steps"]), &"position": staff.current_cell, &"role": &"logistics"}
|
|
source_cells[staff.staff_id] = access[&"cell"]
|
|
if carriers.is_empty():
|
|
request.state_reason = "NO_AVAILABLE_PORTER_OR_ROUTE"
|
|
return
|
|
var before := _capture(request)
|
|
var assigned: Array = _runtime.logistics_dispatcher.dispatch([request], carriers, minute)
|
|
if assigned.is_empty():
|
|
request.state_reason = "CARRIER_CAPACITY_EXCEEDED"
|
|
return
|
|
var staff_id := request.assigned_carrier_id
|
|
var task := _create_task(request, PICKUP_TYPE, source_cells[staff_id], minute)
|
|
if task == null or not _runtime.staff_service.assign_task_to_staff(task.task_id, staff_id, minute).is_success():
|
|
_rollback(request, before)
|
|
request.state_reason = "TASK_ASSIGNMENT_FAILED"
|
|
return
|
|
request.state_reason = ""
|
|
|
|
|
|
func _pickup(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, minute: int) -> bool:
|
|
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
|
if source == null or not _cell_touches_facility(staff.current_cell, source):
|
|
return _block_before_pickup(request, "INVALID_PICKUP_ACCESS", minute) and false
|
|
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
|
if destination == null or not destination.operator_open or not _has_storage_space(destination, request.total_weight):
|
|
return _block_before_pickup(request, "DESTINATION_FULL_OR_CLOSED", minute) and false
|
|
var route := _access_path(staff.current_cell, destination)
|
|
if not bool(route.get(&"reachable", false)):
|
|
return _block_before_pickup(request, "NO_DELIVERY_ROUTE", minute) and false
|
|
var before := _capture(request)
|
|
if request.state == LogisticsRequest.State.ASSIGNED and not _advance_request(request, minute):
|
|
_rollback(request, before)
|
|
return false
|
|
if not _advance_request(request, minute):
|
|
_rollback(request, before)
|
|
return _block_before_pickup(request, "PICKUP_FAILED", minute) and false
|
|
if not _runtime.staff_service.progress_task(task.task_id, 1, minute, true).is_success():
|
|
_rollback(request, before)
|
|
return false
|
|
staff.carried_inventory_id = _cargo_id(staff.staff_id)
|
|
var delivery := _create_task(request, DELIVERY_TYPE, route[&"cell"], minute)
|
|
if delivery == null or not _runtime.staff_service.assign_task_to_staff(delivery.task_id, staff.staff_id, minute).is_success():
|
|
_rollback(request, before)
|
|
return false
|
|
request.state_reason = ""
|
|
return true
|
|
|
|
|
|
func _deliver(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, minute: int) -> bool:
|
|
var returning := task.task_type == RETURN_TYPE
|
|
var destination_id := request.source_inventory_id if returning else request.destination_inventory_id
|
|
var destination := _facility_for_inventory(_runtime.facility_service, destination_id)
|
|
if destination == null or not _cell_touches_facility(staff.current_cell, destination):
|
|
request.state_reason = "INVALID_DELIVERY_ACCESS"
|
|
return false
|
|
var quantities := _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id))
|
|
if destination == null or not destination.operator_open or not _has_storage_space(destination, SupplyCatalog.inventory_weight(_runtime.inventory_service, _cargo_id(staff.staff_id))):
|
|
if not returning:
|
|
request.state_reason = "DESTINATION_FULL"
|
|
return false
|
|
var before := _capture(request)
|
|
if returning:
|
|
var transfers: Array[Dictionary] = []
|
|
for item_id: StringName in _sorted_keys(quantities):
|
|
transfers.append({&"source_inventory_id": _cargo_id(staff.staff_id), &"destination_inventory_id": destination_id,
|
|
&"item_id": item_id, &"quantity": int(quantities[item_id]), &"operation_id": StringName("supply.return.%s.%s" % [request.request_id, item_id])})
|
|
if not _runtime.inventory_service.transfer_available_batch(transfers, minute)[&"ok"] \
|
|
or not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, staff.staff_id, request.total_weight):
|
|
_rollback(request, before)
|
|
return false
|
|
request.cancel("CARGO_EXPIRED_RETURNED", minute)
|
|
else:
|
|
if not _advance_request(request, minute):
|
|
_rollback(request, before)
|
|
request.state_reason = "DELIVERY_RETRY"
|
|
return false
|
|
if not _runtime.staff_service.progress_task(task.task_id, 1, minute, true).is_success():
|
|
_rollback(request, before)
|
|
return false
|
|
staff.carried_inventory_id = &""
|
|
return true
|
|
|
|
|
|
func _block_before_pickup(request: LogisticsRequest, reason: String, minute: int) -> bool:
|
|
var before := _capture(request)
|
|
var task := _active_task(_runtime.staff_service, request.request_id)
|
|
var carrier_id := request.assigned_carrier_id
|
|
var worker: StaffRuntime = _runtime.staff_service.get_staff(carrier_id)
|
|
var unavailable_state := worker.state if worker != null and not worker.can_take_shift() else -1
|
|
for reservation_id: StringName in request.reservation_ids:
|
|
var reservation: InventoryReservation = _runtime.inventory_service.get_reservation(reservation_id)
|
|
if reservation != null and reservation.is_active() \
|
|
and not _runtime.inventory_service.release_reservation(reservation_id, &"", minute)[&"ok"]:
|
|
_rollback(request, before)
|
|
return false
|
|
if not carrier_id.is_empty() and not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, carrier_id, request.total_weight):
|
|
_rollback(request, before)
|
|
return false
|
|
if task != null and not _runtime.staff_service.cancel_task(task.task_id, StringName(reason), minute, true).is_success():
|
|
_rollback(request, before)
|
|
return false
|
|
if unavailable_state >= 0:
|
|
worker.transition_to(unavailable_state, StringName(reason), minute)
|
|
request.mark_blocked(reason, minute)
|
|
return true
|
|
|
|
|
|
func _rearm(request: LogisticsRequest, minute: int) -> void:
|
|
if request.deadline >= 0 and request.deadline <= minute:
|
|
request.cancel("DEADLINE_EXPIRED", minute)
|
|
return
|
|
var before := _capture(request)
|
|
var ids: Array[StringName] = []
|
|
var stacks: Array[StringName] = []
|
|
var weight := 0
|
|
for item_id: StringName in _sorted_keys(request.item_requirements):
|
|
var result: Dictionary = _runtime.inventory_service.reserve_item(request.source_inventory_id, item_id,
|
|
int(request.item_requirements[item_id]), request.request_id, &"logistics", minute, request.deadline,
|
|
StringName("supply.retry.%s.%d.%s" % [request.request_id, minute, item_id]))
|
|
if not bool(result.get(&"ok", false)):
|
|
_rollback(request, before)
|
|
request.state_changed_at = minute
|
|
request.state_reason = "WAITING_FOR_SOURCE_STOCK"
|
|
return
|
|
for id: StringName in result[&"reservation_ids"]:
|
|
ids.append(id)
|
|
var reservation: InventoryReservation = _runtime.inventory_service.get_reservation(id)
|
|
stacks.append(reservation.stack_id)
|
|
weight += reservation.quantity * int(_runtime.inventory_service.get_stack(reservation.stack_id)[&"unit_weight"])
|
|
if weight != request.total_weight or not request.mark_reserved(ids, stacks, minute):
|
|
_rollback(request, before)
|
|
request.state_changed_at = minute
|
|
request.state_reason = "SOURCE_WEIGHT_CHANGED"
|
|
|
|
|
|
func _cancel_empty_cargo(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, reason: String, minute: int) -> void:
|
|
var before := _capture(request)
|
|
var unavailable_state := staff.state if not staff.can_take_shift() else -1
|
|
if not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, staff.staff_id, request.total_weight) \
|
|
or not _runtime.staff_service.cancel_task(task.task_id, StringName(reason), minute, true).is_success():
|
|
_rollback(request, before)
|
|
return
|
|
staff.carried_inventory_id = &""
|
|
if unavailable_state >= 0:
|
|
staff.transition_to(unavailable_state, StringName(reason), minute)
|
|
request.cancel(reason, minute)
|
|
|
|
|
|
func _create_task(request: LogisticsRequest, type: StringName, cell: Vector3i, minute: int) -> StaffTask:
|
|
var leg := "pickup" if type == PICKUP_TYPE else "delivery"
|
|
var task_id := StringName("supply.%s.%s.%d" % [leg, request.request_id, request.retry_count])
|
|
var result: CommandResult = _runtime.staff_service.create_task(type, request.priority, minute, {
|
|
&"target_entity_id": request.request_id, &"target_cell": cell, &"required_role_tags": [&"porter"],
|
|
&"task_tags": [&"logistics"], &"estimated_minutes": 1, &"interrupt_level": 0,
|
|
}, task_id)
|
|
return _runtime.staff_service.get_task(task_id) if result.is_success() else null
|
|
|
|
|
|
func _advance_request(request: LogisticsRequest, minute: int) -> bool:
|
|
var command := SimulationCommand.new()
|
|
command.command_id = 0
|
|
command.command_type = &"advance_logistics_request"
|
|
command.payload = {&"request_id": request.request_id}
|
|
command.issued_tick = minute
|
|
command.execute_tick = minute
|
|
return _runtime._execute_advance_logistics_request(command).is_success()
|
|
|
|
|
|
func _capture(request: LogisticsRequest) -> Dictionary:
|
|
return {&"request": request.create_snapshot(), &"staff": _runtime.staff_service.create_snapshot(),
|
|
&"inventory": _runtime.inventory_service.create_snapshot(), &"dispatcher": _runtime.logistics_dispatcher.create_snapshot(),
|
|
&"events": _runtime.domain_events.create_snapshot(), &"ids": _runtime.id_generator.create_snapshot()}
|
|
|
|
|
|
func _rollback(request: LogisticsRequest, before: Dictionary) -> void:
|
|
_runtime.staff_service.restore_snapshot(before[&"staff"])
|
|
_runtime.inventory_service.restore_snapshot(before[&"inventory"])
|
|
_runtime.logistics_dispatcher.restore_snapshot(before[&"dispatcher"])
|
|
_runtime.domain_events.restore_snapshot(before[&"events"])
|
|
_runtime.id_generator.restore_snapshot(before[&"ids"])
|
|
var restored := LogisticsRequest.from_snapshot(before[&"request"])
|
|
# Keep the Request identity while callers finish handling this transition.
|
|
for property: Dictionary in restored.get_property_list():
|
|
var name := StringName(property[&"name"])
|
|
if name in [&"RefCounted", &"script"] or not (int(property[&"usage"]) & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
|
continue
|
|
request.set(name, restored.get(name))
|
|
_runtime.logistics_requests[request.request_id] = request
|
|
|
|
|
|
func _reserved_stock_valid(request: LogisticsRequest) -> bool:
|
|
return _reservation_quantities_valid(request, _runtime.inventory_service, true)
|
|
|
|
|
|
func _has_storage_space(facility: FacilityInstance, incoming_weight: int) -> bool:
|
|
var capacity := SupplyCatalog.storage_capacity(facility.definition_id)
|
|
return capacity > 0 and SupplyCatalog.inventory_weight(_runtime.inventory_service, facility.local_inventory_id) + incoming_weight <= capacity
|
|
|
|
|
|
func find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
|
return _access_path(start, facility)
|
|
|
|
|
|
func _access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
|
if facility == null:
|
|
return {&"reachable": false}
|
|
var cells: Dictionary = {}
|
|
for cell: Vector3i in facility.occupied_cells:
|
|
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
|
|
if _runtime.path_service.has_cell(cell + delta):
|
|
cells[cell + delta] = true
|
|
if cells.is_empty() and _runtime.path_service.has_cell(facility.origin):
|
|
cells[facility.origin] = true
|
|
var ordered: Array = cells.keys()
|
|
ordered.sort_custom(func(a: Vector3i, b: Vector3i) -> bool:
|
|
if a.z != b.z: return a.z < b.z
|
|
if a.y != b.y: return a.y < b.y
|
|
return a.x < b.x)
|
|
var best: Array[Vector3i] = []
|
|
for cell: Vector3i in ordered:
|
|
var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"])
|
|
if not path.is_empty() and (best.is_empty() or path.size() < best.size()):
|
|
best = path
|
|
return {&"reachable": not best.is_empty(), &"cell": best[-1] if not best.is_empty() else facility.origin, &"steps": maxi(0, best.size() - 1)}
|
|
|
|
|
|
static func _arrived_for_work(staff: StaffRuntime, task: StaffTask, minute: int) -> bool:
|
|
return task.state == StaffTask.State.WORKING and task.state_changed_at < minute and staff.current_cell == task.target_cell
|
|
|
|
|
|
static func _cargo_id(staff_id: StringName) -> StringName:
|
|
return StringName("cargo:%s" % staff_id)
|
|
|
|
|
|
static func _request_ids(requests: Dictionary) -> Array[StringName]:
|
|
var ids: Array[StringName] = []
|
|
for id: StringName in _sorted_keys(requests):
|
|
if String(id).begins_with(REQUEST_PREFIX):
|
|
ids.append(id)
|
|
return ids
|
|
|
|
|
|
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
|
|
var ids: Array[StringName] = []
|
|
for key: Variant in source:
|
|
ids.append(StringName(key))
|
|
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return ids
|
|
|
|
|
|
static func _active_task(staff: StaffService, request_id: StringName) -> StaffTask:
|
|
for task: StaffTask in staff.get_tasks():
|
|
if task.target_entity_id == request_id and task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] and not task.is_terminal():
|
|
return task
|
|
return null
|
|
|
|
|
|
static func _facility_for_inventory(facilities: FacilityService, inventory_id: StringName) -> FacilityInstance:
|
|
for facility: FacilityInstance in facilities.get_instances():
|
|
if facility.local_inventory_id == inventory_id:
|
|
return facility
|
|
return null
|
|
|
|
|
|
static func _cargo_quantities(inventory: InventoryService, cargo_id: StringName) -> Dictionary:
|
|
var quantities: Dictionary = {}
|
|
for stack: Dictionary in inventory.get_stacks(cargo_id):
|
|
var item_id := StringName(stack[&"item_id"])
|
|
quantities[item_id] = int(quantities.get(item_id, 0)) + int(stack[&"quantity"])
|
|
return quantities
|
|
|
|
|
|
static func _quantities_cover(actual: Dictionary, requested: Dictionary) -> bool:
|
|
for item_id: StringName in _sorted_keys(requested):
|
|
if int(actual.get(item_id, 0)) < int(requested[item_id]):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _reservation_quantities_valid(request: LogisticsRequest, inventory: InventoryService, active: bool) -> bool:
|
|
var totals: Dictionary = {}
|
|
var weight := 0
|
|
var seen: Dictionary = {}
|
|
if request.reservation_ids.is_empty():
|
|
return false
|
|
for id: StringName in request.reservation_ids:
|
|
if seen.has(id):
|
|
return false
|
|
seen[id] = true
|
|
var reservation := inventory.get_reservation(id)
|
|
if reservation == null or reservation.requester_id != request.request_id \
|
|
or reservation.owner_inventory_id != request.source_inventory_id:
|
|
return false
|
|
if active and (not reservation.is_active() or reservation.is_expired(inventory.current_minute) \
|
|
or inventory.is_stack_expired(reservation.stack_id, inventory.current_minute)):
|
|
return false
|
|
if not active and reservation.state != InventoryReservation.State.CONSUMED:
|
|
return false
|
|
totals[reservation.item_id] = int(totals.get(reservation.item_id, 0)) + reservation.quantity
|
|
if active:
|
|
var stack := inventory.get_stack(reservation.stack_id)
|
|
if stack.is_empty():
|
|
return false
|
|
weight += reservation.quantity * int(stack[&"unit_weight"])
|
|
return totals == request.item_requirements and (not active or weight == request.total_weight)
|
|
|
|
|
|
static func validate_crosslinks(requests: Dictionary, staff: StaffService, inventory: InventoryService, facilities: FacilityService) -> bool:
|
|
var carrier_owners: Dictionary = {}
|
|
var active_task_ids: Dictionary = {}
|
|
for id: StringName in _request_ids(requests):
|
|
var request: LogisticsRequest = requests[id]
|
|
var source := _facility_for_inventory(facilities, request.source_inventory_id)
|
|
var destination := _facility_for_inventory(facilities, request.destination_inventory_id)
|
|
if not String(request.source_inventory_id).begins_with("facility.inventory:") or not String(request.destination_inventory_id).begins_with("facility.inventory:"):
|
|
return false
|
|
if not request.is_terminal() and (source == null or destination == null):
|
|
return false
|
|
var task := _active_task(staff, id)
|
|
if request.state in [LogisticsRequest.State.RESERVED, LogisticsRequest.State.BLOCKED] or request.is_terminal():
|
|
if task != null:
|
|
return false
|
|
if request.state == LogisticsRequest.State.RESERVED and not _reservation_quantities_valid(request, inventory, true):
|
|
return false
|
|
continue
|
|
if request.state not in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP, LogisticsRequest.State.DELIVERING]:
|
|
return false
|
|
var worker := staff.get_staff(request.assigned_carrier_id)
|
|
if worker == null or worker.role_id != &"porter" or carrier_owners.has(worker.staff_id) \
|
|
or task == null or task.assigned_staff_id != worker.staff_id or worker.current_task_id != task.task_id \
|
|
or task.state not in [StaffTask.State.RESERVED, StaffTask.State.MOVING, StaffTask.State.WORKING] \
|
|
or task.interrupt_level != 0 or not task.required_items.is_empty() or not task.reservation_ids.is_empty():
|
|
return false
|
|
carrier_owners[worker.staff_id] = id
|
|
active_task_ids[task.task_id] = true
|
|
var loaded := request.state == LogisticsRequest.State.DELIVERING
|
|
var endpoint := source if not loaded or task.task_type == RETURN_TYPE else destination
|
|
if not _cell_touches_facility(task.target_cell, endpoint):
|
|
return false
|
|
if (loaded and task.task_type not in [DELIVERY_TYPE, RETURN_TYPE]) or (not loaded and task.task_type != PICKUP_TYPE) \
|
|
or not String(task.task_id).begins_with("supply.%s.%s." % ["delivery" if loaded else "pickup", id]):
|
|
return false
|
|
if not _reservation_quantities_valid(request, inventory, not loaded):
|
|
return false
|
|
var cargo := _cargo_quantities(inventory, _cargo_id(worker.staff_id))
|
|
if loaded:
|
|
if worker.carried_inventory_id != _cargo_id(worker.staff_id) or cargo.is_empty():
|
|
return false
|
|
for item_id: StringName in _sorted_keys(cargo):
|
|
if int(cargo[item_id]) > int(request.item_requirements.get(item_id, 0)):
|
|
return false
|
|
elif not cargo.is_empty() or not worker.carried_inventory_id.is_empty():
|
|
return false
|
|
for task: StaffTask in staff.get_tasks():
|
|
if task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] and not task.is_terminal() \
|
|
and (not requests.has(task.target_entity_id) or not active_task_ids.has(task.task_id)):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _cell_touches_facility(cell: Vector3i, facility: FacilityInstance) -> bool:
|
|
for occupied: Vector3i in facility.occupied_cells:
|
|
if cell.z == occupied.z and absi(cell.x - occupied.x) + absi(cell.y - occupied.y) <= 1:
|
|
return true
|
|
return cell == facility.origin
|