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, Position, Stop, Velocity};
9use crate::dispatch::{BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo};
10use crate::door::DoorState;
11use crate::entity::EntityId;
12use crate::error::SimError;
13use crate::events::Event;
14use crate::ids::GroupId;
15use crate::topology::TopologyGraph;
16
17use super::{ElevatorParams, LineParams, Simulation};
18
19impl Simulation {
20    // ── Dynamic topology ────────────────────────────────────────────
21
22    /// Mark the topology graph dirty so it is rebuilt on next query.
23    pub(super) fn mark_topo_dirty(&self) {
24        if let Ok(mut g) = self.topo_graph.lock() {
25            g.mark_dirty();
26        }
27    }
28
29    /// Find the (`group_index`, `line_index`) for a line entity.
30    pub(super) fn find_line(&self, line: EntityId) -> Result<(usize, usize), SimError> {
31        self.groups
32            .iter()
33            .enumerate()
34            .find_map(|(gi, g)| {
35                g.lines()
36                    .iter()
37                    .position(|li| li.entity() == line)
38                    .map(|li_idx| (gi, li_idx))
39            })
40            .ok_or(SimError::LineNotFound(line))
41    }
42
43    /// Add a new stop to a group at runtime. Returns its `EntityId`.
44    ///
45    /// Runtime-added stops have no `StopId` — they are identified purely
46    /// by `EntityId`. The `stop_lookup` (config `StopId` → `EntityId`)
47    /// is not updated.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
52    pub fn add_stop(
53        &mut self,
54        name: String,
55        position: f64,
56        line: EntityId,
57    ) -> Result<EntityId, SimError> {
58        if !position.is_finite() {
59            return Err(SimError::InvalidConfig {
60                field: "position",
61                reason: format!(
62                    "stop position must be finite (got {position}); NaN/±inf \
63                     corrupt SortedStops ordering and find_stop_at_position lookup"
64                ),
65            });
66        }
67
68        let group_id = self
69            .world
70            .line(line)
71            .map(|l| l.group)
72            .ok_or(SimError::LineNotFound(line))?;
73
74        let (group_idx, line_idx) = self.find_line(line)?;
75
76        let eid = self.world.spawn();
77        self.world.set_stop(eid, Stop { name, position });
78        self.world.set_position(eid, Position { value: position });
79
80        // Add to the line's serves list.
81        self.groups[group_idx].lines_mut()[line_idx]
82            .serves_mut()
83            .push(eid);
84
85        // Add to the group's flat cache.
86        self.groups[group_idx].push_stop(eid);
87
88        // Maintain sorted-stops index for O(log n) PassingFloor detection.
89        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
90            let idx = sorted.0.partition_point(|&(p, _)| p < position);
91            sorted.0.insert(idx, (position, eid));
92        }
93
94        self.mark_topo_dirty();
95        self.events.emit(Event::StopAdded {
96            stop: eid,
97            line,
98            group: group_id,
99            tick: self.tick,
100        });
101        Ok(eid)
102    }
103
104    /// Add a new elevator to a line at runtime. Returns its `EntityId`.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`SimError::LineNotFound`] if the line entity does not exist.
109    pub fn add_elevator(
110        &mut self,
111        params: &ElevatorParams,
112        line: EntityId,
113        starting_position: f64,
114    ) -> Result<EntityId, SimError> {
115        let group_id = self
116            .world
117            .line(line)
118            .map(|l| l.group)
119            .ok_or(SimError::LineNotFound(line))?;
120
121        let (group_idx, line_idx) = self.find_line(line)?;
122
123        // Enforce max_cars limit.
124        if let Some(max) = self.world.line(line).and_then(Line::max_cars) {
125            let current_count = self.groups[group_idx].lines()[line_idx].elevators().len();
126            if current_count >= max {
127                return Err(SimError::InvalidConfig {
128                    field: "line.max_cars",
129                    reason: format!("line already has {current_count} cars (max {max})"),
130                });
131            }
132        }
133
134        let eid = self.world.spawn();
135        self.world.set_position(
136            eid,
137            Position {
138                value: starting_position,
139            },
140        );
141        self.world.set_velocity(eid, Velocity { value: 0.0 });
142        self.world.set_elevator(
143            eid,
144            Elevator {
145                phase: ElevatorPhase::Idle,
146                door: DoorState::Closed,
147                max_speed: params.max_speed,
148                acceleration: params.acceleration,
149                deceleration: params.deceleration,
150                weight_capacity: params.weight_capacity,
151                current_load: 0.0,
152                riders: Vec::new(),
153                target_stop: None,
154                door_transition_ticks: params.door_transition_ticks,
155                door_open_ticks: params.door_open_ticks,
156                line,
157                repositioning: false,
158                restricted_stops: params.restricted_stops.clone(),
159                inspection_speed_factor: params.inspection_speed_factor,
160                going_up: true,
161                going_down: true,
162                move_count: 0,
163            },
164        );
165        self.world
166            .set_destination_queue(eid, crate::components::DestinationQueue::new());
167        self.groups[group_idx].lines_mut()[line_idx]
168            .elevators_mut()
169            .push(eid);
170        self.groups[group_idx].push_elevator(eid);
171
172        // Tag the elevator with its line's "line:{name}" tag.
173        let line_name = self.world.line(line).map(|l| l.name.clone());
174        if let Some(name) = line_name
175            && let Some(tags) = self
176                .world
177                .resource_mut::<crate::tagged_metrics::MetricTags>()
178        {
179            tags.tag(eid, format!("line:{name}"));
180        }
181
182        self.mark_topo_dirty();
183        self.events.emit(Event::ElevatorAdded {
184            elevator: eid,
185            line,
186            group: group_id,
187            tick: self.tick,
188        });
189        Ok(eid)
190    }
191
192    // ── Line / group topology ───────────────────────────────────────
193
194    /// Add a new line to a group. Returns the line entity.
195    ///
196    /// # Errors
197    ///
198    /// Returns [`SimError::GroupNotFound`] if the specified group does not exist.
199    pub fn add_line(&mut self, params: &LineParams) -> Result<EntityId, SimError> {
200        let group_id = params.group;
201        let group = self
202            .groups
203            .iter_mut()
204            .find(|g| g.id() == group_id)
205            .ok_or(SimError::GroupNotFound(group_id))?;
206
207        let line_tag = format!("line:{}", params.name);
208
209        let eid = self.world.spawn();
210        self.world.set_line(
211            eid,
212            Line {
213                name: params.name.clone(),
214                group: group_id,
215                orientation: params.orientation,
216                position: params.position,
217                min_position: params.min_position,
218                max_position: params.max_position,
219                max_cars: params.max_cars,
220            },
221        );
222
223        group
224            .lines_mut()
225            .push(LineInfo::new(eid, Vec::new(), Vec::new()));
226
227        // Tag the line entity with "line:{name}" for per-line metrics.
228        if let Some(tags) = self
229            .world
230            .resource_mut::<crate::tagged_metrics::MetricTags>()
231        {
232            tags.tag(eid, line_tag);
233        }
234
235        self.mark_topo_dirty();
236        self.events.emit(Event::LineAdded {
237            line: eid,
238            group: group_id,
239            tick: self.tick,
240        });
241        Ok(eid)
242    }
243
244    /// Remove a line and all its elevators from the simulation.
245    ///
246    /// Elevators on the line are disabled (not despawned) so riders are
247    /// properly ejected to the nearest stop.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`SimError::LineNotFound`] if the line entity is not found
252    /// in any group.
253    pub fn remove_line(&mut self, line: EntityId) -> Result<(), SimError> {
254        let (group_idx, line_idx) = self.find_line(line)?;
255
256        let group_id = self.groups[group_idx].id();
257
258        // Collect elevator entities to disable.
259        let elevator_ids: Vec<EntityId> = self.groups[group_idx].lines()[line_idx]
260            .elevators()
261            .to_vec();
262
263        // Disable each elevator (ejects riders properly).
264        for eid in &elevator_ids {
265            // Ignore errors from already-disabled elevators.
266            let _ = self.disable(*eid);
267        }
268
269        // Remove the LineInfo from the group.
270        self.groups[group_idx].lines_mut().remove(line_idx);
271
272        // Rebuild flat caches.
273        self.groups[group_idx].rebuild_caches();
274
275        // Remove Line component from world.
276        self.world.remove_line(line);
277
278        self.mark_topo_dirty();
279        self.events.emit(Event::LineRemoved {
280            line,
281            group: group_id,
282            tick: self.tick,
283        });
284        Ok(())
285    }
286
287    /// Remove an elevator from the simulation.
288    ///
289    /// The elevator is disabled first (ejecting any riders), then removed
290    /// from its line and despawned from the world.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
295    pub fn remove_elevator(&mut self, elevator: EntityId) -> Result<(), SimError> {
296        let line = self
297            .world
298            .elevator(elevator)
299            .ok_or(SimError::EntityNotFound(elevator))?
300            .line();
301
302        // Disable first to eject riders and reset state.
303        let _ = self.disable(elevator);
304
305        // Find and remove from group/line topology.
306        let mut group_id = GroupId(0);
307        if let Ok((group_idx, line_idx)) = self.find_line(line) {
308            self.groups[group_idx].lines_mut()[line_idx]
309                .elevators_mut()
310                .retain(|&e| e != elevator);
311            self.groups[group_idx].rebuild_caches();
312
313            // Notify dispatch strategy.
314            group_id = self.groups[group_idx].id();
315            if let Some(dispatcher) = self.dispatchers.get_mut(&group_id) {
316                dispatcher.notify_removed(elevator);
317            }
318        }
319
320        self.events.emit(Event::ElevatorRemoved {
321            elevator,
322            line,
323            group: group_id,
324            tick: self.tick,
325        });
326
327        // Despawn from world.
328        self.world.despawn(elevator);
329
330        self.mark_topo_dirty();
331        Ok(())
332    }
333
334    /// Remove a stop from the simulation.
335    ///
336    /// The stop is disabled first (invalidating routes that reference it),
337    /// then removed from all lines and despawned from the world.
338    ///
339    /// # Errors
340    ///
341    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
342    pub fn remove_stop(&mut self, stop: EntityId) -> Result<(), SimError> {
343        if self.world.stop(stop).is_none() {
344            return Err(SimError::EntityNotFound(stop));
345        }
346
347        // Disable first to invalidate routes referencing this stop.
348        let _ = self.disable(stop);
349
350        // Scrub references to the removed stop from every elevator so the
351        // post-despawn tick loop does not chase a dead EntityId through
352        // `target_stop`, the destination queue, or access-control checks.
353        let elevator_ids: Vec<EntityId> =
354            self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
355        for eid in elevator_ids {
356            if let Some(car) = self.world.elevator_mut(eid) {
357                if car.target_stop == Some(stop) {
358                    car.target_stop = None;
359                }
360                car.restricted_stops.remove(&stop);
361            }
362            if let Some(q) = self.world.destination_queue_mut(eid) {
363                q.retain(|s| s != stop);
364            }
365        }
366
367        // Remove from all lines and groups.
368        for group in &mut self.groups {
369            for line_info in group.lines_mut() {
370                line_info.serves_mut().retain(|&s| s != stop);
371            }
372            group.rebuild_caches();
373        }
374
375        // Remove from SortedStops resource.
376        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
377            sorted.0.retain(|&(_, s)| s != stop);
378        }
379
380        // Remove from stop_lookup.
381        self.stop_lookup.retain(|_, &mut eid| eid != stop);
382
383        self.events.emit(Event::StopRemoved {
384            stop,
385            tick: self.tick,
386        });
387
388        // Despawn from world.
389        self.world.despawn(stop);
390
391        self.mark_topo_dirty();
392        Ok(())
393    }
394
395    /// Create a new dispatch group. Returns the group ID.
396    pub fn add_group(
397        &mut self,
398        name: impl Into<String>,
399        dispatch: impl DispatchStrategy + 'static,
400    ) -> GroupId {
401        let next_id = self
402            .groups
403            .iter()
404            .map(|g| g.id().0)
405            .max()
406            .map_or(0, |m| m + 1);
407        let group_id = GroupId(next_id);
408
409        self.groups
410            .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
411
412        self.dispatchers.insert(group_id, Box::new(dispatch));
413        self.strategy_ids.insert(group_id, BuiltinStrategy::Scan);
414        self.mark_topo_dirty();
415        group_id
416    }
417
418    /// Reassign a line to a different group. Returns the old `GroupId`.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
423    /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
424    pub fn assign_line_to_group(
425        &mut self,
426        line: EntityId,
427        new_group: GroupId,
428    ) -> Result<GroupId, SimError> {
429        let (old_group_idx, line_idx) = self.find_line(line)?;
430
431        // Verify new group exists.
432        if !self.groups.iter().any(|g| g.id() == new_group) {
433            return Err(SimError::GroupNotFound(new_group));
434        }
435
436        let old_group_id = self.groups[old_group_idx].id();
437
438        // Remove LineInfo from old group.
439        let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
440        self.groups[old_group_idx].rebuild_caches();
441
442        // Add LineInfo to new group.
443        // Re-lookup new_group_idx since removal may have shifted indices
444        // (only possible if old and new are different groups; if same group
445        // the line_info was already removed above).
446        let new_group_idx = self
447            .groups
448            .iter()
449            .position(|g| g.id() == new_group)
450            .ok_or(SimError::GroupNotFound(new_group))?;
451        self.groups[new_group_idx].lines_mut().push(line_info);
452        self.groups[new_group_idx].rebuild_caches();
453
454        // Update Line component's group field.
455        if let Some(line_comp) = self.world.line_mut(line) {
456            line_comp.group = new_group;
457        }
458
459        self.mark_topo_dirty();
460        self.events.emit(Event::LineReassigned {
461            line,
462            old_group: old_group_id,
463            new_group,
464            tick: self.tick,
465        });
466
467        Ok(old_group_id)
468    }
469
470    /// Reassign an elevator to a different line (swing-car pattern).
471    ///
472    /// The elevator is moved from its current line to the target line.
473    /// Both lines must be in the same group, or you must reassign the
474    /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
475    ///
476    /// # Errors
477    ///
478    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
479    /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
480    pub fn reassign_elevator_to_line(
481        &mut self,
482        elevator: EntityId,
483        new_line: EntityId,
484    ) -> Result<(), SimError> {
485        let old_line = self
486            .world
487            .elevator(elevator)
488            .ok_or(SimError::EntityNotFound(elevator))?
489            .line();
490
491        if old_line == new_line {
492            return Ok(());
493        }
494
495        // Validate both lines exist BEFORE mutating anything.
496        let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
497        let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
498
499        // Enforce max_cars on target line.
500        if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
501            let current_count = self.groups[new_group_idx].lines()[new_line_idx]
502                .elevators()
503                .len();
504            if current_count >= max {
505                return Err(SimError::InvalidConfig {
506                    field: "line.max_cars",
507                    reason: format!("target line already has {current_count} cars (max {max})"),
508                });
509            }
510        }
511
512        let old_group_id = self.groups[old_group_idx].id();
513        let new_group_id = self.groups[new_group_idx].id();
514
515        self.groups[old_group_idx].lines_mut()[old_line_idx]
516            .elevators_mut()
517            .retain(|&e| e != elevator);
518        self.groups[new_group_idx].lines_mut()[new_line_idx]
519            .elevators_mut()
520            .push(elevator);
521
522        if let Some(car) = self.world.elevator_mut(elevator) {
523            car.line = new_line;
524        }
525
526        self.groups[old_group_idx].rebuild_caches();
527        if new_group_idx != old_group_idx {
528            self.groups[new_group_idx].rebuild_caches();
529
530            // Notify the old group's dispatcher so it clears per-elevator
531            // state (ScanDispatch/LookDispatch track direction by
532            // EntityId). Matches the symmetry with `remove_elevator`.
533            if let Some(old_dispatcher) = self.dispatchers.get_mut(&old_group_id) {
534                old_dispatcher.notify_removed(elevator);
535            }
536        }
537
538        self.mark_topo_dirty();
539
540        let _ = new_group_id; // reserved for symmetric notify_added once the trait gains one
541        self.events.emit(Event::ElevatorReassigned {
542            elevator,
543            old_line,
544            new_line,
545            tick: self.tick,
546        });
547
548        Ok(())
549    }
550
551    /// Add a stop to a line's served stops.
552    ///
553    /// # Errors
554    ///
555    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
556    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
557    pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
558        // Verify stop exists.
559        if self.world.stop(stop).is_none() {
560            return Err(SimError::EntityNotFound(stop));
561        }
562
563        let (group_idx, line_idx) = self.find_line(line)?;
564
565        let li = &mut self.groups[group_idx].lines_mut()[line_idx];
566        if !li.serves().contains(&stop) {
567            li.serves_mut().push(stop);
568        }
569
570        self.groups[group_idx].push_stop(stop);
571
572        self.mark_topo_dirty();
573        Ok(())
574    }
575
576    /// Remove a stop from a line's served stops.
577    ///
578    /// # Errors
579    ///
580    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
581    pub fn remove_stop_from_line(
582        &mut self,
583        stop: EntityId,
584        line: EntityId,
585    ) -> Result<(), SimError> {
586        let (group_idx, line_idx) = self.find_line(line)?;
587
588        self.groups[group_idx].lines_mut()[line_idx]
589            .serves_mut()
590            .retain(|&s| s != stop);
591
592        // Rebuild group's stop_entities from all lines.
593        self.groups[group_idx].rebuild_caches();
594
595        self.mark_topo_dirty();
596        Ok(())
597    }
598
599    // ── Line / group queries ────────────────────────────────────────
600
601    /// Get all line entities across all groups.
602    #[must_use]
603    pub fn all_lines(&self) -> Vec<EntityId> {
604        self.groups
605            .iter()
606            .flat_map(|g| g.lines().iter().map(LineInfo::entity))
607            .collect()
608    }
609
610    /// Number of lines in the simulation.
611    #[must_use]
612    pub fn line_count(&self) -> usize {
613        self.groups.iter().map(|g| g.lines().len()).sum()
614    }
615
616    /// Get all line entities in a group.
617    #[must_use]
618    pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
619        self.groups
620            .iter()
621            .find(|g| g.id() == group)
622            .map_or_else(Vec::new, |g| {
623                g.lines().iter().map(LineInfo::entity).collect()
624            })
625    }
626
627    /// Get elevator entities on a specific line.
628    #[must_use]
629    pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
630        self.groups
631            .iter()
632            .flat_map(ElevatorGroup::lines)
633            .find(|li| li.entity() == line)
634            .map_or_else(Vec::new, |li| li.elevators().to_vec())
635    }
636
637    /// Get stop entities served by a specific line.
638    #[must_use]
639    pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
640        self.groups
641            .iter()
642            .flat_map(ElevatorGroup::lines)
643            .find(|li| li.entity() == line)
644            .map_or_else(Vec::new, |li| li.serves().to_vec())
645    }
646
647    /// Get the line entity for an elevator.
648    #[must_use]
649    pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
650        self.groups
651            .iter()
652            .flat_map(ElevatorGroup::lines)
653            .find(|li| li.elevators().contains(&elevator))
654            .map(LineInfo::entity)
655    }
656
657    /// Iterate over elevators currently repositioning.
658    pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
659        self.world
660            .iter_elevators()
661            .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
662    }
663
664    /// Get all line entities that serve a given stop.
665    #[must_use]
666    pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
667        self.groups
668            .iter()
669            .flat_map(ElevatorGroup::lines)
670            .filter(|li| li.serves().contains(&stop))
671            .map(LineInfo::entity)
672            .collect()
673    }
674
675    /// Get all group IDs that serve a given stop.
676    #[must_use]
677    pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
678        self.groups
679            .iter()
680            .filter(|g| g.stop_entities().contains(&stop))
681            .map(ElevatorGroup::id)
682            .collect()
683    }
684
685    // ── Topology queries ─────────────────────────────────────────────
686
687    /// Rebuild the topology graph if any mutation has invalidated it.
688    pub(super) fn ensure_graph_built(&self) {
689        if let Ok(mut graph) = self.topo_graph.lock()
690            && graph.is_dirty()
691        {
692            graph.rebuild(&self.groups);
693        }
694    }
695
696    /// All stops reachable from a given stop through the line/group topology.
697    pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
698        self.ensure_graph_built();
699        self.topo_graph
700            .lock()
701            .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
702    }
703
704    /// Stops that serve as transfer points between groups.
705    pub fn transfer_points(&self) -> Vec<EntityId> {
706        self.ensure_graph_built();
707        TopologyGraph::transfer_points(&self.groups)
708    }
709
710    /// Find the shortest route between two stops, possibly spanning multiple groups.
711    pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
712        self.ensure_graph_built();
713        self.topo_graph
714            .lock()
715            .ok()
716            .and_then(|g| g.shortest_route(from, to))
717    }
718}