elevator-core 5.10.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
# elevator-core Architecture

## 1. Overview

`elevator-core` is an engine-agnostic, tick-based elevator simulation library.
It is pure Rust with zero `unsafe`, no ECS framework dependency, and no
rendering or I/O. The library models elevators at "stops" positioned at
arbitrary distances along a 1D axis, making it suitable for both conventional
buildings and exotic configurations like space elevators.

Key properties:

- **Tick-based** -- deterministic fixed-timestep simulation (`dt = 1 / ticks_per_second`)
- **Struct-of-arrays ECS** -- custom implementation using `SlotMap` + `SecondaryMap`
- **Pluggable dispatch** -- swappable algorithms per elevator group
- **Game-agnostic riders** -- `Rider` is anything that rides; games add semantics via extensions
- **Config-validated** -- invalid configs rejected at construction time

## 2. Core Architecture: ECS-like World

### Entity storage

All entities share a single `SlotMap<EntityId, ()>` that acts as the existence
table. Each component type gets its own `SecondaryMap<EntityId, T>`, enabling
independent mutable borrows of different component storages within the same
system function.

```rust
pub struct World {
    alive:       SlotMap<EntityId, ()>,

    // Built-in component storages (one SecondaryMap per component type)
    positions, velocities, floor_positions,
    elevators, riders, stops, routes, lines,
    patience, preferences, access_control,
    destination_queues, service_modes,
    disabled: SecondaryMap<EntityId, ()>,

    // Extension storage (game-specific components)
    extensions:  HashMap<TypeId, Box<dyn AnyExtMap>>,
    ext_names:   HashMap<TypeId, String>,

    // Global resources (singletons — e.g. SortedStops, MetricTags)
    resources:   HashMap<TypeId, Box<dyn Any + Send + Sync>>,
}
```

The listing above elides the individual `SecondaryMap<EntityId, T>`
types for readability; see [`world.rs`](../src/world.rs) for the
concrete struct definition.

### Built-in components

| Component          | Attached to       | Purpose                                                          |
|--------------------|-------------------|------------------------------------------------------------------|
| `Position`         | Elevator, Stop    | Shaft-axis position (`f64`). Stops use it for lookup.            |
| `Velocity`         | Elevator          | Shaft-axis velocity (`f64`, signed).                             |
| `FloorPosition`    | Line              | Optional floor-plan position for rendering.                      |
| `Elevator`         | Elevator          | Phase, door FSM, riders, capacity, physics, direction lamps.     |
| `Rider`            | Rider             | Phase, weight, spawn/board tick.                                 |
| `Stop`             | Stop              | Name + position pair.                                            |
| `Line`             | Line              | Group, orientation, axis bounds, optional `max_cars`.            |
| `Route`            | Rider             | Multi-leg route (optional — Route-less riders are game-managed). |
| `Patience`         | Rider             | Patience threshold and tick tracking for abandonment.            |
| `Preferences`      | Rider             | Boarding preferences (`skip_full_elevator`, `max_crowding_factor`). |
| `AccessControl`    | Rider             | Per-rider allowlist of reachable stops.                          |
| `DestinationQueue` | Elevator          | FIFO of pushed target stops; imperative-dispatch escape hatch.   |
| `ServiceMode`      | Elevator          | `Normal` / `Independent` / `Inspection`.                         |
| `Orientation`      | Line              | Vertical vs horizontal axis (for visualization).                 |

All components above are re-exported from `elevator_core::prelude` so
consumers don't need to dig into the `components` submodule for
everyday code. Games attach additional per-entity data via the
[extension storage system](#extension-storage) without modifying the
library.

### Query builder

The `world.query::<Q>()` API provides ECS-style iteration with compile-time
component selection, `With`/`Without` filters, and extension component access:

```rust
// All riders with a position
for (id, rider, pos) in world.query::<(EntityId, &Rider, &Position)>().iter() { ... }

// Extension components
for (id, vip) in world.query::<(EntityId, &Ext<VipTag>)>().iter() { ... }

// Mutable extension queries (keys-snapshot pattern)
world.query_ext_mut::<VipTag>().for_each_mut(|id, tag| { tag.level += 1; });
```

## 3. The 8-Phase Tick Loop

Each call to `sim.step()` runs all eight phases in order, then advances the
tick counter. Events emitted during a tick are buffered and available to
consumers via `drain_events()` after the tick completes.

```
┌───────────────────────┐
│ 1. AdvanceTransient   │  systems/advance_transient.rs
│                       │  Boarding→Riding, Exiting→Arrived, walk legs, patience
├───────────────────────┤
│ 2. Dispatch           │  systems/dispatch.rs
│                       │  Build manifest, call DispatchStrategy.decide_all()
├───────────────────────┤
│ 3. Reposition         │  systems/reposition.rs
│                       │  Move idle elevators via RepositionStrategy (optional)
├───────────────────────┤
│ 4. AdvanceQueue       │  systems/advance_queue.rs
│                       │  Reconcile phase/target with DestinationQueue front
├───────────────────────┤
│ 5. Movement           │  systems/movement.rs
│                       │  Trapezoidal velocity profile, PassingFloor detection
├───────────────────────┤
│ 6. Doors              │  systems/doors.rs
│                       │  DoorState FSM: Opening→Open→Closing→Closed
├───────────────────────┤
│ 7. Loading            │  systems/loading.rs
│                       │  Board/exit riders, capacity checks, rejections
├───────────────────────┤
│ 8. Metrics            │  systems/metrics.rs
│                       │  Aggregate wait/ride times, throughput, tagged metrics
└───────────┬───────────┘
┌───────────────────────┐
│   advance_tick()      │  flush events to output, tick += 1
└───────────────────────┘
```

### Phase 1: AdvanceTransient (`systems/advance_transient.rs`)

Transitions riders through their one-tick transient states:

- `Boarding(elevator)` becomes `Riding(elevator)`
- `Exiting(elevator)` checks for more route legs; becomes `Waiting` (next leg)
  or `Arrived` (route complete)
- Walk legs are executed immediately (rider teleported to walk destination)
- Patience is ticked for waiting riders; expired patience emits `RiderAbandoned`

**Events:** `RiderAbandoned`

### Phase 2: Dispatch (`systems/dispatch.rs`)

Builds a `DispatchManifest` from current rider state, then calls each group's
`DispatchStrategy.decide_all()` for idle/stopped elevators.

The manifest contains per-rider metadata (`RiderInfo`) grouped into two maps:
- `waiting_at_stop`: riders at each stop wanting service
- `riding_to_stop`: riders aboard elevators heading to each destination

When a strategy returns `GoToStop`, the elevator transitions to
`MovingToStop(stop)` and an `ElevatorAssigned` event is emitted. If the
elevator is already at the target stop, doors open immediately.

**Events:** `ElevatorAssigned`, `ElevatorDeparted`

### Phase 3: Reposition (`systems/reposition.rs`)

Optional per-group phase. Only acts on elevators still in `Idle` phase after
dispatch (no pending assignment). Each group's `RepositionStrategy` decides
where to send idle cars for better coverage. Sets phase to
`ElevatorPhase::Repositioning(stop)` (distinct from `MovingToStop(stop)`)
and flips `Elevator.repositioning = true` as a convenience flag for
call-site predicates that still inspect it; the phase variant is the
authoritative discriminator.

Groups without a registered strategy skip this phase entirely.

**Events:** `ElevatorRepositioning`

### Phase 4: AdvanceQueue (`systems/advance_queue.rs`)

Reconciles each elevator's phase and target stop with the front of its
[`DestinationQueue`]. When imperative callers have pushed a stop via
`push_destination` or `push_destination_front`, this phase redirects the
car before movement is applied. If the front of the queue matches the
current target, the phase is a no-op.

Queue entries are consumed when a loading cycle completes at the target
stop, so imperative and dispatch-driven itineraries compose naturally.

**Events:** `ElevatorAssigned` (when a new target is adopted from the queue)

### Phase 5: Movement (`systems/movement.rs`)

Applies trapezoidal velocity profile physics (via `movement::tick_movement`)
to all elevators in `MovingToStop` phase. The profile has three regions:
acceleration, cruise at max speed, and deceleration to stop.

Uses the `SortedStops` resource for O(log n) detection of stops passed
during each tick. When an elevator passes a stop without stopping, a
`PassingFloor` event is emitted.

On arrival, dispatched elevators transition to `DoorOpening` and doors begin
opening. Repositioned elevators go directly to `Idle` (no door cycle) and
emit `ElevatorRepositioned` instead of `ElevatorArrived`.

**Events:** `ElevatorArrived`, `PassingFloor`, `ElevatorRepositioned`

**Key design:** Physics parameters (max_speed, acceleration, deceleration) are
per-elevator, stored on the `Elevator` component.

### Phase 6: Doors (`systems/doors.rs`)

Ticks the `DoorState` finite-state machine for each elevator:

```
Closed → Opening (transition_ticks) → Open (open_ticks) → Closing (transition_ticks) → Closed
```

Phase transitions on completion:
- Finished opening → `Loading` (riders can board/exit)
- Finished open hold → `DoorClosing`
- Finished closing → `Stopped` (available for next dispatch)

**Events:** `DoorOpened`, `DoorClosed`

### Phase 7: Loading (`systems/loading.rs`)

Boards and exits riders at elevators in `Loading` phase. Uses a **two-pass
read-then-write** approach to avoid aliasing issues:

1. **Read pass** (`collect_actions`): scans all loading elevators and their
   stops, collecting `LoadAction` values (Exit, Board, or Reject)
2. **Write pass** (`apply_actions`): mutates world state based on collected
   actions

One rider action per elevator per tick. Exit takes priority over boarding.
Boarding checks weight capacity and rider preferences; failures emit
`RiderRejected` with a typed `RejectionReason`.

**Events:** `RiderBoarded`, `RiderExited`, `RiderRejected`

### Phase 8: Metrics (`systems/metrics.rs`)

Reads events emitted during the current tick (via `EventBus::peek()`) and
updates aggregate `Metrics`:
- Spawn count, board count, delivery count, abandonment count
- Wait time distribution (per-rider ticks between spawn and board)
- Ride time distribution (per-rider ticks between board and exit)

Also updates per-tag metric accumulators via `MetricTags`, enabling
line-level and custom-tag breakdowns.

**Events:** none (read-only consumer)

## 4. Entity Relationships

```
 ElevatorGroup (runtime struct, not an entity)
 ├── id: GroupId
 ├── lines: Vec<LineInfo>
 │   ├── entity: EntityId ──────────► Line (component)
 │   │                                ├── group: GroupId ──► back to group
 │   │                                ├── orientation
 │   │                                └── min/max_position
 │   ├── elevators: Vec<EntityId> ──► Elevator (component)
 │   │                                ├── line: EntityId ──► Line entity
 │   │                                ├── riders: Vec<EntityId> ──► Rider entities
 │   │                                ├── phase: ElevatorPhase
 │   │                                ├── door: DoorState
 │   │                                └── weight_capacity, current_load, ...
 │   └── serves: Vec<EntityId> ─────► Stop (component)
 │                                    ├── name: String
 │                                    └── position: f64
 │
 └── stop_entities (derived, deduplicated union of all lines' stops)

 Rider (component, entity)
 ├── phase: RiderPhase (Waiting | Boarding(elev) | Riding(elev) | Exiting(elev) | ...)
 ├── current_stop: Option<EntityId> ──► Stop entity
 ├── weight: f64
 └── spawn_tick, board_tick

 Route (optional component on Rider entity)
 ├── legs: Vec<RouteLeg>
 │   └── RouteLeg { from: EntityId, to: EntityId, via: TransportMode }
 │       └── TransportMode::Group(GroupId) | Line(EntityId) | Walk
 └── current_leg: usize
```

Key relationship invariants:
- An `Elevator` always has a `Line` (`elevator.line → EntityId`)
- A `Line` always belongs to exactly one group (`line.group → GroupId`)
- Riders aboard an elevator appear in both `elevator.riders` and have
  `RiderPhase::Riding(elevator_id)`
- On `despawn()`, cross-references are cleaned up automatically

## 5. Dispatch System

### DispatchStrategy trait

```rust
pub trait DispatchStrategy: Send + Sync {
    fn decide(
        &mut self,
        elevator: EntityId,
        elevator_position: f64,
        group: &ElevatorGroup,
        manifest: &DispatchManifest,
        world: &World,
    ) -> DispatchDecision;

    fn decide_all(
        &mut self,
        elevators: &[(EntityId, f64)],
        group: &ElevatorGroup,
        manifest: &DispatchManifest,
        world: &World,
    ) -> Vec<(EntityId, DispatchDecision)>;

    fn notify_removed(&mut self, _elevator: EntityId) {}
}
```

`decide()` handles a single elevator; `decide_all()` enables group-wide
coordination (default: calls `decide()` per elevator). Strategies receive
full `&World` access for reading extension components or custom state.

### DispatchManifest

Built fresh each tick from current rider state:

```rust
pub struct DispatchManifest {
    pub waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
    pub riding_to_stop:  BTreeMap<EntityId, Vec<RiderInfo>>,
}

pub struct RiderInfo {
    pub id: EntityId,
    pub destination: Option<EntityId>,
    pub weight: f64,
    pub wait_ticks: u64,
}
```

`BTreeMap` ensures deterministic iteration order across platforms.

### Built-in strategies

| Strategy       | Algorithm                                          |
|----------------|----------------------------------------------------|
| `Scan`         | Sweeps end-to-end like a disk arm (SCAN/elevator)  |
| `Look`         | Like Scan but reverses at last request (LOOK)      |
| `NearestCar`   | Assigns closest idle elevator to each call         |
| `Etd`          | Estimated Time to Destination (see below)          |

### ETD cost model

```
cost = wait_weight  * travel_time_to_stop
     + delay_weight * existing_rider_delay
     + door_weight  * estimated_door_overhead
     + direction_bonus
```

For each pending call, ETD evaluates every elevator and picks the one
minimizing total cost. The `riding_to_stop` manifest data estimates how many
existing riders would be delayed by a detour. Weights are configurable via
`EtdDispatch::with_weights()`.

## 6. Repositioning System

### RepositionStrategy trait

```rust
pub trait RepositionStrategy: Send + Sync {
    fn reposition(
        &mut self,
        idle_elevators: &[(EntityId, f64)],    // (entity, position)
        stop_positions: &[(EntityId, f64)],    // (entity, position)
        group: &ElevatorGroup,
        world: &World,
    ) -> Vec<(EntityId, EntityId)>;  // (elevator, target_stop)
}
```

Repositioning runs as Phase 3, only on elevators still `Idle` after dispatch.
Elevators not in the returned vec remain where they are.

### Built-in strategies

| Strategy         | Behavior                                         |
|------------------|--------------------------------------------------|
| `SpreadEvenly`   | Distribute idle cars evenly across stops         |
| `ReturnToLobby`  | Send idle cars to a configured home stop         |
| `DemandWeighted` | Position near stops with historically high demand|
| `NearestIdle`    | Keep idle cars where they are (no-op)            |

Repositioning is optional per group. Groups without a registered strategy
skip the phase entirely.

## 7. Extension System

Extensions let games attach custom typed components to simulation entities
without modifying the core library.

### Attaching data

```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VipTag { level: u32 }

world.insert_ext(entity, VipTag { level: 3 }, "vip_tag");
world.get_ext::<VipTag>(entity);        // Option<VipTag> (cloned)
world.get_ext_mut::<VipTag>(entity);    // Option<&mut VipTag>
```

The `name` string is required for serialization roundtrips in snapshots.
Extension components must implement `Serialize + DeserializeOwned`.

### Querying extensions

Extensions integrate with the query builder:

```rust
// Read-only (cloned via Ext<T>)
for (id, vip) in world.query::<(EntityId, &Ext<VipTag>)>().iter() { ... }

// Mutable access (keys-snapshot pattern via ExtMut<T>)
world.query_ext_mut::<VipTag>().for_each_mut(|id, tag| { tag.level += 1; });
```

### Snapshot compatibility

Extension types must be registered before restoring a snapshot:

```rust
world.register_ext::<VipTag>("vip_tag");
sim.load_extensions(&snapshot.extensions);
```

Unregistered types are stored in a `PendingExtensions` resource until
registration. Extension data is serialized as RON strings in the snapshot.

### Cleanup

Extension components are automatically removed on `despawn()` -- no manual
cleanup required.

## 8. Event System

### EventBus

The internal `EventBus` is a per-tick buffer using a drain pattern:

```rust
pub struct EventBus {
    events: Vec<Event>,
}
```

Systems emit events during their phase via `events.emit(...)`. The metrics
phase reads events via `events.peek()`. After all phases complete,
`advance_tick()` drains the bus into `pending_output`, making events
available to consumers via `sim.drain_events()`.

### Event variants

The `Event` enum has ~25 variants organized by domain:

| Category       | Events                                                          |
|----------------|-----------------------------------------------------------------|
| Elevator       | `ElevatorDeparted`, `ElevatorArrived`, `DoorOpened`, `DoorClosed`, `PassingFloor` |
| Rider          | `RiderSpawned`, `RiderBoarded`, `RiderExited`, `RiderRejected`, `RiderAbandoned`, `RiderEjected` |
| Dispatch       | `ElevatorAssigned`, `ElevatorIdle`, `DestinationQueued`, `DirectionIndicatorChanged` |
| Topology       | `StopAdded`, `ElevatorAdded`, `EntityDisabled`, `EntityEnabled`, `RouteInvalidated`, `RiderRerouted` |
| Line lifecycle | `LineAdded`, `LineRemoved`, `LineReassigned`, `ElevatorReassigned` |
| Repositioning  | `ElevatorRepositioning`, `ElevatorRepositioned`                 |

All events carry the `tick` when they occurred and reference entities by
`EntityId`.

### EventChannel\<T\>

For game-specific typed events, insert an `EventChannel<T>` as a world
resource:

```rust
world.insert_resource(EventChannel::<MyGameEvent>::new());
world.resource_mut::<EventChannel<MyGameEvent>>().unwrap().emit(MyEvent::Score(100));
```

## 9. Snapshot Save/Load

### WorldSnapshot

`sim.snapshot()` captures the full simulation state in a serializable
`WorldSnapshot`:

- All entities and their components (built-in + extensions)
- Elevator groups with line topology
- Stop ID lookup table
- Metrics and tagged metrics
- Tick counter and time configuration
- Dispatch strategy identifiers

### Entity ID remapping

On restore, fresh `EntityId` values are generated (SlotMap keys are not
stable across sessions). The snapshot stores entity data by index;
`restore()` builds an `old_id → new_id` mapping and remaps all
cross-references (elevator riders, rider phases, route legs, group caches).

### Extension data handling

Extension components are serialized as `HashMap<String, HashMap<EntityId, String>>`
(name to entity-RON-string mapping). On restore, this data is stored in a
`PendingExtensions` resource. After the game registers its extension types
via `world.register_ext::<T>(name)`, calling `sim.load_extensions()` deserializes
and attaches the data.

### Custom dispatch strategies

Built-in strategies (`Scan`, `Look`, `NearestCar`, `Etd`) are restored
automatically via their `BuiltinStrategy` enum. Custom strategies require
a factory function:

```rust
let sim = snapshot.restore(Some(&|name: &str| match name {
    "my_strategy" => Some(Box::new(MyStrategy::new())),
    _ => None,
}));
```

## 10. Performance Characteristics

| Operation                   | Complexity      | Notes                                  |
|-----------------------------|-----------------|----------------------------------------|
| Entity iteration            | O(n)            | Linear scan over `SecondaryMap`        |
| Stop-passing detection      | O(log n)        | Binary search on `SortedStops` resource|
| Dispatch manifest build     | O(riders)       | Per group, per tick                    |
| Loading (board/exit)        | O(elevators)    | One rider action per elevator per tick |
| Topology queries            | O(V+E)          | Lazy graph rebuild, BFS via `TopologyGraph` |
| Tagged metrics              | O(tags/entity)  | Per event, lookups in `MetricTags`     |
| Query iteration             | O(alive)        | Filters applied during iteration       |
| Snapshot save               | O(entities)     | Single pass over all component maps    |
| Snapshot restore            | O(entities)     | Spawn + remap pass                     |