272 lines
14 KiB
GDScript
272 lines
14 KiB
GDScript
extends SceneTree
|
|
|
|
## MOVE-06 component comparison: fixed arrival demand, actual public construction, and the
|
|
## production Path/Elevator services. These are measurement actors, not the
|
|
## economic Guest AI. Route choice is committed once at common-lobby arrival.
|
|
## Both scenarios use Path's production explicit floor-connector policy.
|
|
const SEED := 806042
|
|
const POPULATION := 48
|
|
const WINDOW_MINUTES := 480
|
|
const START := Vector3i(2, 0, 0)
|
|
const GOAL := Vector3i(2, 1, 0)
|
|
const CREDIT_SCALE := 1000
|
|
const REPORT_PATH := "res://artifacts/transport/path-comparison.json"
|
|
const DEMAND_PATH := "res://artifacts/transport/comparison-demand.json"
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
var global_clock := root.get_node_or_null("GameClock")
|
|
if global_clock != null: global_clock.set_paused(true)
|
|
var demand := _demand()
|
|
_expect(demand.size() == POPULATION and SimulationDigest.compute(demand) == SimulationDigest.compute(_demand()), "seed fixes all 48 actor IDs, arrival minutes, origins and destinations")
|
|
var one := _prepare(1)
|
|
var two := _prepare(2)
|
|
if one.is_empty() or two.is_empty():
|
|
_finish()
|
|
return
|
|
var before := _observe(one, demand, WINDOW_MINUTES)
|
|
var after := _observe(two, demand, WINDOW_MINUTES)
|
|
_expect(before[&"demand_hash"] == after[&"demand_hash"] and before[&"demand_count"] == after[&"demand_count"], "both configurations replay the identical demand manifest")
|
|
_expect(before[&"window_minutes"] == after[&"window_minutes"], "both configurations use the same fixed observation window")
|
|
_expect(before[&"arrived_count"] == POPULATION and after[&"arrived_count"] == POPULATION, "no passengers are removed to make the improved layout faster")
|
|
_expect(before[&"completed_count"] + before[&"unfinished_count"] == POPULATION and after[&"completed_count"] + after[&"unfinished_count"] == POPULATION, "completed and censored unfinished populations account for every arrival")
|
|
_expect(before[&"failed_count"] == 0 and after[&"failed_count"] == 0, "every measurement actor has a legal route and conserved Path ownership")
|
|
_expect(before[&"walking_steps_min"] == 4 and before[&"walking_steps_max"] == 4 and after[&"walking_steps_min"] == 4 and after[&"walking_steps_max"] == 4, "left and right boarding positions have the same four horizontal walking steps")
|
|
_expect(after[&"shaft_usage"].size() == 2, "public shortest-path costs distribute demand to both installed cabins")
|
|
_expect(before[&"completed_count"] == POPULATION and after[&"completed_count"] == POPULATION, "both layouts complete the entire cohort inside the fixed window")
|
|
_expect(_is_improved(before, after), "second identical cabin improves wait and journey P95 only after both layouts complete equal demand")
|
|
var replay := _observe(one, demand, WINDOW_MINUTES)
|
|
_expect(replay == before, "same topology snapshot and seed reproduce all measured outcomes exactly")
|
|
var censored := _observe(two, demand, 30)
|
|
_expect(censored[&"unfinished_count"] > 0 and not _is_improved(before, censored), "short-window incomplete observations cannot masquerade as an improvement")
|
|
var report := {
|
|
&"scope": "Fixed-demand Path/Elevator actor harness; not natural economic Guest AI or full S14 congestion acceptance.",
|
|
&"route_policy": "Public find_path at common-lobby arrival; commit that route for this journey; real shared-cabin boarding and minute ticks.",
|
|
&"topology_policy": "Production explicit vertical connectors; no benchmark floor-separation blockers.",
|
|
&"seed": SEED, &"demand_count": POPULATION, &"observation_minutes": WINDOW_MINUTES,
|
|
&"demand_hash": SimulationDigest.compute(demand), &"before": before, &"after": after,
|
|
&"equipment_cost_difference": {
|
|
&"gold": two[&"costs"][&"gold"] - one[&"costs"][&"gold"],
|
|
&"materials": two[&"costs"][&"materials"] - one[&"costs"][&"materials"],
|
|
&"maintenance_mana_demand": two[&"costs"][&"maintenance_mana_demand"] - one[&"costs"][&"maintenance_mana_demand"]},
|
|
&"improvement_accepted": _is_improved(before, after), &"checks": _checks, &"failures": _failures}
|
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://artifacts/transport"))
|
|
var output := FileAccess.open(REPORT_PATH, FileAccess.WRITE)
|
|
if output != null: output.store_string(JSON.stringify(report, "\t") + "\n")
|
|
var manifest := FileAccess.open(DEMAND_PATH, FileAccess.WRITE)
|
|
if manifest != null: manifest.store_string(JSON.stringify(demand, "\t") + "\n")
|
|
print("TRANSPORT_COMPARISON ", JSON.stringify({&"before": _summary(before), &"after": _summary(after), &"improved": report[&"improvement_accepted"]}))
|
|
_finish()
|
|
|
|
|
|
func _demand() -> Array[Dictionary]:
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = SEED
|
|
var result: Array[Dictionary] = []
|
|
for index: int in range(POPULATION):
|
|
result.append({&"actor_id": StringName("comparison/%03d" % index),
|
|
&"arrived_at": (index / 4) * 15 + rng.randi_range(0, 2),
|
|
&"from": [START.x, START.y, START.z], &"to": [GOAL.x, GOAL.y, GOAL.z]})
|
|
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
if a[&"arrived_at"] != b[&"arrived_at"]: return a[&"arrived_at"] < b[&"arrived_at"]
|
|
return str(a[&"actor_id"]) < str(b[&"actor_id"]))
|
|
return result
|
|
|
|
|
|
func _prepare(cabins: int) -> Dictionary:
|
|
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
|
|
var clock: Variant = load("res://autoload/game_clock.gd").new()
|
|
var state: Variant = load("res://autoload/game_state.gd").new()
|
|
clock.reset(1, 6, 0)
|
|
state.reset()
|
|
_expect(runtime.initialize(clock, state, SEED)[&"valid"], "%d-cabin fixture initializes with normal 5000G/50 materials" % cabins)
|
|
runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
|
clock.set_paused(true)
|
|
var builds: Array = [
|
|
[&"guest_elevator", Vector3i.ZERO],
|
|
[&"guest_corridor", Vector3i(2, 1, 0)],
|
|
[&"guest_corridor", Vector3i(3, 1, 0)]]
|
|
if cabins == 2: builds.append([&"guest_elevator", Vector3i(4, 0, 0)])
|
|
var success := true
|
|
for index: int in range(builds.size()):
|
|
var result: CommandResult = runtime.submit_command(&"build_facility", {
|
|
&"definition_id": builds[index][0], &"origin": builds[index][1]},
|
|
&"player", &"", 0, clock.absolute_minute, StringName("comparison.build.%d" % index))
|
|
_expect(result.is_success(), "%d-cabin case pays for %s at %s" % [cabins, builds[index][0], builds[index][1]])
|
|
success = success and result.is_success()
|
|
clock.advance_minutes(310)
|
|
var report: Dictionary = runtime.elevators.get_report()
|
|
_expect(report[&"shafts"].size() == cabins, "%d-cabin construction physically completes" % cabins)
|
|
success = success and report[&"shafts"].size() == cabins
|
|
var maintenance := 0
|
|
for shaft: Dictionary in report[&"shafts"]:
|
|
_expect(shaft[&"operational"] and shaft[&"capacity"] == 4 and shaft[&"travel_minutes_per_floor"] == 10, "comparison cabins have identical power, capacity and speed")
|
|
for module: StringName in shaft[&"module_ids"]:
|
|
var facility: Dictionary = runtime.construction_service.get_facility(module)
|
|
var definition: FacilityDefinition = runtime.definition_registry.get_facility(facility[&"definition_id"])
|
|
maintenance += definition.maintenance_mana
|
|
var result := {&"shaft_count": cabins, &"minute": clock.absolute_minute,
|
|
&"cells": runtime.route_subslot_path_adapter.get_projected_walkable_cells(),
|
|
&"path_snapshot": runtime.path_service.create_snapshot(),
|
|
&"elevator_snapshot": runtime.elevators.create_snapshot(),
|
|
&"costs": {&"gold": 5000 - state.get_resource(&"gold"), &"materials": 50 - state.get_resource(&"materials"),
|
|
&"maintenance_mana_demand": maintenance}}
|
|
runtime.free()
|
|
clock.free()
|
|
state.free()
|
|
return result if success else {}
|
|
|
|
|
|
func _observe(prepared: Dictionary, demand: Array[Dictionary], window: int) -> Dictionary:
|
|
var path := PathService.new()
|
|
path.rebuild_graph(prepared[&"cells"])
|
|
path.restore_snapshot(prepared[&"path_snapshot"])
|
|
_expect(path.get_vertical_walk_requires_connector(), "prepared Runtime uses production strict floor routing without benchmark overrides")
|
|
var elevators := ElevatorService.new()
|
|
elevators.configure(path)
|
|
elevators.restore_snapshot(prepared[&"elevator_snapshot"])
|
|
var actors: Dictionary = {}
|
|
var shaft_usage: Dictionary = {}
|
|
var completed: Array[Dictionary] = []
|
|
var failed := 0
|
|
var start_minute: int = prepared[&"minute"]
|
|
for offset: int in range(window + 1):
|
|
var minute := start_minute + offset
|
|
if offset > 0:
|
|
for arrival: Dictionary in elevators.advance_one_minute(minute):
|
|
var actor: Dictionary = actors[arrival[&"actor_id"]]
|
|
actor[&"cell"] = arrival[&"cell"]
|
|
actor[&"route_index"] += 1
|
|
actor[&"alighted_at"] = offset
|
|
actor[&"next_move_at"] = offset + 1
|
|
path.project_actor_position(arrival[&"actor_id"], arrival[&"cell"], minute)
|
|
for row: Dictionary in demand:
|
|
if row[&"arrived_at"] != offset: continue
|
|
var actor_id: StringName = row[&"actor_id"]
|
|
path.actor_enter_cell(actor_id, START, minute)
|
|
var route := path.find_path(START, GOAL, [0])
|
|
var actor := {&"actor_id": actor_id, &"arrived_at": offset, &"cell": START,
|
|
&"route": route, &"route_index": 0, &"requested_at": -1, &"boarded_at": -1,
|
|
&"alighted_at": -1, &"completed_at": -1, &"walking_steps": 0,
|
|
&"credit": 0, &"next_move_at": offset + 1, &"failed": route.is_empty()}
|
|
actors[actor_id] = actor
|
|
if route.is_empty(): failed += 1
|
|
var actor_ids: Array = actors.keys()
|
|
actor_ids.sort()
|
|
for actor_id: StringName in actor_ids:
|
|
var actor: Dictionary = actors[actor_id]
|
|
if actor[&"failed"] or actor[&"completed_at"] >= 0: continue
|
|
var trip := elevators.get_actor_trip(actor_id)
|
|
if not trip.is_empty():
|
|
if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED and actor[&"boarded_at"] < 0:
|
|
actor[&"boarded_at"] = offset
|
|
continue
|
|
if actor[&"cell"] == GOAL:
|
|
actor[&"completed_at"] = offset
|
|
completed.append(actor.duplicate(true))
|
|
path.despawn_actor(actor_id)
|
|
continue
|
|
if actor[&"next_move_at"] > offset: continue
|
|
var route: Array = actor[&"route"]
|
|
var index: int = actor[&"route_index"]
|
|
var next: Vector3i = route[index + 1]
|
|
var connector := path.get_step_connector(actor[&"cell"], next)
|
|
if elevators.owns_connector(connector):
|
|
var request := elevators.request_trip(actor_id, actor[&"cell"], next, minute)
|
|
if not request[&"ok"]:
|
|
actor[&"failed"] = true
|
|
failed += 1
|
|
else:
|
|
actor[&"requested_at"] = offset
|
|
var shaft: StringName = request[&"trip"][&"shaft_id"]
|
|
shaft_usage[shaft] = int(shaft_usage.get(shaft, 0)) + 1
|
|
continue
|
|
actor[&"credit"] += roundi(path.get_speed_multiplier(actor[&"cell"]) * CREDIT_SCALE)
|
|
if actor[&"credit"] < CREDIT_SCALE: continue
|
|
if not path.project_actor_position(actor_id, next, minute):
|
|
actor[&"failed"] = true
|
|
failed += 1
|
|
continue
|
|
actor[&"cell"] = next
|
|
actor[&"route_index"] += 1
|
|
actor[&"walking_steps"] += 1
|
|
actor[&"credit"] -= CREDIT_SCALE
|
|
if next == GOAL:
|
|
actor[&"completed_at"] = offset
|
|
completed.append(actor.duplicate(true))
|
|
path.despawn_actor(actor_id)
|
|
var waits: Array[int] = []
|
|
var journeys: Array[int] = []
|
|
var rides: Array[int] = []
|
|
var walking_steps: Array[int] = []
|
|
var unfinished: Array[Dictionary] = []
|
|
var waiting := 0
|
|
var aboard := 0
|
|
var walking := 0
|
|
for actor_id: StringName in actors:
|
|
var actor: Dictionary = actors[actor_id]
|
|
if actor[&"boarded_at"] >= 0: waits.append(actor[&"boarded_at"] - actor[&"requested_at"])
|
|
if actor[&"alighted_at"] >= 0: rides.append(actor[&"alighted_at"] - actor[&"boarded_at"])
|
|
if actor[&"completed_at"] >= 0:
|
|
journeys.append(actor[&"completed_at"] - actor[&"arrived_at"])
|
|
walking_steps.append(actor[&"walking_steps"])
|
|
else:
|
|
var state: StringName = elevators.get_actor_trip(actor_id).get(&"state", &"WALKING")
|
|
if state == PathService.ELEVATOR_REQUEST_QUEUED: waiting += 1
|
|
elif state == PathService.ELEVATOR_REQUEST_BOARDED: aboard += 1
|
|
else: walking += 1
|
|
unfinished.append({&"actor_id": actor_id, &"state": state, &"age_minutes": window - actor[&"arrived_at"],
|
|
&"observed_wait_minutes": window - actor[&"requested_at"] if state == PathService.ELEVATOR_REQUEST_QUEUED else 0})
|
|
walking_steps.sort()
|
|
return {&"shaft_count": prepared[&"shaft_count"], &"costs": prepared[&"costs"],
|
|
&"window_minutes": window, &"demand_count": demand.size(), &"demand_hash": SimulationDigest.compute(demand),
|
|
&"arrived_count": actors.size(), &"not_arrived_count": demand.size() - actors.size(),
|
|
&"completed_count": completed.size(), &"unfinished_count": actors.size() - completed.size(),
|
|
&"waiting_count": waiting, &"aboard_count": aboard, &"walking_count": walking, &"failed_count": failed,
|
|
&"queue_wait_minutes": _stats(waits), &"journey_minutes": _stats(journeys), &"ride_minutes": _stats(rides),
|
|
&"walking_steps_min": walking_steps[0] if not walking_steps.is_empty() else -1,
|
|
&"walking_steps_max": walking_steps[-1] if not walking_steps.is_empty() else -1,
|
|
&"shaft_usage": shaft_usage, &"completed_actors": completed, &"unfinished_actors": unfinished}
|
|
|
|
|
|
func _stats(values: Array[int]) -> Dictionary:
|
|
if values.is_empty(): return {&"samples": 0, &"mean": null, &"p95": null, &"max": null}
|
|
var sorted := values.duplicate()
|
|
sorted.sort()
|
|
var total := 0
|
|
for value: int in sorted: total += value
|
|
return {&"samples": sorted.size(), &"mean": float(total) / sorted.size(),
|
|
&"p95": sorted[maxi(0, ceili(sorted.size() * 0.95) - 1)], &"max": sorted[-1]}
|
|
|
|
|
|
func _is_improved(before: Dictionary, after: Dictionary) -> bool:
|
|
return before[&"demand_hash"] == after[&"demand_hash"] and before[&"window_minutes"] == after[&"window_minutes"] \
|
|
and before[&"completed_count"] == before[&"demand_count"] and after[&"completed_count"] == before[&"completed_count"] \
|
|
and before[&"failed_count"] == 0 and after[&"failed_count"] == 0 \
|
|
and after[&"queue_wait_minutes"][&"p95"] < before[&"queue_wait_minutes"][&"p95"] \
|
|
and after[&"journey_minutes"][&"p95"] < before[&"journey_minutes"][&"p95"]
|
|
|
|
|
|
func _summary(report: Dictionary) -> Dictionary:
|
|
return {&"cabins": report[&"shaft_count"], &"completed": report[&"completed_count"],
|
|
&"unfinished": report[&"unfinished_count"], &"wait": report[&"queue_wait_minutes"],
|
|
&"journey": report[&"journey_minutes"], &"shaft_usage": report[&"shaft_usage"]}
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if not condition: _failures += 1
|
|
print("%s: %s" % ["PASS" if condition else "FAIL", label])
|
|
|
|
|
|
func _finish() -> void:
|
|
print("Path and cabin comparison: %d checks, %d failures" % [_checks, _failures])
|
|
quit(0 if _failures == 0 else 1)
|