Riders queued for any floor pair in a shaft now delay every pair: the elevator service publishes the rest of the shaft queue as each connector's dynamic cost, refreshed on trip changes and each cabin minute, and restore rejects saves whose wait disagrees with their trips. A two-shaft fixture with real guests shows rush diversion, shared cabins within capacity, recovery, route loss and warp-exit congestion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
769 lines
38 KiB
GDScript
769 lines
38 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
|
|
_refresh_shaft_wait(config[&"shaft_id"])
|
|
last_error = ""
|
|
return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)}
|
|
|
|
|
|
## Reserve two real seats atomically; no synthetic passenger or hidden capacity.
|
|
func request_pair(first: StringName, second: StringName, from: Vector3i, to: Vector3i,
|
|
minute: int, access: Array[StringName] = []) -> Dictionary:
|
|
if first.is_empty() or second.is_empty() or first == second:
|
|
return _failure(&"INVALID_PAIR", "Two distinct passengers are required.")
|
|
if _trips.has(first) or _trips.has(second):
|
|
if _trips.has(first) and _trips.has(second) and _trips[first].get(&"companion_id", &"") == second and _trips[second].get(&"companion_id", &"") == first and _trips[first][&"from"] == from and _trips[first][&"to"] == to:
|
|
return {&"ok": true, &"replay": true, &"trip": _trips[first].duplicate(true)}
|
|
return _failure(&"ACTOR_ALREADY_TRAVELLING", "Pair member already owns another trip.")
|
|
var path_before := _path.create_snapshot()
|
|
var sequence_before := _sequence
|
|
var a := request_trip(first, from, to, minute, access)
|
|
if not a[&"ok"]:
|
|
_path.restore_snapshot(path_before)
|
|
_sequence = sequence_before
|
|
return a
|
|
var b := request_trip(second, from, to, minute, access)
|
|
if not b[&"ok"] or _shafts[_trips[first][&"shaft_id"]][&"config"][&"capacity"] < 2:
|
|
_path.restore_snapshot(path_before)
|
|
_trips.erase(first)
|
|
_trips.erase(second)
|
|
_sequence = sequence_before
|
|
return _failure(&"PAIR_REJECTED", "Both passengers must fit in the same cabin queue.")
|
|
_trips[first][&"companion_id"] = second
|
|
_trips[second][&"companion_id"] = first
|
|
return {&"ok": true, &"replay": false, &"trip": _trips[first].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:
|
|
var partner: StringName = _trips.get(actor_id, {}).get(&"companion_id", &"")
|
|
if not partner.is_empty():
|
|
if _trips[actor_id][&"state"] == PathService.ELEVATOR_REQUEST_BOARDED:
|
|
last_error = "An onboard pair must alight together at a real landing."
|
|
return false
|
|
var path_before := _path.create_snapshot()
|
|
var trips_before := _trips.duplicate(true)
|
|
if not _cancel_single(actor_id) or not _cancel_single(partner):
|
|
_path.restore_snapshot(path_before)
|
|
_trips = trips_before
|
|
return false
|
|
return true
|
|
return _cancel_single(actor_id)
|
|
|
|
|
|
func _cancel_single(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)
|
|
_refresh_shaft_wait(trip[&"shaft_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):
|
|
if not _trips.has(actor_id): continue
|
|
var waiting: Dictionary = _trips[actor_id]
|
|
if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and _cancel_single(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:
|
|
# The shaft-wide wait is derived from trips and checked below.
|
|
if key == &"dynamic_cost": continue
|
|
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
|
|
var keys: Array = [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"]
|
|
if trip.has(&"companion_id"):
|
|
if not (trip[&"companion_id"] is String or trip[&"companion_id"] is StringName) or String(trip[&"companion_id"]).is_empty(): return _invalid("Invalid companion identity.")
|
|
keys.append(&"companion_id")
|
|
if not _exact_keys(trip, keys):
|
|
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 next_trips:
|
|
var trip: Dictionary = next_trips[actor_id]
|
|
var partner: StringName = trip.get(&"companion_id", &"")
|
|
if partner.is_empty(): continue
|
|
if partner == actor_id or not next_trips.has(partner): return _invalid("Missing companion trip.")
|
|
var other: Dictionary = next_trips[partner]
|
|
if other.get(&"companion_id", &"") != actor_id: return _invalid("Companion ownership is not reciprocal.")
|
|
for key: StringName in [&"shaft_id", &"connector_id", &"from", &"to", &"state", &"enqueued_at"]:
|
|
if trip[key] != other[key]: return _invalid("Companion trip was split.")
|
|
for actor_id: StringName in boarded_owners:
|
|
if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.")
|
|
for shaft_id: StringName in next_shafts:
|
|
var queued := 0
|
|
for trip: Dictionary in next_trips.values():
|
|
if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
|
|
queued += 1
|
|
for connector: Dictionary in _connector_configs(next_shafts[shaft_id][&"config"]):
|
|
var expected_wait := maxi(queued - _path.elevator_queue_size(connector[&"id"]), 0) * PathService.ORTHOGONAL_MOVE_COST
|
|
if int(_path.get_connector(connector[&"id"]).get(&"dynamic_cost", -1)) != expected_wait:
|
|
return _invalid("Path shaft wait disagrees with queued trips.")
|
|
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 companions: Dictionary = {}
|
|
for actor: StringName in _trips:
|
|
if _trips[actor].has(&"companion_id"): companions[actor] = _trips[actor][&"companion_id"]
|
|
var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"], companions)
|
|
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))
|
|
_refresh_shaft_wait(car[&"config"][&"shaft_id"])
|
|
|
|
|
|
## The cabin serves the whole shaft, so riders queued for any other floor pair
|
|
## delay this pair too. PathService already prices the connector's own queue;
|
|
## the rest of the shaft queue is published as the connector's dynamic cost.
|
|
func _refresh_shaft_wait(shaft_id: StringName) -> void:
|
|
if not _shafts.has(shaft_id):
|
|
return
|
|
var queued := 0
|
|
for trip: Dictionary in _trips.values():
|
|
if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
|
|
queued += 1
|
|
for connector: Dictionary in _connector_configs(_shafts[shaft_id][&"config"]):
|
|
var own := _path.elevator_queue_size(connector[&"id"])
|
|
_path.set_connector_dynamic_cost(connector[&"id"], maxi(queued - own, 0) * PathService.ORTHOGONAL_MOVE_COST)
|
|
|
|
|
|
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
|