291 lines
11 KiB
GDScript
291 lines
11 KiB
GDScript
class_name LegacyStairService
|
|
extends RefCounted
|
|
|
|
## Owns only the stairs materialized once from pre-policy vertical WALK edges.
|
|
## Their original route score and access remain unchanged. Demolished landings
|
|
## permanently remove their compatibility connector; new floors need stairs.
|
|
|
|
const PathType = preload("res://scripts/world/path_service.gd")
|
|
const SNAPSHOT_VERSION := 1
|
|
const CONNECTOR_PREFIX := "legacy.stairs:"
|
|
const CONNECTOR_COST := 100
|
|
const MAX_STAIRS := 131_072
|
|
const MAX_COORDINATE_ABS := 100_000
|
|
|
|
var last_error := ""
|
|
var _path: PathService
|
|
var _stairs: Dictionary = {}
|
|
var _migration_closed := false
|
|
var _transaction_active := false
|
|
var _quarantined := false
|
|
|
|
|
|
## Bind before or after Path restoration. restore_snapshot performs the exact
|
|
## namespace and topology crosslink checks after all dependencies are loaded.
|
|
func configure(path_value: Variant) -> bool:
|
|
if _transaction_active or _path != null:
|
|
return _fail("Legacy stairs are already configured or transacting.")
|
|
if not path_value is PathService:
|
|
return _fail("Legacy stairs require PathService.")
|
|
_path = path_value
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func migrate_existing_vertical_walks() -> bool:
|
|
if not _available():
|
|
return false
|
|
if _path.get_vertical_walk_requires_connector():
|
|
if not _live_matches(_stairs, true):
|
|
return _fail("Current legacy stairs do not match Path ownership or landings.")
|
|
_migration_closed = true
|
|
last_error = ""
|
|
return true
|
|
if _migration_closed or not _stairs.is_empty():
|
|
return _fail("Legacy vertical WALK migration cannot be run again.")
|
|
if not _live_matches({}, false):
|
|
return _fail("Legacy stair connector namespace contains unowned state.")
|
|
var capture := _capture_path()
|
|
if capture.is_empty():
|
|
return false
|
|
var blocked := _shaft_cells()
|
|
var next_stairs: Dictionary = {}
|
|
for from_cell: Vector3i in capture[&"cells"]:
|
|
for raw_edge: Variant in _path.get_neighbors(from_cell):
|
|
var edge: Dictionary = raw_edge
|
|
var to_cell: Vector3i = edge[&"to"]
|
|
if edge[&"movement_type"] != PathType.MOVEMENT_WALK \
|
|
or not StringName(edge[&"connector_id"]).is_empty() \
|
|
or to_cell != from_cell + Vector3i(0, 1, 0) \
|
|
or blocked.has(from_cell) or blocked.has(to_cell):
|
|
continue
|
|
if not _valid_cell(from_cell) or not _valid_cell(to_cell):
|
|
return _fail("Legacy vertical WALK endpoints exceed the supported coordinate range.")
|
|
var stair_id := _stable_id(from_cell)
|
|
next_stairs[stair_id] = {&"id": stair_id, &"from": from_cell, &"to": to_cell}
|
|
if next_stairs.size() > MAX_STAIRS:
|
|
return _fail("Legacy stair migration exceeds the supported capacity.")
|
|
return _apply_candidate(next_stairs, capture)
|
|
|
|
|
|
## Called after authoritative cells/shafts change. Missing endpoints and new
|
|
## shafts retire records; no adjacency scan is ever performed here.
|
|
func prune_invalid() -> bool:
|
|
if not _available():
|
|
return false
|
|
if not _live_matches(_stairs, true, false):
|
|
return _fail("Legacy stair ownership or vertical policy was altered.")
|
|
var blocked := _shaft_cells()
|
|
var next_stairs: Dictionary = {}
|
|
for stair_id: StringName in _sorted_ids(_stairs):
|
|
var record: Dictionary = _stairs[stair_id]
|
|
if _landing_is_valid(record, blocked):
|
|
next_stairs[stair_id] = record.duplicate(true)
|
|
if next_stairs == _stairs:
|
|
last_error = ""
|
|
return true
|
|
var capture := _capture_path()
|
|
if capture.is_empty():
|
|
return false
|
|
return _apply_candidate(next_stairs, capture)
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
if _path == null:
|
|
return {}
|
|
var rows: Array[Dictionary] = []
|
|
for stair_id: StringName in _sorted_ids(_stairs):
|
|
var record: Dictionary = _stairs[stair_id]
|
|
rows.append({&"id": String(stair_id), &"from": record[&"from"], &"to": record[&"to"]})
|
|
return {&"schema_version": SNAPSHOT_VERSION, &"stairs": rows}
|
|
|
|
|
|
func restore_snapshot(data: Dictionary) -> bool:
|
|
if _path == null or _transaction_active:
|
|
return _fail("Legacy stair restore requires an idle configured service.")
|
|
var parsed := _parse_snapshot(data)
|
|
if not bool(parsed[&"ok"]):
|
|
return _fail(String(parsed[&"error"]))
|
|
var next_stairs: Dictionary = parsed[&"stairs"]
|
|
if not _live_matches(next_stairs, true):
|
|
return _fail("Legacy stair snapshot does not exactly match Path connectors and landings.")
|
|
_stairs = next_stairs.duplicate(true)
|
|
_migration_closed = true
|
|
_quarantined = false
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func validate_invariants() -> Dictionary:
|
|
var ok := _path != null and not _quarantined and _live_matches(_stairs, true)
|
|
return {&"ok": ok, &"stair_count": _stairs.size(), &"quarantined": _quarantined}
|
|
|
|
|
|
## Assemble one Path snapshot so migration remains linear in graph size and
|
|
## preserves every unrelated connector, actor, traffic row, and shaft owner.
|
|
func _apply_candidate(next_stairs: Dictionary, capture: Dictionary) -> bool:
|
|
var before: Dictionary = capture[&"snapshot"]
|
|
var next_snapshot := before.duplicate(true)
|
|
var connectors: Array = []
|
|
for raw_connector: Dictionary in before[&"connectors"]:
|
|
if not String(raw_connector[&"id"]).begins_with(CONNECTOR_PREFIX):
|
|
connectors.append(raw_connector.duplicate(true))
|
|
for stair_id: StringName in _sorted_ids(next_stairs):
|
|
connectors.append(_expected_connector(next_stairs[stair_id]))
|
|
connectors.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"id"]) < String(right[&"id"]))
|
|
next_snapshot[&"connectors"] = connectors
|
|
next_snapshot[&"vertical_walk_requires_connector"] = true
|
|
next_snapshot[&"graph_revision"] = int(before[&"graph_revision"]) + 1
|
|
var verifier := PathType.new()
|
|
if not verifier.restore_snapshot(next_snapshot) or not verifier.rebuild_graph(capture[&"cells"]):
|
|
return _fail("Legacy stair candidate Path snapshot is invalid: %s" % verifier.last_error)
|
|
var expected := verifier.create_snapshot()
|
|
var stairs_before := _stairs.duplicate(true)
|
|
var closed_before := _migration_closed
|
|
var path_before := _path
|
|
_transaction_active = true
|
|
var applied := _path.restore_snapshot(expected.duplicate(true))
|
|
if not applied or _path != path_before or not _transaction_active or _stairs != stairs_before \
|
|
or _migration_closed != closed_before \
|
|
or _path.create_snapshot() != expected \
|
|
or not _live_matches(next_stairs, true):
|
|
_path = path_before
|
|
var restored := false
|
|
for _attempt: int in range(2):
|
|
_path.rebuild_graph(capture[&"cells"])
|
|
_path.restore_snapshot(before.duplicate(true))
|
|
_path.rebuild_graph(capture[&"cells"])
|
|
if _path.create_snapshot() == before:
|
|
restored = true
|
|
break
|
|
_stairs = stairs_before
|
|
_migration_closed = closed_before
|
|
_transaction_active = false
|
|
_quarantined = not restored
|
|
return _fail("Legacy stair transaction failed; exact rollback %s." % ("restored" if restored else "is uncertain"))
|
|
_stairs = next_stairs.duplicate(true)
|
|
_migration_closed = true
|
|
_transaction_active = false
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _live_matches(stairs: Dictionary, require_policy: bool, require_landings := true) -> bool:
|
|
if _path == null or (require_policy and not _path.get_vertical_walk_requires_connector()):
|
|
return false
|
|
var seen: Dictionary = {}
|
|
for raw_connector: Dictionary in _path.create_snapshot().get(&"connectors", []):
|
|
var stair_id := StringName(raw_connector[&"id"])
|
|
if not String(stair_id).begins_with(CONNECTOR_PREFIX):
|
|
continue
|
|
if not stairs.has(stair_id) or _path.get_connector(stair_id) != _expected_connector(stairs[stair_id]):
|
|
return false
|
|
seen[stair_id] = true
|
|
if seen.size() != stairs.size():
|
|
return false
|
|
if require_landings:
|
|
var blocked := _shaft_cells()
|
|
for record: Dictionary in stairs.values():
|
|
if not _landing_is_valid(record, blocked):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _landing_is_valid(record: Dictionary, blocked: Dictionary) -> bool:
|
|
return _path.has_cell(record[&"from"]) and _path.has_cell(record[&"to"]) \
|
|
and not blocked.has(record[&"from"]) and not blocked.has(record[&"to"])
|
|
|
|
|
|
func _shaft_cells() -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for cells: Array in _path.get_vertical_walk_blockers().values():
|
|
for cell: Vector3i in cells:
|
|
result[cell] = true
|
|
return result
|
|
|
|
|
|
func _capture_path() -> Dictionary:
|
|
var snapshot := _path.create_snapshot()
|
|
var cells: Array[Vector3i] = []
|
|
for row: Dictionary in snapshot.get(&"cell_traffic", []):
|
|
var cell: Vector3i = row[&"cell"]
|
|
if not _path.has_cell(cell):
|
|
_fail("Legacy stair migration requires the materialized Path graph.")
|
|
return {}
|
|
cells.append(cell)
|
|
return {&"snapshot": snapshot, &"cells": cells}
|
|
|
|
|
|
static func _parse_snapshot(data: Dictionary) -> Dictionary:
|
|
if not _keys_match(data, [&"schema_version", &"stairs"]) \
|
|
or typeof(data.get(&"schema_version")) != TYPE_INT \
|
|
or data[&"schema_version"] != SNAPSHOT_VERSION \
|
|
or not data.get(&"stairs") is Array \
|
|
or data[&"stairs"].size() > MAX_STAIRS:
|
|
return {&"ok": false, &"error": "Legacy stair snapshot fields or version are invalid."}
|
|
var stairs: Dictionary = {}
|
|
var previous_id := ""
|
|
for raw: Variant in data[&"stairs"]:
|
|
if not raw is Dictionary or not _keys_match(raw, [&"id", &"from", &"to"]) \
|
|
or typeof(raw.get(&"id")) != TYPE_STRING \
|
|
or not _valid_cell(raw.get(&"from")) or not _valid_cell(raw.get(&"to")):
|
|
return {&"ok": false, &"error": "Legacy stair record types are invalid."}
|
|
var lower: Vector3i = raw[&"from"]
|
|
var upper: Vector3i = raw[&"to"]
|
|
var stair_id := StringName(raw[&"id"])
|
|
if upper != lower + Vector3i(0, 1, 0) or stair_id != _stable_id(lower) \
|
|
or (not previous_id.is_empty() and String(stair_id) <= previous_id):
|
|
return {&"ok": false, &"error": "Legacy stair geometry, stable ID, or order is invalid."}
|
|
stairs[stair_id] = {&"id": stair_id, &"from": lower, &"to": upper}
|
|
previous_id = String(stair_id)
|
|
return {&"ok": true, &"stairs": stairs}
|
|
|
|
|
|
static func _expected_connector(record: Dictionary) -> Dictionary:
|
|
var lower: Vector3i = record[&"from"]
|
|
return {
|
|
&"id": record[&"id"], &"from": lower, &"to": record[&"to"],
|
|
&"cost": CONNECTOR_COST, &"bidirectional": true, &"access_id": &"", &"enabled": true,
|
|
&"movement_type": PathType.MOVEMENT_CONNECTOR, &"dynamic_cost": 0,
|
|
&"operational_state": PathType.CONNECTOR_OPERATIONAL,
|
|
&"cabin_capacity": 8, &"queue_capacity": 256, &"cabin_state": PathType.CABIN_IDLE,
|
|
&"current_floor": lower.y, &"travel_minutes_per_floor": 1, &"request_queue": [],
|
|
}
|
|
|
|
|
|
static func _stable_id(lower: Vector3i) -> StringName:
|
|
return StringName("%s%d:%d:%d" % [CONNECTOR_PREFIX, lower.z, lower.x, lower.y])
|
|
|
|
|
|
static func _valid_cell(value: Variant) -> bool:
|
|
return value is Vector3i and value.z in [PathType.FRONT, PathType.BACK] \
|
|
and absi(value.x) <= MAX_COORDINATE_ABS and absi(value.y) <= MAX_COORDINATE_ABS
|
|
|
|
|
|
static func _keys_match(value: Dictionary, expected: Array[StringName]) -> bool:
|
|
if value.size() != expected.size():
|
|
return false
|
|
for key: Variant in value:
|
|
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(key) not in expected:
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _sorted_ids(value: Dictionary) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for stair_id: StringName in value:
|
|
result.append(stair_id)
|
|
result.sort_custom(func(left: StringName, right: StringName) -> bool: return String(left) < String(right))
|
|
return result
|
|
|
|
|
|
func _available() -> bool:
|
|
if _path == null or _transaction_active or _quarantined:
|
|
return _fail("Legacy stairs require an idle, configured, available service.")
|
|
return true
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
last_error = message
|
|
return false
|