P14-03: Price whole-shaft elevator waits in A* route choice

Riders queued for any floor pair in a shaft now delay every pair: the
elevator service publishes the rest of the shaft queue as each connector's
dynamic cost, refreshed on trip changes and each cabin minute, and restore
rejects saves whose wait disagrees with their trips. A two-shaft fixture with
real guests shows rush diversion, shared cabins within capacity, recovery,
route loss and warp-exit congestion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Takeshi
2026-10-20 15:57:28 +09:00
co-authored by Claude Opus 5.5
parent 50ceca0b7d
commit 0754f412a8
4 changed files with 246 additions and 0 deletions
+29
View File
@@ -170,6 +170,7 @@ func request_trip(actor_id: StringName, from: Vector3i, to: Vector3i, minute: in
&"connector_id": connector_id, &"from": from, &"to": to,
&"enqueued_at": minute, &"state": PathService.ELEVATOR_REQUEST_QUEUED}
_trips[actor_id] = trip
_refresh_shaft_wait(config[&"shaft_id"])
last_error = ""
return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)}
@@ -264,6 +265,7 @@ func _cancel_single(actor_id: StringName) -> bool:
last_error = _path.last_error
return false
_trips.erase(actor_id)
_refresh_shaft_wait(trip[&"shaft_id"])
last_error = ""
return true
@@ -400,6 +402,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
var connector_id: StringName = expected[&"id"]
var live := _path.get_connector(connector_id)
for key: StringName in expected:
# The shaft-wide wait is derived from trips and checked below.
if key == &"dynamic_cost": continue
if not live.has(key) or live[key] != expected[key]: return _invalid("Path elevator configuration disagrees with cabin.")
if live[&"cabin_state"] != _path_cabin_state(car) \
or live[&"operational_state"] != (PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED):
@@ -455,6 +459,15 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
if trip[key] != other[key]: return _invalid("Companion trip was split.")
for actor_id: StringName in boarded_owners:
if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.")
for shaft_id: StringName in next_shafts:
var queued := 0
for trip: Dictionary in next_trips.values():
if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
queued += 1
for connector: Dictionary in _connector_configs(next_shafts[shaft_id][&"config"]):
var expected_wait := maxi(queued - _path.elevator_queue_size(connector[&"id"]), 0) * PathService.ORTHOGONAL_MOVE_COST
if int(_path.get_connector(connector[&"id"]).get(&"dynamic_cost", -1)) != expected_wait:
return _invalid("Path shaft wait disagrees with queued trips.")
var path_snapshot := _path.create_snapshot()
for connector: Dictionary in path_snapshot[&"connectors"]:
if str(connector[&"id"]).begins_with(PREFIX) and not expected_connectors.has(connector[&"id"]):
@@ -605,6 +618,22 @@ func _publish_cabin(car: Dictionary) -> void:
for connector: Dictionary in _connector_configs(car[&"config"]):
_path.set_connector_operational_state(connector[&"id"], PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED)
_path.set_elevator_cabin_state(connector[&"id"], _path_cabin_state(car))
_refresh_shaft_wait(car[&"config"][&"shaft_id"])
## The cabin serves the whole shaft, so riders queued for any other floor pair
## delay this pair too. PathService already prices the connector's own queue;
## the rest of the shaft queue is published as the connector's dynamic cost.
func _refresh_shaft_wait(shaft_id: StringName) -> void:
if not _shafts.has(shaft_id):
return
var queued := 0
for trip: Dictionary in _trips.values():
if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
queued += 1
for connector: Dictionary in _connector_configs(_shafts[shaft_id][&"config"]):
var own := _path.elevator_queue_size(connector[&"id"])
_path.set_connector_dynamic_cost(connector[&"id"], maxi(queued - own, 0) * PathService.ORTHOGONAL_MOVE_COST)
func _shaft_busy(shaft_id: StringName) -> bool:
+21
View File
@@ -395,6 +395,27 @@ func set_connector_enabled(connector_id: StringName, enabled: bool) -> bool:
return true
## Cost-only update (no topology change), e.g. waiting riders elsewhere in the
## same elevator shaft. Any change may make routes cheaper, so all caches drop.
func set_connector_dynamic_cost(connector_id: StringName, dynamic_cost: int) -> bool:
if not _connectors.has(connector_id):
last_error = "Unknown connector id: %s" % connector_id
return false
if dynamic_cost < 0:
last_error = "Connector dynamic cost must be non-negative."
return false
var connector: Dictionary = _connectors[connector_id]
if int(connector[&"dynamic_cost"]) == dynamic_cost:
last_error = ""
return true
connector[&"dynamic_cost"] = dynamic_cost
_connectors[connector_id] = connector
traffic_revision += 1
invalidate_all_cache()
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