The FRONT Mana warp is guest-only: its connectors carry the "!staff" access, which path access checks treat as open to everyone except staff. A new 2x2 BACK service warp links only to other service warps for staff, with A07 values. Registry and palette now hold 25 definitions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3115 lines
115 KiB
GDScript
3115 lines
115 KiB
GDScript
class_name PathService
|
|
extends RefCounted
|
|
|
|
signal path_query_measured(duration_us: int)
|
|
|
|
## Deterministic path graph derived from WorldService-owned passable cells.
|
|
##
|
|
## Coordinates are Vector3i(x, y, layer), where layer is FRONT (0) or BACK (1).
|
|
## This service owns connector configuration, but not the authoritative world cells.
|
|
|
|
const FRONT := 0
|
|
const BACK := 1
|
|
const ORTHOGONAL_MOVE_COST := 100
|
|
const DEFAULT_CONNECTOR_COST := 150
|
|
const DEFAULT_CELL_CAPACITY := 4
|
|
const CONGESTION_COST_PER_EXCESS := 75
|
|
const MINIMUM_SPEED_MULTIPLIER := 0.5
|
|
const STUCK_THRESHOLD_MINUTES := 30
|
|
const ELEVATOR_STARVATION_MINUTES := 60
|
|
const SNAPSHOT_VERSION := 5
|
|
const SHAFT_SNAPSHOT_VERSION := 4
|
|
const ELEVATOR_INDEX_SNAPSHOT_VERSION := 3
|
|
const MAX_VERTICAL_BLOCKER_OWNERS := 4_096
|
|
const MAX_VERTICAL_BLOCKER_CELLS := 262_144
|
|
const LEGACY_SNAPSHOT_VERSION := 1
|
|
const TRAFFIC_SNAPSHOT_VERSION := 2
|
|
|
|
const ELEVATOR_REQUEST_QUEUED := &"QUEUED"
|
|
const ELEVATOR_REQUEST_BOARDED := &"BOARDED"
|
|
|
|
const MOVEMENT_WALK := &"WALK"
|
|
const MOVEMENT_CONNECTOR := &"CONNECTOR"
|
|
const MOVEMENT_ELEVATOR := &"ELEVATOR"
|
|
|
|
const CONNECTOR_OPERATIONAL := &"OPERATIONAL"
|
|
const CONNECTOR_STOPPED := &"STOPPED"
|
|
const CONNECTOR_OUTAGE := &"OUTAGE"
|
|
const CONNECTOR_MAINTENANCE := &"MAINTENANCE"
|
|
|
|
const CABIN_IDLE := &"IDLE"
|
|
const CABIN_MOVING_UP := &"MOVING_UP"
|
|
const CABIN_MOVING_DOWN := &"MOVING_DOWN"
|
|
const CABIN_DOOR_OPEN := &"DOOR_OPEN"
|
|
const CABIN_OUTAGE := &"OUTAGE"
|
|
|
|
const ELEVATOR_PRIORITY_RESCUE := 0
|
|
const ELEVATOR_PRIORITY_SECURITY := 10
|
|
const ELEVATOR_PRIORITY_PROTAGONIST := 20
|
|
const ELEVATOR_PRIORITY_VIP := 30
|
|
const ELEVATOR_PRIORITY_NORMAL := 40
|
|
|
|
const ROUTE_OK := &"OK"
|
|
const ROUTE_START_NOT_PASSABLE := &"START_NOT_PASSABLE"
|
|
const ROUTE_GOAL_NOT_PASSABLE := &"GOAL_NOT_PASSABLE"
|
|
const ROUTE_START_BLOCKED := &"START_TEMPORARILY_BLOCKED"
|
|
const ROUTE_GOAL_BLOCKED := &"GOAL_TEMPORARILY_BLOCKED"
|
|
const ROUTE_ACCESS_LAYER_DENIED := &"ACCESS_LAYER_DENIED"
|
|
const ROUTE_CONNECTOR_UNAVAILABLE := &"CONNECTOR_UNAVAILABLE"
|
|
const ROUTE_TEMPORARILY_BLOCKED := &"TEMPORARILY_BLOCKED"
|
|
const ROUTE_GOAL_UNREACHABLE := &"GOAL_UNREACHABLE"
|
|
|
|
const _CARDINAL_DIRECTIONS: Array[Vector3i] = [
|
|
Vector3i(-1, 0, 0),
|
|
Vector3i(0, -1, 0),
|
|
Vector3i(0, 1, 0),
|
|
Vector3i(1, 0, 0),
|
|
]
|
|
const _INFINITE_COST := 9223372036854775807
|
|
|
|
var graph_revision := 0
|
|
var traffic_revision := 0
|
|
var cache_hits := 0
|
|
var cache_misses := 0
|
|
var last_error := ""
|
|
var last_no_route_reason: StringName = ROUTE_OK
|
|
var last_path_cost := 0
|
|
|
|
var _passable_cells: Dictionary = {}
|
|
var _sorted_passable_cell_keys: Array = []
|
|
var _connectors: Dictionary = {}
|
|
var _adjacency: Dictionary = {}
|
|
var _vertical_walk_blockers: Dictionary = {}
|
|
var _vertical_walk_blocked_cells: Dictionary = {}
|
|
var _path_cache: Dictionary = {}
|
|
# Derived state limited to one synchronous candidate projection, never serialized.
|
|
var _query_batch: Dictionary = {}
|
|
var _cell_traffic: Dictionary = {}
|
|
var _actor_cells: Dictionary = {}
|
|
var _actor_progress: Dictionary = {}
|
|
var _vertical_walk_requires_connector := false
|
|
var _transit_actor_ids: Dictionary = {}
|
|
var _known_elevator_request_ids: Dictionary = {}
|
|
var _active_elevator_units: Dictionary = {}
|
|
var _pending_graph_revision := -1
|
|
var _traffic_loaded_without_graph := false
|
|
|
|
|
|
func rebuild_graph(passable_cells: Variant) -> bool:
|
|
var normalization := _normalize_cell_set(passable_cells)
|
|
if not normalization[&"ok"]:
|
|
last_error = normalization[&"error"]
|
|
return false
|
|
|
|
var next_cells: Dictionary = normalization[&"cells"]
|
|
if _traffic_loaded_without_graph:
|
|
for cell_value: Variant in _cell_traffic:
|
|
if not next_cells.has(cell_value):
|
|
last_error = "Snapshot traffic references a cell absent from the authoritative world: %s" % cell_value
|
|
return false
|
|
for actor_id: Variant in _actor_cells:
|
|
if not next_cells.has(_actor_cells[actor_id]):
|
|
last_error = "Snapshot actor projection references a non-walkable cell: %s" % actor_id
|
|
return false
|
|
if next_cells == _passable_cells:
|
|
_ensure_cell_traffic_for_graph(next_cells, not _traffic_loaded_without_graph)
|
|
_traffic_loaded_without_graph = false
|
|
if _pending_graph_revision >= 0:
|
|
graph_revision = _pending_graph_revision
|
|
_pending_graph_revision = -1
|
|
invalidate_all_cache()
|
|
last_error = ""
|
|
return true
|
|
|
|
_passable_cells = next_cells
|
|
_ensure_cell_traffic_for_graph(next_cells, not _traffic_loaded_without_graph)
|
|
_traffic_loaded_without_graph = false
|
|
_rebuild_derived_graph()
|
|
if _pending_graph_revision >= 0:
|
|
graph_revision = _pending_graph_revision
|
|
_pending_graph_revision = -1
|
|
invalidate_all_cache()
|
|
else:
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
## A shaft is always physical topology, independent of cabin availability.
|
|
## Owners may overlap; removing one owner does not clear another owner's cells.
|
|
func set_vertical_walk_blockers(owner_id: StringName, cells: Array[Vector3i]) -> bool:
|
|
if not _vertical_blocker_owner_is_valid(owner_id):
|
|
last_error = "Vertical walk blockers require a bounded, nonempty owner id."
|
|
return false
|
|
if cells.is_empty() or cells.size() > MAX_VERTICAL_BLOCKER_CELLS:
|
|
last_error = "Vertical walk blockers require a bounded, nonempty cell set."
|
|
return false
|
|
var normalization := _normalize_cell_set(cells)
|
|
if not bool(normalization[&"ok"]) or (normalization[&"cells"] as Dictionary).size() != cells.size():
|
|
last_error = "Vertical walk blocker cells must be valid and unique."
|
|
return false
|
|
for cell: Vector3i in cells:
|
|
if not _passable_cells.has(cell):
|
|
last_error = "Vertical walk blocker cells must belong to the current graph."
|
|
return false
|
|
var canonical: Array[Vector3i] = cells.duplicate()
|
|
canonical.sort_custom(Callable(self, "_coordinate_less"))
|
|
if _vertical_walk_blockers.get(owner_id, []) == canonical:
|
|
last_error = ""
|
|
return true
|
|
var candidate := _vertical_walk_blockers.duplicate(true)
|
|
candidate[owner_id] = canonical
|
|
var count := 0
|
|
for owned_cells: Array in candidate.values():
|
|
count += owned_cells.size()
|
|
if candidate.size() > MAX_VERTICAL_BLOCKER_OWNERS or count > MAX_VERTICAL_BLOCKER_CELLS:
|
|
last_error = "Vertical walk blocker ownership capacity exceeded."
|
|
return false
|
|
_vertical_walk_blockers = candidate
|
|
_rebuild_vertical_walk_blocked_cells()
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func remove_vertical_walk_blockers(owner_id: StringName) -> bool:
|
|
if not _vertical_walk_blockers.has(owner_id):
|
|
last_error = "Unknown vertical walk blocker owner."
|
|
return false
|
|
_vertical_walk_blockers.erase(owner_id)
|
|
_rebuild_vertical_walk_blocked_cells()
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func get_vertical_walk_blockers() -> Dictionary:
|
|
return _vertical_walk_blockers.duplicate(true)
|
|
|
|
|
|
func set_vertical_walk_requires_connector(required: bool) -> bool:
|
|
if _vertical_walk_requires_connector == required:
|
|
last_error = ""
|
|
return true
|
|
_vertical_walk_requires_connector = required
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func get_vertical_walk_requires_connector() -> bool:
|
|
return _vertical_walk_requires_connector
|
|
|
|
|
|
func get_transit_actor_ids() -> Array[StringName]:
|
|
return _sorted_name_keys(_transit_actor_ids)
|
|
|
|
|
|
func is_actor_in_transit(actor_id: StringName) -> bool:
|
|
return _transit_actor_ids.has(actor_id)
|
|
|
|
|
|
func _rebuild_vertical_walk_blocked_cells() -> void:
|
|
_vertical_walk_blocked_cells.clear()
|
|
for cells: Array in _vertical_walk_blockers.values():
|
|
for cell: Vector3i in cells:
|
|
_vertical_walk_blocked_cells[cell] = true
|
|
|
|
|
|
func _ordinary_walk_edge_allowed(from_cell: Vector3i, to_cell: Vector3i) -> bool:
|
|
return from_cell.y == to_cell.y or (
|
|
not _vertical_walk_blocked_cells.has(from_cell)
|
|
and not _vertical_walk_blocked_cells.has(to_cell)
|
|
)
|
|
|
|
|
|
func _vertical_blocker_owner_is_valid(owner_id: StringName) -> bool:
|
|
var value := String(owner_id)
|
|
return not value.is_empty() and value.length() <= 128 and value == value.strip_edges()
|
|
|
|
|
|
func configure_connector(
|
|
connector_id: StringName,
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
cost := DEFAULT_CONNECTOR_COST,
|
|
bidirectional := true,
|
|
access_id: StringName = &"",
|
|
enabled := true,
|
|
options: Dictionary = {}
|
|
) -> bool:
|
|
var raw_configuration := options.duplicate(true)
|
|
raw_configuration.merge({
|
|
&"id": connector_id,
|
|
&"from": from_cell,
|
|
&"to": to_cell,
|
|
&"cost": cost,
|
|
&"bidirectional": bidirectional,
|
|
&"access_id": access_id,
|
|
&"enabled": enabled,
|
|
}, true)
|
|
var normalized := _normalize_connector(raw_configuration)
|
|
if not normalized[&"ok"]:
|
|
last_error = normalized[&"error"]
|
|
return false
|
|
|
|
var configuration: Dictionary = normalized[&"connector"]
|
|
if _connectors.has(connector_id) and _connectors[connector_id] == configuration:
|
|
last_error = ""
|
|
return true
|
|
for unit_id: Variant in _active_elevator_units:
|
|
var active: Dictionary = _active_elevator_units[unit_id]
|
|
if active[&"connector_id"] == connector_id:
|
|
last_error = "Cannot reconfigure a connector with active elevator requests."
|
|
return false
|
|
var proposed_connectors := _connectors.duplicate(true)
|
|
proposed_connectors[connector_id] = configuration
|
|
var index_result := _derive_elevator_indexes_from_connectors(proposed_connectors)
|
|
if not index_result[&"ok"]:
|
|
last_error = index_result[&"error"]
|
|
return false
|
|
for request_id: Variant in index_result[&"known_request_ids"]:
|
|
if _known_elevator_request_ids.has(request_id) \
|
|
and not _active_request_id_exists(StringName(request_id)):
|
|
last_error = "Connector configuration reuses a retired elevator request id."
|
|
return false
|
|
var previous_configuration: Dictionary = _connectors.get(connector_id, {})
|
|
_connectors[connector_id] = configuration
|
|
var next_active: Dictionary = index_result[&"active_units"]
|
|
for unit_id: Variant in _active_elevator_units:
|
|
var active: Dictionary = _active_elevator_units[unit_id]
|
|
if active[&"state"] == ELEVATOR_REQUEST_BOARDED:
|
|
next_active[unit_id] = active.duplicate(true)
|
|
_active_elevator_units = next_active
|
|
_refresh_transit_actor_ids()
|
|
for request_id: Variant in index_result[&"known_request_ids"]:
|
|
_known_elevator_request_ids[request_id] = true
|
|
_rebuild_connector_endpoint_edges(previous_configuration, configuration)
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func add_connector(
|
|
connector_id: StringName,
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
cost := DEFAULT_CONNECTOR_COST,
|
|
bidirectional := true,
|
|
access_id: StringName = &""
|
|
) -> bool:
|
|
return configure_connector(
|
|
connector_id,
|
|
from_cell,
|
|
to_cell,
|
|
cost,
|
|
bidirectional,
|
|
access_id,
|
|
true
|
|
)
|
|
|
|
|
|
func configure_elevator(
|
|
connector_id: StringName,
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
options: Dictionary = {},
|
|
access_id: StringName = &""
|
|
) -> bool:
|
|
var elevator_options := options.duplicate(true)
|
|
elevator_options[&"movement_type"] = MOVEMENT_ELEVATOR
|
|
var cost := int(_dictionary_value(elevator_options, &"cost", DEFAULT_CONNECTOR_COST))
|
|
var bidirectional := bool(_dictionary_value(elevator_options, &"bidirectional", true))
|
|
var enabled := bool(_dictionary_value(elevator_options, &"enabled", true))
|
|
return configure_connector(
|
|
connector_id,
|
|
from_cell,
|
|
to_cell,
|
|
cost,
|
|
bidirectional,
|
|
access_id,
|
|
enabled,
|
|
elevator_options
|
|
)
|
|
|
|
|
|
func set_connectors(configurations: Array) -> bool:
|
|
var normalized_collection := _normalize_connector_collection(configurations)
|
|
if not normalized_collection[&"ok"]:
|
|
last_error = normalized_collection[&"error"]
|
|
return false
|
|
var next_connectors: Dictionary = normalized_collection[&"connectors"]
|
|
|
|
if next_connectors == _connectors:
|
|
last_error = ""
|
|
return true
|
|
for request_id: Variant in normalized_collection[&"known_request_ids"]:
|
|
if _known_elevator_request_ids.has(request_id) \
|
|
and not _active_request_id_exists(StringName(request_id)):
|
|
last_error = "Connector set reuses a retired elevator request id."
|
|
return false
|
|
for unit_id: Variant in _active_elevator_units:
|
|
if (_active_elevator_units[unit_id] as Dictionary)[&"state"] == ELEVATOR_REQUEST_BOARDED:
|
|
last_error = "Cannot replace connector set while an elevator trip is boarded."
|
|
return false
|
|
_connectors = next_connectors
|
|
_active_elevator_units = normalized_collection[&"active_units"]
|
|
_refresh_transit_actor_ids()
|
|
for request_id: Variant in normalized_collection[&"known_request_ids"]:
|
|
_known_elevator_request_ids[request_id] = true
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func remove_connector(connector_id: StringName) -> bool:
|
|
if not _connectors.has(connector_id):
|
|
return false
|
|
for unit_id: Variant in _active_elevator_units:
|
|
if (_active_elevator_units[unit_id] as Dictionary)[&"connector_id"] == connector_id:
|
|
last_error = "Cannot remove a connector with active elevator requests: %s" % connector_id
|
|
return false
|
|
_connectors.erase(connector_id)
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func set_connector_enabled(connector_id: StringName, enabled: bool) -> bool:
|
|
if not _connectors.has(connector_id):
|
|
last_error = "Unknown connector id: %s" % connector_id
|
|
return false
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if bool(connector[&"enabled"]) == enabled:
|
|
last_error = ""
|
|
return true
|
|
connector[&"enabled"] = enabled
|
|
_connectors[connector_id] = connector
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func set_connector_operational_state(connector_id: StringName, state: StringName) -> bool:
|
|
if not _connectors.has(connector_id):
|
|
last_error = "Unknown connector id: %s" % connector_id
|
|
return false
|
|
if state not in [
|
|
CONNECTOR_OPERATIONAL,
|
|
CONNECTOR_STOPPED,
|
|
CONNECTOR_OUTAGE,
|
|
CONNECTOR_MAINTENANCE,
|
|
]:
|
|
last_error = "Unsupported connector operational state: %s" % state
|
|
return false
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"operational_state"] == state:
|
|
last_error = ""
|
|
return true
|
|
connector[&"operational_state"] = state
|
|
if connector[&"movement_type"] == MOVEMENT_ELEVATOR:
|
|
connector[&"cabin_state"] = CABIN_IDLE if state == CONNECTOR_OPERATIONAL else CABIN_OUTAGE
|
|
_connectors[connector_id] = connector
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func set_elevator_operational(connector_id: StringName, operational: bool) -> bool:
|
|
return set_connector_operational_state(
|
|
connector_id,
|
|
CONNECTOR_OPERATIONAL if operational else CONNECTOR_OUTAGE
|
|
)
|
|
|
|
|
|
func set_elevator_cabin_state(connector_id: StringName, cabin_state: StringName) -> bool:
|
|
if not _connectors.has(connector_id):
|
|
last_error = "Unknown connector id: %s" % connector_id
|
|
return false
|
|
if cabin_state not in [
|
|
CABIN_IDLE,
|
|
CABIN_MOVING_UP,
|
|
CABIN_MOVING_DOWN,
|
|
CABIN_DOOR_OPEN,
|
|
CABIN_OUTAGE,
|
|
]:
|
|
last_error = "Unsupported elevator cabin state: %s" % cabin_state
|
|
return false
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
last_error = "Connector is not an elevator: %s" % connector_id
|
|
return false
|
|
if connector[&"cabin_state"] == cabin_state:
|
|
last_error = ""
|
|
return true
|
|
connector[&"cabin_state"] = cabin_state
|
|
connector[&"operational_state"] = (
|
|
CONNECTOR_OUTAGE if cabin_state == CABIN_OUTAGE else CONNECTOR_OPERATIONAL
|
|
)
|
|
_connectors[connector_id] = connector
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func clear_connectors() -> void:
|
|
if _connectors.is_empty():
|
|
return
|
|
if not _active_elevator_units.is_empty():
|
|
last_error = "Cannot clear connectors while elevator requests are active."
|
|
return
|
|
_connectors.clear()
|
|
_rebuild_derived_graph()
|
|
_mark_graph_changed()
|
|
last_error = ""
|
|
|
|
|
|
func configure_cell_traffic(
|
|
cell: Vector3i,
|
|
base_capacity: int = DEFAULT_CELL_CAPACITY,
|
|
dynamic_cost: int = 0,
|
|
temporary_block: bool = false
|
|
) -> bool:
|
|
if not _passable_cells.has(cell):
|
|
last_error = "Traffic configuration requires a passable cell: %s" % cell
|
|
return false
|
|
if base_capacity <= 0 or dynamic_cost < 0:
|
|
last_error = "Cell capacity must be positive and dynamic cost non-negative."
|
|
return false
|
|
var current: Dictionary = _cell_traffic.get(cell, _default_cell_traffic())
|
|
var updated := {
|
|
&"base_capacity": base_capacity,
|
|
&"current_density": int(current.get(&"current_density", 0)),
|
|
&"dynamic_cost": dynamic_cost,
|
|
&"temporary_block": temporary_block,
|
|
}
|
|
if current == updated:
|
|
last_error = ""
|
|
return true
|
|
var may_make_routes_cheaper := (
|
|
_traffic_effective_cost(current) > _traffic_effective_cost(updated)
|
|
or (bool(current[&"temporary_block"]) and not temporary_block)
|
|
)
|
|
_cell_traffic[cell] = updated
|
|
_mark_traffic_changed([cell], may_make_routes_cheaper)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func set_cell_base_capacity(cell: Vector3i, base_capacity: int) -> bool:
|
|
var current := get_cell_traffic(cell)
|
|
if current.is_empty():
|
|
last_error = "Unknown path cell: %s" % cell
|
|
return false
|
|
return configure_cell_traffic(
|
|
cell,
|
|
base_capacity,
|
|
int(current[&"dynamic_cost"]),
|
|
bool(current[&"temporary_block"])
|
|
)
|
|
|
|
|
|
func set_cell_dynamic_cost(cell: Vector3i, dynamic_cost: int) -> bool:
|
|
var current := get_cell_traffic(cell)
|
|
if current.is_empty():
|
|
last_error = "Unknown path cell: %s" % cell
|
|
return false
|
|
return configure_cell_traffic(
|
|
cell,
|
|
int(current[&"base_capacity"]),
|
|
dynamic_cost,
|
|
bool(current[&"temporary_block"])
|
|
)
|
|
|
|
|
|
func set_cell_temporary_block(cell: Vector3i, temporary_block: bool) -> bool:
|
|
var current := get_cell_traffic(cell)
|
|
if current.is_empty():
|
|
last_error = "Unknown path cell: %s" % cell
|
|
return false
|
|
return configure_cell_traffic(
|
|
cell,
|
|
int(current[&"base_capacity"]),
|
|
int(current[&"dynamic_cost"]),
|
|
temporary_block
|
|
)
|
|
|
|
|
|
func get_cell_traffic(cell: Vector3i) -> Dictionary:
|
|
if not _passable_cells.has(cell):
|
|
return {}
|
|
return (_cell_traffic.get(cell, _default_cell_traffic()) as Dictionary).duplicate(true)
|
|
|
|
|
|
func get_current_density(cell: Vector3i) -> int:
|
|
return int(_cell_traffic.get(cell, {}).get(&"current_density", 0))
|
|
|
|
|
|
func get_congestion_cost(cell: Vector3i) -> int:
|
|
var traffic: Dictionary = _cell_traffic.get(cell, {})
|
|
if traffic.is_empty():
|
|
return 0
|
|
return maxi(
|
|
0,
|
|
int(traffic[&"current_density"]) - int(traffic[&"base_capacity"])
|
|
) * CONGESTION_COST_PER_EXCESS
|
|
|
|
|
|
func get_cell_dynamic_cost(cell: Vector3i) -> int:
|
|
var traffic: Dictionary = _cell_traffic.get(cell, {})
|
|
if traffic.is_empty():
|
|
return 0
|
|
return int(traffic[&"dynamic_cost"]) + get_congestion_cost(cell)
|
|
|
|
|
|
func get_speed_multiplier(cell: Vector3i) -> float:
|
|
var traffic: Dictionary = _cell_traffic.get(cell, {})
|
|
if traffic.is_empty():
|
|
return 1.0
|
|
# Original 4.3: overlapping units move at half speed. A wide corridor's
|
|
# pathfinding capacity must not silently redefine this movement debuff.
|
|
return MINIMUM_SPEED_MULTIPLIER if int(traffic[&"current_density"]) > 1 else 1.0
|
|
|
|
|
|
|
|
func project_actor_position(
|
|
actor_id: StringName,
|
|
cell: Vector3i,
|
|
absolute_minute: int = -1
|
|
) -> bool:
|
|
if actor_id.is_empty() or not _passable_cells.has(cell):
|
|
last_error = "Actor projection requires an id and a passable destination."
|
|
return false
|
|
if bool(_cell_traffic[cell][&"temporary_block"]):
|
|
last_error = "Actor cannot enter a temporarily blocked cell."
|
|
return false
|
|
if absolute_minute >= 0 and _actor_progress.has(actor_id) \
|
|
and absolute_minute < int((_actor_progress[actor_id] as Dictionary)[&"last_observed_at"]):
|
|
last_error = "Actor projection time cannot move backwards."
|
|
return false
|
|
if _actor_cells.has(actor_id) and _actor_cells[actor_id] == cell:
|
|
if absolute_minute >= 0:
|
|
var progress_result := record_actor_progress(actor_id, cell, absolute_minute)
|
|
if not bool(progress_result[&"ok"]):
|
|
return false
|
|
last_error = ""
|
|
return true
|
|
|
|
var changed_cells: Array[Vector3i] = []
|
|
if _actor_cells.has(actor_id):
|
|
var previous: Vector3i = _actor_cells[actor_id]
|
|
if not is_actor_in_transit(actor_id) and not _decrement_density(previous):
|
|
last_error = "Actor density projection is inconsistent for: %s" % actor_id
|
|
return false
|
|
if not is_actor_in_transit(actor_id):
|
|
changed_cells.append(previous)
|
|
_actor_cells[actor_id] = cell
|
|
if _active_elevator_units.get(actor_id, {}).get(&"state", &"") == ELEVATOR_REQUEST_BOARDED:
|
|
_transit_actor_ids[actor_id] = true
|
|
else:
|
|
_transit_actor_ids.erase(actor_id)
|
|
_increment_density(cell)
|
|
changed_cells.append(cell)
|
|
# Leaving the previous cell can make an unselected alternative cheaper, so a
|
|
# move requires broad cache invalidation even though congestion itself does
|
|
# not advance graph_revision.
|
|
if not changed_cells.is_empty():
|
|
_mark_traffic_changed(changed_cells, changed_cells.size() > 1)
|
|
if absolute_minute >= 0:
|
|
record_actor_progress(actor_id, cell, absolute_minute)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func actor_enter_cell(actor_id: StringName, cell: Vector3i, absolute_minute: int = -1) -> bool:
|
|
return project_actor_position(actor_id, cell, absolute_minute)
|
|
|
|
|
|
func actor_leave_cell(actor_id: StringName, clear_progress: bool = true) -> bool:
|
|
if actor_id.is_empty() or not _actor_cells.has(actor_id):
|
|
last_error = "Actor has no projected cell: %s" % actor_id
|
|
return false
|
|
var previous: Vector3i = _actor_cells[actor_id]
|
|
var was_in_transit := is_actor_in_transit(actor_id)
|
|
if not was_in_transit and not _decrement_density(previous):
|
|
last_error = "Actor density projection is inconsistent for: %s" % actor_id
|
|
return false
|
|
_actor_cells.erase(actor_id)
|
|
_transit_actor_ids.erase(actor_id)
|
|
if clear_progress:
|
|
_actor_progress.erase(actor_id)
|
|
if not was_in_transit:
|
|
_mark_traffic_changed([previous], true)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func despawn_actor(actor_id: StringName) -> bool:
|
|
var removed := false
|
|
if _actor_cells.has(actor_id):
|
|
removed = actor_leave_cell(actor_id, true)
|
|
elif _actor_progress.erase(actor_id):
|
|
removed = true
|
|
if not removed:
|
|
last_error = "Unknown actor id: %s" % actor_id
|
|
return false
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func has_actor_projection(actor_id: StringName) -> bool:
|
|
return _actor_cells.has(actor_id)
|
|
|
|
|
|
func get_actor_projected_cell(actor_id: StringName) -> Variant:
|
|
return _actor_cells.get(actor_id, null)
|
|
|
|
|
|
func project_occupancy(entries: Array, clear_unprojected_progress: bool = true) -> bool:
|
|
var next_actor_cells: Dictionary = {}
|
|
var next_densities: Dictionary = {}
|
|
for raw_entry: Variant in entries:
|
|
if not raw_entry is Dictionary:
|
|
last_error = "Occupancy projection entries must be Dictionaries."
|
|
return false
|
|
var actor_id := StringName(str(_dictionary_value(raw_entry, &"actor_id", "")))
|
|
var cell: Variant = _dictionary_value(raw_entry, &"cell", null)
|
|
if actor_id.is_empty() or not cell is Vector3i or not _passable_cells.has(cell):
|
|
last_error = "Occupancy projection contains an invalid actor or cell."
|
|
return false
|
|
if bool(_cell_traffic[cell][&"temporary_block"]) or next_actor_cells.has(actor_id):
|
|
last_error = "Occupancy projection contains a duplicate actor or blocked cell."
|
|
return false
|
|
next_actor_cells[actor_id] = cell
|
|
if _active_elevator_units.get(actor_id, {}).get(&"state", &"") != ELEVATOR_REQUEST_BOARDED:
|
|
next_densities[cell] = int(next_densities.get(cell, 0)) + 1
|
|
|
|
var changed: Dictionary = {}
|
|
var may_make_routes_cheaper := false
|
|
for cell_value: Variant in _passable_cells:
|
|
var cell: Vector3i = cell_value
|
|
var old_density := get_current_density(cell)
|
|
var next_density := int(next_densities.get(cell, 0))
|
|
if old_density != next_density:
|
|
changed[cell] = true
|
|
if next_density < old_density:
|
|
may_make_routes_cheaper = true
|
|
var traffic: Dictionary = _cell_traffic[cell]
|
|
traffic[&"current_density"] = next_density
|
|
_cell_traffic[cell] = traffic
|
|
_actor_cells = next_actor_cells
|
|
_transit_actor_ids = _derive_transit_actor_ids(_actor_cells, _active_elevator_units)
|
|
if clear_unprojected_progress:
|
|
var stale_progress_ids: Array = []
|
|
for actor_id: Variant in _actor_progress:
|
|
if not next_actor_cells.has(actor_id):
|
|
stale_progress_ids.append(actor_id)
|
|
for actor_id: Variant in stale_progress_ids:
|
|
_actor_progress.erase(actor_id)
|
|
if not changed.is_empty():
|
|
_mark_traffic_changed(changed.keys(), may_make_routes_cheaper)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func enqueue_elevator_request(connector_id: StringName, request: Dictionary) -> Dictionary:
|
|
if not _connectors.has(connector_id):
|
|
return _queue_failure(&"UNKNOWN_CONNECTOR", "Unknown connector id: %s" % connector_id)
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
return _queue_failure(&"NOT_ELEVATOR", "Connector is not an elevator: %s" % connector_id)
|
|
var normalized := _normalize_elevator_request(request)
|
|
if not normalized[&"ok"]:
|
|
return _queue_failure(&"INVALID_REQUEST", normalized[&"error"])
|
|
var candidate: Dictionary = normalized[&"request"]
|
|
var queue: Array = connector[&"request_queue"]
|
|
var request_id: StringName = candidate[&"request_id"]
|
|
var unit_id: StringName = candidate[&"unit_id"]
|
|
if _known_elevator_request_ids.has(request_id):
|
|
var active: Dictionary = _active_elevator_units.get(unit_id, {})
|
|
if not active.is_empty() \
|
|
and active[&"request_id"] == request_id \
|
|
and active[&"connector_id"] == connector_id \
|
|
and active[&"state"] == ELEVATOR_REQUEST_QUEUED:
|
|
for existing_value: Variant in queue:
|
|
var existing: Dictionary = existing_value
|
|
if existing[&"request_id"] == request_id and existing == candidate:
|
|
last_error = ""
|
|
return {
|
|
&"ok": true,
|
|
&"replay": true,
|
|
&"request": existing.duplicate(true),
|
|
&"queue_size": queue.size(),
|
|
}
|
|
return _queue_failure(
|
|
&"DUPLICATE_REQUEST",
|
|
"Elevator request id has already been used on this or another connector."
|
|
)
|
|
if _active_elevator_units.has(unit_id):
|
|
var existing_active: Dictionary = _active_elevator_units[unit_id]
|
|
return _queue_failure(
|
|
&"UNIT_ALREADY_ACTIVE",
|
|
"Elevator unit already has an active %s request." % existing_active[&"state"]
|
|
)
|
|
if queue.size() >= int(connector[&"queue_capacity"]):
|
|
return _queue_failure(&"QUEUE_FULL", "Elevator request queue is full.")
|
|
queue.append(candidate)
|
|
queue.sort_custom(Callable(self, "_elevator_request_base_less"))
|
|
connector[&"request_queue"] = queue
|
|
_connectors[connector_id] = connector
|
|
_known_elevator_request_ids[request_id] = true
|
|
_active_elevator_units[unit_id] = {
|
|
&"unit_id": unit_id,
|
|
&"request_id": request_id,
|
|
&"connector_id": connector_id,
|
|
&"state": ELEVATOR_REQUEST_QUEUED,
|
|
}
|
|
_mark_connector_traffic_changed(connector, false)
|
|
last_error = ""
|
|
return {
|
|
&"ok": true,
|
|
&"replay": false,
|
|
&"request": candidate.duplicate(true),
|
|
&"queue_size": queue.size(),
|
|
}
|
|
|
|
|
|
func queue_elevator_request(connector_id: StringName, request: Dictionary) -> Dictionary:
|
|
return enqueue_elevator_request(connector_id, request)
|
|
|
|
|
|
func cancel_elevator_request(connector_id: StringName, request_id: StringName) -> bool:
|
|
if not _connectors.has(connector_id):
|
|
last_error = "Unknown connector id: %s" % connector_id
|
|
return false
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
last_error = "Connector is not an elevator: %s" % connector_id
|
|
return false
|
|
var queue: Array = connector[&"request_queue"]
|
|
for index: int in range(queue.size()):
|
|
var request: Dictionary = queue[index]
|
|
if request[&"request_id"] == request_id:
|
|
queue.remove_at(index)
|
|
connector[&"request_queue"] = queue
|
|
_connectors[connector_id] = connector
|
|
_active_elevator_units.erase(request[&"unit_id"])
|
|
_refresh_transit_actor_ids()
|
|
# Request ids are tombstoned permanently; cancellation permits a new
|
|
# id for the same unit but never reuse of the cancelled id.
|
|
_mark_connector_traffic_changed(connector, true)
|
|
last_error = ""
|
|
return true
|
|
last_error = "Unknown elevator request id: %s" % request_id
|
|
return false
|
|
|
|
|
|
func get_elevator_queue(connector_id: StringName, absolute_minute: int = 0) -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
if not _connectors.has(connector_id):
|
|
return result
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
return result
|
|
var queue: Array = (connector[&"request_queue"] as Array).duplicate(true)
|
|
queue.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
|
|
return _elevator_request_less_at(left, right, absolute_minute)
|
|
)
|
|
for value: Variant in queue:
|
|
result.append((value as Dictionary).duplicate(true))
|
|
return result
|
|
|
|
|
|
func elevator_queue_size(connector_id: StringName) -> int:
|
|
if not _connectors.has(connector_id):
|
|
return 0
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
return 0
|
|
return (connector[&"request_queue"] as Array).size()
|
|
|
|
|
|
func dequeue_elevator_boarding(
|
|
connector_id: StringName,
|
|
current_floor: int,
|
|
absolute_minute: int,
|
|
available_capacity: int = -1,
|
|
companions: Dictionary = {}
|
|
) -> Array[Dictionary]:
|
|
var boarded: Array[Dictionary] = []
|
|
if not _connectors.has(connector_id):
|
|
last_error = "Unknown connector id: %s" % connector_id
|
|
return boarded
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
last_error = "Connector is not an elevator: %s" % connector_id
|
|
return boarded
|
|
if not _connector_is_operational(connector):
|
|
last_error = "Elevator is not operational: %s" % connector_id
|
|
return boarded
|
|
var boarding_capacity := int(connector[&"cabin_capacity"])
|
|
if available_capacity >= 0:
|
|
boarding_capacity = mini(boarding_capacity, available_capacity)
|
|
if boarding_capacity <= 0:
|
|
last_error = ""
|
|
return boarded
|
|
|
|
var queue := get_elevator_queue(connector_id, absolute_minute)
|
|
var selected: Dictionary = {}
|
|
var by_actor: Dictionary = {}
|
|
for request: Dictionary in queue: by_actor[request[&"unit_id"]] = request
|
|
for request: Dictionary in queue:
|
|
var actor: StringName = request[&"unit_id"]
|
|
if selected.has(actor) or int(request[&"floor_from"]) != current_floor: continue
|
|
var partner: StringName = companions.get(actor, &"")
|
|
var group: Array[Dictionary] = [request]
|
|
if not partner.is_empty():
|
|
if companions.get(partner, &"") != actor or not by_actor.has(partner): continue
|
|
var other: Dictionary = by_actor[partner]
|
|
if other[&"floor_from"] != current_floor or other[&"floor_to"] != request[&"floor_to"]: continue
|
|
group.append(other)
|
|
if boarded.size() + group.size() > boarding_capacity:
|
|
break # Keep the oldest pair together and ahead of later requests.
|
|
for member: Dictionary in group:
|
|
selected[member[&"unit_id"]] = true
|
|
boarded.append(member.duplicate(true))
|
|
var remaining: Array = []
|
|
for request: Dictionary in queue:
|
|
if not selected.has(request[&"unit_id"]): remaining.append(request.duplicate(true))
|
|
connector[&"request_queue"] = remaining
|
|
connector[&"current_floor"] = current_floor
|
|
connector[&"cabin_state"] = CABIN_DOOR_OPEN if not boarded.is_empty() else CABIN_IDLE
|
|
_connectors[connector_id] = connector
|
|
if not boarded.is_empty():
|
|
for request: Dictionary in boarded:
|
|
var unit_id: StringName = request[&"unit_id"]
|
|
var active: Dictionary = _active_elevator_units.get(unit_id, {})
|
|
if not active.is_empty() and active[&"request_id"] == request[&"request_id"]:
|
|
active[&"state"] = ELEVATOR_REQUEST_BOARDED
|
|
_active_elevator_units[unit_id] = active
|
|
_refresh_transit_actor_ids()
|
|
_mark_connector_traffic_changed(connector, true)
|
|
last_error = ""
|
|
return boarded
|
|
|
|
|
|
func complete_elevator_request(connector_id: StringName, request_id: StringName) -> bool:
|
|
if connector_id.is_empty() or request_id.is_empty():
|
|
last_error = "Elevator completion requires connector and request ids."
|
|
return false
|
|
for unit_id: StringName in _sorted_name_keys(_active_elevator_units):
|
|
var active: Dictionary = _active_elevator_units[unit_id]
|
|
if active[&"request_id"] != request_id:
|
|
continue
|
|
if active[&"connector_id"] != connector_id:
|
|
last_error = "Elevator request belongs to another connector."
|
|
return false
|
|
if active[&"state"] != ELEVATOR_REQUEST_BOARDED:
|
|
last_error = "Only a boarded elevator request can complete."
|
|
return false
|
|
_active_elevator_units.erase(unit_id)
|
|
_refresh_transit_actor_ids()
|
|
last_error = ""
|
|
return true
|
|
last_error = "Unknown active elevator request id: %s" % request_id
|
|
return false
|
|
|
|
|
|
func complete_elevator_trip(connector_id: StringName, request_id: StringName) -> bool:
|
|
return complete_elevator_request(connector_id, request_id)
|
|
|
|
|
|
func get_active_elevator_request(unit_id: StringName) -> Dictionary:
|
|
if not _active_elevator_units.has(unit_id):
|
|
return {}
|
|
return (_active_elevator_units[unit_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
func _active_request_id_exists(request_id: StringName) -> bool:
|
|
for unit_id: Variant in _active_elevator_units:
|
|
if (_active_elevator_units[unit_id] as Dictionary)[&"request_id"] == request_id:
|
|
return true
|
|
return false
|
|
|
|
|
|
func estimate_elevator_wait_cost(connector_id: StringName) -> int:
|
|
if not _connectors.has(connector_id):
|
|
return 0
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
return 0
|
|
var queue_size := (connector[&"request_queue"] as Array).size()
|
|
var capacity := maxi(int(connector[&"cabin_capacity"]), 1)
|
|
# Boarding costs one minute per unit. Each full cabin ahead adds another
|
|
# door cycle, represented here as two game minutes.
|
|
var cabin_cycles := ceili(float(queue_size) / float(capacity))
|
|
return (queue_size + cabin_cycles * 2) * ORTHOGONAL_MOVE_COST
|
|
|
|
|
|
func find_path(
|
|
start: Vector3i,
|
|
goal: Vector3i,
|
|
access_layers: Variant = null,
|
|
connector_access: Variant = null
|
|
) -> Array[Vector3i]:
|
|
var started_usec := Time.get_ticks_usec()
|
|
var path := _find_path_internal(start, goal, access_layers, connector_access)
|
|
path_query_measured.emit(maxi(Time.get_ticks_usec() - started_usec, 0))
|
|
return path
|
|
|
|
|
|
func _find_path_internal(
|
|
start: Vector3i,
|
|
goal: Vector3i,
|
|
access_layers: Variant = null,
|
|
connector_access: Variant = null
|
|
) -> Array[Vector3i]:
|
|
var raw_layers: Variant = access_layers
|
|
var raw_connector_access: Variant = connector_access
|
|
if access_layers is Dictionary:
|
|
var access_profile: Dictionary = access_layers
|
|
raw_layers = _profile_value(
|
|
access_profile,
|
|
[&"allowed_layers", &"access_layers", &"access_layer", &"layer"],
|
|
null
|
|
)
|
|
if connector_access == null:
|
|
raw_connector_access = _profile_value(
|
|
access_profile,
|
|
[&"connector_access", &"connector_access_ids", &"access_ids"],
|
|
null
|
|
)
|
|
|
|
var layer_result := _normalize_access_layers(raw_layers)
|
|
if not layer_result[&"ok"]:
|
|
last_error = layer_result[&"error"]
|
|
last_no_route_reason = ROUTE_ACCESS_LAYER_DENIED
|
|
last_path_cost = 0
|
|
return []
|
|
var connector_result := _normalize_access_ids(raw_connector_access)
|
|
if not connector_result[&"ok"]:
|
|
last_error = connector_result[&"error"]
|
|
last_no_route_reason = ROUTE_CONNECTOR_UNAVAILABLE
|
|
last_path_cost = 0
|
|
return []
|
|
|
|
var allowed_layers: Array[int] = layer_result[&"values"]
|
|
var allowed_access_ids: Array[StringName] = connector_result[&"values"]
|
|
if not _passable_cells.has(start):
|
|
return _route_failure(ROUTE_START_NOT_PASSABLE)
|
|
if not _passable_cells.has(goal):
|
|
return _route_failure(ROUTE_GOAL_NOT_PASSABLE)
|
|
if start.z not in allowed_layers or goal.z not in allowed_layers:
|
|
return _route_failure(ROUTE_ACCESS_LAYER_DENIED)
|
|
if _cell_is_temporarily_blocked(start):
|
|
return _route_failure(ROUTE_START_BLOCKED)
|
|
if _cell_is_temporarily_blocked(goal):
|
|
return _route_failure(ROUTE_GOAL_BLOCKED)
|
|
|
|
var cache_key := _cache_key(start, goal, allowed_layers, allowed_access_ids)
|
|
if _path_cache.has(cache_key):
|
|
var cached: Array = _path_cache[cache_key]
|
|
if _path_is_currently_valid(cached, allowed_layers, allowed_access_ids):
|
|
cache_hits += 1
|
|
last_no_route_reason = ROUTE_OK
|
|
last_path_cost = _calculate_path_cost_for_access(cached, allowed_access_ids)
|
|
last_error = ""
|
|
return _copy_path(cached)
|
|
_path_cache.erase(cache_key)
|
|
|
|
cache_misses += 1
|
|
if start.z != goal.z and not _guest_batch_has_permitted_layer_crossing(start, goal, allowed_layers, allowed_access_ids):
|
|
return _route_failure(ROUTE_GOAL_UNREACHABLE)
|
|
var shared: Variant = _query_batch_path(start, goal, allowed_layers, allowed_access_ids)
|
|
var path: Array[Vector3i] = []
|
|
if shared == null:
|
|
path = _run_a_star(start, goal, allowed_layers, allowed_access_ids)
|
|
else:
|
|
path.assign(shared)
|
|
if path.is_empty():
|
|
return _route_failure(_diagnose_no_route_reason(
|
|
start,
|
|
goal,
|
|
allowed_layers,
|
|
allowed_access_ids
|
|
))
|
|
_path_cache[cache_key] = _copy_path(path)
|
|
last_no_route_reason = ROUTE_OK
|
|
var batch_cost := -1
|
|
if allowed_access_ids.is_empty() and allowed_layers == [FRONT,BACK]:
|
|
batch_cost = _guest_batch_path_cost(start,goal,path)
|
|
last_path_cost = batch_cost if batch_cost >= 0 else _calculate_path_cost_for_access(path, allowed_access_ids)
|
|
last_error = ""
|
|
return path
|
|
|
|
|
|
# Only the default guest access profile is shared. A mutation invalidates the
|
|
# whole batch through the same public cache invalidation paths as ordinary routes.
|
|
func begin_guest_path_query_batch(start: Vector3i, goals: Array) -> void:
|
|
_query_batch.clear()
|
|
# Without connectors the original Manhattan solver stops on goal pop.
|
|
# Equal-cost canonical parents can then differ: retain that solver exactly.
|
|
if _connectors.is_empty(): return
|
|
var layers: Array[int] = [FRONT, BACK]
|
|
var access: Array[StringName] = []
|
|
var targets: Dictionary = {}
|
|
var crossings: Dictionary = {}
|
|
for value: Variant in goals:
|
|
if not value is Vector3i: continue
|
|
var goal: Vector3i = value
|
|
if not _passable_cells.has(goal) or _cell_is_temporarily_blocked(goal): continue
|
|
if start.z != goal.z:
|
|
if not crossings.has(goal.z): crossings[goal.z] = _has_permitted_layer_crossing(start.z, goal.z, access)
|
|
if not bool(crossings[goal.z]): continue
|
|
targets[goal] = true
|
|
if not targets.is_empty() and _passable_cells.has(start) and not _cell_is_temporarily_blocked(start):
|
|
_query_batch = {&"start":start, &"targets":targets, &"ready":false, &"tree":{}, &"crossings":crossings}
|
|
|
|
|
|
# Directed layer permission depends only on connectors/access in this synchronous
|
|
# default guest batch. All existing invalidation and restore paths clear it.
|
|
func _guest_batch_has_permitted_layer_crossing(start: Vector3i, goal: Vector3i, layers: Array[int], access: Array[StringName]) -> bool:
|
|
if not _query_batch.is_empty() and start == _query_batch[&"start"] and layers == [FRONT,BACK] and access.is_empty():
|
|
var crossings: Dictionary = _query_batch[&"crossings"]
|
|
if crossings.has(goal.z): return bool(crossings[goal.z])
|
|
return _has_permitted_layer_crossing(start.z,goal.z,access)
|
|
|
|
|
|
func end_guest_path_query_batch() -> void:
|
|
_query_batch.clear()
|
|
|
|
|
|
func _query_batch_path(start: Vector3i, goal: Vector3i, layers: Array[int], access: Array[StringName]) -> Variant:
|
|
if _query_batch.is_empty() or start != _query_batch[&"start"] \
|
|
or layers != [FRONT, BACK] or not access.is_empty() or not _query_batch[&"targets"].has(goal):
|
|
return null
|
|
if start == goal: return [start]
|
|
if not bool(_query_batch[&"ready"]):
|
|
_query_batch[&"tree"] = _build_guest_query_tree(start, _query_batch[&"targets"], layers, access)
|
|
_query_batch[&"arithmetic_wrapped"] = _guest_tree_arithmetic_wrapped
|
|
_query_batch[&"ready"] = true
|
|
var tree: Dictionary = _query_batch[&"tree"]
|
|
if not bool(tree.get(&"usable", false)): return null
|
|
var settled: Dictionary = tree[&"settled"]
|
|
if not settled.has(goal): return []
|
|
return _reconstruct_path(tree[&"parents"], goal)
|
|
|
|
|
|
# Positive edge costs make every shortest predecessor settle before its child.
|
|
# Resolving equal costs by the original coordinate parent rule gives the same
|
|
# canonical paths as the admissible goal search. Stop after requested goals, not
|
|
# after visiting the entire graph. Any nonpositive edge retains the old solver.
|
|
var _guest_tree_workspace: Dictionary = {}
|
|
# Derived search state only. Wrapped accumulated costs cannot price a cell path.
|
|
var _guest_tree_arithmetic_wrapped: bool = false
|
|
|
|
func _build_guest_query_tree(start: Vector3i, goals: Dictionary, layers: Array[int], access: Array[StringName]) -> Dictionary:
|
|
_guest_tree_arithmetic_wrapped = false
|
|
if get_script().resource_path != "res://scripts/world/path_service.gd" or layers != [FRONT,BACK] or not access.is_empty():
|
|
return _build_guest_query_tree_uncached(start,goals,layers,access)
|
|
return _build_guest_query_tree_shared(start,goals,layers,access)
|
|
|
|
func _build_guest_query_tree_uncached(start: Vector3i, goals: Dictionary, layers: Array[int], access: Array[StringName]) -> Dictionary:
|
|
# The sorted graph cells preserve the original x/y/layer heap and parent order.
|
|
# All numeric workspace is local to this search, including the coordinate map.
|
|
var cells: Array = _sorted_passable_cell_keys
|
|
var indices: Dictionary = {}
|
|
for index: int in range(cells.size()): indices[cells[index]] = index
|
|
if not indices.has(start): return {&"usable":false}
|
|
var costs := PackedInt64Array()
|
|
var parents := PackedInt64Array()
|
|
var finalized := PackedByteArray()
|
|
if costs.resize(cells.size()) != OK or parents.resize(cells.size()) != OK or finalized.resize(cells.size()) != OK:
|
|
return {&"usable":false}
|
|
costs.fill(_INFINITE_COST)
|
|
parents.fill(-1)
|
|
var start_index: int = indices[start]
|
|
costs[start_index] = 0
|
|
var discovered: Array[int] = [start_index]
|
|
var parent_discovered: Array[int] = []
|
|
var heap_nodes: Array[int] = []
|
|
var heap_costs: Array[int] = []
|
|
_indexed_tree_heap_push(heap_nodes, heap_costs, start_index, 0)
|
|
var pending: Dictionary = goals.duplicate()
|
|
var settled: Dictionary = {}
|
|
while not heap_nodes.is_empty():
|
|
var entry_cost: int = heap_costs[0]
|
|
var current_index := _indexed_tree_heap_pop(heap_nodes, heap_costs)
|
|
if finalized[current_index] != 0 or entry_cost != costs[current_index]: continue
|
|
var current: Vector3i = cells[current_index]
|
|
finalized[current_index] = 1
|
|
settled[current] = true
|
|
pending.erase(current)
|
|
if pending.is_empty(): break
|
|
var edges: Array = _adjacency.get(current, [])
|
|
for value: Variant in edges:
|
|
var edge: Dictionary = value
|
|
var neighbor: Vector3i = edge[&"to"]
|
|
if neighbor.z not in layers or _cell_is_temporarily_blocked(neighbor) \
|
|
or not _edge_meets_vertical_policy(current,edge) or not _edge_is_accessible(edge,access): continue
|
|
var edge_cost := _edge_total_cost(edge)
|
|
if edge_cost <= 0: return {&"usable":false}
|
|
if not indices.has(neighbor): return {&"usable":false}
|
|
var neighbor_index: int = indices[neighbor]
|
|
var cost := costs[current_index] + edge_cost
|
|
var known := costs[neighbor_index]
|
|
var better_parent := cost == known and parents[neighbor_index] >= 0 and current_index < parents[neighbor_index]
|
|
if cost > known or (cost == known and not better_parent): continue
|
|
if parents[neighbor_index] < 0:
|
|
parent_discovered.append(neighbor_index)
|
|
if neighbor_index != start_index: discovered.append(neighbor_index)
|
|
parents[neighbor_index] = current_index
|
|
costs[neighbor_index] = cost
|
|
finalized[neighbor_index] = 0
|
|
settled.erase(neighbor)
|
|
_indexed_tree_heap_push(heap_nodes, heap_costs, neighbor_index, cost)
|
|
# Keep the existing result dictionaries and their first-discovery order.
|
|
var result_parents: Dictionary = {}
|
|
var result_costs: Dictionary = {}
|
|
for index: int in discovered:
|
|
var cell: Vector3i = cells[index]
|
|
result_costs[cell] = costs[index]
|
|
for index: int in parent_discovered:
|
|
result_parents[cells[index]] = cells[parents[index]]
|
|
return {&"usable":true,&"settled":settled,&"parents":result_parents,&"costs":result_costs}
|
|
|
|
|
|
func _build_guest_query_tree_shared(start: Vector3i, goals: Dictionary, layers: Array[int], access: Array[StringName]) -> Dictionary:
|
|
# The sorted graph cells preserve the original x/y/layer heap and parent order.
|
|
# All numeric workspace is local to this search, including the coordinate map.
|
|
var cells: Array = _sorted_passable_cell_keys
|
|
if _guest_tree_workspace.is_empty():
|
|
var fresh_indices: Dictionary = {}
|
|
for index: int in range(cells.size()): fresh_indices[cells[index]] = index
|
|
_guest_tree_workspace = {&"indices":fresh_indices, &"edges":{}}
|
|
var indices: Dictionary = _guest_tree_workspace[&"indices"]
|
|
if not indices.has(start): return {&"usable":false}
|
|
var costs := PackedInt64Array()
|
|
var parents := PackedInt64Array()
|
|
var finalized := PackedByteArray()
|
|
if costs.resize(cells.size()) != OK or parents.resize(cells.size()) != OK or finalized.resize(cells.size()) != OK:
|
|
return {&"usable":false}
|
|
costs.fill(_INFINITE_COST)
|
|
parents.fill(-1)
|
|
var start_index: int = indices[start]
|
|
costs[start_index] = 0
|
|
var discovered: Array[int] = [start_index]
|
|
var parent_discovered: Array[int] = []
|
|
var heap_nodes: Array[int] = []
|
|
var heap_costs: Array[int] = []
|
|
_indexed_tree_heap_push(heap_nodes, heap_costs, start_index, 0)
|
|
var pending: Dictionary = goals.duplicate()
|
|
var settled: Dictionary = {}
|
|
while not heap_nodes.is_empty():
|
|
var entry_cost: int = heap_costs[0]
|
|
var current_index := _indexed_tree_heap_pop(heap_nodes, heap_costs)
|
|
if finalized[current_index] != 0 or entry_cost != costs[current_index]: continue
|
|
var current: Vector3i = cells[current_index]
|
|
finalized[current_index] = 1
|
|
settled[current] = true
|
|
pending.erase(current)
|
|
if pending.is_empty(): break
|
|
var edges: Array = _guest_tree_shared_edges(current, layers, access)
|
|
for value: Variant in edges:
|
|
var row: Array = value
|
|
var neighbor: Vector3i = row[0]
|
|
var edge_cost: int = row[1]
|
|
if edge_cost <= 0: return {&"usable":false}
|
|
if not indices.has(neighbor): return {&"usable":false}
|
|
var neighbor_index: int = indices[neighbor]
|
|
var cost := costs[current_index] + edge_cost
|
|
if cost < costs[current_index]: _guest_tree_arithmetic_wrapped = true
|
|
var known := costs[neighbor_index]
|
|
var better_parent := cost == known and parents[neighbor_index] >= 0 and current_index < parents[neighbor_index]
|
|
if cost > known or (cost == known and not better_parent): continue
|
|
if parents[neighbor_index] < 0:
|
|
parent_discovered.append(neighbor_index)
|
|
if neighbor_index != start_index: discovered.append(neighbor_index)
|
|
parents[neighbor_index] = current_index
|
|
costs[neighbor_index] = cost
|
|
finalized[neighbor_index] = 0
|
|
settled.erase(neighbor)
|
|
_indexed_tree_heap_push(heap_nodes, heap_costs, neighbor_index, cost)
|
|
# Keep the existing result dictionaries and their first-discovery order.
|
|
var result_parents: Dictionary = {}
|
|
var result_costs: Dictionary = {}
|
|
for index: int in discovered:
|
|
var cell: Vector3i = cells[index]
|
|
result_costs[cell] = costs[index]
|
|
for index: int in parent_discovered:
|
|
result_parents[cells[index]] = cells[parents[index]]
|
|
return {&"usable":true,&"settled":settled,&"parents":result_parents,&"costs":result_costs}
|
|
|
|
|
|
func _guest_tree_shared_edges(current: Vector3i, layers: Array[int], access: Array[StringName]) -> Array:
|
|
var shared: Dictionary = _guest_tree_workspace[&"edges"]
|
|
if shared.has(current): return shared[current]
|
|
var result: Array = []
|
|
for value: Variant in _adjacency.get(current,[]):
|
|
var edge: Dictionary = value
|
|
var neighbor: Vector3i = edge[&"to"]
|
|
if neighbor.z not in layers or _cell_is_temporarily_blocked(neighbor) \
|
|
or not _edge_meets_vertical_policy(current,edge) or not _edge_is_accessible(edge,access): continue
|
|
var cost := _edge_total_cost(edge)
|
|
result.append([neighbor,cost])
|
|
if cost <= 0 or not (_guest_tree_workspace[&"indices"] as Dictionary).has(neighbor): break
|
|
shared[current]=result
|
|
return result
|
|
|
|
func _indexed_tree_heap_push(nodes: Array[int], costs: Array[int], node: int, cost: int) -> void:
|
|
nodes.append(node)
|
|
costs.append(cost)
|
|
var index := nodes.size() - 1
|
|
while index > 0:
|
|
var parent := (index - 1) >> 1
|
|
if cost > costs[parent] or (cost == costs[parent] and node >= nodes[parent]): break
|
|
nodes[index] = nodes[parent]
|
|
costs[index] = costs[parent]
|
|
index = parent
|
|
nodes[index] = node
|
|
costs[index] = cost
|
|
|
|
|
|
func _indexed_tree_heap_pop(nodes: Array[int], costs: Array[int]) -> int:
|
|
var first := nodes[0]
|
|
var last_node: int = nodes.pop_back()
|
|
var last_cost: int = costs.pop_back()
|
|
if nodes.is_empty(): return first
|
|
var index := 0
|
|
while index * 2 + 1 < nodes.size():
|
|
var child := index * 2 + 1
|
|
if child + 1 < nodes.size() and (costs[child + 1] < costs[child] or (costs[child + 1] == costs[child] and nodes[child + 1] < nodes[child])):
|
|
child += 1
|
|
if costs[child] > last_cost or (costs[child] == last_cost and nodes[child] >= last_node): break
|
|
nodes[index] = nodes[child]
|
|
costs[index] = costs[child]
|
|
index = child
|
|
nodes[index] = last_node
|
|
costs[index] = last_cost
|
|
return first
|
|
|
|
|
|
# A matching current batch route without arithmetic wrap has an exact cost. Compare
|
|
# the whole path, not only its endpoints: a valid alternate may cost differently.
|
|
# Mutation/restore invalidation clears the batch even for equal revision numbers.
|
|
func _guest_batch_path_cost(start: Vector3i, goal: Vector3i, path: Array) -> int:
|
|
if _query_batch.is_empty() or start != _query_batch[&"start"] or not _query_batch[&"targets"].has(goal): return -1
|
|
if start == goal and path == [start]: return 0
|
|
if not bool(_query_batch[&"ready"]): return -1
|
|
if bool(_query_batch.get(&"arithmetic_wrapped", false)): return -1
|
|
var tree: Dictionary = _query_batch[&"tree"]
|
|
if not bool(tree.get(&"usable",false)) or not tree[&"settled"].has(goal): return -1
|
|
if path != _reconstruct_path(tree[&"parents"],goal): return -1
|
|
return int(tree[&"costs"][goal])
|
|
|
|
|
|
# Same public last_error behavior as default-access calculate_path_cost.
|
|
func calculate_guest_batch_path_cost(start: Vector3i, goal: Vector3i, path: Array) -> int:
|
|
var cost := _guest_batch_path_cost(start,goal,path)
|
|
if cost < 0: return calculate_path_cost(path)
|
|
last_error = ""
|
|
return cost
|
|
|
|
|
|
func find_path_report(
|
|
start: Vector3i,
|
|
goal: Vector3i,
|
|
access_layers: Variant = null,
|
|
connector_access: Variant = null
|
|
) -> Dictionary:
|
|
var path := find_path(start, goal, access_layers, connector_access)
|
|
return {
|
|
&"reachable": not path.is_empty(),
|
|
&"reason": last_no_route_reason,
|
|
&"cost": last_path_cost,
|
|
&"path": path,
|
|
&"graph_revision": graph_revision,
|
|
&"traffic_revision": traffic_revision,
|
|
}
|
|
|
|
|
|
func calculate_path_cost(path: Array, connector_access: Variant = null) -> int:
|
|
var access_result := _normalize_access_ids(connector_access)
|
|
if not access_result[&"ok"]:
|
|
last_error = access_result[&"error"]
|
|
return -1
|
|
var total := _calculate_path_cost_for_access(path, access_result[&"values"])
|
|
last_error = "" if total >= 0 else "Path contains an unavailable edge."
|
|
return total
|
|
|
|
|
|
func _calculate_path_cost_for_access(
|
|
path: Array,
|
|
allowed_access_ids: Array[StringName]
|
|
) -> int:
|
|
if path.is_empty():
|
|
return 0
|
|
var total := 0
|
|
for index: int in range(1, path.size()):
|
|
var from_cell: Variant = path[index - 1]
|
|
var to_cell: Variant = path[index]
|
|
if not from_cell is Vector3i or not to_cell is Vector3i:
|
|
return -1
|
|
var edge := _find_edge(from_cell, to_cell, allowed_access_ids)
|
|
if edge.is_empty():
|
|
return -1
|
|
total += _edge_total_cost(edge)
|
|
return total
|
|
|
|
|
|
func invalidate_cache_for_cells(cells: Variant) -> int:
|
|
var normalization := _normalize_cell_set(cells, false)
|
|
if not normalization[&"ok"]:
|
|
last_error = normalization[&"error"]
|
|
return 0
|
|
_query_batch.clear()
|
|
_guest_tree_workspace.clear()
|
|
var affected: Dictionary = normalization[&"cells"]
|
|
var keys_to_remove: Array = []
|
|
for cache_key: Variant in _path_cache:
|
|
var cached_path: Array = _path_cache[cache_key]
|
|
if cached_path.is_empty() or _path_touches(cached_path, affected):
|
|
keys_to_remove.append(cache_key)
|
|
for cache_key: Variant in keys_to_remove:
|
|
_path_cache.erase(cache_key)
|
|
last_error = ""
|
|
return keys_to_remove.size()
|
|
|
|
|
|
func invalidate_local_cache(cells: Variant) -> int:
|
|
return invalidate_cache_for_cells(cells)
|
|
|
|
|
|
func invalidate_all_cache() -> int:
|
|
_query_batch.clear()
|
|
_guest_tree_workspace.clear()
|
|
var removed := _path_cache.size()
|
|
_path_cache.clear()
|
|
return removed
|
|
|
|
|
|
func cached_path_count() -> int:
|
|
return _path_cache.size()
|
|
|
|
|
|
## Compare graph membership without serializing traffic, actors and connectors.
|
|
## Duplicate expected cells are invalid, just as in snapshot crosslink validation.
|
|
func matches_passable_cells(expected_cells: Array[Vector3i]) -> bool:
|
|
if expected_cells.size() != _passable_cells.size():
|
|
return false
|
|
var seen: Dictionary = {}
|
|
for cell: Vector3i in expected_cells:
|
|
if seen.has(cell) or not _passable_cells.has(cell):
|
|
return false
|
|
seen[cell] = true
|
|
return true
|
|
|
|
|
|
func has_cell(cell: Vector3i) -> bool:
|
|
return _passable_cells.has(cell)
|
|
|
|
|
|
func has_connector(connector_id: StringName) -> bool:
|
|
return _connectors.has(connector_id)
|
|
|
|
|
|
func get_connector_ids_with_prefix(prefix: String) -> Array[StringName]:
|
|
var ids: Array[StringName] = []
|
|
for connector_id: Variant in _connectors:
|
|
if String(connector_id).begins_with(prefix):
|
|
ids.append(StringName(connector_id))
|
|
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return ids
|
|
|
|
|
|
func get_connector(connector_id: StringName) -> Dictionary:
|
|
if not _connectors.has(connector_id):
|
|
return {}
|
|
return (_connectors[connector_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
## Resolves the same cheapest accessible edge that find_path/calculate_path_cost
|
|
## use, so a movement driver can distinguish riding from an ordinary step.
|
|
func get_edge_report(from_cell: Vector3i, to_cell: Vector3i, connector_access: Variant = null) -> Dictionary:
|
|
var normalized := _normalize_access_ids(connector_access)
|
|
if not bool(normalized[&"ok"]):
|
|
last_error = String(normalized[&"error"])
|
|
return {}
|
|
if _cell_is_temporarily_blocked(from_cell) or _cell_is_temporarily_blocked(to_cell):
|
|
return {}
|
|
var edge := _find_edge(from_cell, to_cell, normalized[&"values"])
|
|
if edge.is_empty():
|
|
return {}
|
|
var report := edge.duplicate(true)
|
|
report[&"from"] = from_cell
|
|
report[&"total_cost"] = _edge_total_cost(edge)
|
|
return report
|
|
|
|
|
|
# The caller only needs the selected connector id. Avoid constructing the
|
|
# detailed report and its extra cost calculation. Without a connector edge to
|
|
# this destination every possible selected edge has the empty connector id.
|
|
func get_step_connector(from_cell: Vector3i, to_cell: Vector3i, connector_access: Variant = null) -> StringName:
|
|
var normalized := _normalize_access_ids(connector_access)
|
|
if not bool(normalized[&"ok"]):
|
|
last_error = String(normalized[&"error"])
|
|
return &""
|
|
if _cell_is_temporarily_blocked(from_cell) or _cell_is_temporarily_blocked(to_cell):
|
|
return &""
|
|
for edge: Dictionary in _adjacency.get(from_cell, []):
|
|
if edge[&"to"] == to_cell and not StringName(edge[&"connector_id"]).is_empty():
|
|
return StringName(_find_edge(from_cell,to_cell,normalized[&"values"]).get(&"connector_id", &""))
|
|
return &""
|
|
|
|
|
|
func get_neighbors(cell: Vector3i) -> Array:
|
|
if not _adjacency.has(cell):
|
|
return []
|
|
return (_adjacency[cell] as Array).duplicate(true)
|
|
|
|
|
|
func record_actor_progress(actor_id: StringName, cell: Vector3i, absolute_minute: int) -> Dictionary:
|
|
if actor_id.is_empty() or absolute_minute < 0:
|
|
last_error = "Progress tracking requires an actor id and non-negative minute."
|
|
return {&"ok": false, &"reason": &"INVALID_PROGRESS"}
|
|
if _actor_progress.has(actor_id):
|
|
var existing: Dictionary = _actor_progress[actor_id]
|
|
if absolute_minute < int(existing[&"last_observed_at"]):
|
|
last_error = "Actor progress time cannot move backwards."
|
|
return {&"ok": false, &"reason": &"TIME_REVERSED"}
|
|
var advanced: bool = existing[&"last_cell"] != cell
|
|
existing[&"last_cell"] = cell
|
|
existing[&"last_observed_at"] = absolute_minute
|
|
if advanced:
|
|
existing[&"last_progress_at"] = absolute_minute
|
|
existing[&"stuck_reported_at"] = -1
|
|
_actor_progress[actor_id] = existing
|
|
last_error = ""
|
|
return {
|
|
&"ok": true,
|
|
&"advanced": advanced,
|
|
&"minutes_without_progress": absolute_minute - int(existing[&"last_progress_at"]),
|
|
}
|
|
_actor_progress[actor_id] = {
|
|
&"actor_id": actor_id,
|
|
&"last_cell": cell,
|
|
&"last_progress_at": absolute_minute,
|
|
&"last_observed_at": absolute_minute,
|
|
&"stuck_reported_at": -1,
|
|
}
|
|
last_error = ""
|
|
return {&"ok": true, &"advanced": true, &"minutes_without_progress": 0}
|
|
|
|
|
|
func get_actor_stuck_report(
|
|
actor_id: StringName,
|
|
absolute_minute: int,
|
|
allowed_layers: Variant = null
|
|
) -> Dictionary:
|
|
if not _actor_progress.has(actor_id):
|
|
last_error = "Unknown actor progress id: %s" % actor_id
|
|
return {
|
|
&"ok": false,
|
|
&"actor_id": actor_id,
|
|
&"reason": &"PROGRESS_NOT_TRACKED",
|
|
&"should_recover": false,
|
|
}
|
|
var progress: Dictionary = _actor_progress[actor_id]
|
|
if absolute_minute < int(progress[&"last_observed_at"]):
|
|
last_error = "Stuck evaluation time cannot move backwards."
|
|
return {
|
|
&"ok": false,
|
|
&"actor_id": actor_id,
|
|
&"reason": &"TIME_REVERSED",
|
|
&"should_recover": false,
|
|
}
|
|
var minutes_without_progress := absolute_minute - int(progress[&"last_progress_at"])
|
|
var should_recover := minutes_without_progress >= STUCK_THRESHOLD_MINUTES
|
|
var recovery := {
|
|
&"found": false,
|
|
&"reason": &"NOT_STUCK",
|
|
}
|
|
if should_recover:
|
|
recovery = find_nearest_walkable_recovery(progress[&"last_cell"], allowed_layers)
|
|
if int(progress[&"stuck_reported_at"]) < 0:
|
|
progress[&"stuck_reported_at"] = absolute_minute
|
|
progress[&"last_observed_at"] = absolute_minute
|
|
_actor_progress[actor_id] = progress
|
|
last_error = ""
|
|
return {
|
|
&"ok": true,
|
|
&"actor_id": actor_id,
|
|
&"reason": &"STUCK" if should_recover else &"PROGRESS_RECENT",
|
|
&"should_recover": should_recover,
|
|
&"threshold_minutes": STUCK_THRESHOLD_MINUTES,
|
|
&"minutes_without_progress": minutes_without_progress,
|
|
&"last_cell": progress[&"last_cell"],
|
|
&"recovery": recovery,
|
|
&"requires_explicit_move": should_recover,
|
|
}
|
|
|
|
|
|
func check_actor_stuck(
|
|
actor_id: StringName,
|
|
absolute_minute: int,
|
|
allowed_layers: Variant = null
|
|
) -> Dictionary:
|
|
return get_actor_stuck_report(actor_id, absolute_minute, allowed_layers)
|
|
|
|
|
|
func clear_actor_progress(actor_id: StringName) -> bool:
|
|
if not _actor_progress.has(actor_id):
|
|
return false
|
|
_actor_progress.erase(actor_id)
|
|
return true
|
|
|
|
|
|
func find_nearest_walkable_recovery(
|
|
origin: Vector3i,
|
|
allowed_layers: Variant = null
|
|
) -> Dictionary:
|
|
var normalized := _normalize_access_layers(allowed_layers)
|
|
if not normalized[&"ok"]:
|
|
return {&"found": false, &"reason": &"INVALID_ACCESS_LAYERS"}
|
|
var layers: Array[int] = normalized[&"values"]
|
|
var candidates: Array[Vector3i] = []
|
|
for cell_value: Variant in _passable_cells:
|
|
var cell: Vector3i = cell_value
|
|
if cell.z in layers and not _cell_is_temporarily_blocked(cell):
|
|
candidates.append(cell)
|
|
if candidates.is_empty():
|
|
return {&"found": false, &"reason": &"NO_SAFE_WALKABLE_CELL"}
|
|
candidates.sort_custom(func(left: Vector3i, right: Vector3i) -> bool:
|
|
var left_distance := _recovery_distance(origin, left)
|
|
var right_distance := _recovery_distance(origin, right)
|
|
if left_distance != right_distance:
|
|
return left_distance < right_distance
|
|
return _coordinate_less(left, right)
|
|
)
|
|
var target: Vector3i = candidates[0]
|
|
if _vertical_walk_requires_connector:
|
|
var reachable := false
|
|
for candidate: Vector3i in candidates:
|
|
if _route_exists_with_overrides(origin, candidate, layers, [], true, false):
|
|
target = candidate
|
|
reachable = true
|
|
break
|
|
if not reachable:
|
|
return {&"found": false, &"reason": &"NO_SAFE_WALKABLE_CELL"}
|
|
return {
|
|
&"found": true,
|
|
&"reason": &"SAFE_WALKABLE_CELL",
|
|
&"cell": target,
|
|
&"distance": _recovery_distance(origin, target),
|
|
&"implicit_move_applied": false,
|
|
}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
# A nested sorted-name read can otherwise abort to an empty typed array
|
|
# while this outer capture still returns a seemingly valid Dictionary.
|
|
for transit_actor_id: Variant in _transit_actor_ids:
|
|
if typeof(transit_actor_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return {}
|
|
|
|
var connector_ids: Array = _connectors.keys()
|
|
connector_ids.sort_custom(Callable(self, "_string_name_less"))
|
|
var saved_connectors: Array = []
|
|
for connector_id: Variant in connector_ids:
|
|
var connector: Dictionary = (_connectors[connector_id] as Dictionary).duplicate(true)
|
|
var queue: Array = connector[&"request_queue"]
|
|
queue.sort_custom(Callable(self, "_elevator_request_base_less"))
|
|
connector[&"request_queue"] = queue
|
|
saved_connectors.append(connector)
|
|
|
|
var cells: Array = _sorted_passable_cell_keys
|
|
var saved_cell_traffic: Array = []
|
|
for cell_value: Variant in cells:
|
|
var cell: Vector3i = cell_value
|
|
var traffic: Dictionary = _cell_traffic[cell] if _cell_traffic.has(cell) else _default_cell_traffic()
|
|
saved_cell_traffic.append({
|
|
&"cell": cell,
|
|
&"base_capacity": int(traffic[&"base_capacity"]),
|
|
&"current_density": int(traffic[&"current_density"]),
|
|
&"dynamic_cost": int(traffic[&"dynamic_cost"]),
|
|
&"temporary_block": bool(traffic[&"temporary_block"]),
|
|
})
|
|
|
|
var actor_ids: Array = _actor_cells.keys()
|
|
actor_ids.sort_custom(Callable(self, "_string_name_less"))
|
|
var saved_actor_cells: Array = []
|
|
for actor_id: Variant in actor_ids:
|
|
saved_actor_cells.append({
|
|
&"actor_id": StringName(actor_id),
|
|
&"cell": _actor_cells[actor_id],
|
|
})
|
|
|
|
var progress_ids: Array = _actor_progress.keys()
|
|
progress_ids.sort_custom(Callable(self, "_string_name_less"))
|
|
var saved_progress: Array = []
|
|
for actor_id: Variant in progress_ids:
|
|
saved_progress.append((_actor_progress[actor_id] as Dictionary).duplicate(true))
|
|
|
|
var known_request_ids: Array = _known_elevator_request_ids.keys()
|
|
known_request_ids.sort_custom(Callable(self, "_string_name_less"))
|
|
var saved_known_requests: Array[StringName] = []
|
|
for request_id: Variant in known_request_ids:
|
|
saved_known_requests.append(StringName(request_id))
|
|
var active_unit_ids: Array = _active_elevator_units.keys()
|
|
active_unit_ids.sort_custom(Callable(self, "_string_name_less"))
|
|
var saved_active_units: Array = []
|
|
for unit_id: Variant in active_unit_ids:
|
|
saved_active_units.append((_active_elevator_units[unit_id] as Dictionary).duplicate(true))
|
|
var saved_vertical_blockers: Array[Dictionary] = []
|
|
for owner_id: StringName in _sorted_name_keys(_vertical_walk_blockers):
|
|
saved_vertical_blockers.append({&"owner_id": owner_id, &"cells": (_vertical_walk_blockers[owner_id] as Array).duplicate()})
|
|
return {
|
|
&"schema_version": SNAPSHOT_VERSION,
|
|
&"graph_revision": graph_revision,
|
|
&"traffic_revision": traffic_revision,
|
|
&"connectors": saved_connectors,
|
|
&"cell_traffic": saved_cell_traffic,
|
|
&"actor_cells": saved_actor_cells,
|
|
&"actor_progress": saved_progress,
|
|
&"known_elevator_request_ids": saved_known_requests,
|
|
&"active_elevator_units": saved_active_units,
|
|
&"vertical_walk_blockers": saved_vertical_blockers,
|
|
&"vertical_walk_requires_connector": _vertical_walk_requires_connector,
|
|
&"transit_actor_ids": get_transit_actor_ids(),
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
var version := int(_dictionary_value(snapshot, &"schema_version", -1))
|
|
if version not in [LEGACY_SNAPSHOT_VERSION, TRAFFIC_SNAPSHOT_VERSION, ELEVATOR_INDEX_SNAPSHOT_VERSION, SHAFT_SNAPSHOT_VERSION, SNAPSHOT_VERSION]:
|
|
last_error = "Unsupported PathService snapshot version: %d" % version
|
|
return false
|
|
if version < SHAFT_SNAPSHOT_VERSION and (snapshot.has(&"vertical_walk_blockers") or snapshot.has("vertical_walk_blockers")):
|
|
last_error = "Legacy PathService snapshots cannot contain shaft ownership fields."
|
|
return false
|
|
if version < SNAPSHOT_VERSION and (snapshot.has(&"vertical_walk_requires_connector") or snapshot.has("vertical_walk_requires_connector") \
|
|
or snapshot.has(&"transit_actor_ids") or snapshot.has("transit_actor_ids")):
|
|
last_error = "Legacy PathService snapshots cannot contain vertical policy or transit fields."
|
|
return false
|
|
var next_vertical_policy := false
|
|
if version >= SNAPSHOT_VERSION:
|
|
var policy_value: Variant = _dictionary_value(snapshot, &"vertical_walk_requires_connector", null)
|
|
if typeof(policy_value) != TYPE_BOOL:
|
|
last_error = "PathService vertical movement policy must be a bool."
|
|
return false
|
|
next_vertical_policy = bool(policy_value)
|
|
var raw_connectors: Variant = _dictionary_value(snapshot, &"connectors", null)
|
|
if not raw_connectors is Array:
|
|
last_error = "PathService snapshot connectors must be an Array."
|
|
return false
|
|
var connector_result := _normalize_connector_collection(raw_connectors)
|
|
if not connector_result[&"ok"]:
|
|
last_error = connector_result[&"error"]
|
|
return false
|
|
var next_connectors: Dictionary = connector_result[&"connectors"]
|
|
var next_known_requests: Dictionary = connector_result[&"known_request_ids"]
|
|
var next_active_units: Dictionary = connector_result[&"active_units"]
|
|
|
|
if version == LEGACY_SNAPSHOT_VERSION:
|
|
var changed := next_connectors != _connectors or not _vertical_walk_blockers.is_empty() or _vertical_walk_requires_connector
|
|
_vertical_walk_blockers.clear()
|
|
_vertical_walk_requires_connector = false
|
|
_transit_actor_ids.clear()
|
|
_rebuild_vertical_walk_blocked_cells()
|
|
_connectors = next_connectors
|
|
_cell_traffic.clear()
|
|
_actor_cells.clear()
|
|
_actor_progress.clear()
|
|
_known_elevator_request_ids = next_known_requests
|
|
_active_elevator_units = next_active_units
|
|
traffic_revision = 0
|
|
_pending_graph_revision = -1
|
|
_traffic_loaded_without_graph = false
|
|
_ensure_cell_traffic_for_graph(_passable_cells, true)
|
|
_rebuild_derived_graph()
|
|
if changed:
|
|
_mark_graph_changed()
|
|
else:
|
|
invalidate_all_cache()
|
|
last_error = ""
|
|
return true
|
|
|
|
var saved_graph_revision := int(_dictionary_value(snapshot, &"graph_revision", -1))
|
|
var saved_traffic_revision := int(_dictionary_value(snapshot, &"traffic_revision", -1))
|
|
var raw_cell_traffic: Variant = _dictionary_value(snapshot, &"cell_traffic", null)
|
|
var raw_actor_cells: Variant = _dictionary_value(snapshot, &"actor_cells", null)
|
|
var raw_progress: Variant = _dictionary_value(snapshot, &"actor_progress", null)
|
|
if saved_graph_revision < 0 or saved_traffic_revision < 0 \
|
|
or not raw_cell_traffic is Array \
|
|
or not raw_actor_cells is Array \
|
|
or not raw_progress is Array:
|
|
last_error = "PathService traffic snapshot fields are missing or invalid."
|
|
return false
|
|
|
|
var traffic_result := _normalize_snapshot_cell_traffic(raw_cell_traffic)
|
|
if not traffic_result[&"ok"]:
|
|
last_error = traffic_result[&"error"]
|
|
return false
|
|
var next_traffic: Dictionary = traffic_result[&"traffic"]
|
|
var actor_result := _normalize_snapshot_actor_cells(raw_actor_cells, next_traffic)
|
|
if not actor_result[&"ok"]:
|
|
last_error = actor_result[&"error"]
|
|
return false
|
|
var next_actor_cells: Dictionary = actor_result[&"actor_cells"]
|
|
var progress_result := _normalize_snapshot_actor_progress(raw_progress)
|
|
if not progress_result[&"ok"]:
|
|
last_error = progress_result[&"error"]
|
|
return false
|
|
var next_progress: Dictionary = progress_result[&"progress"]
|
|
if version >= ELEVATOR_INDEX_SNAPSHOT_VERSION:
|
|
var raw_known_requests: Variant = _dictionary_value(
|
|
snapshot,
|
|
&"known_elevator_request_ids",
|
|
null
|
|
)
|
|
var raw_active_units: Variant = _dictionary_value(snapshot, &"active_elevator_units", null)
|
|
if not raw_known_requests is Array or not raw_active_units is Array:
|
|
last_error = "PathService v3 elevator request indexes are missing or invalid."
|
|
return false
|
|
var index_result := _normalize_snapshot_elevator_indexes(
|
|
raw_known_requests,
|
|
raw_active_units,
|
|
next_connectors,
|
|
connector_result[&"active_units"]
|
|
)
|
|
if not index_result[&"ok"]:
|
|
last_error = index_result[&"error"]
|
|
return false
|
|
next_known_requests = index_result[&"known_request_ids"]
|
|
next_active_units = index_result[&"active_units"]
|
|
var next_vertical_blockers: Dictionary = {}
|
|
if version >= SHAFT_SNAPSHOT_VERSION:
|
|
var blockers_result := _normalize_snapshot_vertical_blockers(_dictionary_value(snapshot, &"vertical_walk_blockers", null), next_traffic)
|
|
if not bool(blockers_result[&"ok"]):
|
|
last_error = String(blockers_result[&"error"])
|
|
return false
|
|
next_vertical_blockers = blockers_result[&"blockers"]
|
|
var next_transit := _derive_transit_actor_ids(next_actor_cells, next_active_units)
|
|
if version >= SNAPSHOT_VERSION:
|
|
var transit_value: Variant = _dictionary_value(snapshot, &"transit_actor_ids", null)
|
|
if not _transit_snapshot_matches(transit_value, next_transit):
|
|
last_error = "PathService transit actors must match projected boarded elevator units."
|
|
return false
|
|
if not _snapshot_densities_match(next_traffic, next_actor_cells, next_transit if version >= SNAPSHOT_VERSION else {}):
|
|
last_error = "PathService snapshot density does not match actor projections."
|
|
return false
|
|
if not _passable_cells.is_empty():
|
|
if next_traffic.size() != _passable_cells.size():
|
|
last_error = "PathService snapshot traffic topology does not match the current graph."
|
|
return false
|
|
for cell_value: Variant in next_traffic:
|
|
if not _passable_cells.has(cell_value):
|
|
last_error = "PathService snapshot traffic references an unknown cell."
|
|
return false
|
|
|
|
_connectors = next_connectors
|
|
_vertical_walk_blockers = next_vertical_blockers
|
|
_vertical_walk_requires_connector = next_vertical_policy
|
|
_transit_actor_ids = next_transit
|
|
_rebuild_vertical_walk_blocked_cells()
|
|
_cell_traffic = next_traffic
|
|
_actor_cells = next_actor_cells
|
|
_actor_progress = next_progress
|
|
_known_elevator_request_ids = next_known_requests
|
|
_active_elevator_units = next_active_units
|
|
traffic_revision = saved_traffic_revision
|
|
# Older snapshots counted passengers at their boarding anchor. Validate that
|
|
# old rule above before a one-time migration to physical floor occupancy.
|
|
if version < SNAPSHOT_VERSION and not _transit_actor_ids.is_empty():
|
|
_recompute_all_densities()
|
|
traffic_revision += 1
|
|
_pending_graph_revision = saved_graph_revision if _passable_cells.is_empty() else -1
|
|
_traffic_loaded_without_graph = _passable_cells.is_empty() and not next_traffic.is_empty()
|
|
if not _passable_cells.is_empty():
|
|
graph_revision = saved_graph_revision
|
|
_rebuild_derived_graph()
|
|
invalidate_all_cache()
|
|
last_no_route_reason = ROUTE_OK
|
|
last_path_cost = 0
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
## A single connector changes only the outgoing edges of its old/new endpoints.
|
|
## Keep the same walk-edge rules, sorted connector iteration and edge ordering
|
|
## as the full rebuild. Topology changes still use the full graph rebuild.
|
|
func _rebuild_connector_endpoint_edges(previous: Dictionary, current: Dictionary) -> void:
|
|
var affected: Dictionary = {}
|
|
for configuration: Dictionary in [previous, current]:
|
|
if configuration.is_empty():
|
|
continue
|
|
for key: StringName in [&"from", &"to"]:
|
|
var cell: Vector3i = configuration[key]
|
|
if _passable_cells.has(cell):
|
|
affected[cell] = true
|
|
var cells: Array = affected.keys()
|
|
cells.sort_custom(Callable(self, "_coordinate_less"))
|
|
for cell_value: Variant in cells:
|
|
var cell: Vector3i = cell_value
|
|
_adjacency[cell] = []
|
|
for direction in _CARDINAL_DIRECTIONS:
|
|
var neighbor := cell + direction
|
|
if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(cell, neighbor):
|
|
_append_edge(cell, neighbor, ORTHOGONAL_MOVE_COST, &"", &"", MOVEMENT_WALK)
|
|
var connector_ids: Array = _connectors.keys()
|
|
connector_ids.sort_custom(Callable(self, "_string_name_less"))
|
|
for connector_id_value: Variant in connector_ids:
|
|
var connector_id: StringName = connector_id_value
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if not _connector_is_operational(connector):
|
|
continue
|
|
var from_cell: Vector3i = connector[&"from"]
|
|
var to_cell: Vector3i = connector[&"to"]
|
|
if not _passable_cells.has(from_cell) or not _passable_cells.has(to_cell):
|
|
continue
|
|
var cost: int = connector[&"cost"]
|
|
var access_id: StringName = connector[&"access_id"]
|
|
var movement_type: StringName = connector[&"movement_type"]
|
|
if affected.has(from_cell):
|
|
_append_edge(from_cell, to_cell, cost, connector_id, access_id, movement_type)
|
|
if bool(connector[&"bidirectional"]) and affected.has(to_cell):
|
|
_append_edge(to_cell, from_cell, cost, connector_id, access_id, movement_type)
|
|
for cell_value: Variant in cells:
|
|
var edges: Array = _adjacency[cell_value]
|
|
edges.sort_custom(Callable(self, "_edge_less"))
|
|
_adjacency[cell_value] = edges
|
|
|
|
|
|
func _rebuild_derived_graph() -> void:
|
|
_adjacency.clear()
|
|
var cells: Array = _passable_cells.keys()
|
|
cells.sort_custom(Callable(self, "_coordinate_less"))
|
|
_sorted_passable_cell_keys = cells
|
|
for cell_value: Variant in cells:
|
|
var cell: Vector3i = cell_value
|
|
_adjacency[cell] = []
|
|
|
|
for cell_value: Variant in cells:
|
|
var cell: Vector3i = cell_value
|
|
for direction in _CARDINAL_DIRECTIONS:
|
|
var neighbor := cell + direction
|
|
if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(cell, neighbor):
|
|
_append_edge(
|
|
cell,
|
|
neighbor,
|
|
ORTHOGONAL_MOVE_COST,
|
|
&"",
|
|
&"",
|
|
MOVEMENT_WALK
|
|
)
|
|
|
|
var connector_ids: Array = _connectors.keys()
|
|
connector_ids.sort_custom(Callable(self, "_string_name_less"))
|
|
for connector_id_value: Variant in connector_ids:
|
|
var connector_id: StringName = connector_id_value
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
if not _connector_is_operational(connector):
|
|
continue
|
|
var from_cell: Vector3i = connector[&"from"]
|
|
var to_cell: Vector3i = connector[&"to"]
|
|
if not _passable_cells.has(from_cell) or not _passable_cells.has(to_cell):
|
|
continue
|
|
var cost: int = connector[&"cost"]
|
|
var access_id: StringName = connector[&"access_id"]
|
|
var movement_type: StringName = connector[&"movement_type"]
|
|
_append_edge(from_cell, to_cell, cost, connector_id, access_id, movement_type)
|
|
if bool(connector[&"bidirectional"]):
|
|
_append_edge(to_cell, from_cell, cost, connector_id, access_id, movement_type)
|
|
|
|
for cell_value: Variant in cells:
|
|
var edges: Array = _adjacency[cell_value]
|
|
edges.sort_custom(Callable(self, "_edge_less"))
|
|
_adjacency[cell_value] = edges
|
|
|
|
|
|
func _append_edge(
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
cost: int,
|
|
connector_id: StringName,
|
|
access_id: StringName,
|
|
movement_type: StringName
|
|
) -> void:
|
|
var edges: Array = _adjacency[from_cell]
|
|
edges.append({
|
|
&"to": to_cell,
|
|
&"cost": cost,
|
|
&"base_cost": cost,
|
|
&"connector_id": connector_id,
|
|
&"access_id": access_id,
|
|
&"movement_type": movement_type,
|
|
})
|
|
_adjacency[from_cell] = edges
|
|
|
|
|
|
func _run_a_star(
|
|
start: Vector3i,
|
|
goal: Vector3i,
|
|
allowed_layers: Array[int],
|
|
allowed_access_ids: Array[StringName]
|
|
) -> Array[Vector3i]:
|
|
if start == goal:
|
|
return [start]
|
|
|
|
var connector_scale := _positive_connector_heuristic_scale()
|
|
var settled_goal_cost := _INFINITE_COST
|
|
var frontier: Array[Array] = []
|
|
_heap_push(frontier, [start, 0, _query_distance_lower_bound(start, goal, connector_scale)])
|
|
var closed_set: Dictionary = {}
|
|
var came_from: Dictionary = {}
|
|
var g_score: Dictionary = {start: 0}
|
|
|
|
while not frontier.is_empty():
|
|
var entry := _heap_pop(frontier)
|
|
if connector_scale > 0 and int(entry[2]) > settled_goal_cost:
|
|
break
|
|
var current: Vector3i = entry[0]
|
|
if closed_set.has(current) or int(entry[1]) != int(g_score[current]):
|
|
continue
|
|
if current == goal:
|
|
if connector_scale <= 0:
|
|
return _reconstruct_path(came_from, current)
|
|
settled_goal_cost = int(entry[1])
|
|
continue
|
|
|
|
closed_set[current] = true
|
|
var edges: Array = _adjacency.get(current, [])
|
|
for edge_value: Variant in edges:
|
|
var edge: Dictionary = edge_value
|
|
var neighbor: Vector3i = edge[&"to"]
|
|
if neighbor.z not in allowed_layers \
|
|
or _cell_is_temporarily_blocked(neighbor) \
|
|
or not _edge_meets_vertical_policy(current, edge) \
|
|
or not _edge_is_accessible(edge, allowed_access_ids):
|
|
continue
|
|
var tentative_cost: int = int(g_score[current]) + _edge_total_cost(edge)
|
|
var known_cost: int = int(g_score.get(neighbor, _INFINITE_COST))
|
|
var better_parent := false
|
|
if tentative_cost == known_cost and came_from.has(neighbor):
|
|
better_parent = _coordinate_less(current, came_from[neighbor])
|
|
if tentative_cost > known_cost or (tentative_cost == known_cost and not better_parent):
|
|
continue
|
|
|
|
came_from[neighbor] = current
|
|
g_score[neighbor] = tentative_cost
|
|
if closed_set.has(neighbor):
|
|
closed_set.erase(neighbor)
|
|
_heap_push(frontier, [neighbor, tentative_cost, tentative_cost + _query_distance_lower_bound(neighbor, goal, connector_scale)])
|
|
|
|
if settled_goal_cost < _INFINITE_COST:
|
|
return _reconstruct_path(came_from, goal)
|
|
return []
|
|
|
|
|
|
## Every permitted edge costs at least scale * its Manhattan displacement.
|
|
## A zero-cost connector keeps the existing conservative search unchanged.
|
|
func _positive_connector_heuristic_scale() -> int:
|
|
if _connectors.is_empty():
|
|
return 0
|
|
var scale := ORTHOGONAL_MOVE_COST
|
|
for connector_value: Variant in _connectors.values():
|
|
var connector: Dictionary = connector_value
|
|
var from_cell: Vector3i = connector[&"from"]
|
|
var to_cell: Vector3i = connector[&"to"]
|
|
var displacement := absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y) + absi(from_cell.z - to_cell.z)
|
|
if displacement > 0:
|
|
scale = mini(scale, int(connector[&"cost"]) / displacement)
|
|
if scale <= 0:
|
|
return 0
|
|
return scale
|
|
|
|
|
|
func _query_distance_lower_bound(from_cell: Vector3i, to_cell: Vector3i, connector_scale: int) -> int:
|
|
if connector_scale <= 0:
|
|
return _heuristic(from_cell, to_cell)
|
|
return (absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y) + absi(from_cell.z - to_cell.z)) * connector_scale
|
|
|
|
|
|
# Query-local heap. Stale improved-cost entries are skipped on removal.
|
|
# Keep the original f-score / x / y / layer order, including reopened nodes.
|
|
func _heap_less(left: Array, right: Array) -> bool:
|
|
return int(left[2]) < int(right[2]) or (int(left[2]) == int(right[2]) and _coordinate_less(left[0], right[0]))
|
|
|
|
|
|
func _heap_push(heap: Array[Array], entry: Array) -> void:
|
|
heap.append(entry)
|
|
var index := heap.size() - 1
|
|
while index > 0:
|
|
var parent := (index - 1) >> 1
|
|
if not _heap_less(entry, heap[parent]):
|
|
break
|
|
heap[index] = heap[parent]
|
|
index = parent
|
|
heap[index] = entry
|
|
|
|
|
|
func _heap_pop(heap: Array[Array]) -> Array:
|
|
var first := heap[0]
|
|
var last: Array = heap.pop_back()
|
|
if heap.is_empty():
|
|
return first
|
|
var index := 0
|
|
while index * 2 + 1 < heap.size():
|
|
var child := index * 2 + 1
|
|
if child + 1 < heap.size() and _heap_less(heap[child + 1], heap[child]):
|
|
child += 1
|
|
if not _heap_less(heap[child], last):
|
|
break
|
|
heap[index] = heap[child]
|
|
index = child
|
|
heap[index] = last
|
|
return first
|
|
|
|
|
|
func _heuristic(from_cell: Vector3i, to_cell: Vector3i) -> int:
|
|
# A connector may be cheaper than the geometric distance. In that case zero is
|
|
# the conservative admissible heuristic; without connectors Manhattan is exact.
|
|
if not _connectors.is_empty() or from_cell.z != to_cell.z:
|
|
return 0
|
|
return (absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y)) * ORTHOGONAL_MOVE_COST
|
|
|
|
|
|
func _reconstruct_path(came_from: Dictionary, goal: Vector3i) -> Array[Vector3i]:
|
|
var reversed_path: Array[Vector3i] = [goal]
|
|
var current := goal
|
|
var maximum_length := _adjacency.size() + 1
|
|
while came_from.has(current) and reversed_path.size() <= maximum_length:
|
|
current = came_from[current]
|
|
reversed_path.append(current)
|
|
reversed_path.reverse()
|
|
return reversed_path
|
|
|
|
|
|
func _edge_meets_vertical_policy(from_cell: Vector3i, edge: Dictionary) -> bool:
|
|
return not _vertical_walk_requires_connector or from_cell.y == (edge[&"to"] as Vector3i).y \
|
|
or not StringName(edge[&"connector_id"]).is_empty() or edge[&"movement_type"] != MOVEMENT_WALK
|
|
|
|
|
|
func _edge_is_accessible(edge: Dictionary, allowed_access_ids: Array[StringName]) -> bool:
|
|
var required_access: StringName = edge[&"access_id"]
|
|
# "!id" is open to everyone except actors that carry id (guest-only warp).
|
|
if String(required_access).begins_with("!"):
|
|
if StringName(String(required_access).substr(1)) in allowed_access_ids:
|
|
return false
|
|
elif not required_access.is_empty() and required_access not in allowed_access_ids:
|
|
return false
|
|
var connector_id: StringName = edge[&"connector_id"]
|
|
if connector_id.is_empty():
|
|
return true
|
|
return _connectors.has(connector_id) and _connector_is_operational(_connectors[connector_id])
|
|
|
|
|
|
func _edge_total_cost(edge: Dictionary) -> int:
|
|
var to_cell: Vector3i = edge[&"to"]
|
|
var total := int(edge.get(&"base_cost", edge.get(&"cost", ORTHOGONAL_MOVE_COST)))
|
|
total += get_cell_dynamic_cost(to_cell)
|
|
var connector_id: StringName = edge.get(&"connector_id", &"")
|
|
if connector_id.is_empty() or not _connectors.has(connector_id):
|
|
return total
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
total += int(connector[&"dynamic_cost"])
|
|
if connector[&"movement_type"] == MOVEMENT_ELEVATOR:
|
|
total += estimate_elevator_wait_cost(connector_id)
|
|
var from_cell: Vector3i = connector[&"from"]
|
|
var destination: Vector3i = connector[&"to"]
|
|
var floor_distance := absi(destination.y - from_cell.y)
|
|
total += floor_distance * int(connector[&"travel_minutes_per_floor"]) * ORTHOGONAL_MOVE_COST
|
|
return total
|
|
|
|
|
|
func _find_edge(
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
allowed_access_ids: Variant = null
|
|
) -> Dictionary:
|
|
var best: Dictionary = {}
|
|
var best_cost := _INFINITE_COST
|
|
for edge_value: Variant in _adjacency.get(from_cell, []):
|
|
var edge: Dictionary = edge_value
|
|
if edge[&"to"] != to_cell:
|
|
continue
|
|
if not _edge_meets_vertical_policy(from_cell, edge):
|
|
continue
|
|
if allowed_access_ids is Array and not _edge_is_accessible(edge, allowed_access_ids):
|
|
continue
|
|
var cost := _edge_total_cost(edge)
|
|
if best.is_empty() or cost < best_cost \
|
|
or (cost == best_cost and _edge_less(edge, best)):
|
|
best = edge
|
|
best_cost = cost
|
|
return best
|
|
|
|
|
|
func _path_is_currently_valid(
|
|
path: Array,
|
|
allowed_layers: Array[int],
|
|
allowed_access_ids: Array[StringName]
|
|
) -> bool:
|
|
if path.is_empty():
|
|
return false
|
|
for cell_value: Variant in path:
|
|
if not cell_value is Vector3i:
|
|
return false
|
|
var cell: Vector3i = cell_value
|
|
if not _passable_cells.has(cell) or cell.z not in allowed_layers \
|
|
or _cell_is_temporarily_blocked(cell):
|
|
return false
|
|
for index: int in range(1, path.size()):
|
|
var edge := _find_edge(path[index - 1], path[index], allowed_access_ids)
|
|
if edge.is_empty():
|
|
return false
|
|
return true
|
|
|
|
|
|
func _route_failure(reason: StringName) -> Array[Vector3i]:
|
|
last_no_route_reason = reason
|
|
last_path_cost = 0
|
|
last_error = str(reason)
|
|
return []
|
|
|
|
|
|
func _cell_is_temporarily_blocked(cell: Vector3i) -> bool:
|
|
var traffic: Dictionary = _cell_traffic.get(cell, {})
|
|
return not traffic.is_empty() and bool(traffic[&"temporary_block"])
|
|
|
|
|
|
## Cardinal walking never changes layers. Even an unavailable or blocked
|
|
## connector must be retained here so the ordinary solver preserves its reason.
|
|
## Only the absence of any permitted directed crossing proves unreachable.
|
|
func _has_permitted_layer_crossing(from_layer: int, to_layer: int, allowed_access_ids: Array[StringName]) -> bool:
|
|
for connector: Dictionary in _connectors.values():
|
|
var access_id: StringName = connector[&"access_id"]
|
|
if not access_id.is_empty() and access_id not in allowed_access_ids:
|
|
continue
|
|
var from_cell: Vector3i = connector[&"from"]
|
|
var to_cell: Vector3i = connector[&"to"]
|
|
if from_cell.z == from_layer and to_cell.z == to_layer:
|
|
return true
|
|
if bool(connector[&"bidirectional"]) and to_cell.z == from_layer and from_cell.z == to_layer:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _diagnose_no_route_reason(
|
|
start: Vector3i,
|
|
goal: Vector3i,
|
|
allowed_layers: Array[int],
|
|
allowed_access_ids: Array[StringName]
|
|
) -> StringName:
|
|
# Diagnose only obstacles which actually separate this start/goal pair. A
|
|
# stopped lift or blocked cell in another disconnected region must not leak
|
|
# into this route's reason.
|
|
if _route_exists_with_overrides(
|
|
start,
|
|
goal,
|
|
allowed_layers,
|
|
allowed_access_ids,
|
|
true,
|
|
false
|
|
):
|
|
return ROUTE_TEMPORARILY_BLOCKED
|
|
if _route_exists_with_overrides(
|
|
start,
|
|
goal,
|
|
allowed_layers,
|
|
allowed_access_ids,
|
|
false,
|
|
true
|
|
):
|
|
return ROUTE_CONNECTOR_UNAVAILABLE
|
|
return ROUTE_GOAL_UNREACHABLE
|
|
|
|
|
|
func _route_exists_with_overrides(
|
|
start: Vector3i,
|
|
goal: Vector3i,
|
|
allowed_layers: Array[int],
|
|
allowed_access_ids: Array[StringName],
|
|
allow_blocked_cells: bool,
|
|
allow_unavailable_connectors: bool
|
|
) -> bool:
|
|
var frontier: Array[Vector3i] = [start]
|
|
var visited: Dictionary = {start: true}
|
|
var cursor := 0
|
|
var connector_ids := _sorted_name_keys(_connectors)
|
|
while cursor < frontier.size():
|
|
var current: Vector3i = frontier[cursor]
|
|
cursor += 1
|
|
if current == goal:
|
|
return true
|
|
var neighbor_set: Dictionary = {}
|
|
for direction: Vector3i in _CARDINAL_DIRECTIONS:
|
|
var neighbor := current + direction
|
|
if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(current, neighbor) \
|
|
and (not _vertical_walk_requires_connector or current.y == neighbor.y):
|
|
neighbor_set[neighbor] = true
|
|
for connector_id: StringName in connector_ids:
|
|
var connector: Dictionary = _connectors[connector_id]
|
|
var required_access: StringName = connector[&"access_id"]
|
|
if not required_access.is_empty() and required_access not in allowed_access_ids:
|
|
continue
|
|
if not allow_unavailable_connectors and not _connector_is_operational(connector):
|
|
continue
|
|
var from_cell: Vector3i = connector[&"from"]
|
|
var to_cell: Vector3i = connector[&"to"]
|
|
if current == from_cell:
|
|
neighbor_set[to_cell] = true
|
|
elif bool(connector[&"bidirectional"]) and current == to_cell:
|
|
neighbor_set[from_cell] = true
|
|
var neighbors: Array = neighbor_set.keys()
|
|
neighbors.sort_custom(Callable(self, "_coordinate_less"))
|
|
for neighbor_value: Variant in neighbors:
|
|
var neighbor: Vector3i = neighbor_value
|
|
if visited.has(neighbor) or neighbor.z not in allowed_layers:
|
|
continue
|
|
if not allow_blocked_cells and _cell_is_temporarily_blocked(neighbor):
|
|
continue
|
|
visited[neighbor] = true
|
|
frontier.append(neighbor)
|
|
return false
|
|
|
|
|
|
func _connector_is_operational(connector: Dictionary) -> bool:
|
|
return bool(connector[&"enabled"]) \
|
|
and connector[&"operational_state"] == CONNECTOR_OPERATIONAL \
|
|
and (
|
|
connector[&"movement_type"] != MOVEMENT_ELEVATOR
|
|
or connector[&"cabin_state"] != CABIN_OUTAGE
|
|
)
|
|
|
|
|
|
func _ensure_cell_traffic_for_graph(cells: Dictionary, reset_missing: bool) -> void:
|
|
var next_traffic: Dictionary = {}
|
|
for cell_value: Variant in cells:
|
|
var cell: Vector3i = cell_value
|
|
if _cell_traffic.has(cell):
|
|
next_traffic[cell] = (_cell_traffic[cell] as Dictionary).duplicate(true)
|
|
elif reset_missing:
|
|
next_traffic[cell] = _default_cell_traffic()
|
|
else:
|
|
next_traffic[cell] = _default_cell_traffic()
|
|
_cell_traffic = next_traffic
|
|
var invalid_actor_ids: Array = []
|
|
for actor_id: Variant in _actor_cells:
|
|
if not cells.has(_actor_cells[actor_id]):
|
|
invalid_actor_ids.append(actor_id)
|
|
for actor_id: Variant in invalid_actor_ids:
|
|
_actor_cells.erase(actor_id)
|
|
_transit_actor_ids = _derive_transit_actor_ids(_actor_cells, _active_elevator_units)
|
|
_recompute_all_densities()
|
|
|
|
|
|
func _default_cell_traffic() -> Dictionary:
|
|
return {
|
|
&"base_capacity": DEFAULT_CELL_CAPACITY,
|
|
&"current_density": 0,
|
|
&"dynamic_cost": 0,
|
|
&"temporary_block": false,
|
|
}
|
|
|
|
|
|
func _increment_density(cell: Vector3i) -> void:
|
|
var traffic: Dictionary = _cell_traffic[cell]
|
|
traffic[&"current_density"] = int(traffic[&"current_density"]) + 1
|
|
_cell_traffic[cell] = traffic
|
|
|
|
|
|
func _decrement_density(cell: Vector3i) -> bool:
|
|
if not _cell_traffic.has(cell):
|
|
return false
|
|
var traffic: Dictionary = _cell_traffic[cell]
|
|
var density := int(traffic[&"current_density"])
|
|
if density <= 0:
|
|
return false
|
|
traffic[&"current_density"] = density - 1
|
|
_cell_traffic[cell] = traffic
|
|
return true
|
|
|
|
|
|
func _recompute_all_densities() -> void:
|
|
for cell_value: Variant in _cell_traffic:
|
|
var traffic: Dictionary = _cell_traffic[cell_value]
|
|
traffic[&"current_density"] = 0
|
|
_cell_traffic[cell_value] = traffic
|
|
for actor_id: Variant in _actor_cells:
|
|
var cell: Vector3i = _actor_cells[actor_id]
|
|
if _cell_traffic.has(cell) and not is_actor_in_transit(actor_id):
|
|
_increment_density(cell)
|
|
|
|
|
|
static func _derive_transit_actor_ids(actor_cells: Dictionary, active_units: Dictionary) -> Dictionary:
|
|
var transit: Dictionary = {}
|
|
for actor_id: Variant in active_units:
|
|
if actor_cells.has(actor_id) and active_units[actor_id][&"state"] == ELEVATOR_REQUEST_BOARDED:
|
|
transit[actor_id] = true
|
|
return transit
|
|
|
|
|
|
func _refresh_transit_actor_ids() -> void:
|
|
var next := _derive_transit_actor_ids(_actor_cells, _active_elevator_units)
|
|
if next == _transit_actor_ids:
|
|
return
|
|
var changed_cells: Dictionary = {}
|
|
var may_make_routes_cheaper := false
|
|
for actor_id: Variant in next:
|
|
if not _transit_actor_ids.has(actor_id):
|
|
changed_cells[_actor_cells[actor_id]] = true
|
|
may_make_routes_cheaper = true
|
|
for actor_id: Variant in _transit_actor_ids:
|
|
if not next.has(actor_id) and _actor_cells.has(actor_id):
|
|
changed_cells[_actor_cells[actor_id]] = true
|
|
_transit_actor_ids = next
|
|
_recompute_all_densities()
|
|
if not changed_cells.is_empty():
|
|
_mark_traffic_changed(changed_cells.keys(), may_make_routes_cheaper)
|
|
|
|
|
|
static func _transit_snapshot_matches(raw_ids: Variant, expected: Dictionary) -> bool:
|
|
if not raw_ids is Array or raw_ids.size() != expected.size():
|
|
return false
|
|
var previous := ""
|
|
for value: Variant in raw_ids:
|
|
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME] or String(value).is_empty() \
|
|
or not expected.has(StringName(value)) or (not previous.is_empty() and String(value) <= previous):
|
|
return false
|
|
previous = String(value)
|
|
return true
|
|
|
|
|
|
func _mark_traffic_changed(cells: Variant, may_make_routes_cheaper: bool = false) -> void:
|
|
traffic_revision += 1
|
|
if may_make_routes_cheaper:
|
|
# A cached path cannot know which non-selected alternatives were explored.
|
|
# Cost decreases and block removal therefore invalidate broadly for
|
|
# correctness; cost increases remain locally invalidated.
|
|
invalidate_all_cache()
|
|
else:
|
|
invalidate_cache_for_cells(cells)
|
|
|
|
|
|
func _mark_connector_traffic_changed(
|
|
connector: Dictionary,
|
|
may_make_routes_cheaper: bool = false
|
|
) -> void:
|
|
_mark_traffic_changed(
|
|
[connector[&"from"], connector[&"to"]],
|
|
may_make_routes_cheaper
|
|
)
|
|
|
|
|
|
func _traffic_effective_cost(traffic: Dictionary) -> int:
|
|
return int(traffic[&"dynamic_cost"]) + maxi(
|
|
0,
|
|
int(traffic[&"current_density"]) - int(traffic[&"base_capacity"])
|
|
) * CONGESTION_COST_PER_EXCESS
|
|
|
|
|
|
func _queue_failure(reason: StringName, message: String) -> Dictionary:
|
|
last_error = message
|
|
return {&"ok": false, &"reason": reason, &"error": message}
|
|
|
|
|
|
func _normalize_elevator_request(raw: Dictionary) -> Dictionary:
|
|
var request_id := StringName(str(_dictionary_value(raw, &"request_id", "")))
|
|
var unit_id := StringName(str(_dictionary_value(raw, &"unit_id", "")))
|
|
var floor_from_value: Variant = _dictionary_value(raw, &"floor_from", null)
|
|
var floor_to_value: Variant = _dictionary_value(raw, &"floor_to", null)
|
|
var enqueued_at_value: Variant = _dictionary_value(raw, &"enqueued_at", null)
|
|
if request_id.is_empty() or unit_id.is_empty():
|
|
return {&"ok": false, &"error": "Elevator request and unit ids must not be empty."}
|
|
if not floor_from_value is int or not floor_to_value is int or not enqueued_at_value is int:
|
|
return {&"ok": false, &"error": "Elevator floors and enqueue minute must be integers."}
|
|
var floor_from := int(floor_from_value)
|
|
var floor_to := int(floor_to_value)
|
|
var enqueued_at := int(enqueued_at_value)
|
|
if floor_from == floor_to or enqueued_at < 0:
|
|
return {&"ok": false, &"error": "Elevator request must change floors at a non-negative minute."}
|
|
var expected_direction := &"UP" if floor_to > floor_from else &"DOWN"
|
|
var direction := StringName(str(_dictionary_value(raw, &"direction", expected_direction)))
|
|
if direction != expected_direction:
|
|
return {&"ok": false, &"error": "Elevator request direction disagrees with its floors."}
|
|
var priority_result := _normalize_elevator_priority(_dictionary_value(
|
|
raw,
|
|
&"priority",
|
|
ELEVATOR_PRIORITY_NORMAL
|
|
))
|
|
if not priority_result[&"ok"]:
|
|
return priority_result
|
|
return {
|
|
&"ok": true,
|
|
&"request": {
|
|
&"request_id": request_id,
|
|
&"unit_id": unit_id,
|
|
&"floor_from": floor_from,
|
|
&"floor_to": floor_to,
|
|
&"direction": direction,
|
|
&"priority": int(priority_result[&"priority"]),
|
|
&"enqueued_at": enqueued_at,
|
|
},
|
|
}
|
|
|
|
|
|
func _normalize_elevator_priority(value: Variant) -> Dictionary:
|
|
if value is int:
|
|
var priority := int(value)
|
|
if priority < ELEVATOR_PRIORITY_RESCUE or priority > ELEVATOR_PRIORITY_NORMAL:
|
|
return {&"ok": false, &"error": "Elevator priority is outside the supported range."}
|
|
return {&"ok": true, &"priority": priority}
|
|
if value is String or value is StringName:
|
|
var key := StringName(str(value).to_upper())
|
|
var priorities := {
|
|
&"RESCUE": ELEVATOR_PRIORITY_RESCUE,
|
|
&"SECURITY": ELEVATOR_PRIORITY_SECURITY,
|
|
&"PROTAGONIST": ELEVATOR_PRIORITY_PROTAGONIST,
|
|
&"VIP": ELEVATOR_PRIORITY_VIP,
|
|
&"NORMAL": ELEVATOR_PRIORITY_NORMAL,
|
|
}
|
|
if priorities.has(key):
|
|
return {&"ok": true, &"priority": priorities[key]}
|
|
return {&"ok": false, &"error": "Elevator priority must be a known name or integer."}
|
|
|
|
|
|
func _elevator_request_base_less(left: Variant, right: Variant) -> bool:
|
|
var left_request: Dictionary = left
|
|
var right_request: Dictionary = right
|
|
return _compare_elevator_requests(left_request, right_request, -1)
|
|
|
|
|
|
func _elevator_request_less_at(
|
|
left: Dictionary,
|
|
right: Dictionary,
|
|
absolute_minute: int
|
|
) -> bool:
|
|
return _compare_elevator_requests(left, right, absolute_minute)
|
|
|
|
|
|
func _compare_elevator_requests(
|
|
left: Dictionary,
|
|
right: Dictionary,
|
|
absolute_minute: int
|
|
) -> bool:
|
|
var left_priority := _effective_elevator_priority(left, absolute_minute)
|
|
var right_priority := _effective_elevator_priority(right, absolute_minute)
|
|
if left_priority != right_priority:
|
|
return left_priority < right_priority
|
|
var left_enqueued := int(left[&"enqueued_at"])
|
|
var right_enqueued := int(right[&"enqueued_at"])
|
|
if left_enqueued != right_enqueued:
|
|
return left_enqueued < right_enqueued
|
|
var left_unit := str(left[&"unit_id"])
|
|
var right_unit := str(right[&"unit_id"])
|
|
if left_unit != right_unit:
|
|
return left_unit < right_unit
|
|
return str(left[&"request_id"]) < str(right[&"request_id"])
|
|
|
|
|
|
func _effective_elevator_priority(request: Dictionary, absolute_minute: int) -> int:
|
|
var priority := int(request[&"priority"])
|
|
if absolute_minute >= 0 \
|
|
and priority == ELEVATOR_PRIORITY_NORMAL \
|
|
and absolute_minute - int(request[&"enqueued_at"]) >= ELEVATOR_STARVATION_MINUTES:
|
|
return ELEVATOR_PRIORITY_VIP - 1
|
|
return priority
|
|
|
|
|
|
func _normalize_cell_set(raw_cells: Variant, validate_layers := true) -> Dictionary:
|
|
if not (raw_cells is Array) and not (raw_cells is Dictionary):
|
|
return {
|
|
&"ok": false,
|
|
&"error": "Passable cells must be an Array or Dictionary set.",
|
|
}
|
|
|
|
var values: Array = []
|
|
if raw_cells is Array:
|
|
values = raw_cells
|
|
else:
|
|
for key: Variant in raw_cells:
|
|
if bool(raw_cells[key]):
|
|
values.append(key)
|
|
|
|
var cells: Dictionary = {}
|
|
for value: Variant in values:
|
|
if not value is Vector3i:
|
|
return {
|
|
&"ok": false,
|
|
&"error": "Every passable coordinate must be a Vector3i.",
|
|
}
|
|
var cell: Vector3i = value
|
|
if validate_layers and not _is_valid_layer(cell.z):
|
|
return {
|
|
&"ok": false,
|
|
&"error": "Coordinate layer must be FRONT (0) or BACK (1): %s" % cell,
|
|
}
|
|
cells[cell] = true
|
|
return {
|
|
&"ok": true,
|
|
&"cells": cells,
|
|
}
|
|
|
|
|
|
func _normalize_connector(raw: Dictionary) -> Dictionary:
|
|
var connector_id := StringName(str(_dictionary_value(raw, &"id", "")))
|
|
var from_value: Variant = _dictionary_value(raw, &"from", null)
|
|
var to_value: Variant = _dictionary_value(raw, &"to", null)
|
|
var cost := int(_dictionary_value(raw, &"cost", DEFAULT_CONNECTOR_COST))
|
|
var bidirectional := bool(_dictionary_value(raw, &"bidirectional", true))
|
|
var access_id := StringName(str(_dictionary_value(raw, &"access_id", "")))
|
|
var enabled := bool(_dictionary_value(raw, &"enabled", true))
|
|
var movement_type := StringName(str(_dictionary_value(raw, &"movement_type", MOVEMENT_CONNECTOR)))
|
|
var dynamic_cost := int(_dictionary_value(raw, &"dynamic_cost", 0))
|
|
var operational_state := StringName(str(_dictionary_value(
|
|
raw,
|
|
&"operational_state",
|
|
CONNECTOR_OPERATIONAL
|
|
)))
|
|
var cabin_capacity := int(_dictionary_value(raw, &"cabin_capacity", 8))
|
|
var queue_capacity := int(_dictionary_value(raw, &"queue_capacity", 256))
|
|
var cabin_state := StringName(str(_dictionary_value(raw, &"cabin_state", CABIN_IDLE)))
|
|
var current_floor := int(_dictionary_value(
|
|
raw,
|
|
&"current_floor",
|
|
from_value.y if from_value is Vector3i else 0
|
|
))
|
|
var travel_minutes_per_floor := int(_dictionary_value(raw, &"travel_minutes_per_floor", 1))
|
|
var raw_queue: Variant = _dictionary_value(raw, &"request_queue", [])
|
|
|
|
if connector_id.is_empty():
|
|
return {&"ok": false, &"error": "Connector id must not be empty."}
|
|
if not from_value is Vector3i or not to_value is Vector3i:
|
|
return {&"ok": false, &"error": "Connector endpoints must be Vector3i coordinates."}
|
|
var from_cell: Vector3i = from_value
|
|
var to_cell: Vector3i = to_value
|
|
if not _is_valid_layer(from_cell.z) or not _is_valid_layer(to_cell.z):
|
|
return {&"ok": false, &"error": "Connector endpoint layer must be FRONT (0) or BACK (1)."}
|
|
if from_cell == to_cell:
|
|
return {&"ok": false, &"error": "Connector endpoints must differ."}
|
|
if cost <= 0:
|
|
return {&"ok": false, &"error": "Connector cost must be a positive integer."}
|
|
if dynamic_cost < 0:
|
|
return {&"ok": false, &"error": "Connector dynamic cost must be non-negative."}
|
|
if movement_type not in [MOVEMENT_CONNECTOR, MOVEMENT_ELEVATOR]:
|
|
return {&"ok": false, &"error": "Unsupported connector movement type: %s" % movement_type}
|
|
if operational_state not in [
|
|
CONNECTOR_OPERATIONAL,
|
|
CONNECTOR_STOPPED,
|
|
CONNECTOR_OUTAGE,
|
|
CONNECTOR_MAINTENANCE,
|
|
]:
|
|
return {&"ok": false, &"error": "Unsupported connector operational state."}
|
|
if cabin_capacity <= 0 or queue_capacity <= 0 or travel_minutes_per_floor <= 0:
|
|
return {&"ok": false, &"error": "Elevator capacities and travel time must be positive."}
|
|
if cabin_state not in [
|
|
CABIN_IDLE,
|
|
CABIN_MOVING_UP,
|
|
CABIN_MOVING_DOWN,
|
|
CABIN_DOOR_OPEN,
|
|
CABIN_OUTAGE,
|
|
]:
|
|
return {&"ok": false, &"error": "Unsupported elevator cabin state."}
|
|
if not raw_queue is Array:
|
|
return {&"ok": false, &"error": "Connector request queue must be an Array."}
|
|
var request_queue: Array = []
|
|
var request_ids: Dictionary = {}
|
|
for raw_request: Variant in raw_queue:
|
|
if not raw_request is Dictionary:
|
|
return {&"ok": false, &"error": "Elevator queue entries must be Dictionaries."}
|
|
var normalized_request := _normalize_elevator_request(raw_request)
|
|
if not normalized_request[&"ok"]:
|
|
return normalized_request
|
|
var request: Dictionary = normalized_request[&"request"]
|
|
if request_ids.has(request[&"request_id"]):
|
|
return {&"ok": false, &"error": "Duplicate elevator request id."}
|
|
request_ids[request[&"request_id"]] = true
|
|
request_queue.append(request)
|
|
if request_queue.size() > queue_capacity:
|
|
return {&"ok": false, &"error": "Elevator request queue exceeds configured capacity."}
|
|
if movement_type != MOVEMENT_ELEVATOR and not request_queue.is_empty():
|
|
return {&"ok": false, &"error": "Only elevator connectors can own requests."}
|
|
request_queue.sort_custom(Callable(self, "_elevator_request_base_less"))
|
|
if movement_type == MOVEMENT_ELEVATOR:
|
|
if operational_state != CONNECTOR_OPERATIONAL:
|
|
cabin_state = CABIN_OUTAGE
|
|
elif cabin_state == CABIN_OUTAGE:
|
|
return {&"ok": false, &"error": "An operational elevator cannot have OUTAGE cabin state."}
|
|
else:
|
|
# Cabin state has no semantic meaning for doors/stairs. Keeping one fixed
|
|
# canonical value prevents an OUTAGE connector from changing digest merely
|
|
# by passing through snapshot normalization.
|
|
cabin_state = CABIN_IDLE
|
|
|
|
return {
|
|
&"ok": true,
|
|
&"connector": {
|
|
&"id": connector_id,
|
|
&"from": from_cell,
|
|
&"to": to_cell,
|
|
&"cost": cost,
|
|
&"bidirectional": bidirectional,
|
|
&"access_id": access_id,
|
|
&"enabled": enabled,
|
|
&"movement_type": movement_type,
|
|
&"dynamic_cost": dynamic_cost,
|
|
&"operational_state": operational_state,
|
|
&"cabin_capacity": cabin_capacity,
|
|
&"queue_capacity": queue_capacity,
|
|
&"cabin_state": cabin_state,
|
|
&"current_floor": current_floor,
|
|
&"travel_minutes_per_floor": travel_minutes_per_floor,
|
|
&"request_queue": request_queue,
|
|
},
|
|
}
|
|
|
|
|
|
func _normalize_connector_collection(raw_connectors: Array) -> Dictionary:
|
|
var connectors: Dictionary = {}
|
|
for raw_configuration: Variant in raw_connectors:
|
|
if not raw_configuration is Dictionary:
|
|
return {&"ok": false, &"error": "Connector configuration must be a Dictionary."}
|
|
var normalized := _normalize_connector(raw_configuration)
|
|
if not normalized[&"ok"]:
|
|
return normalized
|
|
var connector: Dictionary = normalized[&"connector"]
|
|
var connector_id: StringName = connector[&"id"]
|
|
if connectors.has(connector_id):
|
|
return {&"ok": false, &"error": "Duplicate connector id: %s" % connector_id}
|
|
connectors[connector_id] = connector
|
|
var index_result := _derive_elevator_indexes_from_connectors(connectors)
|
|
if not index_result[&"ok"]:
|
|
return index_result
|
|
return {
|
|
&"ok": true,
|
|
&"connectors": connectors,
|
|
&"known_request_ids": index_result[&"known_request_ids"],
|
|
&"active_units": index_result[&"active_units"],
|
|
}
|
|
|
|
|
|
func _derive_elevator_indexes_from_connectors(connectors: Dictionary) -> Dictionary:
|
|
var known_request_ids: Dictionary = {}
|
|
var active_units: Dictionary = {}
|
|
for connector_id: StringName in _sorted_name_keys(connectors):
|
|
var connector: Dictionary = connectors[connector_id]
|
|
for request_value: Variant in connector[&"request_queue"]:
|
|
var request: Dictionary = request_value
|
|
var request_id: StringName = request[&"request_id"]
|
|
var unit_id: StringName = request[&"unit_id"]
|
|
if known_request_ids.has(request_id):
|
|
return {
|
|
&"ok": false,
|
|
&"error": "Elevator request id is duplicated across connectors: %s" % request_id,
|
|
}
|
|
if active_units.has(unit_id):
|
|
return {
|
|
&"ok": false,
|
|
&"error": "Elevator unit has requests on multiple connectors: %s" % unit_id,
|
|
}
|
|
known_request_ids[request_id] = true
|
|
active_units[unit_id] = {
|
|
&"unit_id": unit_id,
|
|
&"request_id": request_id,
|
|
&"connector_id": connector_id,
|
|
&"state": ELEVATOR_REQUEST_QUEUED,
|
|
}
|
|
return {
|
|
&"ok": true,
|
|
&"known_request_ids": known_request_ids,
|
|
&"active_units": active_units,
|
|
}
|
|
|
|
|
|
func _normalize_snapshot_elevator_indexes(
|
|
raw_known_requests: Array,
|
|
raw_active_units: Array,
|
|
connectors: Dictionary,
|
|
queued_units: Dictionary
|
|
) -> Dictionary:
|
|
var known_request_ids: Dictionary = {}
|
|
for raw_request_id: Variant in raw_known_requests:
|
|
if not raw_request_id is String and not raw_request_id is StringName:
|
|
return {&"ok": false, &"error": "Known elevator request ids must be names."}
|
|
var request_id := StringName(str(raw_request_id))
|
|
if request_id.is_empty() or known_request_ids.has(request_id):
|
|
return {&"ok": false, &"error": "Known elevator request ids must be unique and non-empty."}
|
|
known_request_ids[request_id] = true
|
|
|
|
var active_units: Dictionary = {}
|
|
var active_request_ids: Dictionary = {}
|
|
for raw_active: Variant in raw_active_units:
|
|
if not raw_active is Dictionary:
|
|
return {&"ok": false, &"error": "Active elevator unit entries must be Dictionaries."}
|
|
var unit_id := StringName(str(_dictionary_value(raw_active, &"unit_id", "")))
|
|
var request_id := StringName(str(_dictionary_value(raw_active, &"request_id", "")))
|
|
var connector_id := StringName(str(_dictionary_value(raw_active, &"connector_id", "")))
|
|
var state := StringName(str(_dictionary_value(raw_active, &"state", "")))
|
|
if unit_id.is_empty() or request_id.is_empty() or connector_id.is_empty() \
|
|
or state not in [ELEVATOR_REQUEST_QUEUED, ELEVATOR_REQUEST_BOARDED]:
|
|
return {&"ok": false, &"error": "Active elevator unit entry is malformed."}
|
|
if active_units.has(unit_id) or active_request_ids.has(request_id):
|
|
return {&"ok": false, &"error": "Active elevator request indexes contain duplicates."}
|
|
if not known_request_ids.has(request_id) or not connectors.has(connector_id):
|
|
return {&"ok": false, &"error": "Active elevator request references unknown state."}
|
|
var connector: Dictionary = connectors[connector_id]
|
|
if connector[&"movement_type"] != MOVEMENT_ELEVATOR:
|
|
return {&"ok": false, &"error": "Active elevator request references a non-elevator connector."}
|
|
active_units[unit_id] = {
|
|
&"unit_id": unit_id,
|
|
&"request_id": request_id,
|
|
&"connector_id": connector_id,
|
|
&"state": state,
|
|
}
|
|
active_request_ids[request_id] = true
|
|
|
|
for unit_id: Variant in queued_units:
|
|
if not active_units.has(unit_id) or active_units[unit_id] != queued_units[unit_id]:
|
|
return {&"ok": false, &"error": "Queued elevator request is missing from active indexes."}
|
|
for unit_id: Variant in active_units:
|
|
var active: Dictionary = active_units[unit_id]
|
|
if active[&"state"] == ELEVATOR_REQUEST_QUEUED \
|
|
and (not queued_units.has(unit_id) or queued_units[unit_id] != active):
|
|
return {&"ok": false, &"error": "Active QUEUED index has no matching connector request."}
|
|
return {
|
|
&"ok": true,
|
|
&"known_request_ids": known_request_ids,
|
|
&"active_units": active_units,
|
|
}
|
|
|
|
|
|
func _normalize_snapshot_vertical_blockers(raw: Variant, traffic: Dictionary) -> Dictionary:
|
|
var failure := {&"ok": false, &"error": "Path shaft ownership rows are invalid, unordered, or crosslink unknown cells."}
|
|
if not raw is Array or raw.size() > MAX_VERTICAL_BLOCKER_OWNERS:
|
|
return failure
|
|
var blockers: Dictionary = {}
|
|
var previous_owner := ""
|
|
var total_cells := 0
|
|
for row: Variant in raw:
|
|
if not row is Dictionary or row.size() != 2:
|
|
return failure
|
|
var owner_value: Variant = _dictionary_value(row, &"owner_id", null)
|
|
var cells_value: Variant = _dictionary_value(row, &"cells", null)
|
|
if typeof(owner_value) not in [TYPE_STRING, TYPE_STRING_NAME] or not cells_value is Array:
|
|
return failure
|
|
var owner_id := StringName(owner_value)
|
|
if not _vertical_blocker_owner_is_valid(owner_id) or String(owner_id) <= previous_owner or cells_value.is_empty():
|
|
return failure
|
|
previous_owner = String(owner_id)
|
|
total_cells += cells_value.size()
|
|
if total_cells > MAX_VERTICAL_BLOCKER_CELLS:
|
|
return failure
|
|
var cells: Array[Vector3i] = []
|
|
var seen: Dictionary = {}
|
|
for cell: Variant in cells_value:
|
|
if not cell is Vector3i or not traffic.has(cell) or seen.has(cell):
|
|
return failure
|
|
seen[cell] = true
|
|
cells.append(cell)
|
|
cells.sort_custom(Callable(self, "_coordinate_less"))
|
|
if cells != cells_value:
|
|
return failure
|
|
blockers[owner_id] = cells
|
|
return {&"ok": true, &"blockers": blockers}
|
|
|
|
|
|
func _normalize_snapshot_cell_traffic(raw_entries: Array) -> Dictionary:
|
|
var traffic: Dictionary = {}
|
|
for raw_entry: Variant in raw_entries:
|
|
if not raw_entry is Dictionary:
|
|
return {&"ok": false, &"error": "Path traffic entries must be Dictionaries."}
|
|
var cell: Variant = _dictionary_value(raw_entry, &"cell", null)
|
|
var capacity_value: Variant = _dictionary_value(raw_entry, &"base_capacity", null)
|
|
var density_value: Variant = _dictionary_value(raw_entry, &"current_density", null)
|
|
var cost_value: Variant = _dictionary_value(raw_entry, &"dynamic_cost", null)
|
|
var block_value: Variant = _dictionary_value(raw_entry, &"temporary_block", null)
|
|
if not cell is Vector3i or not _is_valid_layer(cell.z) \
|
|
or not capacity_value is int or not density_value is int \
|
|
or not cost_value is int or not block_value is bool:
|
|
return {&"ok": false, &"error": "Path traffic entry has invalid field types."}
|
|
if traffic.has(cell):
|
|
return {&"ok": false, &"error": "Path traffic snapshot contains a duplicate cell."}
|
|
if int(capacity_value) <= 0 or int(density_value) < 0 or int(cost_value) < 0:
|
|
return {&"ok": false, &"error": "Path traffic values are outside valid bounds."}
|
|
traffic[cell] = {
|
|
&"base_capacity": int(capacity_value),
|
|
&"current_density": int(density_value),
|
|
&"dynamic_cost": int(cost_value),
|
|
&"temporary_block": bool(block_value),
|
|
}
|
|
return {&"ok": true, &"traffic": traffic}
|
|
|
|
|
|
func _normalize_snapshot_actor_cells(raw_entries: Array, traffic: Dictionary) -> Dictionary:
|
|
var actor_cells: Dictionary = {}
|
|
for raw_entry: Variant in raw_entries:
|
|
if not raw_entry is Dictionary:
|
|
return {&"ok": false, &"error": "Actor cell projections must be Dictionaries."}
|
|
var actor_id := StringName(str(_dictionary_value(raw_entry, &"actor_id", "")))
|
|
var cell: Variant = _dictionary_value(raw_entry, &"cell", null)
|
|
if actor_id.is_empty() or not cell is Vector3i or not traffic.has(cell):
|
|
return {&"ok": false, &"error": "Actor projection references an invalid id or cell."}
|
|
if actor_cells.has(actor_id):
|
|
return {&"ok": false, &"error": "Actor projection contains a duplicate actor id."}
|
|
actor_cells[actor_id] = cell
|
|
return {&"ok": true, &"actor_cells": actor_cells}
|
|
|
|
|
|
func _normalize_snapshot_actor_progress(raw_entries: Array) -> Dictionary:
|
|
var progress: Dictionary = {}
|
|
for raw_entry: Variant in raw_entries:
|
|
if not raw_entry is Dictionary:
|
|
return {&"ok": false, &"error": "Actor progress entries must be Dictionaries."}
|
|
var actor_id := StringName(str(_dictionary_value(raw_entry, &"actor_id", "")))
|
|
var last_cell: Variant = _dictionary_value(raw_entry, &"last_cell", null)
|
|
var progress_value: Variant = _dictionary_value(raw_entry, &"last_progress_at", null)
|
|
var observed_value: Variant = _dictionary_value(raw_entry, &"last_observed_at", null)
|
|
var stuck_value: Variant = _dictionary_value(raw_entry, &"stuck_reported_at", null)
|
|
if actor_id.is_empty() or not last_cell is Vector3i \
|
|
or not progress_value is int or not observed_value is int or not stuck_value is int:
|
|
return {&"ok": false, &"error": "Actor progress entry has invalid field types."}
|
|
var last_progress_at := int(progress_value)
|
|
var last_observed_at := int(observed_value)
|
|
var stuck_reported_at := int(stuck_value)
|
|
if last_progress_at < 0 or last_observed_at < last_progress_at \
|
|
or (stuck_reported_at >= 0 and stuck_reported_at < last_progress_at):
|
|
return {&"ok": false, &"error": "Actor progress timestamps are inconsistent."}
|
|
if progress.has(actor_id):
|
|
return {&"ok": false, &"error": "Actor progress contains a duplicate actor id."}
|
|
progress[actor_id] = {
|
|
&"actor_id": actor_id,
|
|
&"last_cell": last_cell,
|
|
&"last_progress_at": last_progress_at,
|
|
&"last_observed_at": last_observed_at,
|
|
&"stuck_reported_at": stuck_reported_at,
|
|
}
|
|
return {&"ok": true, &"progress": progress}
|
|
|
|
|
|
func _snapshot_densities_match(traffic: Dictionary, actor_cells: Dictionary, transit: Dictionary = {}) -> bool:
|
|
var expected: Dictionary = {}
|
|
for actor_id: Variant in actor_cells:
|
|
if transit.has(actor_id):
|
|
continue
|
|
var cell: Vector3i = actor_cells[actor_id]
|
|
expected[cell] = int(expected.get(cell, 0)) + 1
|
|
for cell_value: Variant in traffic:
|
|
var saved_density := int((traffic[cell_value] as Dictionary)[&"current_density"])
|
|
if saved_density != int(expected.get(cell_value, 0)):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _recovery_distance(origin: Vector3i, candidate: Vector3i) -> int:
|
|
# Layer changes require an explicit connector in normal travel. Recovery is a
|
|
# proposal only, so a large layer penalty keeps the actor on its current layer
|
|
# whenever any valid local cell exists.
|
|
return absi(origin.x - candidate.x) + absi(origin.y - candidate.y) \
|
|
+ absi(origin.z - candidate.z) * 1_000_000
|
|
|
|
|
|
func _normalize_access_layers(raw_layers: Variant) -> Dictionary:
|
|
var values: Array = []
|
|
if raw_layers == null:
|
|
values = [FRONT, BACK]
|
|
elif raw_layers is int:
|
|
values = [raw_layers]
|
|
elif raw_layers is Array or raw_layers is PackedInt32Array:
|
|
values.assign(raw_layers)
|
|
else:
|
|
return {&"ok": false, &"error": "Access layers must be an integer or integer Array."}
|
|
|
|
var unique: Dictionary = {}
|
|
for value: Variant in values:
|
|
if not value is int or not _is_valid_layer(int(value)):
|
|
return {&"ok": false, &"error": "Access layer must be FRONT (0) or BACK (1)."}
|
|
unique[int(value)] = true
|
|
var normalized: Array[int] = []
|
|
for value: Variant in unique:
|
|
normalized.append(int(value))
|
|
normalized.sort()
|
|
return {&"ok": true, &"values": normalized}
|
|
|
|
|
|
func _normalize_access_ids(raw_access_ids: Variant) -> Dictionary:
|
|
var values: Array = []
|
|
if raw_access_ids == null:
|
|
values = []
|
|
elif raw_access_ids is String or raw_access_ids is StringName:
|
|
values = [raw_access_ids]
|
|
elif raw_access_ids is Array or raw_access_ids is PackedStringArray:
|
|
values.assign(raw_access_ids)
|
|
elif raw_access_ids is Dictionary:
|
|
for key: Variant in raw_access_ids:
|
|
if bool(raw_access_ids[key]):
|
|
values.append(key)
|
|
else:
|
|
return {&"ok": false, &"error": "Connector access ids must be a name, Array, or Dictionary set."}
|
|
|
|
var unique: Dictionary = {}
|
|
for value: Variant in values:
|
|
if not (value is String) and not (value is StringName):
|
|
return {&"ok": false, &"error": "Every connector access id must be a String or StringName."}
|
|
var access_id := StringName(str(value))
|
|
if not access_id.is_empty():
|
|
unique[access_id] = true
|
|
var normalized: Array[StringName] = []
|
|
for value: Variant in unique:
|
|
normalized.append(value)
|
|
normalized.sort_custom(Callable(self, "_string_name_less"))
|
|
return {&"ok": true, &"values": normalized}
|
|
|
|
|
|
func _cache_key(
|
|
start: Vector3i,
|
|
goal: Vector3i,
|
|
allowed_layers: Array[int],
|
|
allowed_access_ids: Array[StringName]
|
|
) -> String:
|
|
var layer_parts := PackedStringArray()
|
|
for layer in allowed_layers:
|
|
layer_parts.append(str(layer))
|
|
var access_parts := PackedStringArray()
|
|
for access_id in allowed_access_ids:
|
|
var value := str(access_id)
|
|
access_parts.append("%d:%s" % [value.length(), value])
|
|
return "%d,%d,%d>%d,%d,%d|L:%s|A:%s|R:%d" % [
|
|
start.x,
|
|
start.y,
|
|
start.z,
|
|
goal.x,
|
|
goal.y,
|
|
goal.z,
|
|
",".join(layer_parts),
|
|
",".join(access_parts),
|
|
graph_revision,
|
|
]
|
|
|
|
|
|
func _path_touches(path: Array, affected: Dictionary) -> bool:
|
|
for cell: Variant in path:
|
|
if affected.has(cell):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _copy_path(path: Array) -> Array[Vector3i]:
|
|
var copied: Array[Vector3i] = []
|
|
for cell: Variant in path:
|
|
copied.append(cell)
|
|
return copied
|
|
|
|
|
|
func _mark_graph_changed() -> void:
|
|
graph_revision += 1
|
|
invalidate_all_cache()
|
|
|
|
|
|
func _is_valid_layer(layer: int) -> bool:
|
|
return layer == FRONT or layer == BACK
|
|
|
|
|
|
func _coordinate_less(left: Variant, right: Variant) -> bool:
|
|
var left_cell: Vector3i = left
|
|
var right_cell: Vector3i = right
|
|
if left_cell.x != right_cell.x:
|
|
return left_cell.x < right_cell.x
|
|
if left_cell.y != right_cell.y:
|
|
return left_cell.y < right_cell.y
|
|
return left_cell.z < right_cell.z
|
|
|
|
|
|
func _edge_less(left: Variant, right: Variant) -> bool:
|
|
var left_edge: Dictionary = left
|
|
var right_edge: Dictionary = right
|
|
var left_to: Vector3i = left_edge[&"to"]
|
|
var right_to: Vector3i = right_edge[&"to"]
|
|
if left_to != right_to:
|
|
return _coordinate_less(left_to, right_to)
|
|
var left_connector := str(left_edge[&"connector_id"])
|
|
var right_connector := str(right_edge[&"connector_id"])
|
|
if left_connector != right_connector:
|
|
return left_connector < right_connector
|
|
return int(left_edge[&"cost"]) < int(right_edge[&"cost"])
|
|
|
|
|
|
func _string_name_less(left: Variant, right: Variant) -> bool:
|
|
return str(left) < str(right)
|
|
|
|
|
|
func _sorted_name_keys(source: Dictionary) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for key: Variant in source:
|
|
result.append(StringName(key))
|
|
result.sort_custom(Callable(self, "_string_name_less"))
|
|
return result
|
|
|
|
|
|
func _profile_value(profile: Dictionary, keys: Array[StringName], default_value: Variant) -> Variant:
|
|
for key in keys:
|
|
if profile.has(key):
|
|
return profile[key]
|
|
var string_key := str(key)
|
|
if profile.has(string_key):
|
|
return profile[string_key]
|
|
return default_value
|
|
|
|
|
|
func _dictionary_value(source: Dictionary, key: StringName, default_value: Variant) -> Variant:
|
|
if source.has(key):
|
|
return source[key]
|
|
var string_key := str(key)
|
|
return source.get(string_key, default_value)
|
|
|
|
|
|
## Rescue uses BACK circulation, with FRONT access legs at either endpoint.
|
|
## Optional managed elevators must have room for both patient and carrier.
|
|
func find_backyard_path(start: Vector3i, goal: Vector3i, allow_elevators := false) -> Array[Vector3i]:
|
|
if not has_cell(start) or not has_cell(goal) or _cell_is_temporarily_blocked(start) or _cell_is_temporarily_blocked(goal):
|
|
return []
|
|
if start == goal:
|
|
return [start]
|
|
var first := Vector4i(start.x, start.y, start.z, 2 if start.z == 1 else 0)
|
|
var frontier: Array[Vector4i] = [first]
|
|
var visited := {first: true}
|
|
var parents: Dictionary = {}
|
|
var cursor := 0
|
|
while cursor < frontier.size():
|
|
var state := frontier[cursor]
|
|
cursor += 1
|
|
var cell := Vector3i(state.x,state.y,state.z)
|
|
for edge: Dictionary in _adjacency.get(cell, []):
|
|
var next: Vector3i = edge[&"to"]
|
|
if _cell_is_temporarily_blocked(next) or not _edge_meets_vertical_policy(cell, edge) or not _edge_is_accessible(edge, [&"staff"]):
|
|
continue
|
|
var connector: Dictionary = _connectors.get(edge[&"connector_id"], {})
|
|
if connector.get(&"movement_type", &"") == MOVEMENT_ELEVATOR:
|
|
if not allow_elevators or cell.z != 1 or next.z != 1 or not String(edge[&"connector_id"]).begins_with("transport.") or int(connector.get(&"cabin_capacity", 0)) < 2:
|
|
continue
|
|
if state.w == 3 and next.z == 1:
|
|
continue
|
|
var phase := state.w
|
|
if next.z == 1: phase = 1 if state.w == 0 else 2
|
|
elif state.w == 1: continue
|
|
elif state.w == 2: phase = 3
|
|
var key := Vector4i(next.x,next.y,next.z,phase)
|
|
if visited.has(key): continue
|
|
visited[key] = true
|
|
parents[key] = state
|
|
if next == goal and phase > 0:
|
|
var result: Array[Vector3i] = [next]
|
|
while parents.has(key):
|
|
key = parents[key]
|
|
result.push_front(Vector3i(key.x,key.y,key.z))
|
|
return result
|
|
frontier.append(key)
|
|
return []
|