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:
co-authored by
Claude Opus 5.5
parent
50ceca0b7d
commit
0754f412a8
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user