Connect physical elevators and staff commutes to construction, saves, and cutaway UI
This commit is contained in:
@@ -468,6 +468,12 @@ func demolish_facility(
|
||||
})
|
||||
|
||||
_facilities.erase(instance_id)
|
||||
# A completed construction job owns its live facility. Once demolition has
|
||||
# committed, retire both records so snapshots cannot retain an orphaned
|
||||
# COMPLETED job. Sequence counters and emitted domain events retain history.
|
||||
var source_job_id := StringName(instance.get(&"source_job_id", &""))
|
||||
_jobs.erase(source_job_id)
|
||||
_last_progress_minutes.erase(source_job_id)
|
||||
_mutation_in_progress = false
|
||||
facility_demolished.emit(instance_id, refund)
|
||||
return CommandResultType.succeeded(0, {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
class_name StaffCommuteService
|
||||
extends RefCounted
|
||||
|
||||
## Commutes derive from the saved work plan, Shift and current actor position.
|
||||
## Runtime owns movement credit, Path projection, elevator rides and events.
|
||||
## This adapter deliberately owns no additional snapshot payload.
|
||||
const ROUTE_BLOCKED := &"COMMUTE_ROUTE_BLOCKED"
|
||||
const WORKPLACE_MISSING := &"COMMUTE_WORKPLACE_MISSING"
|
||||
const WAITING_SHIFT := &"WAITING_FOR_SHIFT"
|
||||
const MOVING_TO_WORK := &"MOVING_TO_WORK"
|
||||
|
||||
var _runtime: Variant
|
||||
|
||||
|
||||
func configure(runtime: Variant) -> StaffCommuteService:
|
||||
_runtime = runtime
|
||||
_runtime.staff_service.configure_attendance_driver(Callable(self, "can_attend_shift"))
|
||||
return self
|
||||
|
||||
|
||||
## A porter checks in once at the warehouse, then remains dispatchable at the
|
||||
## end of a delivery. Fixed-post workers return before resuming desk service.
|
||||
func can_attend_shift(staff: StaffRuntime, shift: ShiftInstance, _minute: int) -> bool:
|
||||
if _runtime == null or staff == null or shift == null:
|
||||
return false
|
||||
if _in_transit(staff.staff_id) and (staff.current_task_id.is_empty() or shift.actual_started_at < 0):
|
||||
return false
|
||||
if shift.actual_started_at >= 0 and (staff.role_id == &"porter" or not staff.current_task_id.is_empty()):
|
||||
return true
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(shift.assigned_facility_id)
|
||||
return is_at_facility(staff, facility)
|
||||
|
||||
|
||||
## Physical attendance is separate from dispatch availability. A porter away
|
||||
## with goods does not provide staff capacity to an empty warehouse.
|
||||
func is_present(staff: StaffRuntime, facility: FacilityInstance) -> bool:
|
||||
if _runtime == null or staff == null or facility == null \
|
||||
or staff.assigned_facility_id != facility.instance_id \
|
||||
or staff.state != StaffRuntime.State.AVAILABLE or not staff.can_take_shift():
|
||||
return false
|
||||
if _in_transit(staff.staff_id):
|
||||
return false
|
||||
if not _runtime.staff_service.requires_attendance(staff.staff_id):
|
||||
return true
|
||||
return is_at_facility(staff, facility)
|
||||
|
||||
|
||||
func tick(absolute_minute: int) -> Dictionary:
|
||||
if _runtime == null:
|
||||
return {&"ok": false, &"reason": &"NOT_CONFIGURED"}
|
||||
var moved: Array[StringName] = []
|
||||
var waiting: Array[StringName] = []
|
||||
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
if not _runtime.staff_service.requires_attendance(staff.staff_id) \
|
||||
or staff.state != StaffRuntime.State.COMMUTING \
|
||||
or not staff.can_take_shift() or not staff.current_task_id.is_empty():
|
||||
continue
|
||||
var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id)
|
||||
if shift == null or (not shift.contains_minute(absolute_minute) and not shift.is_commute_window(absolute_minute)):
|
||||
continue
|
||||
if _in_transit(staff.staff_id):
|
||||
_wait(staff, absolute_minute, &"ELEVATOR_WAIT")
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(shift.assigned_facility_id)
|
||||
if facility == null:
|
||||
_wait(staff, absolute_minute, WORKPLACE_MISSING)
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
if is_at_facility(staff, facility):
|
||||
staff.absence_reason = &""
|
||||
_wait(staff, absolute_minute, WAITING_SHIFT if shift.is_commute_window(absolute_minute) else &"COMMUTE_ARRIVED")
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
var access := find_facility_access_path(staff.current_cell, facility)
|
||||
if not bool(access.get(&"reachable", false)):
|
||||
_wait(staff, absolute_minute, ROUTE_BLOCKED)
|
||||
waiting.append(staff.staff_id)
|
||||
continue
|
||||
staff.absence_reason = &""
|
||||
var result: Dictionary = _runtime.advance_staff_commute(staff.staff_id, access[&"cell"], absolute_minute)
|
||||
if bool(result.get(&"moved", false)):
|
||||
moved.append(staff.staff_id)
|
||||
else:
|
||||
waiting.append(staff.staff_id)
|
||||
return {&"ok": true, &"moved_staff_ids": moved, &"waiting_staff_ids": waiting}
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
var rows: Array[Dictionary] = []
|
||||
if _runtime == null:
|
||||
return {&"staff": rows}
|
||||
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
if not _runtime.staff_service.requires_attendance(staff.staff_id):
|
||||
continue
|
||||
var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id)
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(staff.assigned_facility_id)
|
||||
var reason := staff.absence_reason
|
||||
var target := staff.current_cell
|
||||
if _in_transit(staff.staff_id):
|
||||
reason = &"ELEVATOR_WAIT"
|
||||
if facility != null:
|
||||
var access := find_facility_access_path(staff.current_cell, facility)
|
||||
target = access.get(&"cell", facility.origin)
|
||||
if staff.state == StaffRuntime.State.COMMUTING and reason.is_empty():
|
||||
reason = WAITING_SHIFT if is_at_facility(staff, facility) else MOVING_TO_WORK
|
||||
rows.append({
|
||||
&"staff_id": staff.staff_id, &"state": StaffRuntime.State.keys()[staff.state],
|
||||
&"reason": reason, &"facility_id": staff.assigned_facility_id,
|
||||
&"current_cell": staff.current_cell, &"target_cell": target,
|
||||
&"present": is_present(staff, facility),
|
||||
&"late_minutes": shift.late_minutes if shift != null else 0,
|
||||
})
|
||||
return {&"staff": rows}
|
||||
|
||||
|
||||
func find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
||||
if _runtime == null or facility == null:
|
||||
return {&"reachable": false}
|
||||
var candidates: Dictionary = {}
|
||||
var occupied := facility.occupied_cells.duplicate()
|
||||
if occupied.is_empty():
|
||||
occupied.append(facility.origin)
|
||||
for cell: Vector3i in occupied:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]:
|
||||
if _runtime.path_service.has_cell(cell + delta):
|
||||
candidates[cell + delta] = true
|
||||
var ordered: Array = candidates.keys()
|
||||
ordered.sort_custom(func(a: Vector3i, b: Vector3i) -> bool:
|
||||
if a.z != b.z: return a.z < b.z
|
||||
if a.y != b.y: return a.y < b.y
|
||||
return a.x < b.x)
|
||||
var best: Array[Vector3i] = []
|
||||
var best_cost := 9223372036854775807
|
||||
for cell: Vector3i in ordered:
|
||||
var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell,
|
||||
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"])
|
||||
var cost: int = _runtime.path_service.calculate_path_cost(path, [&"staff"]) if not path.is_empty() else -1
|
||||
if cost >= 0 and (best.is_empty() or cost < best_cost):
|
||||
best = path
|
||||
best_cost = cost
|
||||
return {&"reachable": not best.is_empty(), &"path": best,
|
||||
&"cell": best[-1] if not best.is_empty() else facility.origin,
|
||||
&"steps": maxi(0, best.size() - 1)}
|
||||
|
||||
|
||||
static func is_at_facility(staff: StaffRuntime, facility: FacilityInstance) -> bool:
|
||||
if staff == null or facility == null:
|
||||
return false
|
||||
var occupied := facility.occupied_cells.duplicate()
|
||||
if occupied.is_empty():
|
||||
occupied.append(facility.origin)
|
||||
for cell: Vector3i in occupied:
|
||||
if cell.z == staff.current_cell.z and cell.y == staff.current_cell.y \
|
||||
and absi(cell.x - staff.current_cell.x) <= 1:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _wait(staff: StaffRuntime, minute: int, reason: StringName) -> void:
|
||||
if reason in [ROUTE_BLOCKED, WORKPLACE_MISSING]:
|
||||
staff.absence_reason = reason
|
||||
_runtime.advance_staff_commute(staff.staff_id, staff.current_cell, minute, reason)
|
||||
|
||||
|
||||
func _in_transit(staff_id: StringName) -> bool:
|
||||
return _runtime != null and _runtime.has_method("is_staff_in_transit") \
|
||||
and bool(_runtime.is_staff_in_transit(staff_id))
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddcf3c4qyyysc
|
||||
@@ -49,6 +49,7 @@ var _item_reserver := Callable()
|
||||
var _item_releaser := Callable()
|
||||
var _travel_estimator := Callable()
|
||||
var _external_task_driver := Callable()
|
||||
var _attendance_driver := Callable()
|
||||
|
||||
|
||||
func configure_callbacks(
|
||||
@@ -69,6 +70,17 @@ func configure_external_task_driver(driver: Callable = Callable()) -> void:
|
||||
_external_task_driver = driver
|
||||
|
||||
|
||||
## Runtime attendance uses the employee's real position. Component consumers
|
||||
## and legacy one-off shifts retain their existing scheduling contract.
|
||||
func configure_attendance_driver(driver: Callable = Callable()) -> void:
|
||||
_attendance_driver = driver
|
||||
|
||||
|
||||
func requires_attendance(staff_id: StringName) -> bool:
|
||||
return _attendance_driver.is_valid() and _work_assignments.has(staff_id) \
|
||||
and not String(_work_assignments[staff_id][&"facility_id"]).is_empty()
|
||||
|
||||
|
||||
func is_externally_driven_task(task_id: StringName) -> bool:
|
||||
return _external_task_driver.is_valid() and bool(_external_task_driver.call(task_id))
|
||||
|
||||
@@ -461,6 +473,17 @@ func set_work_assignment(staff_id: StringName, facility_id: StringName, template
|
||||
if shift.cancelled or shift.end_at <= minute:
|
||||
continue
|
||||
var replacement := ShiftInstanceType.from_snapshot(shift.create_snapshot())
|
||||
# Attendance can contain gaps, so clamping by scheduled duration alone
|
||||
# cannot remove an already recorded paused-edit boundary minute. The
|
||||
# current AVAILABLE employee recorded that minute; its replacement plan
|
||||
# records it only if the employee is physically at the new workplace.
|
||||
if requires_attendance(staff_id) and minute == current_minute \
|
||||
and staff.state == StaffRuntimeType.State.AVAILABLE \
|
||||
and staff.shift_id == shift.shift_instance_id \
|
||||
and shift.contains_minute(minute) and shift.worked_minutes > 0:
|
||||
replacement.worked_minutes -= 1
|
||||
if replacement.worked_minutes == 0 and replacement.overtime_minutes == 0:
|
||||
replacement.actual_started_at = -1
|
||||
if replacement.start_at >= minute:
|
||||
replacement.cancelled = true
|
||||
else:
|
||||
@@ -496,6 +519,8 @@ func set_work_assignment(staff_id: StringName, facility_id: StringName, template
|
||||
staff.shift_id = &""
|
||||
staff.assigned_facility_id = &""
|
||||
staff.is_overtime = false
|
||||
if String(staff.absence_reason).begins_with("COMMUTE_"):
|
||||
staff.absence_reason = &""
|
||||
staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"WORK_ASSIGNMENT_CHANGED", minute)
|
||||
if new_shift != null:
|
||||
_shifts[new_shift.shift_instance_id] = new_shift
|
||||
@@ -1379,6 +1404,8 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re
|
||||
task_reopened.emit(current_task.task_id, &"SHIFT_ENDED")
|
||||
staff.current_task_id = &""
|
||||
staff.is_overtime = false
|
||||
if String(staff.absence_reason).begins_with("COMMUTE_"):
|
||||
staff.absence_reason = &""
|
||||
staff.shift_id = &""
|
||||
if staff.assigned_facility_id == shift.assigned_facility_id:
|
||||
staff.assigned_facility_id = &""
|
||||
@@ -1410,16 +1437,26 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re
|
||||
continue
|
||||
if shift.contains_minute(absolute_minute):
|
||||
staff.is_overtime = false
|
||||
var attendance_required := requires_attendance(staff.staff_id)
|
||||
var attended := not attendance_required or bool(_attendance_driver.call(staff, shift, absolute_minute))
|
||||
if attendance_required and attended and String(staff.absence_reason).begins_with("COMMUTE_"):
|
||||
staff.absence_reason = &""
|
||||
if staff.current_task_id.is_empty() and staff.state in [
|
||||
StaffRuntimeType.State.OFF_DUTY,
|
||||
StaffRuntimeType.State.COMMUTING,
|
||||
StaffRuntimeType.State.AVAILABLE,
|
||||
]:
|
||||
staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute)
|
||||
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
|
||||
if record_duty:
|
||||
var next_state := StaffRuntimeType.State.AVAILABLE if attended else StaffRuntimeType.State.COMMUTING
|
||||
if staff.state != next_state:
|
||||
var reason := &"SHIFT_STARTED" if attended else (&"RETURN_TO_POST" if shift.actual_started_at >= 0 else &"SHIFT_COMMUTE")
|
||||
staff.transition_to(next_state, reason, absolute_minute)
|
||||
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
|
||||
if record_duty and attended:
|
||||
shift.record_duty_minute(absolute_minute)
|
||||
if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING:
|
||||
shift.mark_late(absolute_minute, DEFAULT_LATE_REASON)
|
||||
if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING \
|
||||
and (not attendance_required or shift.actual_started_at < 0):
|
||||
var late_reason := staff.absence_reason if attendance_required and not staff.absence_reason.is_empty() else DEFAULT_LATE_REASON
|
||||
shift.mark_late(absolute_minute, late_reason)
|
||||
|
||||
|
||||
func _find_relevant_shift(staff_id: StringName, absolute_minute: int) -> ShiftInstance:
|
||||
|
||||
@@ -403,7 +403,7 @@ func _access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
||||
return {&"reachable": false}
|
||||
var cells: Dictionary = {}
|
||||
for cell: Vector3i in facility.occupied_cells:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]:
|
||||
if _runtime.path_service.has_cell(cell + delta):
|
||||
cells[cell + delta] = true
|
||||
if cells.is_empty() and _runtime.path_service.has_cell(facility.origin):
|
||||
@@ -414,10 +414,13 @@ func _access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
||||
if a.y != b.y: return a.y < b.y
|
||||
return a.x < b.x)
|
||||
var best: Array[Vector3i] = []
|
||||
var best_cost := 9223372036854775807
|
||||
for cell: Vector3i in ordered:
|
||||
var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"])
|
||||
if not path.is_empty() and (best.is_empty() or path.size() < best.size()):
|
||||
var cost: int = _runtime.path_service.calculate_path_cost(path, [&"staff"]) if not path.is_empty() else -1
|
||||
if cost >= 0 and (best.is_empty() or cost < best_cost):
|
||||
best = path
|
||||
best_cost = cost
|
||||
return {&"reachable": not best.is_empty(), &"cell": best[-1] if not best.is_empty() else facility.origin, &"steps": maxi(0, best.size() - 1)}
|
||||
|
||||
|
||||
@@ -557,6 +560,6 @@ static func validate_crosslinks(requests: Dictionary, staff: StaffService, inven
|
||||
|
||||
static func _cell_touches_facility(cell: Vector3i, facility: FacilityInstance) -> bool:
|
||||
for occupied: Vector3i in facility.occupied_cells:
|
||||
if cell.z == occupied.z and absi(cell.x - occupied.x) + absi(cell.y - occupied.y) <= 1:
|
||||
if cell.z == occupied.z and cell.y == occupied.y and absi(cell.x - occupied.x) <= 1:
|
||||
return true
|
||||
return cell == facility.origin
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
class_name ElevatorService
|
||||
extends RefCounted
|
||||
|
||||
## One physical cabin per contiguous shaft. Path connectors expose reachability;
|
||||
## boarding and arrival here remain authoritative for actual movement.
|
||||
## Dispatch is oldest request first, batching one origin/destination at a time.
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const PREFIX := "transport."
|
||||
const REQUEST_PREFIX := "transport.trip."
|
||||
const DOOR_MINUTES := 2
|
||||
const QUEUE_CAPACITY := 256
|
||||
|
||||
var last_error := ""
|
||||
var _path: PathService
|
||||
var _shafts: Dictionary = {}
|
||||
var _trips: Dictionary = {}
|
||||
var _minute := -1
|
||||
var _sequence := 0
|
||||
var current_minute: int:
|
||||
get: return _minute
|
||||
|
||||
|
||||
func configure(path: PathService) -> void:
|
||||
_path = path
|
||||
|
||||
|
||||
static func configurations_from_facilities(facilities: Array, registry: DefinitionRegistry) -> Array:
|
||||
var modules: Array[Dictionary] = []
|
||||
for value: Variant in facilities:
|
||||
if not value is Dictionary:
|
||||
continue
|
||||
var facility: Dictionary = value
|
||||
var definition_id := StringName(str(facility.get(&"definition_id", "")))
|
||||
if definition_id not in [&"guest_elevator", &"service_elevator"]:
|
||||
continue
|
||||
var definition := registry.get_facility(definition_id)
|
||||
var origin: Variant = facility.get(&"origin")
|
||||
if definition == null or not origin is Vector3i:
|
||||
continue
|
||||
var width := definition.footprint.x
|
||||
var height := definition.footprint.y
|
||||
var cells: Array = facility.get(&"occupied_cells", [])
|
||||
if not cells.is_empty():
|
||||
width = 1
|
||||
height = 1
|
||||
for cell: Vector3i in cells:
|
||||
width = maxi(width, cell.x - origin.x + 1)
|
||||
height = maxi(height, cell.y - origin.y + 1)
|
||||
modules.append({&"origin": origin, &"width": width, &"height": height,
|
||||
&"module_id": StringName(str(facility.get(&"instance_id", "")))})
|
||||
modules.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var ac: Vector3i = a[&"origin"]
|
||||
var bc: Vector3i = b[&"origin"]
|
||||
if ac.z != bc.z: return ac.z < bc.z
|
||||
if ac.x != bc.x: return ac.x < bc.x
|
||||
if a[&"width"] != b[&"width"]: return a[&"width"] < b[&"width"]
|
||||
return ac.y < bc.y)
|
||||
var result: Array = []
|
||||
for module: Dictionary in modules:
|
||||
var origin: Vector3i = module[&"origin"]
|
||||
var width: int = module[&"width"]
|
||||
var config: Dictionary = {}
|
||||
if not result.is_empty():
|
||||
var previous: Dictionary = result.back()
|
||||
var stops: Array = previous[&"stops"]
|
||||
var top: Vector3i = stops.back()
|
||||
if top.x == origin.x and top.z == origin.z and top.y + 1 == origin.y and previous[&"width"] == width:
|
||||
config = previous
|
||||
if config.is_empty():
|
||||
config = {&"shaft_id": StringName("transport.shaft.%d.%d.%d.%d" % [origin.z, origin.x, origin.y, width]),
|
||||
&"module_ids": [], &"stops": [], &"width": width,
|
||||
&"capacity": 4 if origin.z == 0 else 8,
|
||||
&"travel_minutes_per_floor": 10 if origin.z == 0 else 5,
|
||||
&"access_id": &"" if origin.z == 0 else &"staff"}
|
||||
result.append(config)
|
||||
(config[&"module_ids"] as Array).append(module[&"module_id"])
|
||||
for y: int in range(origin.y, origin.y + int(module[&"height"])):
|
||||
(config[&"stops"] as Array).append(Vector3i(origin.x, y, origin.z))
|
||||
return result
|
||||
|
||||
|
||||
func synchronize(configurations: Array, minute: int) -> Dictionary:
|
||||
if _path == null or minute < 0 or (_minute >= 0 and minute < _minute):
|
||||
return _failure(&"INVALID_CLOCK", "Elevator synchronization requires a path and current time.")
|
||||
var normalized := _normalize_configs(configurations)
|
||||
if not normalized[&"ok"]:
|
||||
return normalized
|
||||
var configs: Dictionary = normalized[&"configs"]
|
||||
for shaft_id: StringName in _names(_shafts):
|
||||
var car: Dictionary = _shafts[shaft_id]
|
||||
if configs.has(shaft_id) and configs[shaft_id] == car[&"config"]:
|
||||
continue
|
||||
if _shaft_busy(shaft_id):
|
||||
return _failure(&"ELEVATOR_BUSY", "An occupied or moving shaft cannot be modified.")
|
||||
# All new IDs must belong to us and must not overwrite an unrelated connector.
|
||||
var old_ids: Dictionary = {}
|
||||
for value: Variant in _shafts.values():
|
||||
for connector: Dictionary in _connector_configs(value[&"config"]):
|
||||
old_ids[connector[&"id"]] = true
|
||||
for value: Variant in configs.values():
|
||||
for connector: Dictionary in _connector_configs(value):
|
||||
if _path.has_connector(connector[&"id"]) and not old_ids.has(connector[&"id"]):
|
||||
return _failure(&"CONNECTOR_CONFLICT", "Elevator connector is already owned.")
|
||||
var path_before := _path.create_snapshot()
|
||||
var next: Dictionary = {}
|
||||
for shaft_id: StringName in _names(_shafts):
|
||||
var car: Dictionary = _shafts[shaft_id]
|
||||
if configs.has(shaft_id) and configs[shaft_id] == car[&"config"]:
|
||||
next[shaft_id] = car
|
||||
continue
|
||||
for connector: Dictionary in _connector_configs(car[&"config"]):
|
||||
if not _path.remove_connector(connector[&"id"]):
|
||||
_path.restore_snapshot(path_before)
|
||||
return _failure(&"CONNECTOR_BUSY", _path.last_error)
|
||||
for shaft_id: StringName in _names(configs):
|
||||
if next.has(shaft_id):
|
||||
continue
|
||||
var config: Dictionary = configs[shaft_id]
|
||||
var car := _new_car(config)
|
||||
if _shafts.has(shaft_id):
|
||||
car[&"operational"] = _shafts[shaft_id][&"operational"]
|
||||
car[&"operator_open"] = _shafts[shaft_id][&"operator_open"]
|
||||
car[&"powered"] = _shafts[shaft_id][&"powered"]
|
||||
var previous_floor: int = _shafts[shaft_id][&"current_floor"]
|
||||
if _has_floor(config, previous_floor): car[&"current_floor"] = previous_floor
|
||||
for connector: Dictionary in _connector_configs(config):
|
||||
if not _path.configure_connector(connector[&"id"], connector[&"from"], connector[&"to"],
|
||||
PathService.ORTHOGONAL_MOVE_COST, true, config[&"access_id"], true, connector):
|
||||
_path.restore_snapshot(path_before)
|
||||
return _failure(&"CONNECTOR_CONFIGURATION_FAILED", _path.last_error)
|
||||
next[shaft_id] = car
|
||||
_shafts = next
|
||||
if _minute < 0: _minute = minute
|
||||
for shaft_id: StringName in _names(_shafts): _publish_cabin(_shafts[shaft_id])
|
||||
last_error = ""
|
||||
return {&"ok": true, &"shaft_count": _shafts.size()}
|
||||
|
||||
|
||||
func request_trip(actor_id: StringName, from: Vector3i, to: Vector3i, minute: int,
|
||||
access: Array[StringName] = []) -> Dictionary:
|
||||
if actor_id.is_empty() or minute < _minute or minute > _minute + 1:
|
||||
return _failure(&"INVALID_REQUEST", "Elevator request has invalid actor or time.")
|
||||
if _trips.has(actor_id):
|
||||
var existing: Dictionary = _trips[actor_id]
|
||||
if existing[&"from"] == from and existing[&"to"] == to:
|
||||
return {&"ok": true, &"replay": true, &"trip": existing.duplicate(true)}
|
||||
return _failure(&"ACTOR_ALREADY_TRAVELLING", "Actor already owns an elevator trip.")
|
||||
var selected: Dictionary = {}
|
||||
for shaft_id: StringName in _names(_shafts):
|
||||
var car: Dictionary = _shafts[shaft_id]
|
||||
var config: Dictionary = car[&"config"]
|
||||
if from != to and (config[&"stops"] as Array).has(from) and (config[&"stops"] as Array).has(to):
|
||||
selected = car
|
||||
break
|
||||
if selected.is_empty(): return _failure(&"NO_ELEVATOR", "No shaft joins those stops.")
|
||||
var config: Dictionary = selected[&"config"]
|
||||
if not selected[&"operational"]: return _failure(&"ELEVATOR_STOPPED", "Elevator is stopped.")
|
||||
if not StringName(config[&"access_id"]).is_empty() and not access.has(config[&"access_id"]):
|
||||
return _failure(&"ACCESS_DENIED", "Elevator access is not permitted.")
|
||||
if not _valid_stop(from) or not _valid_stop(to):
|
||||
return _failure(&"STOP_UNAVAILABLE", "Elevator landing is unavailable.")
|
||||
var connector_id := _connector_id(config[&"shaft_id"], from.y, to.y)
|
||||
_sequence += 1
|
||||
var request_id := StringName("transport.trip.%012d" % _sequence)
|
||||
var queued := _path.enqueue_elevator_request(connector_id, {&"request_id": request_id,
|
||||
&"unit_id": actor_id, &"floor_from": from.y, &"floor_to": to.y, &"enqueued_at": minute})
|
||||
if not queued[&"ok"]:
|
||||
return _failure(queued.get(&"reason", &"QUEUE_REJECTED"), _path.last_error)
|
||||
var trip := {&"actor_id": actor_id, &"request_id": request_id, &"shaft_id": config[&"shaft_id"],
|
||||
&"connector_id": connector_id, &"from": from, &"to": to,
|
||||
&"enqueued_at": minute, &"state": PathService.ELEVATOR_REQUEST_QUEUED}
|
||||
_trips[actor_id] = trip
|
||||
last_error = ""
|
||||
return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)}
|
||||
|
||||
|
||||
func advance_one_minute(minute: int) -> Array[Dictionary]:
|
||||
var arrivals: Array[Dictionary] = []
|
||||
if minute == _minute: return arrivals
|
||||
if minute != _minute + 1:
|
||||
last_error = "Elevators must advance in consecutive minutes."
|
||||
return arrivals
|
||||
_minute = minute
|
||||
for shaft_id: StringName in _names(_shafts):
|
||||
var car: Dictionary = _shafts[shaft_id]
|
||||
if not car[&"operational"]: continue
|
||||
var unavailable := false
|
||||
for actor_id: StringName in car[&"boarded"]:
|
||||
if not _valid_stop(_trips[actor_id][&"to"]): unavailable = true
|
||||
if unavailable:
|
||||
arrivals.append_array(set_operational(shaft_id, false, minute))
|
||||
continue
|
||||
if car[&"phase"] == &"IDLE":
|
||||
_dispatch(car)
|
||||
elif int(car[&"remaining_minutes"]) > 0:
|
||||
car[&"remaining_minutes"] -= 1
|
||||
if int(car[&"remaining_minutes"]) == 0:
|
||||
_finish_phase(car, arrivals)
|
||||
_publish_cabin(car)
|
||||
last_error = ""
|
||||
return arrivals
|
||||
|
||||
|
||||
func get_actor_trip(actor_id: StringName) -> Dictionary:
|
||||
return (_trips.get(actor_id, {}) as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func cancel_actor(actor_id: StringName) -> bool:
|
||||
if not _trips.has(actor_id): return true
|
||||
var trip: Dictionary = _trips[actor_id]
|
||||
if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED:
|
||||
if not _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]):
|
||||
last_error = _path.last_error
|
||||
return false
|
||||
(_shafts[trip[&"shaft_id"]][&"boarded"] as Array).erase(actor_id)
|
||||
_trips.erase(actor_id)
|
||||
last_error = ""
|
||||
return true
|
||||
if not _path.cancel_elevator_request(trip[&"connector_id"], trip[&"request_id"]):
|
||||
last_error = _path.last_error
|
||||
return false
|
||||
_trips.erase(actor_id)
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func set_operator_open(shaft_id: StringName, enabled: bool, minute: int) -> Array[Dictionary]:
|
||||
if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1:
|
||||
last_error = "Unknown shaft or invalid operator change time."
|
||||
return []
|
||||
_shafts[shaft_id][&"operator_open"] = enabled
|
||||
return _apply_operational(shaft_id, enabled and _shafts[shaft_id][&"powered"], minute)
|
||||
|
||||
|
||||
func set_powered(shaft_id: StringName, powered: bool, minute: int) -> Array[Dictionary]:
|
||||
if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1:
|
||||
last_error = "Unknown shaft or invalid power change time."
|
||||
return []
|
||||
_shafts[shaft_id][&"powered"] = powered
|
||||
return _apply_operational(shaft_id, powered and _shafts[shaft_id][&"operator_open"], minute)
|
||||
|
||||
|
||||
func set_operational(shaft_id: StringName, operational: bool, minute: int) -> Array[Dictionary]:
|
||||
return set_powered(shaft_id, operational, minute)
|
||||
|
||||
|
||||
func _apply_operational(shaft_id: StringName, operational: bool, minute: int) -> Array[Dictionary]:
|
||||
var arrivals: Array[Dictionary] = []
|
||||
if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1:
|
||||
last_error = "Unknown shaft or invalid operational change time."
|
||||
return arrivals
|
||||
var car: Dictionary = _shafts[shaft_id]
|
||||
if car[&"operational"] == operational: return arrivals
|
||||
if not operational:
|
||||
var landing: Variant = _nearest_valid_stop(car)
|
||||
if landing == null and not (car[&"boarded"] as Array).is_empty():
|
||||
last_error = "Occupied elevator has no safe landing; cabin remains held."
|
||||
# No legal teleportation destination exists. The caller must repair a
|
||||
# landing; movement remains held and requests remain owned.
|
||||
car[&"operational"] = false
|
||||
for actor_id: StringName in _names(_trips):
|
||||
var waiting: Dictionary = _trips[actor_id]
|
||||
if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and cancel_actor(actor_id):
|
||||
arrivals.append({&"actor_id": actor_id, &"request_id": waiting[&"request_id"], &"cell": waiting[&"from"], &"emergency": false, &"cancelled": true})
|
||||
_publish_cabin(car)
|
||||
return arrivals
|
||||
for actor_id: StringName in _names(_trips):
|
||||
var trip: Dictionary = _trips[actor_id]
|
||||
if trip[&"shaft_id"] != shaft_id: continue
|
||||
var boarded: bool = trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED
|
||||
if boarded:
|
||||
if not _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]): continue
|
||||
else:
|
||||
if not _path.cancel_elevator_request(trip[&"connector_id"], trip[&"request_id"]): continue
|
||||
arrivals.append({&"actor_id": actor_id, &"request_id": trip[&"request_id"],
|
||||
&"cell": landing if boarded else trip[&"from"], &"emergency": boarded, &"cancelled": not boarded})
|
||||
_trips.erase(actor_id)
|
||||
car[&"boarded"] = []
|
||||
if landing != null: car[&"current_floor"] = (landing as Vector3i).y
|
||||
_idle(car)
|
||||
if operational and car[&"phase"] != &"IDLE":
|
||||
car[&"phase_started_at"] = _minute - (int(car[&"phase_total_minutes"]) - int(car[&"remaining_minutes"]))
|
||||
car[&"operational"] = operational
|
||||
_publish_cabin(car)
|
||||
last_error = ""
|
||||
return arrivals
|
||||
|
||||
|
||||
func owns_connector(connector_id: StringName) -> bool:
|
||||
return not get_shaft_for_connector(connector_id).is_empty()
|
||||
|
||||
|
||||
func get_shaft_for_connector(connector_id: StringName) -> StringName:
|
||||
for shaft_id: StringName in _names(_shafts):
|
||||
for connector: Dictionary in _connector_configs(_shafts[shaft_id][&"config"]):
|
||||
if connector[&"id"] == connector_id: return shaft_id
|
||||
return &""
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
var rows: Array[Dictionary] = []
|
||||
for shaft_id: StringName in _names(_shafts):
|
||||
var car: Dictionary = _shafts[shaft_id]
|
||||
var row: Dictionary = car.duplicate(true)
|
||||
row.merge(car[&"config"], true)
|
||||
row[&"visual_floor"] = _visual_floor(car)
|
||||
row[&"waiting_count"] = 0
|
||||
row[&"passenger_count"] = (car[&"boarded"] as Array).size()
|
||||
row[&"passenger_ids"] = car[&"boarded"].duplicate()
|
||||
for trip: Dictionary in _trips.values():
|
||||
if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
|
||||
row[&"waiting_count"] += 1
|
||||
rows.append(row)
|
||||
return {&"current_minute": _minute, &"shafts": rows, &"trips": _ordered_trips()}
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var cars: Array[Dictionary] = []
|
||||
for shaft_id: StringName in _names(_shafts): cars.append(_shafts[shaft_id].duplicate(true))
|
||||
return {&"version": SNAPSHOT_VERSION, &"current_minute": _minute,
|
||||
&"request_sequence": _sequence, &"shafts": cars, &"trips": _ordered_trips()}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if _path == null or not _exact_keys(snapshot, [&"version", &"current_minute", &"request_sequence", &"shafts", &"trips"]):
|
||||
return _invalid("Elevator snapshot shape is invalid.")
|
||||
if snapshot[&"version"] != SNAPSHOT_VERSION or not snapshot[&"current_minute"] is int or snapshot[&"current_minute"] < 0 \
|
||||
or not snapshot[&"request_sequence"] is int or snapshot[&"request_sequence"] < 0 \
|
||||
or not snapshot[&"shafts"] is Array or not snapshot[&"trips"] is Array:
|
||||
return _invalid("Elevator snapshot header is invalid.")
|
||||
var next_shafts: Dictionary = {}
|
||||
var next_trips: Dictionary = {}
|
||||
var seen_modules: Dictionary = {}
|
||||
var expected_connectors: Dictionary = {}
|
||||
var boarded_owners: Dictionary = {}
|
||||
for value: Variant in snapshot[&"shafts"]:
|
||||
if not value is Dictionary: return _invalid("Elevator cabin must be a Dictionary.")
|
||||
var car: Dictionary = value
|
||||
if not _exact_keys(car, _new_car({&"stops": [Vector3i.ZERO]}).keys()) or not car[&"config"] is Dictionary:
|
||||
return _invalid("Elevator cabin fields are invalid.")
|
||||
var normalized := _normalize_configs([car[&"config"]])
|
||||
if not normalized[&"ok"]: return false
|
||||
var config: Dictionary = car[&"config"]
|
||||
var shaft_id := StringName(config[&"shaft_id"])
|
||||
if next_shafts.has(shaft_id): return _invalid("Duplicate elevator shaft.")
|
||||
for module_id: StringName in config[&"module_ids"]:
|
||||
if seen_modules.has(module_id): return _invalid("Module belongs to multiple shafts.")
|
||||
seen_modules[module_id] = true
|
||||
if not _validate_car(car, int(snapshot[&"current_minute"])): return false
|
||||
for actor: Variant in car[&"boarded"]:
|
||||
if not (actor is String or actor is StringName) or str(actor).is_empty() or boarded_owners.has(StringName(actor)):
|
||||
return _invalid("Elevator cabin actor is invalid or duplicated.")
|
||||
boarded_owners[StringName(actor)] = shaft_id
|
||||
for expected: Dictionary in _connector_configs(config):
|
||||
var connector_id: StringName = expected[&"id"]
|
||||
var live := _path.get_connector(connector_id)
|
||||
for key: StringName in expected:
|
||||
if not live.has(key) or live[key] != expected[key]: return _invalid("Path elevator configuration disagrees with cabin.")
|
||||
if live[&"cabin_state"] != _path_cabin_state(car) \
|
||||
or live[&"operational_state"] != (PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED):
|
||||
return _invalid("Path elevator operation disagrees with cabin.")
|
||||
expected_connectors[connector_id] = true
|
||||
next_shafts[shaft_id] = car.duplicate(true)
|
||||
for value: Variant in snapshot[&"trips"]:
|
||||
if not value is Dictionary: return _invalid("Elevator trip must be a Dictionary.")
|
||||
var trip: Dictionary = value
|
||||
if not _exact_keys(trip, [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"]):
|
||||
return _invalid("Elevator trip fields are invalid.")
|
||||
for key: StringName in [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"state"]:
|
||||
if not (trip[key] is String or trip[key] is StringName) or str(trip[key]).is_empty(): return _invalid("Elevator trip names are invalid.")
|
||||
var actor_id := StringName(trip[&"actor_id"])
|
||||
var shaft_id := StringName(trip[&"shaft_id"])
|
||||
if next_trips.has(actor_id) or not next_shafts.has(shaft_id): return _invalid("Elevator trip actor or shaft is duplicated/missing.")
|
||||
var config: Dictionary = next_shafts[shaft_id][&"config"]
|
||||
if not trip[&"from"] is Vector3i or not trip[&"to"] is Vector3i \
|
||||
or trip[&"from"] == trip[&"to"] or not (config[&"stops"] as Array).has(trip[&"from"]) or not (config[&"stops"] as Array).has(trip[&"to"]):
|
||||
return _invalid("Elevator trip stops are invalid.")
|
||||
if trip[&"connector_id"] != _connector_id(shaft_id, trip[&"from"].y, trip[&"to"].y) \
|
||||
or not trip[&"enqueued_at"] is int or trip[&"enqueued_at"] < 0 or trip[&"enqueued_at"] > snapshot[&"current_minute"]:
|
||||
return _invalid("Elevator trip connector/time is invalid.")
|
||||
var active := _path.get_active_elevator_request(actor_id)
|
||||
if active.get(&"request_id") != trip[&"request_id"] or active.get(&"connector_id") != trip[&"connector_id"] or active.get(&"state") != trip[&"state"]:
|
||||
return _invalid("Elevator trip and Path active owner disagree.")
|
||||
if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED:
|
||||
if boarded_owners.get(actor_id) != shaft_id: return _invalid("Boarded trip has no cabin seat.")
|
||||
var car: Dictionary = next_shafts[shaft_id]
|
||||
if trip[&"to"].y != car[&"target_floor"] or trip[&"from"].y != car[&"boarding_floor"]:
|
||||
return _invalid("Boarded trip and cabin destination disagree.")
|
||||
elif trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
|
||||
if boarded_owners.has(actor_id): return _invalid("Queued trip also owns a cabin seat.")
|
||||
var found := false
|
||||
for queued: Dictionary in _path.get_elevator_queue(trip[&"connector_id"]):
|
||||
if queued[&"request_id"] == trip[&"request_id"]:
|
||||
found = queued[&"unit_id"] == actor_id and queued[&"floor_from"] == trip[&"from"].y and queued[&"floor_to"] == trip[&"to"].y and queued[&"enqueued_at"] == trip[&"enqueued_at"] and queued[&"priority"] == PathService.ELEVATOR_PRIORITY_NORMAL
|
||||
if not found: return _invalid("Queued trip details disagree with Path queue.")
|
||||
else: return _invalid("Unknown elevator trip state.")
|
||||
next_trips[actor_id] = trip.duplicate(true)
|
||||
for actor_id: StringName in boarded_owners:
|
||||
if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.")
|
||||
var path_snapshot := _path.create_snapshot()
|
||||
for connector: Dictionary in path_snapshot[&"connectors"]:
|
||||
if str(connector[&"id"]).begins_with(PREFIX) and not expected_connectors.has(connector[&"id"]):
|
||||
return _invalid("Path contains an orphan transport connector.")
|
||||
for active: Dictionary in path_snapshot[&"active_elevator_units"]:
|
||||
if expected_connectors.has(active[&"connector_id"]) and not next_trips.has(active[&"unit_id"]):
|
||||
return _invalid("Path contains an orphan transport passenger.")
|
||||
for request_id: Variant in path_snapshot[&"known_elevator_request_ids"]:
|
||||
var name := str(request_id)
|
||||
if not name.begins_with(REQUEST_PREFIX): continue
|
||||
var suffix := name.trim_prefix(REQUEST_PREFIX)
|
||||
if not suffix.is_valid_int() or int(suffix) <= 0 or int(suffix) > snapshot[&"request_sequence"]:
|
||||
return _invalid("Elevator sequence would reuse an active or retired request ID.")
|
||||
var all_configs: Array = []
|
||||
for car: Dictionary in next_shafts.values(): all_configs.append(car[&"config"])
|
||||
if not _normalize_configs(all_configs)[&"ok"]: return false
|
||||
_shafts = next_shafts
|
||||
_trips = next_trips
|
||||
_minute = snapshot[&"current_minute"]
|
||||
_sequence = snapshot[&"request_sequence"]
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func _dispatch(car: Dictionary) -> void:
|
||||
var candidates: Array[Dictionary] = []
|
||||
for trip: Dictionary in _trips.values():
|
||||
if trip[&"shaft_id"] == car[&"config"][&"shaft_id"] and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and trip[&"enqueued_at"] <= _minute:
|
||||
candidates.append(trip)
|
||||
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
if a[&"enqueued_at"] != b[&"enqueued_at"]: return a[&"enqueued_at"] < b[&"enqueued_at"]
|
||||
return str(a[&"actor_id"]) < str(b[&"actor_id"]))
|
||||
if candidates.is_empty():
|
||||
_idle(car)
|
||||
return
|
||||
var trip: Dictionary = candidates.front()
|
||||
car[&"boarding_floor"] = trip[&"from"].y
|
||||
car[&"target_floor"] = trip[&"to"].y
|
||||
if car[&"current_floor"] != car[&"boarding_floor"]:
|
||||
_start_phase(car, &"PICKUP", absi(int(car[&"current_floor"]) - int(car[&"boarding_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"]))
|
||||
else:
|
||||
_board(car)
|
||||
|
||||
|
||||
func _board(car: Dictionary) -> void:
|
||||
var config: Dictionary = car[&"config"]
|
||||
var connector_id := _connector_id(config[&"shaft_id"], car[&"boarding_floor"], car[&"target_floor"])
|
||||
var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"])
|
||||
car[&"boarded"] = []
|
||||
for request: Dictionary in boarded:
|
||||
var actor_id: StringName = request[&"unit_id"]
|
||||
if not _trips.has(actor_id): continue
|
||||
_trips[actor_id][&"state"] = PathService.ELEVATOR_REQUEST_BOARDED
|
||||
(car[&"boarded"] as Array).append(actor_id)
|
||||
if boarded.is_empty():
|
||||
_idle(car)
|
||||
return
|
||||
_start_phase(car, &"BOARDING", DOOR_MINUTES + boarded.size())
|
||||
|
||||
|
||||
func _finish_phase(car: Dictionary, arrivals: Array[Dictionary]) -> void:
|
||||
match car[&"phase"]:
|
||||
&"PICKUP":
|
||||
car[&"current_floor"] = car[&"boarding_floor"]
|
||||
_board(car)
|
||||
&"BOARDING":
|
||||
_start_phase(car, &"TRAVEL", absi(int(car[&"target_floor"]) - int(car[&"current_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"]))
|
||||
&"TRAVEL":
|
||||
car[&"current_floor"] = car[&"target_floor"]
|
||||
_start_phase(car, &"ALIGHTING", DOOR_MINUTES + (car[&"boarded"] as Array).size())
|
||||
&"ALIGHTING":
|
||||
for actor_id: StringName in car[&"boarded"]:
|
||||
var trip: Dictionary = _trips[actor_id]
|
||||
if _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]):
|
||||
arrivals.append({&"actor_id": actor_id, &"request_id": trip[&"request_id"], &"cell": trip[&"to"], &"emergency": false, &"cancelled": false})
|
||||
_trips.erase(actor_id)
|
||||
car[&"boarded"] = []
|
||||
_idle(car)
|
||||
_dispatch(car)
|
||||
|
||||
|
||||
func _new_car(config: Dictionary) -> Dictionary:
|
||||
var floor: int = config[&"stops"][0].y
|
||||
return {&"config": config.duplicate(true), &"operational": true, &"operator_open": true, &"powered": true, &"current_floor": floor,
|
||||
&"target_floor": floor, &"boarding_floor": floor, &"phase": &"IDLE",
|
||||
&"remaining_minutes": 0, &"phase_total_minutes": 0, &"phase_started_at": -1,
|
||||
&"phase_origin_floor": floor, &"phase_passenger_count": 0, &"boarded": []}
|
||||
|
||||
|
||||
func _start_phase(car: Dictionary, phase: StringName, duration: int) -> void:
|
||||
car[&"phase"] = phase
|
||||
car[&"remaining_minutes"] = duration
|
||||
car[&"phase_total_minutes"] = duration
|
||||
car[&"phase_started_at"] = _minute
|
||||
car[&"phase_origin_floor"] = car[&"current_floor"]
|
||||
car[&"phase_passenger_count"] = car[&"boarded"].size()
|
||||
|
||||
|
||||
func _idle(car: Dictionary) -> void:
|
||||
car[&"phase"] = &"IDLE"
|
||||
car[&"target_floor"] = car[&"current_floor"]
|
||||
car[&"boarding_floor"] = car[&"current_floor"]
|
||||
car[&"phase_origin_floor"] = car[&"current_floor"]
|
||||
car[&"phase_started_at"] = -1
|
||||
car[&"remaining_minutes"] = 0
|
||||
car[&"phase_total_minutes"] = 0
|
||||
car[&"phase_passenger_count"] = 0
|
||||
|
||||
|
||||
func _visual_floor(car: Dictionary) -> float:
|
||||
if car[&"phase"] not in [&"PICKUP", &"TRAVEL"]: return float(car[&"current_floor"])
|
||||
var destination: int = car[&"boarding_floor"] if car[&"phase"] == &"PICKUP" else car[&"target_floor"]
|
||||
return lerpf(float(car[&"phase_origin_floor"]), float(destination),
|
||||
1.0 - float(car[&"remaining_minutes"]) / maxf(float(car[&"phase_total_minutes"]), 1.0))
|
||||
|
||||
|
||||
func _nearest_valid_stop(car: Dictionary) -> Variant:
|
||||
var floor := _visual_floor(car)
|
||||
var nearest: Variant = null
|
||||
var distance := INF
|
||||
for cell: Vector3i in car[&"config"][&"stops"]:
|
||||
if not _valid_stop(cell): continue
|
||||
var candidate := absf(float(cell.y) - floor)
|
||||
if candidate < distance:
|
||||
nearest = cell
|
||||
distance = candidate
|
||||
return nearest
|
||||
|
||||
|
||||
func _valid_stop(cell: Vector3i) -> bool:
|
||||
var traffic := _path.get_cell_traffic(cell)
|
||||
return not traffic.is_empty() and not bool(traffic.get(&"temporary_block", false))
|
||||
|
||||
|
||||
func _path_cabin_state(car: Dictionary) -> StringName:
|
||||
if not car[&"operational"]: return PathService.CABIN_OUTAGE
|
||||
if car[&"phase"] in [&"BOARDING", &"ALIGHTING"]: return PathService.CABIN_DOOR_OPEN
|
||||
if car[&"phase"] in [&"PICKUP", &"TRAVEL"]:
|
||||
var target: int = car[&"boarding_floor"] if car[&"phase"] == &"PICKUP" else car[&"target_floor"]
|
||||
return PathService.CABIN_MOVING_UP if target > int(car[&"current_floor"]) else PathService.CABIN_MOVING_DOWN
|
||||
return PathService.CABIN_IDLE
|
||||
|
||||
|
||||
func _publish_cabin(car: Dictionary) -> void:
|
||||
for connector: Dictionary in _connector_configs(car[&"config"]):
|
||||
_path.set_connector_operational_state(connector[&"id"], PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED)
|
||||
_path.set_elevator_cabin_state(connector[&"id"], _path_cabin_state(car))
|
||||
|
||||
|
||||
func _shaft_busy(shaft_id: StringName) -> bool:
|
||||
if _shafts[shaft_id][&"phase"] != &"IDLE": return true
|
||||
for trip: Dictionary in _trips.values():
|
||||
if trip[&"shaft_id"] == shaft_id: return true
|
||||
return false
|
||||
|
||||
|
||||
static func _connector_id(shaft_id: StringName, from: int, to: int) -> StringName:
|
||||
return StringName("%s.link.%d.%d" % [shaft_id, mini(from, to), maxi(from, to)])
|
||||
|
||||
|
||||
func _connector_configs(config: Dictionary) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var stops: Array = config[&"stops"]
|
||||
for i: int in range(stops.size()):
|
||||
for j: int in range(i + 1, stops.size()):
|
||||
result.append({&"id": _connector_id(config[&"shaft_id"], stops[i].y, stops[j].y),
|
||||
&"from": stops[i], &"to": stops[j], &"movement_type": PathService.MOVEMENT_ELEVATOR,
|
||||
&"cost": PathService.ORTHOGONAL_MOVE_COST, &"bidirectional": true, &"access_id": config[&"access_id"],
|
||||
&"enabled": true, &"cabin_capacity": config[&"capacity"], &"queue_capacity": QUEUE_CAPACITY,
|
||||
&"travel_minutes_per_floor": config[&"travel_minutes_per_floor"], &"dynamic_cost": 0})
|
||||
return result
|
||||
|
||||
|
||||
func _normalize_configs(configurations: Array) -> Dictionary:
|
||||
var configs: Dictionary = {}
|
||||
var modules: Dictionary = {}
|
||||
var landing_owners: Dictionary = {}
|
||||
for value: Variant in configurations:
|
||||
if not value is Dictionary: return _failure(&"INVALID_CONFIG", "Shaft configuration must be a Dictionary.")
|
||||
var config: Dictionary = value
|
||||
if not _exact_keys(config, [&"shaft_id", &"module_ids", &"stops", &"width", &"capacity", &"travel_minutes_per_floor", &"access_id"]):
|
||||
return _failure(&"INVALID_CONFIG", "Shaft configuration fields are invalid.")
|
||||
for key: StringName in [&"shaft_id", &"access_id"]:
|
||||
if not (config[key] is String or config[key] is StringName): return _failure(&"INVALID_CONFIG", "Shaft names are invalid.")
|
||||
var shaft_id := StringName(config[&"shaft_id"])
|
||||
if not str(shaft_id).begins_with("transport.shaft.") or configs.has(shaft_id): return _failure(&"INVALID_CONFIG", "Shaft ID is invalid or duplicated.")
|
||||
if not config[&"stops"] is Array or config[&"stops"].size() < 2 or not config[&"module_ids"] is Array or config[&"module_ids"].is_empty():
|
||||
return _failure(&"INVALID_CONFIG", "Shaft must have modules and at least two stops.")
|
||||
for key: StringName in [&"capacity", &"travel_minutes_per_floor", &"width"]:
|
||||
if not config[key] is int or config[key] <= 0: return _failure(&"INVALID_CONFIG", "Shaft dimensions/timing are invalid.")
|
||||
var first: Variant = config[&"stops"][0]
|
||||
if not first is Vector3i or first.z not in [0, 1]: return _failure(&"INVALID_CONFIG", "Shaft stop layer is invalid.")
|
||||
if config[&"capacity"] != (4 if first.z == 0 else 8) or config[&"travel_minutes_per_floor"] != (10 if first.z == 0 else 5):
|
||||
return _failure(&"INVALID_CONFIG", "Shaft capacity/speed differs from its layer.")
|
||||
for i: int in range(config[&"stops"].size()):
|
||||
var cell: Variant = config[&"stops"][i]
|
||||
if not cell is Vector3i or cell != Vector3i(first.x, first.y + i, first.z) or landing_owners.has(cell):
|
||||
return _failure(&"INVALID_CONFIG", "Shaft stops must form a unique contiguous column.")
|
||||
landing_owners[cell] = true
|
||||
for module: Variant in config[&"module_ids"]:
|
||||
if not (module is String or module is StringName) or str(module).is_empty() or modules.has(StringName(module)):
|
||||
return _failure(&"INVALID_CONFIG", "Shaft module is empty or duplicated.")
|
||||
modules[StringName(module)] = true
|
||||
configs[shaft_id] = config.duplicate(true)
|
||||
return {&"ok": true, &"configs": configs}
|
||||
|
||||
|
||||
func _validate_car(car: Dictionary, minute: int) -> bool:
|
||||
if not car[&"operational"] is bool or not car[&"operator_open"] is bool or not car[&"powered"] is bool or car[&"operational"] != (car[&"operator_open"] and car[&"powered"]) or not car[&"boarded"] is Array \
|
||||
or car[&"boarded"].size() > car[&"config"][&"capacity"]:
|
||||
return _invalid("Elevator cabin operation/capacity is invalid.")
|
||||
for key: StringName in [&"current_floor", &"target_floor", &"boarding_floor", &"phase_origin_floor", &"remaining_minutes", &"phase_total_minutes", &"phase_started_at", &"phase_passenger_count"]:
|
||||
if not car[key] is int: return _invalid("Elevator cabin clocks and floors must be integers.")
|
||||
for key: StringName in [&"current_floor", &"target_floor", &"boarding_floor", &"phase_origin_floor"]:
|
||||
if not _has_floor(car[&"config"], car[key]): return _invalid("Elevator cabin floor is absent from its shaft.")
|
||||
if car[&"phase_passenger_count"] < car[&"boarded"].size() or car[&"phase_passenger_count"] > car[&"config"][&"capacity"]:
|
||||
return _invalid("Elevator phase passenger count is invalid.")
|
||||
var phase := StringName(str(car[&"phase"]))
|
||||
if phase not in [&"IDLE", &"PICKUP", &"BOARDING", &"TRAVEL", &"ALIGHTING"]: return _invalid("Unknown elevator phase.")
|
||||
if phase == &"IDLE":
|
||||
return _valid_or_error(car[&"boarded"].is_empty() and car[&"phase_passenger_count"] == 0 and car[&"remaining_minutes"] == 0 and car[&"phase_total_minutes"] == 0 and car[&"phase_started_at"] == -1 and car[&"target_floor"] == car[&"current_floor"] and car[&"boarding_floor"] == car[&"current_floor"] and car[&"phase_origin_floor"] == car[&"current_floor"], "Idle cabin contains active phase data.")
|
||||
if car[&"remaining_minutes"] <= 0 or car[&"remaining_minutes"] > car[&"phase_total_minutes"] or car[&"phase_started_at"] < 0 or car[&"phase_started_at"] > minute:
|
||||
return _invalid("Elevator phase countdown is invalid.")
|
||||
# Operational time is exact; an outage with no valid floor may hold a phase.
|
||||
if car[&"operational"] and car[&"phase_total_minutes"] - car[&"remaining_minutes"] != minute - car[&"phase_started_at"]:
|
||||
return _invalid("Elevator phase countdown disagrees with simulation time.")
|
||||
var expected := 0
|
||||
if phase in [&"BOARDING", &"ALIGHTING"]:
|
||||
expected = DOOR_MINUTES + int(car[&"phase_passenger_count"])
|
||||
else:
|
||||
var destination: int = car[&"boarding_floor"] if phase == &"PICKUP" else car[&"target_floor"]
|
||||
expected = absi(destination - int(car[&"phase_origin_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"])
|
||||
if car[&"phase_total_minutes"] != expected or expected <= 0: return _invalid("Elevator phase duration does not match distance/passengers.")
|
||||
if phase == &"PICKUP" and not car[&"boarded"].is_empty(): return _invalid("Empty pickup cabin contains passengers.")
|
||||
if car[&"phase_origin_floor"] != car[&"current_floor"]: return _invalid("Elevator phase origin disagrees with logical floor.")
|
||||
if phase == &"ALIGHTING" and car[&"current_floor"] != car[&"target_floor"]: return _invalid("Alighting cabin is at the wrong floor.")
|
||||
if phase in [&"BOARDING", &"TRAVEL"] and car[&"current_floor"] != car[&"boarding_floor"]: return _invalid("Boarding/travel origin disagrees with pickup floor.")
|
||||
return true
|
||||
|
||||
|
||||
func _has_floor(config: Dictionary, floor: int) -> bool:
|
||||
for cell: Vector3i in config[&"stops"]:
|
||||
if cell.y == floor: return true
|
||||
return false
|
||||
|
||||
|
||||
func _ordered_trips() -> Array[Dictionary]:
|
||||
var trips: Array[Dictionary] = []
|
||||
for actor_id: StringName in _names(_trips): trips.append(_trips[actor_id].duplicate(true))
|
||||
return trips
|
||||
|
||||
|
||||
func _names(values: Dictionary) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for value: Variant in values: result.append(StringName(value))
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return str(a) < str(b))
|
||||
return result
|
||||
|
||||
|
||||
func _exact_keys(value: Dictionary, keys: Array) -> bool:
|
||||
if value.size() != keys.size(): return false
|
||||
for key: Variant in keys:
|
||||
if not value.has(key): return false
|
||||
return true
|
||||
|
||||
|
||||
func _failure(reason: StringName, message: String) -> Dictionary:
|
||||
last_error = message
|
||||
return {&"ok": false, &"reason": reason, &"error": message}
|
||||
|
||||
|
||||
func _invalid(message: String) -> bool:
|
||||
last_error = message
|
||||
return false
|
||||
|
||||
|
||||
func _valid_or_error(valid: bool, message: String) -> bool:
|
||||
if not valid: last_error = message
|
||||
return valid
|
||||
@@ -0,0 +1 @@
|
||||
uid://cckw41o08w2xx
|
||||
@@ -44,11 +44,7 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
var w := rect.size.x
|
||||
var top := rect.position.y
|
||||
if id in [&"guest_elevator", &"service_elevator"]:
|
||||
canvas.draw_rect(inner, Color("455855") if not back else Color("506277"))
|
||||
canvas.draw_rect(Rect2(mid - p, top + p, 2 * p, rect.size.y - 2 * p), Color("243c3c"))
|
||||
canvas.draw_rect(Rect2(inner.position.x + 2*p, top + 4*p, inner.size.x - 4*p, inner.size.y - 6*p), Color("84978b"))
|
||||
canvas.draw_line(Vector2(mid, top+4*p), Vector2(mid, rect.end.y-3*p), INK, p)
|
||||
canvas.draw_rect(Rect2(mid-2*p, top+2*p, 4*p, p), Color("f1c774"))
|
||||
elevator_shaft(canvas, rect, p, back)
|
||||
return
|
||||
if id in [&"guest_corridor", &"service_corridor", &"staff_door"]:
|
||||
if id == &"staff_door":
|
||||
@@ -105,6 +101,44 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
table(canvas, Vector2(mid-6*p, floor_y-7*p), p)
|
||||
|
||||
|
||||
## Static shaft rails remain when a car is stopped or the transport service is
|
||||
## unavailable. The car itself is drawn once from its authoritative trip state.
|
||||
static func elevator_shaft(canvas: CanvasItem, rect: Rect2, p: float, back: bool) -> void:
|
||||
canvas.draw_rect(rect, INK)
|
||||
canvas.draw_rect(rect.grow(-p), Color("3e514e") if not back else Color("404f63"))
|
||||
canvas.draw_rect(Rect2(rect.position.x+2*p, rect.position.y, 2*p, rect.size.y), Color("83958a"))
|
||||
canvas.draw_rect(Rect2(rect.end.x-4*p, rect.position.y, 2*p, rect.size.y), Color("83958a"))
|
||||
canvas.draw_rect(Rect2(rect.get_center().x-p, rect.position.y, 2*p, rect.size.y), Color("283a39"))
|
||||
for row: int in range(2):
|
||||
var floor_y := rect.position.y + float(row+1)*rect.size.y*0.5
|
||||
canvas.draw_rect(Rect2(rect.position.x, floor_y-4*p, rect.size.x, 3*p), Color("586c62") if not back else Color("526177"))
|
||||
canvas.draw_rect(Rect2(rect.position.x, floor_y-p, rect.size.x, p), INK)
|
||||
canvas.draw_rect(Rect2(rect.position.x+p, floor_y-7*p, p, 2*p), Color("dcc08b"))
|
||||
canvas.draw_rect(Rect2(rect.end.x-2*p, floor_y-7*p, p, 2*p), Color("dcc08b"))
|
||||
|
||||
|
||||
static func elevator_cabin(canvas: CanvasItem, rect: Rect2, p: float, back: bool, operational: bool) -> void:
|
||||
canvas.draw_rect(rect, INK)
|
||||
canvas.draw_rect(rect.grow(-p), Color("9daa8d") if not back else Color("879eaa"))
|
||||
canvas.draw_rect(Rect2(rect.position+Vector2(2,3)*p,rect.size-Vector2(4,5)*p),Color("556e62") if not back else Color("4d6472"))
|
||||
canvas.draw_rect(Rect2(rect.position.x+p,rect.end.y-3*p,rect.size.x-2*p,2*p),WOOD_LIGHT)
|
||||
canvas.draw_rect(Rect2(rect.position.x+2*p,rect.position.y+p,rect.size.x-4*p,p),CREAM)
|
||||
canvas.draw_rect(Rect2(rect.get_center().x-p,rect.position.y-p,2*p,p),Color("e2bd6c") if operational else Color("ba7157"))
|
||||
|
||||
|
||||
static func elevator_gate(canvas: CanvasItem, rect: Rect2, p: float, doors_open: bool) -> void:
|
||||
# The cutaway keeps passengers visible through the iron gate during travel.
|
||||
var rail := Color("bfbb9a")
|
||||
canvas.draw_rect(Rect2(rect.position.x,rect.position.y,2*p,rect.size.y),rail)
|
||||
canvas.draw_rect(Rect2(rect.end.x-2*p,rect.position.y,2*p,rect.size.y),rail)
|
||||
if doors_open:
|
||||
return
|
||||
var gate_y := rect.end.y-7*p
|
||||
canvas.draw_rect(Rect2(rect.position.x+2*p,gate_y,rect.size.x-4*p,p),rail)
|
||||
for x: int in range(int(rect.position.x+5*p),int(rect.end.x-3*p),maxi(int(6*p),1)):
|
||||
canvas.draw_rect(Rect2(x,rect.position.y+4*p,p,rect.size.y-7*p),Color("a9aa91",0.8))
|
||||
|
||||
|
||||
static func window(canvas: CanvasItem, at: Vector2, p: float) -> void:
|
||||
canvas.draw_rect(Rect2(at, Vector2(7,9)*p), WOOD)
|
||||
canvas.draw_rect(Rect2(at+Vector2.ONE*p, Vector2(5,6)*p), Color("9ab9b3"))
|
||||
|
||||
@@ -37,8 +37,6 @@ const SUPPORTED_KINDS: Array[StringName] = [
|
||||
KIND_GUEST_CORRIDOR,
|
||||
KIND_SERVICE_CORRIDOR,
|
||||
KIND_STAFF_DOOR,
|
||||
]
|
||||
const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = [
|
||||
KIND_GUEST_ELEVATOR,
|
||||
KIND_SERVICE_ELEVATOR,
|
||||
]
|
||||
@@ -50,6 +48,8 @@ const KNOWN_KINDS: Array[StringName] = [
|
||||
KIND_SERVICE_ELEVATOR,
|
||||
]
|
||||
|
||||
const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = []
|
||||
const VERTICAL_KINDS: Array[StringName] = [KIND_GUEST_ELEVATOR, KIND_SERVICE_ELEVATOR]
|
||||
const REASON_UNSUPPORTED_VERTICAL_TOPOLOGY: StringName = &"UNSUPPORTED_VERTICAL_TOPOLOGY"
|
||||
const QUARANTINE_TRANSACTION_ROLLBACK: StringName = &"TRANSACTION_ROLLBACK_UNCERTAIN"
|
||||
const QUARANTINE_RESTORE_ROLLBACK: StringName = &"RESTORE_ROLLBACK_UNCERTAIN"
|
||||
@@ -60,6 +60,7 @@ const QUARANTINE_REASONS: Array[StringName] = [
|
||||
|
||||
const ROUTE_ENTITY_PREFIX := "infra.route:"
|
||||
const CONNECTOR_PREFIX := "infra.connector:"
|
||||
const SHAFT_OWNER_PREFIX := "infra.shaft:"
|
||||
const ADAPTER_PROJECT_REQUEST_PREFIX := "infra.project:"
|
||||
const ADAPTER_UNPROJECT_REQUEST_PREFIX := "infra.unproject:"
|
||||
const STAFF_ACCESS_ID: StringName = &"staff"
|
||||
@@ -168,16 +169,6 @@ func project_facility(
|
||||
var canonical_input: Dictionary = input_result[&"input"]
|
||||
var definition_input: Dictionary = canonical_input[&"definition"]
|
||||
var kind: StringName = definition_input[&"id"]
|
||||
if kind in UNSUPPORTED_VERTICAL_KINDS:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"Vertical facility projection is unsupported until ordinary adjacent walk edges can be suppressed safely.",
|
||||
{
|
||||
&"reason": REASON_UNSUPPORTED_VERTICAL_TOPOLOGY,
|
||||
&"definition_id": kind,
|
||||
&"unchanged": true,
|
||||
}
|
||||
)
|
||||
if kind not in SUPPORTED_KINDS:
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Facility definition is not an infrastructure proxy.")
|
||||
var request_id := StringName(request_id_value)
|
||||
@@ -197,6 +188,7 @@ func project_facility(
|
||||
return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Facility instance is already projected.")
|
||||
var record := _projection_from_project_input(canonical_input)
|
||||
if _source.has_entity(record[&"route_entity_id"]) \
|
||||
or _path.get_vertical_walk_blockers().has(StringName(SHAFT_OWNER_PREFIX + String(instance_id))) \
|
||||
or (not StringName(record[&"connector_id"]).is_empty() and _path.has_connector(record[&"connector_id"])):
|
||||
return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Stable infrastructure ID collides with existing authoritative state.")
|
||||
if not _dependencies_match_current_state():
|
||||
@@ -244,6 +236,11 @@ func project_facility(
|
||||
dependency_capture,
|
||||
"Staff-door connector projection failed or callback tampering was detected."
|
||||
)
|
||||
if kind in VERTICAL_KINDS:
|
||||
var shaft_cells: Array[Vector3i] = []
|
||||
shaft_cells.assign(record[&"cells"])
|
||||
if not _path.set_vertical_walk_blockers(StringName(SHAFT_OWNER_PREFIX + String(instance_id)), shaft_cells) or not _owned_state_matches(guard_state):
|
||||
return _rollback_failure(owned_before, dependency_capture, "Elevator shaft projection failed or callback tampering was detected.")
|
||||
var candidate_projections := _projections.duplicate(true)
|
||||
candidate_projections[instance_id] = record.duplicate(true)
|
||||
if not _dependencies_match_projection_map(candidate_projections) \
|
||||
@@ -332,6 +329,9 @@ func unproject_facility(request_id_value: Variant, instance_id_value: Variant) -
|
||||
dependency_capture,
|
||||
"Staff-door connector removal failed or callback tampering was detected."
|
||||
)
|
||||
if record[&"kind"] in VERTICAL_KINDS:
|
||||
if not _path.remove_vertical_walk_blockers(StringName(SHAFT_OWNER_PREFIX + String(instance_id))) or not _owned_state_matches(guard_state):
|
||||
return _rollback_failure(owned_before, dependency_capture, "Elevator shaft unprojection failed or callback tampering was detected.")
|
||||
var route_result := _adapter.remove_route(
|
||||
StringName(ADAPTER_UNPROJECT_REQUEST_PREFIX + String(request_id)),
|
||||
StringName(record[&"route_entity_id"])
|
||||
@@ -977,6 +977,14 @@ func _projection_live_matches(record: Dictionary) -> bool:
|
||||
if connector_id.is_empty() or not _path.has_connector(connector_id):
|
||||
return false
|
||||
return _path.get_connector(connector_id) == _expected_staff_connector(record)
|
||||
if record[&"kind"] in VERTICAL_KINDS:
|
||||
var blockers := _path.get_vertical_walk_blockers()
|
||||
var owner_id := StringName(SHAFT_OWNER_PREFIX + String(record[&"instance_id"]))
|
||||
if not blockers.has(owner_id):
|
||||
return false
|
||||
var cells: Array = (blockers[owner_id] as Array).duplicate()
|
||||
cells.sort_custom(_cell_less)
|
||||
return connector_id.is_empty() and cells == record[&"cells"]
|
||||
return connector_id.is_empty()
|
||||
|
||||
|
||||
@@ -1009,16 +1017,22 @@ static func _has_managed_namespace_collision(
|
||||
) -> bool:
|
||||
var expected_routes: Dictionary = {}
|
||||
var expected_connectors: Dictionary = {}
|
||||
var expected_shafts: Dictionary = {}
|
||||
for record_value: Variant in projections.values():
|
||||
if not record_value is Dictionary:
|
||||
return true
|
||||
var record: Dictionary = record_value
|
||||
expected_routes[record[&"route_entity_id"]] = true
|
||||
if record[&"kind"] in VERTICAL_KINDS:
|
||||
expected_shafts[StringName(SHAFT_OWNER_PREFIX + String(record[&"instance_id"]))] = true
|
||||
if not StringName(record[&"connector_id"]).is_empty():
|
||||
expected_connectors[record[&"connector_id"]] = true
|
||||
for entity_id: StringName in source_service.get_entity_ids(source_service.CATEGORY_ROUTE):
|
||||
if String(entity_id).begins_with(ROUTE_ENTITY_PREFIX) and not expected_routes.has(entity_id):
|
||||
return true
|
||||
for owner_id: Variant in path_service.get_vertical_walk_blockers():
|
||||
if String(owner_id).begins_with(SHAFT_OWNER_PREFIX) and not expected_shafts.has(owner_id):
|
||||
return true
|
||||
var path_snapshot := path_service.create_snapshot()
|
||||
var connector_rows: Variant = path_snapshot.get(&"connectors", null)
|
||||
if not connector_rows is Array:
|
||||
|
||||
@@ -17,7 +17,10 @@ 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 SNAPSHOT_VERSION := 4
|
||||
const ELEVATOR_INDEX_SNAPSHOT_VERSION := 3
|
||||
const MAX_VERTICAL_BLOCKER_OWNERS := 4_096
|
||||
const MAX_VERTICAL_BLOCKER_CELLS := 262_144
|
||||
const LEGACY_SNAPSHOT_VERSION := 1
|
||||
const TRAFFIC_SNAPSHOT_VERSION := 2
|
||||
|
||||
@@ -74,6 +77,8 @@ var last_path_cost := 0
|
||||
var _passable_cells: Dictionary = {}
|
||||
var _connectors: Dictionary = {}
|
||||
var _adjacency: Dictionary = {}
|
||||
var _vertical_walk_blockers: Dictionary = {}
|
||||
var _vertical_walk_blocked_cells: Dictionary = {}
|
||||
var _path_cache: Dictionary = {}
|
||||
var _cell_traffic: Dictionary = {}
|
||||
var _actor_cells: Dictionary = {}
|
||||
@@ -124,6 +129,79 @@ func rebuild_graph(passable_cells: Variant) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
## A shaft is always physical topology, independent of cabin availability.
|
||||
## Owners may overlap; removing one owner does not clear another owner's cells.
|
||||
func set_vertical_walk_blockers(owner_id: StringName, cells: Array[Vector3i]) -> bool:
|
||||
if not _vertical_blocker_owner_is_valid(owner_id):
|
||||
last_error = "Vertical walk blockers require a bounded, nonempty owner id."
|
||||
return false
|
||||
if cells.is_empty() or cells.size() > MAX_VERTICAL_BLOCKER_CELLS:
|
||||
last_error = "Vertical walk blockers require a bounded, nonempty cell set."
|
||||
return false
|
||||
var normalization := _normalize_cell_set(cells)
|
||||
if not bool(normalization[&"ok"]) or (normalization[&"cells"] as Dictionary).size() != cells.size():
|
||||
last_error = "Vertical walk blocker cells must be valid and unique."
|
||||
return false
|
||||
for cell: Vector3i in cells:
|
||||
if not _passable_cells.has(cell):
|
||||
last_error = "Vertical walk blocker cells must belong to the current graph."
|
||||
return false
|
||||
var canonical: Array[Vector3i] = cells.duplicate()
|
||||
canonical.sort_custom(Callable(self, "_coordinate_less"))
|
||||
if _vertical_walk_blockers.get(owner_id, []) == canonical:
|
||||
last_error = ""
|
||||
return true
|
||||
var candidate := _vertical_walk_blockers.duplicate(true)
|
||||
candidate[owner_id] = canonical
|
||||
var count := 0
|
||||
for owned_cells: Array in candidate.values():
|
||||
count += owned_cells.size()
|
||||
if candidate.size() > MAX_VERTICAL_BLOCKER_OWNERS or count > MAX_VERTICAL_BLOCKER_CELLS:
|
||||
last_error = "Vertical walk blocker ownership capacity exceeded."
|
||||
return false
|
||||
_vertical_walk_blockers = candidate
|
||||
_rebuild_vertical_walk_blocked_cells()
|
||||
_rebuild_derived_graph()
|
||||
_mark_graph_changed()
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func remove_vertical_walk_blockers(owner_id: StringName) -> bool:
|
||||
if not _vertical_walk_blockers.has(owner_id):
|
||||
last_error = "Unknown vertical walk blocker owner."
|
||||
return false
|
||||
_vertical_walk_blockers.erase(owner_id)
|
||||
_rebuild_vertical_walk_blocked_cells()
|
||||
_rebuild_derived_graph()
|
||||
_mark_graph_changed()
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func get_vertical_walk_blockers() -> Dictionary:
|
||||
return _vertical_walk_blockers.duplicate(true)
|
||||
|
||||
|
||||
func _rebuild_vertical_walk_blocked_cells() -> void:
|
||||
_vertical_walk_blocked_cells.clear()
|
||||
for cells: Array in _vertical_walk_blockers.values():
|
||||
for cell: Vector3i in cells:
|
||||
_vertical_walk_blocked_cells[cell] = true
|
||||
|
||||
|
||||
func _ordinary_walk_edge_allowed(from_cell: Vector3i, to_cell: Vector3i) -> bool:
|
||||
return from_cell.y == to_cell.y or (
|
||||
not _vertical_walk_blocked_cells.has(from_cell)
|
||||
and not _vertical_walk_blocked_cells.has(to_cell)
|
||||
)
|
||||
|
||||
|
||||
func _vertical_blocker_owner_is_valid(owner_id: StringName) -> bool:
|
||||
var value := String(owner_id)
|
||||
return not value.is_empty() and value.length() <= 128 and value == value.strip_edges()
|
||||
|
||||
|
||||
func configure_connector(
|
||||
connector_id: StringName,
|
||||
from_cell: Vector3i,
|
||||
@@ -995,6 +1073,28 @@ func get_connector(connector_id: StringName) -> Dictionary:
|
||||
return (_connectors[connector_id] as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
## Resolves the same cheapest accessible edge that find_path/calculate_path_cost
|
||||
## use, so a movement driver can distinguish riding from an ordinary step.
|
||||
func get_edge_report(from_cell: Vector3i, to_cell: Vector3i, connector_access: Variant = null) -> Dictionary:
|
||||
var normalized := _normalize_access_ids(connector_access)
|
||||
if not bool(normalized[&"ok"]):
|
||||
last_error = String(normalized[&"error"])
|
||||
return {}
|
||||
if _cell_is_temporarily_blocked(from_cell) or _cell_is_temporarily_blocked(to_cell):
|
||||
return {}
|
||||
var edge := _find_edge(from_cell, to_cell, normalized[&"values"])
|
||||
if edge.is_empty():
|
||||
return {}
|
||||
var report := edge.duplicate(true)
|
||||
report[&"from"] = from_cell
|
||||
report[&"total_cost"] = _edge_total_cost(edge)
|
||||
return report
|
||||
|
||||
|
||||
func get_step_connector(from_cell: Vector3i, to_cell: Vector3i, connector_access: Variant = null) -> StringName:
|
||||
return StringName(get_edge_report(from_cell, to_cell, connector_access).get(&"connector_id", &""))
|
||||
|
||||
|
||||
func get_neighbors(cell: Vector3i) -> Array:
|
||||
if not _adjacency.has(cell):
|
||||
return []
|
||||
@@ -1179,6 +1279,9 @@ func create_snapshot() -> Dictionary:
|
||||
var saved_active_units: Array = []
|
||||
for unit_id: Variant in active_unit_ids:
|
||||
saved_active_units.append((_active_elevator_units[unit_id] as Dictionary).duplicate(true))
|
||||
var saved_vertical_blockers: Array[Dictionary] = []
|
||||
for owner_id: StringName in _sorted_name_keys(_vertical_walk_blockers):
|
||||
saved_vertical_blockers.append({&"owner_id": owner_id, &"cells": (_vertical_walk_blockers[owner_id] as Array).duplicate()})
|
||||
return {
|
||||
&"schema_version": SNAPSHOT_VERSION,
|
||||
&"graph_revision": graph_revision,
|
||||
@@ -1189,14 +1292,18 @@ func create_snapshot() -> Dictionary:
|
||||
&"actor_progress": saved_progress,
|
||||
&"known_elevator_request_ids": saved_known_requests,
|
||||
&"active_elevator_units": saved_active_units,
|
||||
&"vertical_walk_blockers": saved_vertical_blockers,
|
||||
}
|
||||
|
||||
|
||||
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]:
|
||||
if version not in [LEGACY_SNAPSHOT_VERSION, TRAFFIC_SNAPSHOT_VERSION, ELEVATOR_INDEX_SNAPSHOT_VERSION, SNAPSHOT_VERSION]:
|
||||
last_error = "Unsupported PathService snapshot version: %d" % version
|
||||
return false
|
||||
if version < SNAPSHOT_VERSION and (snapshot.has(&"vertical_walk_blockers") or snapshot.has("vertical_walk_blockers")):
|
||||
last_error = "Legacy PathService snapshots cannot contain shaft ownership fields."
|
||||
return false
|
||||
var raw_connectors: Variant = _dictionary_value(snapshot, &"connectors", null)
|
||||
if not raw_connectors is Array:
|
||||
last_error = "PathService snapshot connectors must be an Array."
|
||||
@@ -1210,7 +1317,9 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
var next_active_units: Dictionary = connector_result[&"active_units"]
|
||||
|
||||
if version == LEGACY_SNAPSHOT_VERSION:
|
||||
var changed := next_connectors != _connectors
|
||||
var changed := next_connectors != _connectors or not _vertical_walk_blockers.is_empty()
|
||||
_vertical_walk_blockers.clear()
|
||||
_rebuild_vertical_walk_blocked_cells()
|
||||
_connectors = next_connectors
|
||||
_cell_traffic.clear()
|
||||
_actor_cells.clear()
|
||||
@@ -1256,7 +1365,7 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
last_error = progress_result[&"error"]
|
||||
return false
|
||||
var next_progress: Dictionary = progress_result[&"progress"]
|
||||
if version == SNAPSHOT_VERSION:
|
||||
if version >= ELEVATOR_INDEX_SNAPSHOT_VERSION:
|
||||
var raw_known_requests: Variant = _dictionary_value(
|
||||
snapshot,
|
||||
&"known_elevator_request_ids",
|
||||
@@ -1277,6 +1386,13 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
return false
|
||||
next_known_requests = index_result[&"known_request_ids"]
|
||||
next_active_units = index_result[&"active_units"]
|
||||
var next_vertical_blockers: Dictionary = {}
|
||||
if version >= SNAPSHOT_VERSION:
|
||||
var blockers_result := _normalize_snapshot_vertical_blockers(_dictionary_value(snapshot, &"vertical_walk_blockers", null), next_traffic)
|
||||
if not bool(blockers_result[&"ok"]):
|
||||
last_error = String(blockers_result[&"error"])
|
||||
return false
|
||||
next_vertical_blockers = blockers_result[&"blockers"]
|
||||
if not _snapshot_densities_match(next_traffic, next_actor_cells):
|
||||
last_error = "PathService snapshot density does not match actor projections."
|
||||
return false
|
||||
@@ -1290,6 +1406,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
return false
|
||||
|
||||
_connectors = next_connectors
|
||||
_vertical_walk_blockers = next_vertical_blockers
|
||||
_rebuild_vertical_walk_blocked_cells()
|
||||
_cell_traffic = next_traffic
|
||||
_actor_cells = next_actor_cells
|
||||
_actor_progress = next_progress
|
||||
@@ -1320,7 +1438,7 @@ func _rebuild_derived_graph() -> void:
|
||||
var cell: Vector3i = cell_value
|
||||
for direction in _CARDINAL_DIRECTIONS:
|
||||
var neighbor := cell + direction
|
||||
if _passable_cells.has(neighbor):
|
||||
if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(cell, neighbor):
|
||||
_append_edge(
|
||||
cell,
|
||||
neighbor,
|
||||
@@ -1587,7 +1705,7 @@ func _route_exists_with_overrides(
|
||||
var neighbor_set: Dictionary = {}
|
||||
for direction: Vector3i in _CARDINAL_DIRECTIONS:
|
||||
var neighbor := current + direction
|
||||
if _passable_cells.has(neighbor):
|
||||
if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(current, neighbor):
|
||||
neighbor_set[neighbor] = true
|
||||
for connector_id: StringName in connector_ids:
|
||||
var connector: Dictionary = _connectors[connector_id]
|
||||
@@ -2080,6 +2198,41 @@ func _normalize_snapshot_elevator_indexes(
|
||||
}
|
||||
|
||||
|
||||
func _normalize_snapshot_vertical_blockers(raw: Variant, traffic: Dictionary) -> Dictionary:
|
||||
var failure := {&"ok": false, &"error": "Path shaft ownership rows are invalid, unordered, or crosslink unknown cells."}
|
||||
if not raw is Array or raw.size() > MAX_VERTICAL_BLOCKER_OWNERS:
|
||||
return failure
|
||||
var blockers: Dictionary = {}
|
||||
var previous_owner := ""
|
||||
var total_cells := 0
|
||||
for row: Variant in raw:
|
||||
if not row is Dictionary or row.size() != 2:
|
||||
return failure
|
||||
var owner_value: Variant = _dictionary_value(row, &"owner_id", null)
|
||||
var cells_value: Variant = _dictionary_value(row, &"cells", null)
|
||||
if typeof(owner_value) not in [TYPE_STRING, TYPE_STRING_NAME] or not cells_value is Array:
|
||||
return failure
|
||||
var owner_id := StringName(owner_value)
|
||||
if not _vertical_blocker_owner_is_valid(owner_id) or String(owner_id) <= previous_owner or cells_value.is_empty():
|
||||
return failure
|
||||
previous_owner = String(owner_id)
|
||||
total_cells += cells_value.size()
|
||||
if total_cells > MAX_VERTICAL_BLOCKER_CELLS:
|
||||
return failure
|
||||
var cells: Array[Vector3i] = []
|
||||
var seen: Dictionary = {}
|
||||
for cell: Variant in cells_value:
|
||||
if not cell is Vector3i or not traffic.has(cell) or seen.has(cell):
|
||||
return failure
|
||||
seen[cell] = true
|
||||
cells.append(cell)
|
||||
cells.sort_custom(Callable(self, "_coordinate_less"))
|
||||
if cells != cells_value:
|
||||
return failure
|
||||
blockers[owner_id] = cells
|
||||
return {&"ok": true, &"blockers": blockers}
|
||||
|
||||
|
||||
func _normalize_snapshot_cell_traffic(raw_entries: Array) -> Dictionary:
|
||||
var traffic: Dictionary = {}
|
||||
for raw_entry: Variant in raw_entries:
|
||||
|
||||
Reference in New Issue
Block a user