Skip to main content

elevator_core/sim/
topology.rs

1//! Dynamic topology mutation and queries.
2//!
3//! Add/remove/reassign lines, elevators, stops, and groups at runtime, plus
4//! read-only topology queries (reachability, shortest route, transfer
5//! points). Split out from `sim.rs` to keep each concern readable.
6
7use crate::components::Route;
8use crate::components::{Elevator, ElevatorPhase, Line, LineKind, Position, Stop, Velocity};
9use crate::dispatch::{BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo};
10use crate::door::DoorState;
11#[cfg(feature = "loop_lines")]
12use crate::entity::ElevatorId;
13use crate::entity::EntityId;
14use crate::error::SimError;
15use crate::events::Event;
16use crate::ids::GroupId;
17use crate::topology::TopologyGraph;
18
19use super::{ElevatorParams, LineParams, Simulation};
20
21/// Enforce `n_cars * min_headway <= circumference` for a Loop line.
22///
23/// `n_cars_after` is the post-operation car count (i.e. existing + 1
24/// when attaching a new car). `op` names the operation in the error
25/// message — e.g. `"attaching car"` or `"reassigning"` — so callers
26/// surface the right context. No-op for Linear lines.
27#[cfg(feature = "loop_lines")]
28fn check_loop_capacity(line: &Line, n_cars_after: usize, op: &'static str) -> Result<(), SimError> {
29    let LineKind::Loop {
30        circumference,
31        min_headway,
32    } = *line.kind()
33    else {
34        return Ok(());
35    };
36    #[allow(
37        clippy::cast_precision_loss,
38        reason = "n_cars_after is bounded by usize; the comparison is against a finite f64"
39    )]
40    let required = (n_cars_after as f64) * min_headway;
41    if required > circumference {
42        return Err(SimError::InvalidConfig {
43            field: "line.kind",
44            reason: format!(
45                "loop line: {op} would require {required} units of headway \
46                 ({n_cars_after} cars × min_headway {min_headway}); \
47                 exceeds circumference {circumference}",
48            ),
49        });
50    }
51    Ok(())
52}
53
54impl Simulation {
55    // ── Dynamic topology ────────────────────────────────────────────
56
57    /// Mark the topology graph dirty so it is rebuilt on next query.
58    fn mark_topo_dirty(&self) {
59        if let Ok(mut g) = self.topo_graph.lock() {
60            g.mark_dirty();
61        }
62    }
63
64    /// Find the (`group_index`, `line_index`) for a line entity.
65    fn find_line(&self, line: EntityId) -> Result<(usize, usize), SimError> {
66        self.groups
67            .iter()
68            .enumerate()
69            .find_map(|(gi, g)| {
70                g.lines()
71                    .iter()
72                    .position(|li| li.entity() == line)
73                    .map(|li_idx| (gi, li_idx))
74            })
75            .ok_or(SimError::LineNotFound(line))
76    }
77
78    /// Add a new stop to a group at runtime. Returns its `EntityId`.
79    ///
80    /// Runtime-added stops have no `StopId` — they are identified purely
81    /// by `EntityId`. The `stop_lookup` (config `StopId` → `EntityId`)
82    /// is not updated.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
87    pub fn add_stop(
88        &mut self,
89        name: String,
90        position: f64,
91        line: EntityId,
92    ) -> Result<EntityId, SimError> {
93        if !position.is_finite() {
94            return Err(SimError::InvalidConfig {
95                field: "position",
96                reason: format!(
97                    "stop position must be finite (got {position}); NaN/±inf \
98                     corrupt SortedStops ordering and find_stop_at_position lookup"
99                ),
100            });
101        }
102
103        let group_id = self
104            .world
105            .line(line)
106            .map(|l| l.group)
107            .ok_or(SimError::LineNotFound(line))?;
108
109        let (group_idx, line_idx) = self.find_line(line)?;
110
111        let eid = self.world.spawn();
112        self.world.set_stop(eid, Stop { name, position });
113        self.world.set_position(eid, Position { value: position });
114
115        // Add to the line's serves list.
116        self.groups[group_idx].lines_mut()[line_idx].add_stop(eid);
117
118        // Add to the group's flat cache.
119        self.groups[group_idx].push_stop(eid);
120
121        // Maintain sorted-stops index for O(log n) PassingFloor detection.
122        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
123            let idx = sorted.0.partition_point(|&(p, _)| p < position);
124            sorted.0.insert(idx, (position, eid));
125        }
126
127        self.mark_topo_dirty();
128        self.events.emit(Event::StopAdded {
129            stop: eid,
130            line,
131            group: group_id,
132            tick: self.tick,
133        });
134        Ok(eid)
135    }
136
137    /// Add a new elevator to a line at runtime. Returns its `EntityId`.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
142    pub fn add_elevator(
143        &mut self,
144        params: &ElevatorParams,
145        line: EntityId,
146        starting_position: f64,
147    ) -> Result<EntityId, SimError> {
148        // Reject malformed params before they reach the world. Without this,
149        // zero/negative physics or zero door ticks crash later phases.
150        super::construction::validate_elevator_physics(
151            params.max_speed.value(),
152            params.acceleration.value(),
153            params.deceleration.value(),
154            params.weight_capacity.value(),
155            params.inspection_speed_factor,
156            params.door_transition_ticks,
157            params.door_open_ticks,
158            params.bypass_load_up_pct,
159            params.bypass_load_down_pct,
160        )?;
161        if !starting_position.is_finite() {
162            return Err(SimError::InvalidConfig {
163                field: "starting_position",
164                reason: format!(
165                    "must be finite (got {starting_position}); NaN/±inf corrupt \
166                     SortedStops ordering and find_stop_at_position lookup"
167                ),
168            });
169        }
170
171        let group_id = self
172            .world
173            .line(line)
174            .map(|l| l.group)
175            .ok_or(SimError::LineNotFound(line))?;
176
177        let (group_idx, line_idx) = self.find_line(line)?;
178
179        // Enforce max_cars limit.
180        if let Some(max) = self.world.line(line).and_then(Line::max_cars) {
181            let current_count = self.groups[group_idx].lines()[line_idx].elevators().len();
182            if current_count >= max {
183                return Err(SimError::InvalidConfig {
184                    field: "line.max_cars",
185                    reason: format!("line already has {current_count} cars (max {max})"),
186                });
187            }
188        }
189
190        // Loop capacity guard: enforce `(n + 1) * min_headway <= circumference`
191        // at car-attach time. `add_line` deliberately skips this check when
192        // `max_cars = None`; this is where the deferred enforcement actually
193        // runs, so a Loop line without a `max_cars` cap cannot silently
194        // accept enough cars to violate the no-overtake invariant.
195        #[cfg(feature = "loop_lines")]
196        if let Some(line_ref) = self.world.line(line) {
197            let n_after = self.groups[group_idx].lines()[line_idx].elevators().len() + 1;
198            check_loop_capacity(line_ref, n_after, "attaching car")?;
199        }
200
201        let eid = self.world.spawn();
202        self.world.set_position(
203            eid,
204            Position {
205                value: starting_position,
206            },
207        );
208        self.world.set_velocity(eid, Velocity { value: 0.0 });
209        let is_loop = self.world.line(line).is_some_and(Line::is_loop);
210        self.world.set_elevator(
211            eid,
212            Elevator {
213                phase: ElevatorPhase::Idle,
214                door: DoorState::Closed,
215                max_speed: params.max_speed,
216                acceleration: params.acceleration,
217                deceleration: params.deceleration,
218                weight_capacity: params.weight_capacity,
219                current_load: crate::components::Weight::ZERO,
220                riders: Vec::new(),
221                target_stop: None,
222                door_transition_ticks: params.door_transition_ticks,
223                door_open_ticks: params.door_open_ticks,
224                line,
225                repositioning: false,
226                restricted_stops: params.restricted_stops.clone(),
227                inspection_speed_factor: params.inspection_speed_factor,
228                going_up: !is_loop,
229                going_down: !is_loop,
230                going_forward: is_loop,
231                move_count: 0,
232                door_command_queue: Vec::new(),
233                manual_target_velocity: None,
234                bypass_load_up_pct: params.bypass_load_up_pct,
235                bypass_load_down_pct: params.bypass_load_down_pct,
236                home_stop: None,
237            },
238        );
239        self.world
240            .set_destination_queue(eid, crate::components::DestinationQueue::new());
241        self.groups[group_idx].lines_mut()[line_idx].add_elevator(eid);
242        self.groups[group_idx].push_elevator(eid);
243
244        // Tag the elevator with its line's "line:{name}" tag.
245        let line_name = self.world.line(line).map(|l| l.name.clone());
246        if let Some(name) = line_name
247            && let Some(tags) = self
248                .world
249                .resource_mut::<crate::tagged_metrics::MetricTags>()
250        {
251            tags.tag(eid, format!("line:{name}"));
252        }
253
254        self.mark_topo_dirty();
255        self.events.emit(Event::ElevatorAdded {
256            elevator: eid,
257            line,
258            group: group_id,
259            tick: self.tick,
260        });
261        Ok(eid)
262    }
263
264    // ── Line / group topology ───────────────────────────────────────
265
266    /// Add a new line to a group. Returns the line entity.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`SimError::GroupNotFound`] if the specified group does not exist.
271    /// Returns [`SimError::InvalidConfig`] for malformed bounds —
272    /// non-finite `min`/`max` or `min > max` on a `Linear` line, or
273    /// non-finite / non-positive `circumference` on a `Loop` line. For
274    /// `Loop` lines, also rejects `max_cars * min_headway > circumference`
275    /// — without enough room around the loop for every car at full
276    /// headway, the no-overtake invariant is unsatisfiable.
277    pub fn add_line(&mut self, params: &LineParams) -> Result<EntityId, SimError> {
278        // Resolve the requested kind; flat fields are the fallback only
279        // when no explicit kind was provided. Validation runs against
280        // the *resolved* kind so callers passing an explicit Loop don't
281        // get a spurious flat-field complaint.
282        let kind = params.kind.unwrap_or(LineKind::Linear {
283            min: params.min_position,
284            max: params.max_position,
285        });
286        kind.validate()
287            .map_err(|(field, reason)| SimError::InvalidConfig { field, reason })?;
288
289        // Loop-specific cross-field invariant — runtime mirror of the
290        // check in `validate_explicit_topology`.
291        //
292        // Asymmetric with the config-time path on `max_cars = None`:
293        // `validate_explicit_topology` falls back to `lc.elevators.len()`
294        // because the config-time line-config bundles its elevators, but
295        // a runtime-added line is always *empty* at this point — cars
296        // attach later via `add_elevator`. The gap is closed there:
297        // `add_elevator` re-evaluates `(n + 1) * min_headway <=
298        // circumference` before each attach, so a line without a
299        // `max_cars` cap still can't violate the no-overtake invariant.
300        #[cfg(feature = "loop_lines")]
301        if let LineKind::Loop {
302            circumference,
303            min_headway,
304        } = kind
305            && let Some(max_cars) = params.max_cars
306            && max_cars > 0
307        {
308            #[allow(
309                clippy::cast_precision_loss,
310                reason = "max_cars is bounded by usize; the comparison is against a finite f64"
311            )]
312            let required = (max_cars as f64) * min_headway;
313            if required > circumference {
314                return Err(SimError::InvalidConfig {
315                    field: "line.kind",
316                    reason: format!(
317                        "loop line: {max_cars} cars × min_headway {min_headway} = {required} \
318                         exceeds circumference {circumference}",
319                    ),
320                });
321            }
322        }
323
324        let group_id = params.group;
325        let group = self
326            .groups
327            .iter_mut()
328            .find(|g| g.id() == group_id)
329            .ok_or(SimError::GroupNotFound(group_id))?;
330
331        let line_tag = format!("line:{}", params.name);
332
333        let eid = self.world.spawn();
334        self.world.set_line(
335            eid,
336            Line {
337                name: params.name.clone(),
338                group: group_id,
339                orientation: params.orientation,
340                position: params.position,
341                kind,
342                max_cars: params.max_cars,
343            },
344        );
345
346        group
347            .lines_mut()
348            .push(LineInfo::new(eid, Vec::new(), Vec::new()));
349
350        // Tag the line entity with "line:{name}" for per-line metrics.
351        if let Some(tags) = self
352            .world
353            .resource_mut::<crate::tagged_metrics::MetricTags>()
354        {
355            tags.tag(eid, line_tag);
356        }
357
358        self.mark_topo_dirty();
359        self.events.emit(Event::LineAdded {
360            line: eid,
361            group: group_id,
362            tick: self.tick,
363        });
364        Ok(eid)
365    }
366
367    /// Set the reachable position range of a line.
368    ///
369    /// Cars whose current position falls outside the new `[min, max]` are
370    /// clamped to the boundary. Phase is left untouched — a car mid-travel
371    /// keeps `MovingToStop` and the movement system reconciles on the
372    /// next tick.
373    ///
374    /// # Errors
375    ///
376    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
377    /// Returns [`SimError::InvalidConfig`] if `min` or `max` is non-finite
378    /// or `min > max`.
379    pub fn set_line_range(&mut self, line: EntityId, min: f64, max: f64) -> Result<(), SimError> {
380        if !min.is_finite() || !max.is_finite() {
381            return Err(SimError::InvalidConfig {
382                field: "line.range",
383                reason: format!("min/max must be finite (got min={min}, max={max})"),
384            });
385        }
386        if min > max {
387            return Err(SimError::InvalidConfig {
388                field: "line.range",
389                reason: format!("min ({min}) must be <= max ({max})"),
390            });
391        }
392        let line_ref = self
393            .world
394            .line_mut(line)
395            .ok_or(SimError::LineNotFound(line))?;
396        // `set_line_range` is a Linear-only operation; loops have no
397        // endpoints to set. Reject early so callers don't silently mutate
398        // the wrong field on a Loop line.
399        match &mut line_ref.kind {
400            LineKind::Linear {
401                min: kmin,
402                max: kmax,
403            } => {
404                *kmin = min;
405                *kmax = max;
406            }
407            #[cfg(feature = "loop_lines")]
408            LineKind::Loop { .. } => {
409                return Err(SimError::InvalidConfig {
410                    field: "line.range",
411                    reason: "set_line_range is not valid on a Loop line; \
412                            change circumference via a future API instead"
413                        .to_string(),
414                });
415            }
416        }
417
418        // Clamp any cars on this line whose position falls outside the new range.
419        let car_ids: Vec<EntityId> = self
420            .world
421            .iter_elevators()
422            .filter_map(|(eid, _, car)| (car.line == line).then_some(eid))
423            .collect();
424        for eid in car_ids {
425            // Skip cars without a Position component — clamping requires
426            // a real reading, and writing velocity alone (without a
427            // matching position update) would silently desync the two.
428            let Some(pos) = self.world.position(eid).map(|p| p.value) else {
429                continue;
430            };
431            if pos < min || pos > max {
432                let clamped = pos.clamp(min, max);
433                if let Some(p) = self.world.position_mut(eid) {
434                    p.value = clamped;
435                }
436                if let Some(v) = self.world.velocity_mut(eid) {
437                    v.value = 0.0;
438                }
439            }
440        }
441
442        self.mark_topo_dirty();
443        Ok(())
444    }
445
446    /// Remove a line and all its elevators from the simulation.
447    ///
448    /// Elevators on the line are disabled (not despawned) so riders are
449    /// properly ejected to the nearest stop.
450    ///
451    /// # Errors
452    ///
453    /// Returns [`SimError::LineNotFound`] if the line entity is not found
454    /// in any group.
455    pub fn remove_line(&mut self, line: EntityId) -> Result<(), SimError> {
456        let (group_idx, line_idx) = self.find_line(line)?;
457
458        let group_id = self.groups[group_idx].id();
459
460        // Collect elevator entities to disable.
461        let elevator_ids: Vec<EntityId> = self.groups[group_idx].lines()[line_idx]
462            .elevators()
463            .to_vec();
464
465        // Disable each elevator (ejects riders properly).
466        for eid in &elevator_ids {
467            // Ignore errors from already-disabled elevators.
468            let _ = self.disable(*eid);
469        }
470
471        // Remove the LineInfo from the group.
472        self.groups[group_idx].lines_mut().remove(line_idx);
473
474        // Rebuild flat caches.
475        self.groups[group_idx].rebuild_caches();
476
477        // Remove Line component from world.
478        self.world.remove_line(line);
479
480        self.mark_topo_dirty();
481        self.events.emit(Event::LineRemoved {
482            line,
483            group: group_id,
484            tick: self.tick,
485        });
486        Ok(())
487    }
488
489    /// Remove an elevator from the simulation.
490    ///
491    /// The elevator is disabled first (ejecting any riders), then removed
492    /// from its line and despawned from the world.
493    ///
494    /// # Errors
495    ///
496    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
497    pub fn remove_elevator(&mut self, elevator: EntityId) -> Result<(), SimError> {
498        let line = self
499            .world
500            .elevator(elevator)
501            .ok_or(SimError::EntityNotFound(elevator))?
502            .line();
503
504        // Disable first to eject riders and reset state.
505        let _ = self.disable(elevator);
506
507        // Find and remove from group/line topology. If `find_line` fails
508        // the elevator's `line` ref points at a removed/moved line — an
509        // inconsistent state, but we still want to despawn for cleanup.
510        //
511        // The `disable` call above already fired `notify_removed` on the
512        // group's dispatcher — the cache still includes the elevator at
513        // that point — so no additional notify is needed here. Custom
514        // `DispatchStrategy::notify_removed` impls that count invocations
515        // (e.g. tests with an `AtomicUsize`) can assume exactly one call
516        // per removal.
517        let resolved_group: Option<GroupId> = match self.find_line(line) {
518            Ok((group_idx, line_idx)) => {
519                self.groups[group_idx].lines_mut()[line_idx].remove_elevator(elevator);
520                self.groups[group_idx].rebuild_caches();
521                Some(self.groups[group_idx].id())
522            }
523            Err(_) => None,
524        };
525
526        // Only emit ElevatorRemoved when we resolved the actual group.
527        // Pre-fix this fired with `GroupId(0)` as a sentinel, masquerading
528        // a dangling-line cleanup as a legitimate group-0 removal (#266).
529        if let Some(group_id) = resolved_group {
530            self.events.emit(Event::ElevatorRemoved {
531                elevator,
532                line,
533                group: group_id,
534                tick: self.tick,
535            });
536        }
537
538        // Despawn from world.
539        self.world.despawn(elevator);
540
541        self.mark_topo_dirty();
542        Ok(())
543    }
544
545    /// Remove a stop from the simulation.
546    ///
547    /// The stop is disabled first (invalidating routes that reference it),
548    /// then removed from all lines and despawned from the world.
549    ///
550    /// # Errors
551    ///
552    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
553    pub fn remove_stop(&mut self, stop: EntityId) -> Result<(), SimError> {
554        if self.world.stop(stop).is_none() {
555            return Err(SimError::EntityNotFound(stop));
556        }
557
558        // Warn if resident riders exist at the stop before we disable it
559        // (disabling will abandon them, clearing the residents index).
560        let residents: Vec<EntityId> = self
561            .rider_index
562            .residents_at(stop)
563            .iter()
564            .copied()
565            .collect();
566        if !residents.is_empty() {
567            self.events
568                .emit(Event::ResidentsAtRemovedStop { stop, residents });
569        }
570
571        // Disable first to invalidate routes referencing this stop.
572        // Use the stop-specific helper so route-invalidation events
573        // carry `StopRemoved` rather than `StopDisabled`.
574        self.disable_stop_inner(stop, true);
575        self.world.disable(stop);
576        self.events.emit(Event::EntityDisabled {
577            entity: stop,
578            tick: self.tick,
579        });
580
581        // Scrub references to the removed stop from every elevator so the
582        // post-despawn tick loop does not chase a dead EntityId through
583        // `target_stop`, the destination queue, or access-control checks.
584        let elevator_ids: Vec<EntityId> =
585            self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
586        for eid in elevator_ids {
587            if let Some(car) = self.world.elevator_mut(eid) {
588                if car.target_stop == Some(stop) {
589                    car.target_stop = None;
590                }
591                car.restricted_stops.remove(&stop);
592            }
593            if let Some(q) = self.world.destination_queue_mut(eid) {
594                q.retain(|s| s != stop);
595            }
596            // Drop any car-call whose floor is the removed stop. Built-in
597            // strategies don't currently route on car_calls but the public
598            // `sim.car_calls(car)` accessor and custom strategies (via
599            // `car_calls_for`) would otherwise return dangling refs (#293).
600            if let Some(calls) = self.world.car_calls_mut(eid) {
601                calls.retain(|c| c.floor != stop);
602            }
603        }
604
605        // Remove from all lines and groups.
606        for group in &mut self.groups {
607            for line_info in group.lines_mut() {
608                line_info.remove_stop(stop);
609            }
610            group.rebuild_caches();
611        }
612
613        // Remove from SortedStops resource.
614        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
615            sorted.0.retain(|&(_, s)| s != stop);
616        }
617
618        // Remove from stop_lookup.
619        self.stop_lookup.retain(|_, &mut eid| eid != stop);
620
621        self.events.emit(Event::StopRemoved {
622            stop,
623            tick: self.tick,
624        });
625
626        // Despawn from world.
627        self.world.despawn(stop);
628
629        // Rebuild the rider index to evict any stale per-stop entries
630        // pointing at the despawned stop. Cheap (O(riders)) and the only
631        // safe option once the stop EntityId is gone.
632        self.rider_index.rebuild(&self.world);
633
634        self.mark_topo_dirty();
635        Ok(())
636    }
637
638    /// Create a new dispatch group. Returns the group ID.
639    pub fn add_group(
640        &mut self,
641        name: impl Into<String>,
642        dispatch: impl DispatchStrategy + 'static,
643    ) -> GroupId {
644        let next_id = self
645            .groups
646            .iter()
647            .map(|g| g.id().0)
648            .max()
649            .map_or(0, |m| m + 1);
650        let group_id = GroupId(next_id);
651
652        self.groups
653            .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
654
655        self.dispatcher_set
656            .insert(group_id, Box::new(dispatch), BuiltinStrategy::Scan);
657        self.mark_topo_dirty();
658        group_id
659    }
660
661    /// Reassign a line to a different group. Returns the old `GroupId`.
662    ///
663    /// # Errors
664    ///
665    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
666    /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
667    pub fn assign_line_to_group(
668        &mut self,
669        line: EntityId,
670        new_group: GroupId,
671    ) -> Result<GroupId, SimError> {
672        let (old_group_idx, line_idx) = self.find_line(line)?;
673
674        // Verify new group exists.
675        if !self.groups.iter().any(|g| g.id() == new_group) {
676            return Err(SimError::GroupNotFound(new_group));
677        }
678
679        let old_group_id = self.groups[old_group_idx].id();
680
681        // Same-group reassign is a no-op. Skip BEFORE the notify_removed
682        // calls or we'd needlessly clear each elevator's dispatcher state
683        // (direction tracking in SCAN/LOOK, etc.) on a redundant move.
684        // Matches the early-return pattern in `reassign_elevator_to_line`.
685        if old_group_id == new_group {
686            return Ok(old_group_id);
687        }
688
689        // Enforce group homogeneity: a Loop line cannot land in a group
690        // with Linear members, and vice versa. The dispatch contract
691        // (e.g. `LoopSweep` / `LoopSchedule` strategies) assumes every
692        // line in their group is the same kind; mixing types silently
693        // breaks both ranking and the headway clamp because the group's
694        // strategy is single-typed.
695        #[cfg(feature = "loop_lines")]
696        if let Some(moved_is_loop) = self.world.line(line).map(Line::is_loop) {
697            let new_group_idx = self
698                .groups
699                .iter()
700                .position(|g| g.id() == new_group)
701                .ok_or(SimError::GroupNotFound(new_group))?;
702            let mismatch = self.groups[new_group_idx]
703                .lines()
704                .iter()
705                .filter_map(|li| self.world.line(li.entity()))
706                .any(|existing| existing.is_loop() != moved_is_loop);
707            if mismatch {
708                return Err(SimError::InvalidConfig {
709                    field: "group.kind",
710                    reason: format!(
711                        "cannot mix Linear and Loop lines in the same group \
712                         (moving line into group {new_group:?} would create a heterogeneous group)",
713                    ),
714                });
715            }
716        }
717
718        // Notify the old dispatcher that these elevators are leaving — its
719        // per-elevator state (e.g. ScanDispatch.direction keyed by EntityId)
720        // would otherwise leak indefinitely as lines move between groups.
721        // Mirrors the cleanup `reassign_elevator_to_line` already does. (#257)
722        let elevators_to_notify: Vec<EntityId> = self.groups[old_group_idx].lines()[line_idx]
723            .elevators()
724            .to_vec();
725        if let Some(dispatcher) = self.dispatcher_set.strategies_mut().get_mut(&old_group_id) {
726            for eid in &elevators_to_notify {
727                dispatcher.notify_removed(*eid);
728            }
729        }
730
731        // Remove LineInfo from old group.
732        let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
733        self.groups[old_group_idx].rebuild_caches();
734
735        // Re-lookup new_group_idx by ID — we didn't capture it before the
736        // mutation. (Removal of a `LineInfo` from a group's inner `lines`
737        // vec doesn't shift `self.groups` indices, so this is purely about
738        // not having stored the index earlier, not about index invalidation.)
739        let new_group_idx = self
740            .groups
741            .iter()
742            .position(|g| g.id() == new_group)
743            .ok_or(SimError::GroupNotFound(new_group))?;
744        self.groups[new_group_idx].lines_mut().push(line_info);
745        self.groups[new_group_idx].rebuild_caches();
746
747        // Update Line component's group field.
748        if let Some(line_comp) = self.world.line_mut(line) {
749            line_comp.group = new_group;
750        }
751
752        self.mark_topo_dirty();
753        self.events.emit(Event::LineReassigned {
754            line,
755            old_group: old_group_id,
756            new_group,
757            tick: self.tick,
758        });
759
760        Ok(old_group_id)
761    }
762
763    /// Reassign an elevator to a different line (swing-car pattern).
764    ///
765    /// The elevator is moved from its current line to the target line.
766    /// Both lines must be in the same group, or you must reassign the
767    /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
768    ///
769    /// # Errors
770    ///
771    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
772    /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
773    pub fn reassign_elevator_to_line(
774        &mut self,
775        elevator: EntityId,
776        new_line: EntityId,
777    ) -> Result<(), SimError> {
778        let old_line = self
779            .world
780            .elevator(elevator)
781            .ok_or(SimError::EntityNotFound(elevator))?
782            .line();
783
784        if old_line == new_line {
785            return Ok(());
786        }
787
788        // Validate both lines exist BEFORE mutating anything.
789        let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
790        let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
791
792        // Enforce max_cars on target line.
793        if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
794            let current_count = self.groups[new_group_idx].lines()[new_line_idx]
795                .elevators()
796                .len();
797            if current_count >= max {
798                return Err(SimError::InvalidConfig {
799                    field: "line.max_cars",
800                    reason: format!("target line already has {current_count} cars (max {max})"),
801                });
802            }
803        }
804
805        // Loop capacity guard: same `(n + 1) * min_headway <= circumference`
806        // invariant `add_elevator` enforces. Without this, a swing
807        // re-assignment can push a Loop line over its headway capacity.
808        #[cfg(feature = "loop_lines")]
809        if let Some(line_ref) = self.world.line(new_line) {
810            let n_after = self.groups[new_group_idx].lines()[new_line_idx]
811                .elevators()
812                .len()
813                + 1;
814            check_loop_capacity(line_ref, n_after, "reassigning")?;
815        }
816
817        let old_group_id = self.groups[old_group_idx].id();
818        let new_group_id = self.groups[new_group_idx].id();
819
820        self.groups[old_group_idx].lines_mut()[old_line_idx].remove_elevator(elevator);
821        self.groups[new_group_idx].lines_mut()[new_line_idx].add_elevator(elevator);
822
823        if let Some(car) = self.world.elevator_mut(elevator) {
824            car.line = new_line;
825        }
826
827        self.groups[old_group_idx].rebuild_caches();
828        if new_group_idx != old_group_idx {
829            self.groups[new_group_idx].rebuild_caches();
830
831            // Notify the old group's dispatcher so it clears per-elevator
832            // state (ScanDispatch/LookDispatch track direction by
833            // EntityId). Matches the symmetry with `remove_elevator`.
834            if let Some(old_dispatcher) =
835                self.dispatcher_set.strategies_mut().get_mut(&old_group_id)
836            {
837                old_dispatcher.notify_removed(elevator);
838            }
839        }
840
841        self.mark_topo_dirty();
842
843        let _ = new_group_id; // reserved for symmetric notify_added once the trait gains one
844        self.events.emit(Event::ElevatorReassigned {
845            elevator,
846            old_line,
847            new_line,
848            tick: self.tick,
849        });
850
851        Ok(())
852    }
853
854    /// Add a stop to a line's served stops.
855    ///
856    /// # Errors
857    ///
858    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
859    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
860    pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
861        // Verify stop exists.
862        if self.world.stop(stop).is_none() {
863            return Err(SimError::EntityNotFound(stop));
864        }
865
866        let (group_idx, line_idx) = self.find_line(line)?;
867
868        let li = &mut self.groups[group_idx].lines_mut()[line_idx];
869        li.add_stop(stop);
870
871        self.groups[group_idx].push_stop(stop);
872
873        self.mark_topo_dirty();
874        Ok(())
875    }
876
877    /// Remove a stop from a line's served stops.
878    ///
879    /// # Errors
880    ///
881    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
882    pub fn remove_stop_from_line(
883        &mut self,
884        stop: EntityId,
885        line: EntityId,
886    ) -> Result<(), SimError> {
887        let (group_idx, line_idx) = self.find_line(line)?;
888
889        self.groups[group_idx].lines_mut()[line_idx].remove_stop(stop);
890
891        // Rebuild group's stop_entities from all lines.
892        self.groups[group_idx].rebuild_caches();
893
894        self.mark_topo_dirty();
895        Ok(())
896    }
897
898    // ── Line / group queries ────────────────────────────────────────
899
900    /// Get all line entities across all groups.
901    #[must_use]
902    pub fn all_lines(&self) -> Vec<EntityId> {
903        self.groups
904            .iter()
905            .flat_map(|g| g.lines().iter().map(LineInfo::entity))
906            .collect()
907    }
908
909    /// Number of lines in the simulation.
910    #[must_use]
911    pub fn line_count(&self) -> usize {
912        self.groups.iter().map(|g| g.lines().len()).sum()
913    }
914
915    /// Get all line entities in a group.
916    #[must_use]
917    pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
918        self.groups
919            .iter()
920            .find(|g| g.id() == group)
921            .map_or_else(Vec::new, |g| {
922                g.lines().iter().map(LineInfo::entity).collect()
923            })
924    }
925
926    /// Get elevator entities on a specific line.
927    #[must_use]
928    pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
929        self.groups
930            .iter()
931            .flat_map(ElevatorGroup::lines)
932            .find(|li| li.entity() == line)
933            .map_or_else(Vec::new, |li| li.elevators().to_vec())
934    }
935
936    /// Get stop entities served by a specific line.
937    #[must_use]
938    pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
939        self.groups
940            .iter()
941            .flat_map(ElevatorGroup::lines)
942            .find(|li| li.entity() == line)
943            .map_or_else(Vec::new, |li| li.serves().to_vec())
944    }
945
946    /// Whether the given line has [`LineKind::Loop`] topology.
947    ///
948    /// Returns `false` for `Linear` lines, lines that don't exist, and
949    /// any future topology that isn't a closed loop. Hosts that wire
950    /// loop-aware rendering or dispatch should branch on this.
951    #[must_use]
952    pub fn is_loop(&self, line: EntityId) -> bool {
953        self.world
954            .line(line)
955            .is_some_and(crate::components::Line::is_loop)
956    }
957
958    /// Total path length of a [`LineKind::Loop`] line.
959    ///
960    /// Returns `None` for `Linear` lines and for missing line entities.
961    /// Hosts use this together with [`Self::is_loop`] to derive a
962    /// rendering radius (e.g. `r = C / (2π)`) for circular layouts.
963    #[must_use]
964    pub fn loop_circumference(&self, line: EntityId) -> Option<f64> {
965        self.world
966            .line(line)
967            .and_then(crate::components::Line::circumference)
968    }
969
970    /// On a [`LineKind::Loop`] line, the elevator that is immediately
971    /// *ahead* of `elevator` in forward cyclic order — i.e. its leader.
972    ///
973    /// Returns `None` for:
974    /// - `Linear` lines and missing entities
975    /// - solo cars on a Loop (no other car to lead them)
976    ///
977    /// A sibling car at the same physical position is treated as a valid
978    /// leader at `forward_distance = 0`; consumers that want a
979    /// strictly-ahead car should filter on `loop_forward_gap > 0`.
980    /// Useful for game-side UI (e.g. rendering coupled cars, "car ahead"
981    /// indicators, or train-style platoon visualisations).
982    #[cfg(feature = "loop_lines")]
983    #[must_use]
984    pub fn loop_leader(&self, elevator: ElevatorId) -> Option<ElevatorId> {
985        self.loop_leader_and_gap(elevator).map(|(id, _)| id)
986    }
987
988    /// On a [`LineKind::Loop`] line, the forward cyclic gap from
989    /// `elevator` to its [leader](Self::loop_leader).
990    ///
991    /// Returns `None` for Linear lines, missing entities, and solo cars.
992    /// Always returns a value in `[0, circumference)` when `Some`;
993    /// callers can compare against the line's `min_headway` to detect a
994    /// car pressed up against the headway clamp (and therefore unable to
995    /// advance regardless of throttle).
996    #[cfg(feature = "loop_lines")]
997    #[must_use]
998    pub fn loop_forward_gap(&self, elevator: ElevatorId) -> Option<f64> {
999        self.loop_leader_and_gap(elevator).map(|(_, gap)| gap)
1000    }
1001
1002    /// Joint helper for `loop_leader` and `loop_forward_gap`: a single
1003    /// `iter_elevators` walk yields both the leader's identity and the
1004    /// forward-cyclic distance to it.
1005    #[cfg(feature = "loop_lines")]
1006    fn loop_leader_and_gap(&self, elevator: ElevatorId) -> Option<(ElevatorId, f64)> {
1007        let eid = elevator.entity();
1008        let car = self.world.elevator(eid)?;
1009        let line = car.line;
1010        let circumference = self.loop_circumference(line)?;
1011        let pos = self.world.position(eid)?.value;
1012        self.world
1013            .iter_elevators()
1014            .filter(|&(other, _, other_car)| other != eid && other_car.line == line)
1015            .map(|(other, p, _)| {
1016                (
1017                    crate::components::cyclic::forward_distance(pos, p.value, circumference),
1018                    other,
1019                )
1020            })
1021            .min_by(|a, b| a.0.total_cmp(&b.0))
1022            .map(|(gap, e)| (ElevatorId::from(e), gap))
1023    }
1024
1025    /// On a [`LineKind::Loop`] line, the stop that comes immediately
1026    /// *after* `position` in forward cyclic order.
1027    ///
1028    /// Walks the line's served stops, computes the forward cyclic
1029    /// distance from `position` to each, and returns the one with the
1030    /// smallest non-zero distance. A stop coincident with `position`
1031    /// is treated as a "full lap ahead" — the caller already *is* at
1032    /// that stop, so the next forward stop is what they want.
1033    ///
1034    /// Returns `None` if the line is not a Loop, the line entity is
1035    /// unknown, the line serves no stops, or `position` is non-finite.
1036    /// Non-finite `position` is rejected up front because
1037    /// [`forward_distance`](crate::components::cyclic::forward_distance)
1038    /// is documented to return `0.0` on non-finite inputs — without the
1039    /// guard, every served stop would tie at `d = circumference` and
1040    /// the first one in the list would be returned as a valid-looking
1041    /// `EntityId` despite the input being meaningless.
1042    #[must_use]
1043    pub fn loop_next_stop(&self, line: EntityId, position: f64) -> Option<EntityId> {
1044        let circumference = self.loop_circumference(line)?;
1045        let stops = self.stops_served_by_line(line);
1046        crate::dispatch::loop_next_stop_forward(&self.world, circumference, &stops, position)
1047    }
1048
1049    /// Find the stop at `position` that's served by `line`.
1050    ///
1051    /// Disambiguates the case where two stops on different lines share
1052    /// the same physical position (e.g. parallel shafts at the same
1053    /// floor, or a sky-lobby served by both a low and high bank). The
1054    /// global [`World::find_stop_at_position`](crate::world::World::find_stop_at_position)
1055    /// returns whichever stop wins the linear scan; this variant
1056    /// scopes the lookup to the line's `serves` list so consumers
1057    /// always get the stop *on the line they asked about*.
1058    ///
1059    /// Returns `None` if the line doesn't exist or no served stop
1060    /// matches the position.
1061    #[must_use]
1062    pub fn find_stop_at_position_on_line(&self, position: f64, line: EntityId) -> Option<EntityId> {
1063        let line_info = self
1064            .groups
1065            .iter()
1066            .flat_map(ElevatorGroup::lines)
1067            .find(|li| li.entity() == line)?;
1068        self.world
1069            .find_stop_at_position_in(position, line_info.serves())
1070    }
1071
1072    /// Get the line entity for an elevator.
1073    #[must_use]
1074    pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
1075        self.groups
1076            .iter()
1077            .flat_map(ElevatorGroup::lines)
1078            .find(|li| li.elevators().contains(&elevator))
1079            .map(LineInfo::entity)
1080    }
1081
1082    /// Iterate over elevators currently repositioning.
1083    pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
1084        self.world
1085            .iter_elevators()
1086            .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
1087    }
1088
1089    /// Get all line entities that serve a given stop.
1090    #[must_use]
1091    pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
1092        self.groups
1093            .iter()
1094            .flat_map(ElevatorGroup::lines)
1095            .filter(|li| li.serves().contains(&stop))
1096            .map(LineInfo::entity)
1097            .collect()
1098    }
1099
1100    /// Get all group IDs that serve a given stop.
1101    #[must_use]
1102    pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
1103        self.groups
1104            .iter()
1105            .filter(|g| g.stop_entities().contains(&stop))
1106            .map(ElevatorGroup::id)
1107            .collect()
1108    }
1109
1110    // ── Topology queries ─────────────────────────────────────────────
1111
1112    /// Rebuild the topology graph if any mutation has invalidated it.
1113    fn ensure_graph_built(&self) {
1114        if let Ok(mut graph) = self.topo_graph.lock()
1115            && graph.is_dirty()
1116        {
1117            graph.rebuild(&self.groups);
1118        }
1119    }
1120
1121    /// All stops reachable from a given stop through the line/group topology.
1122    pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
1123        self.ensure_graph_built();
1124        self.topo_graph
1125            .lock()
1126            .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
1127    }
1128
1129    /// Stops that serve as transfer points between groups.
1130    pub fn transfer_points(&self) -> Vec<EntityId> {
1131        self.ensure_graph_built();
1132        TopologyGraph::transfer_points(&self.groups)
1133    }
1134
1135    /// Find the shortest route between two stops, possibly spanning multiple groups.
1136    pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
1137        self.ensure_graph_built();
1138        self.topo_graph
1139            .lock()
1140            .ok()
1141            .and_then(|g| g.shortest_route(from, to))
1142    }
1143}