elevator-core 16.4.0

Engine-agnostic elevator simulation library with pluggable dispatch strategies
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
//! Headless dispatch-strategy regression harness.
//!
//! Runs two fixed scenarios (mid-rise office, single-line sky-lobby
//! skyscraper) against every built-in dispatch strategy with a
//! deterministic rider driver, then reports the metrics a human would
//! be squinting at in the browser: delivered, abandoned, abandonment
//! rate, avg/max wait, peak waiting-queue length across the whole run.
//!
//! Purpose: catch dispatch regressions across the `ScanDispatch`,
//! `LookDispatch`, `NearestCarDispatch`, `EtdDispatch`, `RsrDispatch`,
//! and `DestinationDispatch` strategies at a glance — a scan that
//! reports 100% delivery and 0% abandonment that suddenly starts
//! abandoning riders is an obvious regression.
//!
//! RSR runs under its tuned [`RsrDispatch::default`] weights (not the
//! zero-baseline `new()`), matching what consumers get when they pick
//! "RSR" from a dropdown. Running `new()` here would silently duplicate
//! the `nearest` row.
//!
//! Scope note: this example used to mirror the playground's scenario
//! set, but the playground now runs a 42-stop multi-line skyscraper
//! (Low / High / Executive / Service banks sharing a sky lobby
//! transfer), and the audit's single-line 13-stop skyscraper is a
//! deliberately simpler stand-in — both for faster iteration and
//! because the audit's driver doesn't exercise the multi-leg route
//! auto-solver. Keep the two intentionally decoupled.
//!
//! Run with:
//! ```sh
//! cargo run --example playground_audit --release
//! cargo run --example playground_audit --release -- office  # single scenario
//! ```

#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::cast_precision_loss,
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    clippy::missing_docs_in_private_items,
    clippy::print_stdout,
    clippy::too_many_lines,
    clippy::panic,
    clippy::option_if_let_else,
    clippy::missing_const_for_fn,
    clippy::suboptimal_flops,
    clippy::while_float
)]

use elevator_core::config::SimConfig;
use elevator_core::dispatch::{
    DestinationDispatch, EtdDispatch, LookDispatch, NearestCarDispatch, RsrDispatch, ScanDispatch,
};
use elevator_core::sim::Simulation;
use elevator_core::stop::StopId;

const TICKS_PER_SECOND: u64 = 60;

/// One phase of a scenario's day cycle. Mirrors `playground/src/types.ts`
/// `Phase` — duration in sim-seconds, rate in riders/min, unnormalized
/// stop weights.
struct Phase {
    duration_sec: u32,
    riders_per_min: f64,
    /// Unnormalized; 0.0 in a slot excludes that stop. Length must match stop count.
    origin_weights: &'static [f64],
    /// Same shape. If drawn `dest == origin`, rotated forward.
    dest_weights: &'static [f64],
}

struct Scenario {
    id: &'static str,
    label: &'static str,
    ron: &'static str,
    phases: &'static [Phase],
    /// `None` = riders never abandon (matches `abandonAfterSec` omission).
    abandon_after_sec: Option<u32>,
    /// Seed pre-loaded spawns before measurement starts. Mirrors
    /// `ScenarioMeta.seedSpawns` — used by convention burst.
    seed_spawns: u32,
}

/// Seconds of sim-time to run per (scenario, strategy). Covers at least
/// one full day-cycle for the scenarios whose phase total is ≤ 5 min.
const RUN_SEC: u32 = 360;

fn main() {
    // Poor-man's CLI: positional filter + optional `--no-abandon` flag.
    // Full `clap` would be overkill — this is an audit tool, not a
    // user-facing binary.
    let args: Vec<String> = std::env::args().skip(1).collect();
    let no_abandon = args.iter().any(|a| a == "--no-abandon");
    let filter = args.iter().find(|a| !a.starts_with("--")).cloned();
    let mut any = false;
    for scenario in SCENARIOS {
        if let Some(f) = &filter
            && !scenario.id.contains(f.as_str())
        {
            continue;
        }
        any = true;
        let effective_budget = if no_abandon {
            None
        } else {
            scenario.abandon_after_sec
        };
        println!("\n=== {} ({}) ===", scenario.label, scenario.id);
        println!(
            "  abandon_after: {}  run: {}s",
            effective_budget.map_or_else(|| "never".to_string(), |s| format!("{s}s")),
            RUN_SEC
        );
        println!(
            "  {:<12} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}",
            "strategy", "delivered", "abandoned", "aband%", "avg_wait", "max_wait", "peak_q"
        );
        for strategy in ["scan", "look", "nearest", "etd", "rsr", "destination"] {
            let report = run_once(scenario, strategy, effective_budget);
            println!(
                "  {:<12} {:>10} {:>10} {:>9.1}% {:>9.1}s {:>9.1}s {:>10}",
                strategy,
                report.delivered,
                report.abandoned,
                report.abandonment_pct,
                report.avg_wait_s,
                report.max_wait_s,
                report.peak_waiting,
            );
        }
    }
    if !any {
        eprintln!("no scenario matched filter {filter:?}");
        std::process::exit(1);
    }
}

struct Report {
    delivered: u64,
    abandoned: u64,
    abandonment_pct: f64,
    avg_wait_s: f64,
    max_wait_s: f64,
    peak_waiting: usize,
}

fn run_once(scenario: &Scenario, strategy_name: &str, abandon_override: Option<u32>) -> Report {
    let config: SimConfig = ron::from_str(scenario.ron).expect("scenario RON must parse");

    // `Simulation::new` wants `impl DispatchStrategy + 'static`, not a
    // trait object — the generic monomorphizes per strategy. Dispatch
    // on the name here rather than box/dyn'ing to avoid the `Sized`
    // bound mismatch.
    let mut sim = match strategy_name {
        "scan" => Simulation::new(&config, ScanDispatch::new()),
        "look" => Simulation::new(&config, LookDispatch::new()),
        "nearest" => Simulation::new(&config, NearestCarDispatch::new()),
        // Use `Default` (tuned stack with the age-linear fairness term
        // active), not `new()` (zero baseline) — `BuiltinStrategy::Etd`
        // maps to `Default`, so the audit should measure what the
        // dropdown actually does.
        "etd" => Simulation::new(&config, EtdDispatch::default()),
        // Use `Default` (tuned stack), not `new()` (zero baseline) —
        // `new()` would reduce RSR to `NearestCarDispatch` and give
        // the audit a misleadingly duplicated row.
        "rsr" => Simulation::new(&config, RsrDispatch::default()),
        "destination" => Simulation::new(&config, DestinationDispatch::new()),
        _ => panic!("unknown strategy: {strategy_name}"),
    }
    .expect("sim build");
    if strategy_name == "destination" {
        // DCS needs Destination hall-call mode + the AssignedCar
        // extension registered, otherwise `pre_dispatch` early-returns
        // and dispatch silently falls back to Idle.
        for g in sim.groups_mut() {
            g.set_hall_call_mode(elevator_core::dispatch::HallCallMode::Destination);
        }
        sim.world_mut()
            .register_ext::<elevator_core::dispatch::AssignedCar>(
                elevator_core::dispatch::destination::ASSIGNED_CAR_KEY,
            );
    }

    let stop_ids: Vec<StopId> = config.building.stops.iter().map(|s| s.id).collect();
    // Resolve StopId → EntityId once; `waiting_count_at` wants the
    // entity form. `stop_entity` is None for unknown StopIds — shouldn't
    // happen for a freshly-built sim, but filter defensively.
    let stop_entities: Vec<_> = stop_ids
        .iter()
        .filter_map(|id| sim.stop_entity(*id))
        .collect();
    let mut driver = Driver::new(42, scenario.phases);

    // Pre-seed — convention scenario burst. Each spawn call advances the
    // driver's internal accumulator by a fixed dt; 300 steps at 1/60s is
    // enough to drain 300 × (rate/60) ticks of scheduled spawns from the
    // first phase. Matches the playground's seedSpawns loop shape.
    for _ in 0..scenario.seed_spawns {
        let specs = driver.drain(1.0 / 60.0);
        for spec in specs {
            spawn(
                &mut sim,
                spec.origin,
                spec.dest,
                spec.weight,
                abandon_override,
            );
        }
    }

    let mut peak_waiting = 0usize;
    let dt = 1.0 / f64::from(TICKS_PER_SECOND as u32);
    let total_ticks = u64::from(RUN_SEC) * TICKS_PER_SECOND;
    for _ in 0..total_ticks {
        for spec in driver.drain(dt) {
            spawn(
                &mut sim,
                spec.origin,
                spec.dest,
                spec.weight,
                abandon_override,
            );
        }
        sim.step();
        sim.drain_events();
        let waiting: usize = stop_entities.iter().map(|&e| sim.waiting_count_at(e)).sum();
        if waiting > peak_waiting {
            peak_waiting = waiting;
        }
    }

    let m = sim.metrics();
    let delivered = m.total_delivered();
    let abandoned = m.total_abandoned();
    let spawned = m.total_spawned();
    let abandonment_pct = if spawned > 0 {
        100.0 * abandoned as f64 / spawned as f64
    } else {
        0.0
    };
    Report {
        delivered,
        abandoned,
        abandonment_pct,
        // Metrics store times in ticks; convert for human-friendly output.
        avg_wait_s: m.avg_wait_time() / TICKS_PER_SECOND as f64,
        max_wait_s: m.max_wait_time() as f64 / TICKS_PER_SECOND as f64,
        peak_waiting,
    }
}

fn spawn(
    sim: &mut Simulation,
    origin: StopId,
    dest: StopId,
    weight: f64,
    abandon_after_sec: Option<u32>,
) {
    let mut builder = match sim.build_rider(origin, dest) {
        Ok(b) => b.weight(weight),
        Err(_) => return, // unreachable pairs silently drop
    };
    if let Some(sec) = abandon_after_sec {
        builder = builder.patience(u64::from(sec) * TICKS_PER_SECOND);
    }
    let _ = builder.spawn();
}

/// Deterministic traffic driver — straight port of
/// `playground/src/traffic.ts` minus the intensity slider (headless
/// audit doesn't need a multiplier). Uses the same splitmix64 seed
/// mixing and weighted-index draw so the same seed produces the same
/// rider stream across Rust and JS.
struct Driver {
    state: u64,
    accumulator: f64,
    elapsed_in_cycle_sec: f64,
    total_duration_sec: f64,
    phases: &'static [Phase],
}

struct Spec {
    origin: StopId,
    dest: StopId,
    weight: f64,
}

impl Driver {
    fn new(seed: u32, phases: &'static [Phase]) -> Self {
        let total = phases.iter().map(|p| f64::from(p.duration_sec)).sum();
        Self {
            state: mix_seed(u64::from(seed)),
            accumulator: 0.0,
            elapsed_in_cycle_sec: 0.0,
            total_duration_sec: total,
            phases,
        }
    }

    fn current_phase(&self) -> &'static Phase {
        if self.phases.is_empty() {
            unreachable!("called current_phase with no phases installed");
        }
        let mut t = self.elapsed_in_cycle_sec;
        for phase in self.phases {
            t -= f64::from(phase.duration_sec);
            if t < 0.0 {
                return phase;
            }
        }
        self.phases.last().unwrap()
    }

    fn drain(&mut self, elapsed_sec: f64) -> Vec<Spec> {
        if self.phases.is_empty() {
            return Vec::new();
        }
        // Snapshot the phase pointer so we can mutate `self` inside
        // the emission loop. Phase data is `&'static` so a raw copy
        // of the reference is cheap and borrow-free.
        let phase: &'static Phase = self.current_phase();
        // Clamp dt the same way the TS driver does (4 frames @ 60Hz).
        let dt = elapsed_sec.min(4.0 / 60.0);
        self.accumulator += phase.riders_per_min / 60.0 * dt;
        self.elapsed_in_cycle_sec =
            (self.elapsed_in_cycle_sec + dt) % self.total_duration_sec.max(1.0);
        let mut out = Vec::new();
        while self.accumulator >= 1.0 {
            self.accumulator -= 1.0;
            out.push(self.next_spec(phase));
        }
        out
    }

    fn next_spec(&mut self, phase: &Phase) -> Spec {
        let n = phase.origin_weights.len();
        let origin_idx = self.pick_weighted(n, phase.origin_weights);
        let mut dest_idx = self.pick_weighted(n, phase.dest_weights);
        if dest_idx == origin_idx {
            dest_idx = (dest_idx + 1) % n;
        }
        Spec {
            origin: StopId(origin_idx as u32),
            dest: StopId(dest_idx as u32),
            weight: 50.0 + self.next_float() * 50.0,
        }
    }

    fn pick_weighted(&mut self, n: usize, weights: &[f64]) -> usize {
        let total: f64 = weights.iter().map(|w| w.max(0.0)).sum();
        if total <= 0.0 {
            return self.next_int(n);
        }
        let mut r = self.next_float() * total;
        for (i, &w) in weights.iter().enumerate() {
            r -= w.max(0.0);
            if r < 0.0 {
                return i;
            }
        }
        n - 1
    }

    fn next_u64(&mut self) -> u64 {
        self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
        let mut z = self.state;
        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
        z ^ (z >> 31)
    }

    fn next_int(&mut self, n: usize) -> usize {
        (self.next_u64() % n as u64) as usize
    }

    fn next_float(&mut self) -> f64 {
        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
    }
}

fn mix_seed(seed: u64) -> u64 {
    let mut z = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
    z ^ (z >> 31)
}

// ─── Scenario data — mirrors playground/src/scenarios.ts ───────────
// KEEP IN SYNC: changes to rates, weights, elevator configs, or
// abandonment budgets in the TS file require a mirror edit here.

// Helper constructors for weight arrays; Rust can't express JS's
// array-spread-fill succinctly, so the vectors are written out.
const OFFICE_PHASES: &[Phase] = &[
    Phase {
        duration_sec: 45,
        riders_per_min: 3.0,
        origin_weights: &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
        dest_weights: &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
    },
    Phase {
        duration_sec: 60,
        riders_per_min: 30.0,
        origin_weights: &[8.5, 0.3, 0.3, 0.3, 0.3, 0.3],
        // topBias(6) × (i==0 ? 0 : w): 0, 1.1, 1.2, 1.3, 1.4, 1.5
        dest_weights: &[0.0, 1.1, 1.2, 1.3, 1.4, 1.5],
    },
    Phase {
        duration_sec: 60,
        riders_per_min: 16.0,
        origin_weights: &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
        dest_weights: &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
    },
    Phase {
        duration_sec: 45,
        riders_per_min: 36.0,
        origin_weights: &[0.3, 3.0, 2.0, 2.0, 2.0, 2.0],
        dest_weights: &[0.3, 3.0, 2.0, 2.0, 2.0, 2.0],
    },
    Phase {
        duration_sec: 60,
        riders_per_min: 30.0,
        // topBias(6) × (i==0 ? 0 : w): 0, 1.1, 1.2, 1.3, 1.4, 1.5
        origin_weights: &[0.0, 1.1, 1.2, 1.3, 1.4, 1.5],
        dest_weights: &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
    },
];

const OFFICE_RON: &str = r#"SimConfig(
    schema_version: 1,
    building: BuildingConfig(
        name: "Mid-Rise Office",
        stops: [
            StopConfig(id: StopId(0), name: "Lobby",   position: 0.0),
            StopConfig(id: StopId(1), name: "Floor 2", position: 4.0),
            StopConfig(id: StopId(2), name: "Floor 3", position: 8.0),
            StopConfig(id: StopId(3), name: "Floor 4", position: 12.0),
            StopConfig(id: StopId(4), name: "Floor 5", position: 16.0),
            StopConfig(id: StopId(5), name: "Floor 6", position: 20.0),
        ],
    ),
    elevators: [
        ElevatorConfig(
            id: 0, name: "Car 1",
            max_speed: 2.2, acceleration: 1.5, deceleration: 2.0,
            weight_capacity: 800.0,
            starting_stop: StopId(0),
            door_open_ticks: 210, door_transition_ticks: 60,
        ),
        ElevatorConfig(
            id: 1, name: "Car 2",
            max_speed: 2.2, acceleration: 1.5, deceleration: 2.0,
            weight_capacity: 800.0,
            starting_stop: StopId(3),
            door_open_ticks: 210, door_transition_ticks: 60,
        ),
    ],
    simulation: SimulationParams(ticks_per_second: 60.0),
    passenger_spawning: PassengerSpawnConfig(
        mean_interval_ticks: 90,
        weight_range: (50.0, 100.0),
    ),
)"#;

// Skyscraper phase data. 13 stops (lobby + 12). topBias(12) = [1.0,
// 1.045, 1.09, 1.136, 1.18, 1.227, 1.273, 1.318, 1.364, 1.409, 1.455,
// 1.5]. The `[14, ...0.25 × 12]` and its mirror encode lobby-heavy
// origin/dest for morning and evening rushes.
const SKY_PHASES: &[Phase] = &[
    Phase {
        duration_sec: 45,
        riders_per_min: 6.0,
        origin_weights: &[1.0; 13],
        dest_weights: &[1.0; 13],
    },
    Phase {
        duration_sec: 75,
        riders_per_min: 20.0,
        origin_weights: &[
            14.0, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25,
        ],
        dest_weights: &[
            0.0, 1.0, 1.045, 1.091, 1.136, 1.182, 1.227, 1.273, 1.318, 1.364, 1.409, 1.455, 1.5,
        ],
    },
    Phase {
        duration_sec: 60,
        riders_per_min: 13.0,
        origin_weights: &[1.0; 13],
        dest_weights: &[1.0; 13],
    },
    Phase {
        duration_sec: 45,
        riders_per_min: 17.0,
        // Sky lobby at index 6 weighted 3× as origin, 4× as dest.
        origin_weights: &[
            1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
        ],
        dest_weights: &[
            1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 4.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
        ],
    },
    Phase {
        duration_sec: 75,
        riders_per_min: 18.0,
        origin_weights: &[
            0.0, 1.0, 1.045, 1.091, 1.136, 1.182, 1.227, 1.273, 1.318, 1.364, 1.409, 1.455, 1.5,
        ],
        dest_weights: &[
            14.0, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25,
        ],
    },
];

const SKY_RON: &str = r#"SimConfig(
    schema_version: 1,
    building: BuildingConfig(
        name: "Skyscraper (Sky Lobby)",
        stops: [
            StopConfig(id: StopId(0),  name: "Lobby",      position: 0.0),
            StopConfig(id: StopId(1),  name: "Floor 2",    position: 4.0),
            StopConfig(id: StopId(2),  name: "Floor 3",    position: 8.0),
            StopConfig(id: StopId(3),  name: "Floor 4",    position: 12.0),
            StopConfig(id: StopId(4),  name: "Floor 5",    position: 16.0),
            StopConfig(id: StopId(5),  name: "Floor 6",    position: 20.0),
            StopConfig(id: StopId(6),  name: "Sky Lobby",  position: 24.0),
            StopConfig(id: StopId(7),  name: "Floor 8",    position: 28.0),
            StopConfig(id: StopId(8),  name: "Floor 9",    position: 32.0),
            StopConfig(id: StopId(9),  name: "Floor 10",   position: 36.0),
            StopConfig(id: StopId(10), name: "Floor 11",   position: 40.0),
            StopConfig(id: StopId(11), name: "Floor 12",   position: 44.0),
            StopConfig(id: StopId(12), name: "Penthouse",  position: 48.0),
        ],
    ),
    elevators: [
        ElevatorConfig(
            id: 0, name: "Car A",
            max_speed: 4.0, acceleration: 2.0, deceleration: 2.5,
            weight_capacity: 1200.0,
            starting_stop: StopId(0),
            door_open_ticks: 300, door_transition_ticks: 72,
            bypass_load_up_pct: Some(0.80), bypass_load_down_pct: Some(0.50),
        ),
        ElevatorConfig(
            id: 1, name: "Car B",
            max_speed: 4.0, acceleration: 2.0, deceleration: 2.5,
            weight_capacity: 1200.0,
            starting_stop: StopId(6),
            door_open_ticks: 300, door_transition_ticks: 72,
            bypass_load_up_pct: Some(0.80), bypass_load_down_pct: Some(0.50),
        ),
        ElevatorConfig(
            id: 2, name: "Car C",
            max_speed: 4.0, acceleration: 2.0, deceleration: 2.5,
            weight_capacity: 1200.0,
            starting_stop: StopId(12),
            door_open_ticks: 300, door_transition_ticks: 72,
            bypass_load_up_pct: Some(0.80), bypass_load_down_pct: Some(0.50),
        ),
    ],
    simulation: SimulationParams(ticks_per_second: 60.0),
    passenger_spawning: PassengerSpawnConfig(
        mean_interval_ticks: 30,
        weight_range: (55.0, 100.0),
    ),
)"#;

const SCENARIOS: &[Scenario] = &[
    Scenario {
        id: "office",
        label: "Mid-rise office",
        ron: OFFICE_RON,
        phases: OFFICE_PHASES,
        abandon_after_sec: Some(90),
        seed_spawns: 0,
    },
    Scenario {
        id: "skyscraper",
        label: "Skyscraper (sky lobby)",
        ron: SKY_RON,
        phases: SKY_PHASES,
        abandon_after_sec: Some(180),
        seed_spawns: 0,
    },
];