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
+1
View File
@@ -242,6 +242,7 @@ test-transport:
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_transport.gd $(GODOT) --headless --path . --script res://tests/transport/test_runtime_transport.gd
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- writer $(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- writer
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- reader $(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- reader
$(GODOT) --headless --path . --script res://tests/transport/test_route_wait_choice.gd
capture-transport: capture-transport:
THE_TREE_TRANSPORT_CAPTURE=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd THE_TREE_TRANSPORT_CAPTURE=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd
+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, &"connector_id": connector_id, &"from": from, &"to": to,
&"enqueued_at": minute, &"state": PathService.ELEVATOR_REQUEST_QUEUED} &"enqueued_at": minute, &"state": PathService.ELEVATOR_REQUEST_QUEUED}
_trips[actor_id] = trip _trips[actor_id] = trip
_refresh_shaft_wait(config[&"shaft_id"])
last_error = "" last_error = ""
return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)} 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 last_error = _path.last_error
return false return false
_trips.erase(actor_id) _trips.erase(actor_id)
_refresh_shaft_wait(trip[&"shaft_id"])
last_error = "" last_error = ""
return true return true
@@ -400,6 +402,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
var connector_id: StringName = expected[&"id"] var connector_id: StringName = expected[&"id"]
var live := _path.get_connector(connector_id) var live := _path.get_connector(connector_id)
for key: StringName in expected: 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 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) \ if live[&"cabin_state"] != _path_cabin_state(car) \
or live[&"operational_state"] != (PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED): 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.") if trip[key] != other[key]: return _invalid("Companion trip was split.")
for actor_id: StringName in boarded_owners: for actor_id: StringName in boarded_owners:
if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.") 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() var path_snapshot := _path.create_snapshot()
for connector: Dictionary in path_snapshot[&"connectors"]: for connector: Dictionary in path_snapshot[&"connectors"]:
if str(connector[&"id"]).begins_with(PREFIX) and not expected_connectors.has(connector[&"id"]): 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"]): 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_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)) _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: 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 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: func set_connector_operational_state(connector_id: StringName, state: StringName) -> bool:
if not _connectors.has(connector_id): if not _connectors.has(connector_id):
last_error = "Unknown connector id: %s" % connector_id last_error = "Unknown connector id: %s" % connector_id
+195
View File
@@ -0,0 +1,195 @@
extends SceneTree
## P14-03 route choice with real waiting: two guest shafts, real guests at real
## cells, A* switching shafts under a whole-shaft rush, shared cabins within
## capacity, recovery after cancellation, exact save of wait costs, route loss
## on demolition, and congestion priced at a warp exit.
var _checks := 0
var _failures := 0
var _serial := 0
var _clock: Variant
var _state: Variant
var _runtime: Variant
const START := Vector3i(1, 0, 0)
const GOAL := Vector3i(2, 1, 0)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("Route wait choice (P14-03)")
_clock = load("res://autoload/game_clock.gd").new()
_state = load("res://autoload/game_state.gd").new()
_runtime = load("res://autoload/simulation_runtime.gd").new()
_clock.reset(1, 6, 0)
_state.reset()
_runtime.initialize(_clock, _state, 1403)
_clock.set_paused(true)
_layout()
_test_shaft_rush()
_test_shared_cabin()
_test_route_loss()
_test_warp_exit_congestion()
_runtime.free(); _clock.free(); _state.free()
print("%s: %d checks" % ["PASS" if _failures == 0 else "FAIL", _checks])
quit(_failures)
func _cmd(kind: StringName, payload: Dictionary) -> CommandResult:
_serial += 1
return _runtime.submit_command(kind, payload, &"player", &"", 0, _clock.absolute_minute, StringName("p1403.%d" % _serial))
func _build(id: StringName, origin: Vector3i) -> void:
var result := _cmd(&"build_facility", {&"definition_id": id, &"origin": origin})
_expect(result.is_success(), "build %s at %s %s" % [id, origin, result.message])
func _next_evaluation() -> void:
_clock.advance_minutes(10 - int(_clock.absolute_minute) % 10 + 1)
func _layout() -> void:
_expect(_cmd(&"change_resources", {&"deltas": {&"gold": 30000, &"materials": 300}}).is_success(), "fund the fixture")
# Shaft A at x0 (floors 0-3), shaft B at x4 (floors 0-1), joined on floor 1.
_build(&"guest_elevator", Vector3i(0, 0, 0))
_build(&"guest_elevator", Vector3i(4, 0, 0))
_build(&"mana_warp", Vector3i(-4, 0, 0))
_clock.advance_minutes(360)
_build(&"guest_elevator", Vector3i(0, 2, 0))
_build(&"mana_warp", Vector3i(-4, 2, 0))
for x: int in [2, 3]:
_build(&"guest_corridor", Vector3i(x, 1, 0))
_clock.advance_minutes(360)
_next_evaluation()
_expect(_runtime.elevators.get_report()[&"shafts"].size() == 2, "two independent shafts")
func _spawn(guest_id: StringName, cell: Vector3i) -> bool:
var result := _cmd(&"spawn_guest", {&"guest_id": guest_id, &"guest_type_id": &"traveler", &"options": {&"current_cell": cell}})
_expect(result.is_success(), "spawn real guest %s at %s %s" % [guest_id, cell, result.message])
return result.is_success()
func _route() -> Array:
return _runtime.path_service.find_path(START, GOAL)
func _uses_shaft_a(path: Array) -> bool:
return path.size() > 1 and path[1] == Vector3i(0, 0, 0)
func _test_shaft_rush() -> void:
var idle := _route()
var idle_cost: int = _runtime.path_service.last_path_cost
_expect(_uses_shaft_a(idle), "idle tower routes through the nearer shaft A")
# Twelve real guests on shaft A's ground stop all wait for floor 3, a
# different floor pair from the queried 0->1 trip.
var riders: Array[StringName] = []
for i: int in 12:
var guest_id := StringName("rush_%02d" % i)
if _spawn(guest_id, Vector3i(0, 0, 0)):
var trip: Dictionary = _runtime.elevators.request_trip(StringName("guest:%s" % guest_id), Vector3i(0, 0, 0), Vector3i(0, 3, 0), _clock.absolute_minute)
_expect(bool(trip.get(&"ok", false)), "real guest %s queues for floor 3" % guest_id)
riders.append(StringName("guest:%s" % guest_id))
var rush := _route()
var rush_cost: int = _runtime.path_service.last_path_cost
_expect(not _uses_shaft_a(rush) and rush.has(Vector3i(4, 0, 0)), "whole-shaft rush diverts the 0->1 trip to idle shaft B")
_expect(_runtime.path_service.last_path_cost > idle_cost, "diverted route is dearer than the idle one")
var a_connector: StringName = _runtime.path_service.get_step_connector(Vector3i(0, 0, 0), Vector3i(0, 1, 0), [])
_expect(int(_runtime.path_service.get_connector(a_connector)[&"dynamic_cost"]) == 12 * PathService.ORTHOGONAL_MOVE_COST, "shaft A 0->1 prices all 12 riders waiting elsewhere in the shaft")
var saved: Dictionary = _runtime.create_snapshot()
var restored_ok: bool = _runtime.restore_snapshot(saved.duplicate(true))
var again: Dictionary = _runtime.create_snapshot()
if restored_ok and again != saved:
for system: Variant in saved[&"systems"][&"runtime"]:
if saved[&"systems"][&"runtime"][system] != again[&"systems"][&"runtime"].get(system):
print("DIFF runtime.", system)
for key: Variant in saved:
if key != &"systems" and saved[key] != again.get(key): print("DIFF ", key)
_expect(restored_ok and again == saved, "rush wait costs and queues survive save restore exactly (restored=%s)" % restored_ok)
_expect(not _uses_shaft_a(_route()), "restored rush still diverts")
var tampered: Dictionary = saved.duplicate(true)
for row: Dictionary in tampered[&"systems"][&"runtime"][&"path"][&"connectors"]:
if row[&"id"] == a_connector: row[&"dynamic_cost"] = 0
var live: Dictionary = _runtime.create_snapshot()
_expect(not _runtime.restore_snapshot(tampered) and _runtime.create_snapshot() == live, "a save whose shaft wait disagrees with its queue is rejected atomically")
var a_route_during_rush: int = _runtime.path_service.calculate_path_cost(idle)
for actor_id: StringName in riders:
_runtime.elevators.cancel_actor(actor_id)
_expect(int(_runtime.path_service.get_connector(a_connector)[&"dynamic_cost"]) == 0, "cancelled rush clears the shaft wait")
var a_route_after: int = _runtime.path_service.calculate_path_cost(idle)
_expect(a_route_after == a_route_during_rush - 12 * PathService.ORTHOGONAL_MOVE_COST, "cleared shaft wait removes exactly the queued riders' cost from shaft A's route")
_expect(rush_cost > 0, "rush route cost recorded")
func _test_shared_cabin() -> void:
var before: Dictionary = _runtime.create_snapshot()
var riders: Array[StringName] = []
for i: int in 6:
var guest_id := StringName("share_%02d" % i)
if _spawn(guest_id, Vector3i(4, 0, 0)):
var actor_id := StringName("guest:%s" % guest_id)
_expect(bool(_runtime.elevators.request_trip(actor_id, Vector3i(4, 0, 0), Vector3i(4, 1, 0), _clock.absolute_minute).get(&"ok", false)), "rider %s queues on shaft B" % guest_id)
riders.append(actor_id)
var max_boarded := 0
var arrived := {}
for minute: int in 60:
_clock.advance_minutes(1)
var boarded := 0
for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]:
if trip[&"shaft_id"] != _shaft_b() or trip[&"state"] != PathService.ELEVATOR_REQUEST_BOARDED:
continue
boarded += 1
max_boarded = maxi(max_boarded, boarded)
for actor_id: StringName in riders:
if _runtime.elevators.get_actor_trip(actor_id).is_empty():
arrived[actor_id] = true
_expect(max_boarded > 1 and max_boarded <= 4, "riders share the cabin up to its capacity of 4 (max %d)" % max_boarded)
_expect(arrived.size() == riders.size(), "all six riders are carried (none lost)")
_expect(_runtime.restore_snapshot(before), "rewind shared-cabin drill")
func _shaft_b() -> StringName:
for shaft: Dictionary in _runtime.elevators.get_report()[&"shafts"]:
if (shaft[&"stops"] as Array).front().x == 4:
return shaft[&"shaft_id"]
return &""
func _test_route_loss() -> void:
var before: Dictionary = _runtime.create_snapshot()
var shaft_b_id: StringName = &""
for facility: Dictionary in _runtime.construction_service.get_facilities():
if facility[&"definition_id"] == &"guest_elevator" and facility[&"origin"] == Vector3i(4, 0, 0):
shaft_b_id = facility[&"instance_id"]
_expect(_cmd(&"demolish_facility", {&"instance_id": shaft_b_id}).is_success(), "demolish idle shaft B")
_expect(_uses_shaft_a(_route()), "route loss falls back to the remaining shaft")
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "post-loss state restores")
_expect(_runtime.restore_snapshot(before), "rewind route-loss drill")
func _test_warp_exit_congestion() -> void:
var ps: PathService = _runtime.path_service
var lower := Vector3i(-4, 0, 0)
var upper := Vector3i(-4, 2, 0)
var quiet: Array = ps.find_path(lower, upper)
var quiet_cost: int = ps.last_path_cost
_expect(quiet == [lower, upper], "idle warp is the cheapest way up")
var crowd := 0
for i: int in 6:
if _spawn(StringName("crowd_%02d" % i), upper):
crowd += 1
ps.find_path(lower, upper)
_expect(crowd > 0 and ps.last_path_cost > quiet_cost, "a crowded warp exit raises the warp route's A* cost")
func _expect(condition: bool, label: String) -> void:
_checks += 1
if condition:
print(" ✓ %s" % label)
else:
_failures += 1
push_error(" ✗ %s" % label)