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: crate::components::Weight::ZERO,
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                door_command_queue: Vec::new(),
164                manual_target_velocity: None,
165            },
166        );
167        self.world
168            .set_destination_queue(eid, crate::components::DestinationQueue::new());
169        self.groups[group_idx].lines_mut()[line_idx]
170            .elevators_mut()
171            .push(eid);
172        self.groups[group_idx].push_elevator(eid);
173
174        // Tag the elevator with its line's "line:{name}" tag.
175        let line_name = self.world.line(line).map(|l| l.name.clone());
176        if let Some(name) = line_name
177            && let Some(tags) = self
178                .world
179                .resource_mut::<crate::tagged_metrics::MetricTags>()
180        {
181            tags.tag(eid, format!("line:{name}"));
182        }
183
184        self.mark_topo_dirty();
185        self.events.emit(Event::ElevatorAdded {
186            elevator: eid,
187            line,
188            group: group_id,
189            tick: self.tick,
190        });
191        Ok(eid)
192    }
193
194    // ── Line / group topology ───────────────────────────────────────
195
196    /// Add a new line to a group. Returns the line entity.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`SimError::GroupNotFound`] if the specified group does not exist.
201    pub fn add_line(&mut self, params: &LineParams) -> Result<EntityId, SimError> {
202        let group_id = params.group;
203        let group = self
204            .groups
205            .iter_mut()
206            .find(|g| g.id() == group_id)
207            .ok_or(SimError::GroupNotFound(group_id))?;
208
209        let line_tag = format!("line:{}", params.name);
210
211        let eid = self.world.spawn();
212        self.world.set_line(
213            eid,
214            Line {
215                name: params.name.clone(),
216                group: group_id,
217                orientation: params.orientation,
218                position: params.position,
219                min_position: params.min_position,
220                max_position: params.max_position,
221                max_cars: params.max_cars,
222            },
223        );
224
225        group
226            .lines_mut()
227            .push(LineInfo::new(eid, Vec::new(), Vec::new()));
228
229        // Tag the line entity with "line:{name}" for per-line metrics.
230        if let Some(tags) = self
231            .world
232            .resource_mut::<crate::tagged_metrics::MetricTags>()
233        {
234            tags.tag(eid, line_tag);
235        }
236
237        self.mark_topo_dirty();
238        self.events.emit(Event::LineAdded {
239            line: eid,
240            group: group_id,
241            tick: self.tick,
242        });
243        Ok(eid)
244    }
245
246    /// Remove a line and all its elevators from the simulation.
247    ///
248    /// Elevators on the line are disabled (not despawned) so riders are
249    /// properly ejected to the nearest stop.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`SimError::LineNotFound`] if the line entity is not found
254    /// in any group.
255    pub fn remove_line(&mut self, line: EntityId) -> Result<(), SimError> {
256        let (group_idx, line_idx) = self.find_line(line)?;
257
258        let group_id = self.groups[group_idx].id();
259
260        // Collect elevator entities to disable.
261        let elevator_ids: Vec<EntityId> = self.groups[group_idx].lines()[line_idx]
262            .elevators()
263            .to_vec();
264
265        // Disable each elevator (ejects riders properly).
266        for eid in &elevator_ids {
267            // Ignore errors from already-disabled elevators.
268            let _ = self.disable(*eid);
269        }
270
271        // Remove the LineInfo from the group.
272        self.groups[group_idx].lines_mut().remove(line_idx);
273
274        // Rebuild flat caches.
275        self.groups[group_idx].rebuild_caches();
276
277        // Remove Line component from world.
278        self.world.remove_line(line);
279
280        self.mark_topo_dirty();
281        self.events.emit(Event::LineRemoved {
282            line,
283            group: group_id,
284            tick: self.tick,
285        });
286        Ok(())
287    }
288
289    /// Remove an elevator from the simulation.
290    ///
291    /// The elevator is disabled first (ejecting any riders), then removed
292    /// from its line and despawned from the world.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
297    pub fn remove_elevator(&mut self, elevator: EntityId) -> Result<(), SimError> {
298        let line = self
299            .world
300            .elevator(elevator)
301            .ok_or(SimError::EntityNotFound(elevator))?
302            .line();
303
304        // Disable first to eject riders and reset state.
305        let _ = self.disable(elevator);
306
307        // Find and remove from group/line topology.
308        let mut group_id = GroupId(0);
309        if let Ok((group_idx, line_idx)) = self.find_line(line) {
310            self.groups[group_idx].lines_mut()[line_idx]
311                .elevators_mut()
312                .retain(|&e| e != elevator);
313            self.groups[group_idx].rebuild_caches();
314
315            // Notify dispatch strategy.
316            group_id = self.groups[group_idx].id();
317            if let Some(dispatcher) = self.dispatchers.get_mut(&group_id) {
318                dispatcher.notify_removed(elevator);
319            }
320        }
321
322        self.events.emit(Event::ElevatorRemoved {
323            elevator,
324            line,
325            group: group_id,
326            tick: self.tick,
327        });
328
329        // Despawn from world.
330        self.world.despawn(elevator);
331
332        self.mark_topo_dirty();
333        Ok(())
334    }
335
336    /// Remove a stop from the simulation.
337    ///
338    /// The stop is disabled first (invalidating routes that reference it),
339    /// then removed from all lines and despawned from the world.
340    ///
341    /// # Errors
342    ///
343    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
344    pub fn remove_stop(&mut self, stop: EntityId) -> Result<(), SimError> {
345        if self.world.stop(stop).is_none() {
346            return Err(SimError::EntityNotFound(stop));
347        }
348
349        // Warn if resident riders exist at the stop before we disable it
350        // (disabling will abandon them, clearing the residents index).
351        let residents: Vec<EntityId> = self
352            .rider_index
353            .residents_at(stop)
354            .iter()
355            .copied()
356            .collect();
357        if !residents.is_empty() {
358            self.events
359                .emit(Event::ResidentsAtRemovedStop { stop, residents });
360        }
361
362        // Disable first to invalidate routes referencing this stop.
363        let _ = self.disable(stop);
364
365        // Scrub references to the removed stop from every elevator so the
366        // post-despawn tick loop does not chase a dead EntityId through
367        // `target_stop`, the destination queue, or access-control checks.
368        let elevator_ids: Vec<EntityId> =
369            self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
370        for eid in elevator_ids {
371            if let Some(car) = self.world.elevator_mut(eid) {
372                if car.target_stop == Some(stop) {
373                    car.target_stop = None;
374                }
375                car.restricted_stops.remove(&stop);
376            }
377            if let Some(q) = self.world.destination_queue_mut(eid) {
378                q.retain(|s| s != stop);
379            }
380        }
381
382        // Remove from all lines and groups.
383        for group in &mut self.groups {
384            for line_info in group.lines_mut() {
385                line_info.serves_mut().retain(|&s| s != stop);
386            }
387            group.rebuild_caches();
388        }
389
390        // Remove from SortedStops resource.
391        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
392            sorted.0.retain(|&(_, s)| s != stop);
393        }
394
395        // Remove from stop_lookup.
396        self.stop_lookup.retain(|_, &mut eid| eid != stop);
397
398        self.events.emit(Event::StopRemoved {
399            stop,
400            tick: self.tick,
401        });
402
403        // Despawn from world.
404        self.world.despawn(stop);
405
406        self.mark_topo_dirty();
407        Ok(())
408    }
409
410    /// Create a new dispatch group. Returns the group ID.
411    pub fn add_group(
412        &mut self,
413        name: impl Into<String>,
414        dispatch: impl DispatchStrategy + 'static,
415    ) -> GroupId {
416        let next_id = self
417            .groups
418            .iter()
419            .map(|g| g.id().0)
420            .max()
421            .map_or(0, |m| m + 1);
422        let group_id = GroupId(next_id);
423
424        self.groups
425            .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
426
427        self.dispatchers.insert(group_id, Box::new(dispatch));
428        self.strategy_ids.insert(group_id, BuiltinStrategy::Scan);
429        self.mark_topo_dirty();
430        group_id
431    }
432
433    /// Reassign a line to a different group. Returns the old `GroupId`.
434    ///
435    /// # Errors
436    ///
437    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
438    /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
439    pub fn assign_line_to_group(
440        &mut self,
441        line: EntityId,
442        new_group: GroupId,
443    ) -> Result<GroupId, SimError> {
444        let (old_group_idx, line_idx) = self.find_line(line)?;
445
446        // Verify new group exists.
447        if !self.groups.iter().any(|g| g.id() == new_group) {
448            return Err(SimError::GroupNotFound(new_group));
449        }
450
451        let old_group_id = self.groups[old_group_idx].id();
452
453        // Remove LineInfo from old group.
454        let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
455        self.groups[old_group_idx].rebuild_caches();
456
457        // Add LineInfo to new group.
458        // Re-lookup new_group_idx since removal may have shifted indices
459        // (only possible if old and new are different groups; if same group
460        // the line_info was already removed above).
461        let new_group_idx = self
462            .groups
463            .iter()
464            .position(|g| g.id() == new_group)
465            .ok_or(SimError::GroupNotFound(new_group))?;
466        self.groups[new_group_idx].lines_mut().push(line_info);
467        self.groups[new_group_idx].rebuild_caches();
468
469        // Update Line component's group field.
470        if let Some(line_comp) = self.world.line_mut(line) {
471            line_comp.group = new_group;
472        }
473
474        self.mark_topo_dirty();
475        self.events.emit(Event::LineReassigned {
476            line,
477            old_group: old_group_id,
478            new_group,
479            tick: self.tick,
480        });
481
482        Ok(old_group_id)
483    }
484
485    /// Reassign an elevator to a different line (swing-car pattern).
486    ///
487    /// The elevator is moved from its current line to the target line.
488    /// Both lines must be in the same group, or you must reassign the
489    /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
490    ///
491    /// # Errors
492    ///
493    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
494    /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
495    pub fn reassign_elevator_to_line(
496        &mut self,
497        elevator: EntityId,
498        new_line: EntityId,
499    ) -> Result<(), SimError> {
500        let old_line = self
501            .world
502            .elevator(elevator)
503            .ok_or(SimError::EntityNotFound(elevator))?
504            .line();
505
506        if old_line == new_line {
507            return Ok(());
508        }
509
510        // Validate both lines exist BEFORE mutating anything.
511        let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
512        let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
513
514        // Enforce max_cars on target line.
515        if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
516            let current_count = self.groups[new_group_idx].lines()[new_line_idx]
517                .elevators()
518                .len();
519            if current_count >= max {
520                return Err(SimError::InvalidConfig {
521                    field: "line.max_cars",
522                    reason: format!("target line already has {current_count} cars (max {max})"),
523                });
524            }
525        }
526
527        let old_group_id = self.groups[old_group_idx].id();
528        let new_group_id = self.groups[new_group_idx].id();
529
530        self.groups[old_group_idx].lines_mut()[old_line_idx]
531            .elevators_mut()
532            .retain(|&e| e != elevator);
533        self.groups[new_group_idx].lines_mut()[new_line_idx]
534            .elevators_mut()
535            .push(elevator);
536
537        if let Some(car) = self.world.elevator_mut(elevator) {
538            car.line = new_line;
539        }
540
541        self.groups[old_group_idx].rebuild_caches();
542        if new_group_idx != old_group_idx {
543            self.groups[new_group_idx].rebuild_caches();
544
545            // Notify the old group's dispatcher so it clears per-elevator
546            // state (ScanDispatch/LookDispatch track direction by
547            // EntityId). Matches the symmetry with `remove_elevator`.
548            if let Some(old_dispatcher) = self.dispatchers.get_mut(&old_group_id) {
549                old_dispatcher.notify_removed(elevator);
550            }
551        }
552
553        self.mark_topo_dirty();
554
555        let _ = new_group_id; // reserved for symmetric notify_added once the trait gains one
556        self.events.emit(Event::ElevatorReassigned {
557            elevator,
558            old_line,
559            new_line,
560            tick: self.tick,
561        });
562
563        Ok(())
564    }
565
566    /// Add a stop to a line's served stops.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
571    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
572    pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
573        // Verify stop exists.
574        if self.world.stop(stop).is_none() {
575            return Err(SimError::EntityNotFound(stop));
576        }
577
578        let (group_idx, line_idx) = self.find_line(line)?;
579
580        let li = &mut self.groups[group_idx].lines_mut()[line_idx];
581        if !li.serves().contains(&stop) {
582            li.serves_mut().push(stop);
583        }
584
585        self.groups[group_idx].push_stop(stop);
586
587        self.mark_topo_dirty();
588        Ok(())
589    }
590
591    /// Remove a stop from a line's served stops.
592    ///
593    /// # Errors
594    ///
595    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
596    pub fn remove_stop_from_line(
597        &mut self,
598        stop: EntityId,
599        line: EntityId,
600    ) -> Result<(), SimError> {
601        let (group_idx, line_idx) = self.find_line(line)?;
602
603        self.groups[group_idx].lines_mut()[line_idx]
604            .serves_mut()
605            .retain(|&s| s != stop);
606
607        // Rebuild group's stop_entities from all lines.
608        self.groups[group_idx].rebuild_caches();
609
610        self.mark_topo_dirty();
611        Ok(())
612    }
613
614    // ── Line / group queries ────────────────────────────────────────
615
616    /// Get all line entities across all groups.
617    #[must_use]
618    pub fn all_lines(&self) -> Vec<EntityId> {
619        self.groups
620            .iter()
621            .flat_map(|g| g.lines().iter().map(LineInfo::entity))
622            .collect()
623    }
624
625    /// Number of lines in the simulation.
626    #[must_use]
627    pub fn line_count(&self) -> usize {
628        self.groups.iter().map(|g| g.lines().len()).sum()
629    }
630
631    /// Get all line entities in a group.
632    #[must_use]
633    pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
634        self.groups
635            .iter()
636            .find(|g| g.id() == group)
637            .map_or_else(Vec::new, |g| {
638                g.lines().iter().map(LineInfo::entity).collect()
639            })
640    }
641
642    /// Get elevator entities on a specific line.
643    #[must_use]
644    pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
645        self.groups
646            .iter()
647            .flat_map(ElevatorGroup::lines)
648            .find(|li| li.entity() == line)
649            .map_or_else(Vec::new, |li| li.elevators().to_vec())
650    }
651
652    /// Get stop entities served by a specific line.
653    #[must_use]
654    pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
655        self.groups
656            .iter()
657            .flat_map(ElevatorGroup::lines)
658            .find(|li| li.entity() == line)
659            .map_or_else(Vec::new, |li| li.serves().to_vec())
660    }
661
662    /// Get the line entity for an elevator.
663    #[must_use]
664    pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
665        self.groups
666            .iter()
667            .flat_map(ElevatorGroup::lines)
668            .find(|li| li.elevators().contains(&elevator))
669            .map(LineInfo::entity)
670    }
671
672    /// Iterate over elevators currently repositioning.
673    pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
674        self.world
675            .iter_elevators()
676            .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
677    }
678
679    /// Get all line entities that serve a given stop.
680    #[must_use]
681    pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
682        self.groups
683            .iter()
684            .flat_map(ElevatorGroup::lines)
685            .filter(|li| li.serves().contains(&stop))
686            .map(LineInfo::entity)
687            .collect()
688    }
689
690    /// Get all group IDs that serve a given stop.
691    #[must_use]
692    pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
693        self.groups
694            .iter()
695            .filter(|g| g.stop_entities().contains(&stop))
696            .map(ElevatorGroup::id)
697            .collect()
698    }
699
700    // ── Topology queries ─────────────────────────────────────────────
701
702    /// Rebuild the topology graph if any mutation has invalidated it.
703    pub(super) fn ensure_graph_built(&self) {
704        if let Ok(mut graph) = self.topo_graph.lock()
705            && graph.is_dirty()
706        {
707            graph.rebuild(&self.groups);
708        }
709    }
710
711    /// All stops reachable from a given stop through the line/group topology.
712    pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
713        self.ensure_graph_built();
714        self.topo_graph
715            .lock()
716            .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
717    }
718
719    /// Stops that serve as transfer points between groups.
720    pub fn transfer_points(&self) -> Vec<EntityId> {
721        self.ensure_graph_built();
722        TopologyGraph::transfer_points(&self.groups)
723    }
724
725    /// Find the shortest route between two stops, possibly spanning multiple groups.
726    pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
727        self.ensure_graph_built();
728        self.topo_graph
729            .lock()
730            .ok()
731            .and_then(|g| g.shortest_route(from, to))
732    }
733}