diff --git a/Makefile b/Makefile index 878109e..318e456 100644 --- a/Makefile +++ b/Makefile @@ -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_warp.gd -- writer $(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: THE_TREE_TRANSPORT_CAPTURE=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd diff --git a/scripts/transport/elevator_service.gd b/scripts/transport/elevator_service.gd index cb927ba..574ba4f 100644 --- a/scripts/transport/elevator_service.gd +++ b/scripts/transport/elevator_service.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, &"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: diff --git a/scripts/world/path_service.gd b/scripts/world/path_service.gd index a33207a..5cc9516 100644 --- a/scripts/world/path_service.gd +++ b/scripts/world/path_service.gd @@ -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 diff --git a/tests/transport/test_route_wait_choice.gd b/tests/transport/test_route_wait_choice.gd new file mode 100644 index 0000000..18f6cc7 --- /dev/null +++ b/tests/transport/test_route_wait_choice.gd @@ -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)