679 lines
33 KiB
GDScript
679 lines
33 KiB
GDScript
class_name ElevatorService
|
|
extends RefCounted
|
|
|
|
## One physical cabin per contiguous shaft. Path connectors expose reachability;
|
|
## boarding and arrival here remain authoritative for actual movement.
|
|
## Dispatch is oldest request first, batching one origin/destination at a time.
|
|
const SNAPSHOT_VERSION := 1
|
|
const PREFIX := "transport."
|
|
const REQUEST_PREFIX := "transport.trip."
|
|
const DOOR_MINUTES := 2
|
|
const QUEUE_CAPACITY := 256
|
|
|
|
var last_error := ""
|
|
var _path: PathService
|
|
var _shafts: Dictionary = {}
|
|
var _trips: Dictionary = {}
|
|
var _minute := -1
|
|
var _sequence := 0
|
|
var current_minute: int:
|
|
get: return _minute
|
|
|
|
|
|
func configure(path: PathService) -> void:
|
|
_path = path
|
|
|
|
|
|
static func configurations_from_facilities(facilities: Array, registry: DefinitionRegistry) -> Array:
|
|
var modules: Array[Dictionary] = []
|
|
for value: Variant in facilities:
|
|
if not value is Dictionary:
|
|
continue
|
|
var facility: Dictionary = value
|
|
var definition_id := StringName(str(facility.get(&"definition_id", "")))
|
|
if definition_id not in [&"guest_elevator", &"service_elevator"]:
|
|
continue
|
|
var definition := registry.get_facility(definition_id)
|
|
var origin: Variant = facility.get(&"origin")
|
|
if definition == null or not origin is Vector3i:
|
|
continue
|
|
var width := definition.footprint.x
|
|
var height := definition.footprint.y
|
|
var cells: Array = facility.get(&"occupied_cells", [])
|
|
if not cells.is_empty():
|
|
width = 1
|
|
height = 1
|
|
for cell: Vector3i in cells:
|
|
width = maxi(width, cell.x - origin.x + 1)
|
|
height = maxi(height, cell.y - origin.y + 1)
|
|
modules.append({&"origin": origin, &"width": width, &"height": height,
|
|
&"module_id": StringName(str(facility.get(&"instance_id", "")))})
|
|
modules.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
var ac: Vector3i = a[&"origin"]
|
|
var bc: Vector3i = b[&"origin"]
|
|
if ac.z != bc.z: return ac.z < bc.z
|
|
if ac.x != bc.x: return ac.x < bc.x
|
|
if a[&"width"] != b[&"width"]: return a[&"width"] < b[&"width"]
|
|
return ac.y < bc.y)
|
|
var result: Array = []
|
|
for module: Dictionary in modules:
|
|
var origin: Vector3i = module[&"origin"]
|
|
var width: int = module[&"width"]
|
|
var config: Dictionary = {}
|
|
if not result.is_empty():
|
|
var previous: Dictionary = result.back()
|
|
var stops: Array = previous[&"stops"]
|
|
var top: Vector3i = stops.back()
|
|
if top.x == origin.x and top.z == origin.z and top.y + 1 == origin.y and previous[&"width"] == width:
|
|
config = previous
|
|
if config.is_empty():
|
|
config = {&"shaft_id": StringName("transport.shaft.%d.%d.%d.%d" % [origin.z, origin.x, origin.y, width]),
|
|
&"module_ids": [], &"stops": [], &"width": width,
|
|
&"capacity": 4 if origin.z == 0 else 8,
|
|
&"travel_minutes_per_floor": 10 if origin.z == 0 else 5,
|
|
&"access_id": &"" if origin.z == 0 else &"staff"}
|
|
result.append(config)
|
|
(config[&"module_ids"] as Array).append(module[&"module_id"])
|
|
for y: int in range(origin.y, origin.y + int(module[&"height"])):
|
|
(config[&"stops"] as Array).append(Vector3i(origin.x, y, origin.z))
|
|
return result
|
|
|
|
|
|
func synchronize(configurations: Array, minute: int) -> Dictionary:
|
|
if _path == null or minute < 0 or (_minute >= 0 and minute < _minute):
|
|
return _failure(&"INVALID_CLOCK", "Elevator synchronization requires a path and current time.")
|
|
var normalized := _normalize_configs(configurations)
|
|
if not normalized[&"ok"]:
|
|
return normalized
|
|
var configs: Dictionary = normalized[&"configs"]
|
|
for shaft_id: StringName in _names(_shafts):
|
|
var car: Dictionary = _shafts[shaft_id]
|
|
if configs.has(shaft_id) and configs[shaft_id] == car[&"config"]:
|
|
continue
|
|
if _shaft_busy(shaft_id):
|
|
return _failure(&"ELEVATOR_BUSY", "An occupied or moving shaft cannot be modified.")
|
|
# All new IDs must belong to us and must not overwrite an unrelated connector.
|
|
var old_ids: Dictionary = {}
|
|
for value: Variant in _shafts.values():
|
|
for connector: Dictionary in _connector_configs(value[&"config"]):
|
|
old_ids[connector[&"id"]] = true
|
|
for value: Variant in configs.values():
|
|
for connector: Dictionary in _connector_configs(value):
|
|
if _path.has_connector(connector[&"id"]) and not old_ids.has(connector[&"id"]):
|
|
return _failure(&"CONNECTOR_CONFLICT", "Elevator connector is already owned.")
|
|
var path_before := _path.create_snapshot()
|
|
var next: Dictionary = {}
|
|
for shaft_id: StringName in _names(_shafts):
|
|
var car: Dictionary = _shafts[shaft_id]
|
|
if configs.has(shaft_id) and configs[shaft_id] == car[&"config"]:
|
|
next[shaft_id] = car
|
|
continue
|
|
for connector: Dictionary in _connector_configs(car[&"config"]):
|
|
if not _path.remove_connector(connector[&"id"]):
|
|
_path.restore_snapshot(path_before)
|
|
return _failure(&"CONNECTOR_BUSY", _path.last_error)
|
|
for shaft_id: StringName in _names(configs):
|
|
if next.has(shaft_id):
|
|
continue
|
|
var config: Dictionary = configs[shaft_id]
|
|
var car := _new_car(config)
|
|
if _shafts.has(shaft_id):
|
|
car[&"operational"] = _shafts[shaft_id][&"operational"]
|
|
car[&"operator_open"] = _shafts[shaft_id][&"operator_open"]
|
|
car[&"powered"] = _shafts[shaft_id][&"powered"]
|
|
var previous_floor: int = _shafts[shaft_id][&"current_floor"]
|
|
if _has_floor(config, previous_floor): car[&"current_floor"] = previous_floor
|
|
for connector: Dictionary in _connector_configs(config):
|
|
if not _path.configure_connector(connector[&"id"], connector[&"from"], connector[&"to"],
|
|
PathService.ORTHOGONAL_MOVE_COST, true, config[&"access_id"], true, connector):
|
|
_path.restore_snapshot(path_before)
|
|
return _failure(&"CONNECTOR_CONFIGURATION_FAILED", _path.last_error)
|
|
next[shaft_id] = car
|
|
_shafts = next
|
|
if _minute < 0: _minute = minute
|
|
for shaft_id: StringName in _names(_shafts): _publish_cabin(_shafts[shaft_id])
|
|
last_error = ""
|
|
return {&"ok": true, &"shaft_count": _shafts.size()}
|
|
|
|
|
|
func request_trip(actor_id: StringName, from: Vector3i, to: Vector3i, minute: int,
|
|
access: Array[StringName] = []) -> Dictionary:
|
|
if actor_id.is_empty() or minute < _minute or minute > _minute + 1:
|
|
return _failure(&"INVALID_REQUEST", "Elevator request has invalid actor or time.")
|
|
if _trips.has(actor_id):
|
|
var existing: Dictionary = _trips[actor_id]
|
|
if existing[&"from"] == from and existing[&"to"] == to:
|
|
return {&"ok": true, &"replay": true, &"trip": existing.duplicate(true)}
|
|
return _failure(&"ACTOR_ALREADY_TRAVELLING", "Actor already owns an elevator trip.")
|
|
var selected: Dictionary = {}
|
|
for shaft_id: StringName in _names(_shafts):
|
|
var car: Dictionary = _shafts[shaft_id]
|
|
var config: Dictionary = car[&"config"]
|
|
if from != to and (config[&"stops"] as Array).has(from) and (config[&"stops"] as Array).has(to):
|
|
selected = car
|
|
break
|
|
if selected.is_empty(): return _failure(&"NO_ELEVATOR", "No shaft joins those stops.")
|
|
var config: Dictionary = selected[&"config"]
|
|
if not selected[&"operational"]: return _failure(&"ELEVATOR_STOPPED", "Elevator is stopped.")
|
|
if not StringName(config[&"access_id"]).is_empty() and not access.has(config[&"access_id"]):
|
|
return _failure(&"ACCESS_DENIED", "Elevator access is not permitted.")
|
|
if not _valid_stop(from) or not _valid_stop(to):
|
|
return _failure(&"STOP_UNAVAILABLE", "Elevator landing is unavailable.")
|
|
var connector_id := _connector_id(config[&"shaft_id"], from.y, to.y)
|
|
_sequence += 1
|
|
var request_id := StringName("transport.trip.%012d" % _sequence)
|
|
var queued := _path.enqueue_elevator_request(connector_id, {&"request_id": request_id,
|
|
&"unit_id": actor_id, &"floor_from": from.y, &"floor_to": to.y, &"enqueued_at": minute})
|
|
if not queued[&"ok"]:
|
|
return _failure(queued.get(&"reason", &"QUEUE_REJECTED"), _path.last_error)
|
|
var trip := {&"actor_id": actor_id, &"request_id": request_id, &"shaft_id": config[&"shaft_id"],
|
|
&"connector_id": connector_id, &"from": from, &"to": to,
|
|
&"enqueued_at": minute, &"state": PathService.ELEVATOR_REQUEST_QUEUED}
|
|
_trips[actor_id] = trip
|
|
last_error = ""
|
|
return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)}
|
|
|
|
|
|
func advance_one_minute(minute: int) -> Array[Dictionary]:
|
|
var arrivals: Array[Dictionary] = []
|
|
if minute == _minute: return arrivals
|
|
if minute != _minute + 1:
|
|
last_error = "Elevators must advance in consecutive minutes."
|
|
return arrivals
|
|
_minute = minute
|
|
for shaft_id: StringName in _names(_shafts):
|
|
var car: Dictionary = _shafts[shaft_id]
|
|
if not car[&"operational"]: continue
|
|
var unavailable := false
|
|
for actor_id: StringName in car[&"boarded"]:
|
|
if not _valid_stop(_trips[actor_id][&"to"]): unavailable = true
|
|
if unavailable:
|
|
arrivals.append_array(set_operational(shaft_id, false, minute))
|
|
continue
|
|
if car[&"phase"] == &"IDLE":
|
|
_dispatch(car)
|
|
elif int(car[&"remaining_minutes"]) > 0:
|
|
car[&"remaining_minutes"] -= 1
|
|
if int(car[&"remaining_minutes"]) == 0:
|
|
_finish_phase(car, arrivals)
|
|
_publish_cabin(car)
|
|
last_error = ""
|
|
return arrivals
|
|
|
|
|
|
func get_actor_trip(actor_id: StringName) -> Dictionary:
|
|
return (_trips.get(actor_id, {}) as Dictionary).duplicate(true)
|
|
|
|
|
|
func cancel_actor(actor_id: StringName) -> bool:
|
|
if not _trips.has(actor_id): return true
|
|
var trip: Dictionary = _trips[actor_id]
|
|
if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED:
|
|
if not _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]):
|
|
last_error = _path.last_error
|
|
return false
|
|
(_shafts[trip[&"shaft_id"]][&"boarded"] as Array).erase(actor_id)
|
|
_trips.erase(actor_id)
|
|
last_error = ""
|
|
return true
|
|
if not _path.cancel_elevator_request(trip[&"connector_id"], trip[&"request_id"]):
|
|
last_error = _path.last_error
|
|
return false
|
|
_trips.erase(actor_id)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func set_operator_open(shaft_id: StringName, enabled: bool, minute: int) -> Array[Dictionary]:
|
|
if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1:
|
|
last_error = "Unknown shaft or invalid operator change time."
|
|
return []
|
|
_shafts[shaft_id][&"operator_open"] = enabled
|
|
return _apply_operational(shaft_id, enabled and _shafts[shaft_id][&"powered"], minute)
|
|
|
|
|
|
func set_powered(shaft_id: StringName, powered: bool, minute: int) -> Array[Dictionary]:
|
|
if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1:
|
|
last_error = "Unknown shaft or invalid power change time."
|
|
return []
|
|
_shafts[shaft_id][&"powered"] = powered
|
|
return _apply_operational(shaft_id, powered and _shafts[shaft_id][&"operator_open"], minute)
|
|
|
|
|
|
func set_operational(shaft_id: StringName, operational: bool, minute: int) -> Array[Dictionary]:
|
|
return set_powered(shaft_id, operational, minute)
|
|
|
|
|
|
func _apply_operational(shaft_id: StringName, operational: bool, minute: int) -> Array[Dictionary]:
|
|
var arrivals: Array[Dictionary] = []
|
|
if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1:
|
|
last_error = "Unknown shaft or invalid operational change time."
|
|
return arrivals
|
|
var car: Dictionary = _shafts[shaft_id]
|
|
if car[&"operational"] == operational: return arrivals
|
|
if not operational:
|
|
var landing: Variant = _nearest_valid_stop(car)
|
|
if landing == null and not (car[&"boarded"] as Array).is_empty():
|
|
last_error = "Occupied elevator has no safe landing; cabin remains held."
|
|
# No legal teleportation destination exists. The caller must repair a
|
|
# landing; movement remains held and requests remain owned.
|
|
car[&"operational"] = false
|
|
for actor_id: StringName in _names(_trips):
|
|
var waiting: Dictionary = _trips[actor_id]
|
|
if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and cancel_actor(actor_id):
|
|
arrivals.append({&"actor_id": actor_id, &"request_id": waiting[&"request_id"], &"cell": waiting[&"from"], &"emergency": false, &"cancelled": true})
|
|
_publish_cabin(car)
|
|
return arrivals
|
|
for actor_id: StringName in _names(_trips):
|
|
var trip: Dictionary = _trips[actor_id]
|
|
if trip[&"shaft_id"] != shaft_id: continue
|
|
var boarded: bool = trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED
|
|
if boarded:
|
|
if not _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]): continue
|
|
else:
|
|
if not _path.cancel_elevator_request(trip[&"connector_id"], trip[&"request_id"]): continue
|
|
arrivals.append({&"actor_id": actor_id, &"request_id": trip[&"request_id"],
|
|
&"cell": landing if boarded else trip[&"from"], &"emergency": boarded, &"cancelled": not boarded})
|
|
_trips.erase(actor_id)
|
|
car[&"boarded"] = []
|
|
if landing != null: car[&"current_floor"] = (landing as Vector3i).y
|
|
_idle(car)
|
|
if operational and car[&"phase"] != &"IDLE":
|
|
car[&"phase_started_at"] = _minute - (int(car[&"phase_total_minutes"]) - int(car[&"remaining_minutes"]))
|
|
car[&"operational"] = operational
|
|
_publish_cabin(car)
|
|
last_error = ""
|
|
return arrivals
|
|
|
|
|
|
func owns_connector(connector_id: StringName) -> bool:
|
|
return not get_shaft_for_connector(connector_id).is_empty()
|
|
|
|
|
|
func get_shaft_for_connector(connector_id: StringName) -> StringName:
|
|
for shaft_id: StringName in _names(_shafts):
|
|
for connector: Dictionary in _connector_configs(_shafts[shaft_id][&"config"]):
|
|
if connector[&"id"] == connector_id: return shaft_id
|
|
return &""
|
|
|
|
|
|
func get_report() -> Dictionary:
|
|
var rows: Array[Dictionary] = []
|
|
for shaft_id: StringName in _names(_shafts):
|
|
var car: Dictionary = _shafts[shaft_id]
|
|
var row: Dictionary = car.duplicate(true)
|
|
row.merge(car[&"config"], true)
|
|
row[&"visual_floor"] = _visual_floor(car)
|
|
row[&"waiting_count"] = 0
|
|
row[&"passenger_count"] = (car[&"boarded"] as Array).size()
|
|
row[&"passenger_ids"] = car[&"boarded"].duplicate()
|
|
for trip: Dictionary in _trips.values():
|
|
if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
|
|
row[&"waiting_count"] += 1
|
|
rows.append(row)
|
|
return {&"current_minute": _minute, &"shafts": rows, &"trips": _ordered_trips()}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var cars: Array[Dictionary] = []
|
|
for shaft_id: StringName in _names(_shafts): cars.append(_shafts[shaft_id].duplicate(true))
|
|
return {&"version": SNAPSHOT_VERSION, &"current_minute": _minute,
|
|
&"request_sequence": _sequence, &"shafts": cars, &"trips": _ordered_trips()}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
if _path == null or not _exact_keys(snapshot, [&"version", &"current_minute", &"request_sequence", &"shafts", &"trips"]):
|
|
return _invalid("Elevator snapshot shape is invalid.")
|
|
if snapshot[&"version"] != SNAPSHOT_VERSION or not snapshot[&"current_minute"] is int or snapshot[&"current_minute"] < 0 \
|
|
or not snapshot[&"request_sequence"] is int or snapshot[&"request_sequence"] < 0 \
|
|
or not snapshot[&"shafts"] is Array or not snapshot[&"trips"] is Array:
|
|
return _invalid("Elevator snapshot header is invalid.")
|
|
var next_shafts: Dictionary = {}
|
|
var next_trips: Dictionary = {}
|
|
var seen_modules: Dictionary = {}
|
|
var expected_connectors: Dictionary = {}
|
|
var boarded_owners: Dictionary = {}
|
|
for value: Variant in snapshot[&"shafts"]:
|
|
if not value is Dictionary: return _invalid("Elevator cabin must be a Dictionary.")
|
|
var car: Dictionary = value
|
|
if not _exact_keys(car, _new_car({&"stops": [Vector3i.ZERO]}).keys()) or not car[&"config"] is Dictionary:
|
|
return _invalid("Elevator cabin fields are invalid.")
|
|
var normalized := _normalize_configs([car[&"config"]])
|
|
if not normalized[&"ok"]: return false
|
|
var config: Dictionary = car[&"config"]
|
|
var shaft_id := StringName(config[&"shaft_id"])
|
|
if next_shafts.has(shaft_id): return _invalid("Duplicate elevator shaft.")
|
|
for module_id: StringName in config[&"module_ids"]:
|
|
if seen_modules.has(module_id): return _invalid("Module belongs to multiple shafts.")
|
|
seen_modules[module_id] = true
|
|
if not _validate_car(car, int(snapshot[&"current_minute"])): return false
|
|
for actor: Variant in car[&"boarded"]:
|
|
if not (actor is String or actor is StringName) or str(actor).is_empty() or boarded_owners.has(StringName(actor)):
|
|
return _invalid("Elevator cabin actor is invalid or duplicated.")
|
|
boarded_owners[StringName(actor)] = shaft_id
|
|
for expected: Dictionary in _connector_configs(config):
|
|
var connector_id: StringName = expected[&"id"]
|
|
var live := _path.get_connector(connector_id)
|
|
for key: StringName in expected:
|
|
if not live.has(key) or live[key] != expected[key]: return _invalid("Path elevator configuration disagrees with cabin.")
|
|
if live[&"cabin_state"] != _path_cabin_state(car) \
|
|
or live[&"operational_state"] != (PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED):
|
|
return _invalid("Path elevator operation disagrees with cabin.")
|
|
expected_connectors[connector_id] = true
|
|
next_shafts[shaft_id] = car.duplicate(true)
|
|
for value: Variant in snapshot[&"trips"]:
|
|
if not value is Dictionary: return _invalid("Elevator trip must be a Dictionary.")
|
|
var trip: Dictionary = value
|
|
if not _exact_keys(trip, [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"]):
|
|
return _invalid("Elevator trip fields are invalid.")
|
|
for key: StringName in [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"state"]:
|
|
if not (trip[key] is String or trip[key] is StringName) or str(trip[key]).is_empty(): return _invalid("Elevator trip names are invalid.")
|
|
var actor_id := StringName(trip[&"actor_id"])
|
|
var shaft_id := StringName(trip[&"shaft_id"])
|
|
if next_trips.has(actor_id) or not next_shafts.has(shaft_id): return _invalid("Elevator trip actor or shaft is duplicated/missing.")
|
|
var config: Dictionary = next_shafts[shaft_id][&"config"]
|
|
if not trip[&"from"] is Vector3i or not trip[&"to"] is Vector3i \
|
|
or trip[&"from"] == trip[&"to"] or not (config[&"stops"] as Array).has(trip[&"from"]) or not (config[&"stops"] as Array).has(trip[&"to"]):
|
|
return _invalid("Elevator trip stops are invalid.")
|
|
if trip[&"connector_id"] != _connector_id(shaft_id, trip[&"from"].y, trip[&"to"].y) \
|
|
or not trip[&"enqueued_at"] is int or trip[&"enqueued_at"] < 0 or trip[&"enqueued_at"] > snapshot[&"current_minute"]:
|
|
return _invalid("Elevator trip connector/time is invalid.")
|
|
var active := _path.get_active_elevator_request(actor_id)
|
|
if active.get(&"request_id") != trip[&"request_id"] or active.get(&"connector_id") != trip[&"connector_id"] or active.get(&"state") != trip[&"state"]:
|
|
return _invalid("Elevator trip and Path active owner disagree.")
|
|
if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED:
|
|
if boarded_owners.get(actor_id) != shaft_id: return _invalid("Boarded trip has no cabin seat.")
|
|
var car: Dictionary = next_shafts[shaft_id]
|
|
if trip[&"to"].y != car[&"target_floor"] or trip[&"from"].y != car[&"boarding_floor"]:
|
|
return _invalid("Boarded trip and cabin destination disagree.")
|
|
elif trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
|
|
if boarded_owners.has(actor_id): return _invalid("Queued trip also owns a cabin seat.")
|
|
var found := false
|
|
for queued: Dictionary in _path.get_elevator_queue(trip[&"connector_id"]):
|
|
if queued[&"request_id"] == trip[&"request_id"]:
|
|
found = queued[&"unit_id"] == actor_id and queued[&"floor_from"] == trip[&"from"].y and queued[&"floor_to"] == trip[&"to"].y and queued[&"enqueued_at"] == trip[&"enqueued_at"] and queued[&"priority"] == PathService.ELEVATOR_PRIORITY_NORMAL
|
|
if not found: return _invalid("Queued trip details disagree with Path queue.")
|
|
else: return _invalid("Unknown elevator trip state.")
|
|
next_trips[actor_id] = trip.duplicate(true)
|
|
for actor_id: StringName in boarded_owners:
|
|
if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.")
|
|
var path_snapshot := _path.create_snapshot()
|
|
for connector: Dictionary in path_snapshot[&"connectors"]:
|
|
if str(connector[&"id"]).begins_with(PREFIX) and not expected_connectors.has(connector[&"id"]):
|
|
return _invalid("Path contains an orphan transport connector.")
|
|
for active: Dictionary in path_snapshot[&"active_elevator_units"]:
|
|
if expected_connectors.has(active[&"connector_id"]) and not next_trips.has(active[&"unit_id"]):
|
|
return _invalid("Path contains an orphan transport passenger.")
|
|
for request_id: Variant in path_snapshot[&"known_elevator_request_ids"]:
|
|
var name := str(request_id)
|
|
if not name.begins_with(REQUEST_PREFIX): continue
|
|
var suffix := name.trim_prefix(REQUEST_PREFIX)
|
|
if not suffix.is_valid_int() or int(suffix) <= 0 or int(suffix) > snapshot[&"request_sequence"]:
|
|
return _invalid("Elevator sequence would reuse an active or retired request ID.")
|
|
var all_configs: Array = []
|
|
for car: Dictionary in next_shafts.values(): all_configs.append(car[&"config"])
|
|
if not _normalize_configs(all_configs)[&"ok"]: return false
|
|
_shafts = next_shafts
|
|
_trips = next_trips
|
|
_minute = snapshot[&"current_minute"]
|
|
_sequence = snapshot[&"request_sequence"]
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _dispatch(car: Dictionary) -> void:
|
|
var candidates: Array[Dictionary] = []
|
|
for trip: Dictionary in _trips.values():
|
|
if trip[&"shaft_id"] == car[&"config"][&"shaft_id"] and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and trip[&"enqueued_at"] <= _minute:
|
|
candidates.append(trip)
|
|
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
if a[&"enqueued_at"] != b[&"enqueued_at"]: return a[&"enqueued_at"] < b[&"enqueued_at"]
|
|
return str(a[&"actor_id"]) < str(b[&"actor_id"]))
|
|
if candidates.is_empty():
|
|
_idle(car)
|
|
return
|
|
var trip: Dictionary = candidates.front()
|
|
car[&"boarding_floor"] = trip[&"from"].y
|
|
car[&"target_floor"] = trip[&"to"].y
|
|
if car[&"current_floor"] != car[&"boarding_floor"]:
|
|
_start_phase(car, &"PICKUP", absi(int(car[&"current_floor"]) - int(car[&"boarding_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"]))
|
|
else:
|
|
_board(car)
|
|
|
|
|
|
func _board(car: Dictionary) -> void:
|
|
var config: Dictionary = car[&"config"]
|
|
var connector_id := _connector_id(config[&"shaft_id"], car[&"boarding_floor"], car[&"target_floor"])
|
|
var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"])
|
|
car[&"boarded"] = []
|
|
for request: Dictionary in boarded:
|
|
var actor_id: StringName = request[&"unit_id"]
|
|
if not _trips.has(actor_id): continue
|
|
_trips[actor_id][&"state"] = PathService.ELEVATOR_REQUEST_BOARDED
|
|
(car[&"boarded"] as Array).append(actor_id)
|
|
if boarded.is_empty():
|
|
_idle(car)
|
|
return
|
|
_start_phase(car, &"BOARDING", DOOR_MINUTES + boarded.size())
|
|
|
|
|
|
func _finish_phase(car: Dictionary, arrivals: Array[Dictionary]) -> void:
|
|
match car[&"phase"]:
|
|
&"PICKUP":
|
|
car[&"current_floor"] = car[&"boarding_floor"]
|
|
_board(car)
|
|
&"BOARDING":
|
|
_start_phase(car, &"TRAVEL", absi(int(car[&"target_floor"]) - int(car[&"current_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"]))
|
|
&"TRAVEL":
|
|
car[&"current_floor"] = car[&"target_floor"]
|
|
_start_phase(car, &"ALIGHTING", DOOR_MINUTES + (car[&"boarded"] as Array).size())
|
|
&"ALIGHTING":
|
|
for actor_id: StringName in car[&"boarded"]:
|
|
var trip: Dictionary = _trips[actor_id]
|
|
if _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]):
|
|
arrivals.append({&"actor_id": actor_id, &"request_id": trip[&"request_id"], &"cell": trip[&"to"], &"emergency": false, &"cancelled": false})
|
|
_trips.erase(actor_id)
|
|
car[&"boarded"] = []
|
|
_idle(car)
|
|
_dispatch(car)
|
|
|
|
|
|
func _new_car(config: Dictionary) -> Dictionary:
|
|
var floor: int = config[&"stops"][0].y
|
|
return {&"config": config.duplicate(true), &"operational": true, &"operator_open": true, &"powered": true, &"current_floor": floor,
|
|
&"target_floor": floor, &"boarding_floor": floor, &"phase": &"IDLE",
|
|
&"remaining_minutes": 0, &"phase_total_minutes": 0, &"phase_started_at": -1,
|
|
&"phase_origin_floor": floor, &"phase_passenger_count": 0, &"boarded": []}
|
|
|
|
|
|
func _start_phase(car: Dictionary, phase: StringName, duration: int) -> void:
|
|
car[&"phase"] = phase
|
|
car[&"remaining_minutes"] = duration
|
|
car[&"phase_total_minutes"] = duration
|
|
car[&"phase_started_at"] = _minute
|
|
car[&"phase_origin_floor"] = car[&"current_floor"]
|
|
car[&"phase_passenger_count"] = car[&"boarded"].size()
|
|
|
|
|
|
func _idle(car: Dictionary) -> void:
|
|
car[&"phase"] = &"IDLE"
|
|
car[&"target_floor"] = car[&"current_floor"]
|
|
car[&"boarding_floor"] = car[&"current_floor"]
|
|
car[&"phase_origin_floor"] = car[&"current_floor"]
|
|
car[&"phase_started_at"] = -1
|
|
car[&"remaining_minutes"] = 0
|
|
car[&"phase_total_minutes"] = 0
|
|
car[&"phase_passenger_count"] = 0
|
|
|
|
|
|
func _visual_floor(car: Dictionary) -> float:
|
|
if car[&"phase"] not in [&"PICKUP", &"TRAVEL"]: return float(car[&"current_floor"])
|
|
var destination: int = car[&"boarding_floor"] if car[&"phase"] == &"PICKUP" else car[&"target_floor"]
|
|
return lerpf(float(car[&"phase_origin_floor"]), float(destination),
|
|
1.0 - float(car[&"remaining_minutes"]) / maxf(float(car[&"phase_total_minutes"]), 1.0))
|
|
|
|
|
|
func _nearest_valid_stop(car: Dictionary) -> Variant:
|
|
var floor := _visual_floor(car)
|
|
var nearest: Variant = null
|
|
var distance := INF
|
|
for cell: Vector3i in car[&"config"][&"stops"]:
|
|
if not _valid_stop(cell): continue
|
|
var candidate := absf(float(cell.y) - floor)
|
|
if candidate < distance:
|
|
nearest = cell
|
|
distance = candidate
|
|
return nearest
|
|
|
|
|
|
func _valid_stop(cell: Vector3i) -> bool:
|
|
var traffic := _path.get_cell_traffic(cell)
|
|
return not traffic.is_empty() and not bool(traffic.get(&"temporary_block", false))
|
|
|
|
|
|
func _path_cabin_state(car: Dictionary) -> StringName:
|
|
if not car[&"operational"]: return PathService.CABIN_OUTAGE
|
|
if car[&"phase"] in [&"BOARDING", &"ALIGHTING"]: return PathService.CABIN_DOOR_OPEN
|
|
if car[&"phase"] in [&"PICKUP", &"TRAVEL"]:
|
|
var target: int = car[&"boarding_floor"] if car[&"phase"] == &"PICKUP" else car[&"target_floor"]
|
|
return PathService.CABIN_MOVING_UP if target > int(car[&"current_floor"]) else PathService.CABIN_MOVING_DOWN
|
|
return PathService.CABIN_IDLE
|
|
|
|
|
|
func _publish_cabin(car: Dictionary) -> void:
|
|
for connector: Dictionary in _connector_configs(car[&"config"]):
|
|
_path.set_connector_operational_state(connector[&"id"], PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED)
|
|
_path.set_elevator_cabin_state(connector[&"id"], _path_cabin_state(car))
|
|
|
|
|
|
func _shaft_busy(shaft_id: StringName) -> bool:
|
|
if _shafts[shaft_id][&"phase"] != &"IDLE": return true
|
|
for trip: Dictionary in _trips.values():
|
|
if trip[&"shaft_id"] == shaft_id: return true
|
|
return false
|
|
|
|
|
|
static func _connector_id(shaft_id: StringName, from: int, to: int) -> StringName:
|
|
return StringName("%s.link.%d.%d" % [shaft_id, mini(from, to), maxi(from, to)])
|
|
|
|
|
|
func _connector_configs(config: Dictionary) -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
var stops: Array = config[&"stops"]
|
|
for i: int in range(stops.size()):
|
|
for j: int in range(i + 1, stops.size()):
|
|
result.append({&"id": _connector_id(config[&"shaft_id"], stops[i].y, stops[j].y),
|
|
&"from": stops[i], &"to": stops[j], &"movement_type": PathService.MOVEMENT_ELEVATOR,
|
|
&"cost": PathService.ORTHOGONAL_MOVE_COST, &"bidirectional": true, &"access_id": config[&"access_id"],
|
|
&"enabled": true, &"cabin_capacity": config[&"capacity"], &"queue_capacity": QUEUE_CAPACITY,
|
|
&"travel_minutes_per_floor": config[&"travel_minutes_per_floor"], &"dynamic_cost": 0})
|
|
return result
|
|
|
|
|
|
func _normalize_configs(configurations: Array) -> Dictionary:
|
|
var configs: Dictionary = {}
|
|
var modules: Dictionary = {}
|
|
var landing_owners: Dictionary = {}
|
|
for value: Variant in configurations:
|
|
if not value is Dictionary: return _failure(&"INVALID_CONFIG", "Shaft configuration must be a Dictionary.")
|
|
var config: Dictionary = value
|
|
if not _exact_keys(config, [&"shaft_id", &"module_ids", &"stops", &"width", &"capacity", &"travel_minutes_per_floor", &"access_id"]):
|
|
return _failure(&"INVALID_CONFIG", "Shaft configuration fields are invalid.")
|
|
for key: StringName in [&"shaft_id", &"access_id"]:
|
|
if not (config[key] is String or config[key] is StringName): return _failure(&"INVALID_CONFIG", "Shaft names are invalid.")
|
|
var shaft_id := StringName(config[&"shaft_id"])
|
|
if not str(shaft_id).begins_with("transport.shaft.") or configs.has(shaft_id): return _failure(&"INVALID_CONFIG", "Shaft ID is invalid or duplicated.")
|
|
if not config[&"stops"] is Array or config[&"stops"].size() < 2 or not config[&"module_ids"] is Array or config[&"module_ids"].is_empty():
|
|
return _failure(&"INVALID_CONFIG", "Shaft must have modules and at least two stops.")
|
|
for key: StringName in [&"capacity", &"travel_minutes_per_floor", &"width"]:
|
|
if not config[key] is int or config[key] <= 0: return _failure(&"INVALID_CONFIG", "Shaft dimensions/timing are invalid.")
|
|
var first: Variant = config[&"stops"][0]
|
|
if not first is Vector3i or first.z not in [0, 1]: return _failure(&"INVALID_CONFIG", "Shaft stop layer is invalid.")
|
|
if config[&"capacity"] != (4 if first.z == 0 else 8) or config[&"travel_minutes_per_floor"] != (10 if first.z == 0 else 5):
|
|
return _failure(&"INVALID_CONFIG", "Shaft capacity/speed differs from its layer.")
|
|
for i: int in range(config[&"stops"].size()):
|
|
var cell: Variant = config[&"stops"][i]
|
|
if not cell is Vector3i or cell != Vector3i(first.x, first.y + i, first.z) or landing_owners.has(cell):
|
|
return _failure(&"INVALID_CONFIG", "Shaft stops must form a unique contiguous column.")
|
|
landing_owners[cell] = true
|
|
for module: Variant in config[&"module_ids"]:
|
|
if not (module is String or module is StringName) or str(module).is_empty() or modules.has(StringName(module)):
|
|
return _failure(&"INVALID_CONFIG", "Shaft module is empty or duplicated.")
|
|
modules[StringName(module)] = true
|
|
configs[shaft_id] = config.duplicate(true)
|
|
return {&"ok": true, &"configs": configs}
|
|
|
|
|
|
func _validate_car(car: Dictionary, minute: int) -> bool:
|
|
if not car[&"operational"] is bool or not car[&"operator_open"] is bool or not car[&"powered"] is bool or car[&"operational"] != (car[&"operator_open"] and car[&"powered"]) or not car[&"boarded"] is Array \
|
|
or car[&"boarded"].size() > car[&"config"][&"capacity"]:
|
|
return _invalid("Elevator cabin operation/capacity is invalid.")
|
|
for key: StringName in [&"current_floor", &"target_floor", &"boarding_floor", &"phase_origin_floor", &"remaining_minutes", &"phase_total_minutes", &"phase_started_at", &"phase_passenger_count"]:
|
|
if not car[key] is int: return _invalid("Elevator cabin clocks and floors must be integers.")
|
|
for key: StringName in [&"current_floor", &"target_floor", &"boarding_floor", &"phase_origin_floor"]:
|
|
if not _has_floor(car[&"config"], car[key]): return _invalid("Elevator cabin floor is absent from its shaft.")
|
|
if car[&"phase_passenger_count"] < car[&"boarded"].size() or car[&"phase_passenger_count"] > car[&"config"][&"capacity"]:
|
|
return _invalid("Elevator phase passenger count is invalid.")
|
|
var phase := StringName(str(car[&"phase"]))
|
|
if phase not in [&"IDLE", &"PICKUP", &"BOARDING", &"TRAVEL", &"ALIGHTING"]: return _invalid("Unknown elevator phase.")
|
|
if phase == &"IDLE":
|
|
return _valid_or_error(car[&"boarded"].is_empty() and car[&"phase_passenger_count"] == 0 and car[&"remaining_minutes"] == 0 and car[&"phase_total_minutes"] == 0 and car[&"phase_started_at"] == -1 and car[&"target_floor"] == car[&"current_floor"] and car[&"boarding_floor"] == car[&"current_floor"] and car[&"phase_origin_floor"] == car[&"current_floor"], "Idle cabin contains active phase data.")
|
|
if car[&"remaining_minutes"] <= 0 or car[&"remaining_minutes"] > car[&"phase_total_minutes"] or car[&"phase_started_at"] < 0 or car[&"phase_started_at"] > minute:
|
|
return _invalid("Elevator phase countdown is invalid.")
|
|
# Operational time is exact; an outage with no valid floor may hold a phase.
|
|
if car[&"operational"] and car[&"phase_total_minutes"] - car[&"remaining_minutes"] != minute - car[&"phase_started_at"]:
|
|
return _invalid("Elevator phase countdown disagrees with simulation time.")
|
|
var expected := 0
|
|
if phase in [&"BOARDING", &"ALIGHTING"]:
|
|
expected = DOOR_MINUTES + int(car[&"phase_passenger_count"])
|
|
else:
|
|
var destination: int = car[&"boarding_floor"] if phase == &"PICKUP" else car[&"target_floor"]
|
|
expected = absi(destination - int(car[&"phase_origin_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"])
|
|
if car[&"phase_total_minutes"] != expected or expected <= 0: return _invalid("Elevator phase duration does not match distance/passengers.")
|
|
if phase == &"PICKUP" and not car[&"boarded"].is_empty(): return _invalid("Empty pickup cabin contains passengers.")
|
|
if car[&"phase_origin_floor"] != car[&"current_floor"]: return _invalid("Elevator phase origin disagrees with logical floor.")
|
|
if phase == &"ALIGHTING" and car[&"current_floor"] != car[&"target_floor"]: return _invalid("Alighting cabin is at the wrong floor.")
|
|
if phase in [&"BOARDING", &"TRAVEL"] and car[&"current_floor"] != car[&"boarding_floor"]: return _invalid("Boarding/travel origin disagrees with pickup floor.")
|
|
return true
|
|
|
|
|
|
func _has_floor(config: Dictionary, floor: int) -> bool:
|
|
for cell: Vector3i in config[&"stops"]:
|
|
if cell.y == floor: return true
|
|
return false
|
|
|
|
|
|
func _ordered_trips() -> Array[Dictionary]:
|
|
var trips: Array[Dictionary] = []
|
|
for actor_id: StringName in _names(_trips): trips.append(_trips[actor_id].duplicate(true))
|
|
return trips
|
|
|
|
|
|
func _names(values: Dictionary) -> 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 str(a) < str(b))
|
|
return result
|
|
|
|
|
|
func _exact_keys(value: Dictionary, keys: Array) -> bool:
|
|
if value.size() != keys.size(): return false
|
|
for key: Variant in keys:
|
|
if not value.has(key): return false
|
|
return true
|
|
|
|
|
|
func _failure(reason: StringName, message: String) -> Dictionary:
|
|
last_error = message
|
|
return {&"ok": false, &"reason": reason, &"error": message}
|
|
|
|
|
|
func _invalid(message: String) -> bool:
|
|
last_error = message
|
|
return false
|
|
|
|
|
|
func _valid_or_error(valid: bool, message: String) -> bool:
|
|
if not valid: last_error = message
|
|
return valid
|