2328 lines
80 KiB
GDScript
2328 lines
80 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.25
|
|
const STUCK_THRESHOLD_MINUTES := 30
|
|
const ELEVATOR_STARVATION_MINUTES := 60
|
|
const SNAPSHOT_VERSION := 3
|
|
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 _connectors: Dictionary = {}
|
|
var _adjacency: Dictionary = {}
|
|
var _path_cache: Dictionary = {}
|
|
var _cell_traffic: Dictionary = {}
|
|
var _actor_cells: Dictionary = {}
|
|
var _actor_progress: 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
|
|
|
|
|
|
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
|
|
_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
|
|
for request_id: Variant in index_result[&"known_request_ids"]:
|
|
_known_elevator_request_ids[request_id] = true
|
|
_rebuild_derived_graph()
|
|
_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"]
|
|
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
|
|
var capacity := int(traffic[&"base_capacity"])
|
|
var density := int(traffic[&"current_density"])
|
|
return clampf(
|
|
float(capacity) / float(maxi(capacity, density)),
|
|
MINIMUM_SPEED_MULTIPLIER,
|
|
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 _decrement_density(previous):
|
|
last_error = "Actor density projection is inconsistent for: %s" % actor_id
|
|
return false
|
|
changed_cells.append(previous)
|
|
_actor_cells[actor_id] = cell
|
|
_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.
|
|
_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]
|
|
if not _decrement_density(previous):
|
|
last_error = "Actor density projection is inconsistent for: %s" % actor_id
|
|
return false
|
|
_actor_cells.erase(actor_id)
|
|
if clear_progress:
|
|
_actor_progress.erase(actor_id)
|
|
_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
|
|
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
|
|
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"])
|
|
# 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
|
|
) -> 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 remaining: Array = []
|
|
for request: Dictionary in queue:
|
|
if boarded.size() < boarding_capacity and int(request[&"floor_from"]) == current_floor:
|
|
boarded.append(request.duplicate(true))
|
|
else:
|
|
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
|
|
_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)
|
|
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
|
|
var path := _run_a_star(start, goal, allowed_layers, allowed_access_ids)
|
|
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
|
|
last_path_cost = _calculate_path_cost_for_access(path, allowed_access_ids)
|
|
last_error = ""
|
|
return path
|
|
|
|
|
|
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
|
|
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:
|
|
var removed := _path_cache.size()
|
|
_path_cache.clear()
|
|
return removed
|
|
|
|
|
|
func cached_path_count() -> int:
|
|
return _path_cache.size()
|
|
|
|
|
|
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(connector_id: StringName) -> Dictionary:
|
|
if not _connectors.has(connector_id):
|
|
return {}
|
|
return (_connectors[connector_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
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]
|
|
return {
|
|
&"found": true,
|
|
&"reason": &"SAFE_WALKABLE_CELL",
|
|
&"cell": target,
|
|
&"distance": _recovery_distance(origin, target),
|
|
&"implicit_move_applied": false,
|
|
}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
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 = _passable_cells.keys()
|
|
cells.sort_custom(Callable(self, "_coordinate_less"))
|
|
var saved_cell_traffic: Array = []
|
|
for cell_value: Variant in cells:
|
|
var cell: Vector3i = cell_value
|
|
var traffic: Dictionary = _cell_traffic.get(cell, _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))
|
|
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,
|
|
}
|
|
|
|
|
|
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, SNAPSHOT_VERSION]:
|
|
last_error = "Unsupported PathService snapshot version: %d" % version
|
|
return false
|
|
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
|
|
_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 == 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"]
|
|
if not _snapshot_densities_match(next_traffic, next_actor_cells):
|
|
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
|
|
_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
|
|
_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
|
|
|
|
|
|
func _rebuild_derived_graph() -> void:
|
|
_adjacency.clear()
|
|
var cells: Array = _passable_cells.keys()
|
|
cells.sort_custom(Callable(self, "_coordinate_less"))
|
|
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):
|
|
_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 open_set: Dictionary = {start: true}
|
|
var closed_set: Dictionary = {}
|
|
var came_from: Dictionary = {}
|
|
var g_score: Dictionary = {start: 0}
|
|
|
|
while not open_set.is_empty():
|
|
var current := _select_best_open_node(open_set, g_score, goal)
|
|
if current == goal:
|
|
return _reconstruct_path(came_from, current)
|
|
|
|
open_set.erase(current)
|
|
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_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)
|
|
open_set[neighbor] = true
|
|
|
|
return []
|
|
|
|
|
|
func _select_best_open_node(open_set: Dictionary, g_score: Dictionary, goal: Vector3i) -> Vector3i:
|
|
var best := Vector3i.ZERO
|
|
var has_best := false
|
|
var best_score := _INFINITE_COST
|
|
for candidate_value: Variant in open_set:
|
|
var candidate: Vector3i = candidate_value
|
|
var score: int = int(g_score[candidate]) + _heuristic(candidate, goal)
|
|
if not has_best or score < best_score:
|
|
best = candidate
|
|
best_score = score
|
|
has_best = true
|
|
elif score == best_score and _coordinate_less(candidate, best):
|
|
best = candidate
|
|
return best
|
|
|
|
|
|
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_is_accessible(edge: Dictionary, allowed_access_ids: Array[StringName]) -> bool:
|
|
var required_access: StringName = edge[&"access_id"]
|
|
if 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 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"])
|
|
|
|
|
|
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):
|
|
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)
|
|
_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):
|
|
_increment_density(cell)
|
|
|
|
|
|
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_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) -> bool:
|
|
var expected: Dictionary = {}
|
|
for actor_id: Variant in actor_cells:
|
|
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)
|