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                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        // Disable first to invalidate routes referencing this stop.
350        let _ = self.disable(stop);
351
352        // Scrub references to the removed stop from every elevator so the
353        // post-despawn tick loop does not chase a dead EntityId through
354        // `target_stop`, the destination queue, or access-control checks.
355        let elevator_ids: Vec<EntityId> =
356            self.world.iter_elevators().map(|(eid, _, _)| eid).collect();
357        for eid in elevator_ids {
358            if let Some(car) = self.world.elevator_mut(eid) {
359                if car.target_stop == Some(stop) {
360                    car.target_stop = None;
361                }
362                car.restricted_stops.remove(&stop);
363            }
364            if let Some(q) = self.world.destination_queue_mut(eid) {
365                q.retain(|s| s != stop);
366            }
367        }
368
369        // Remove from all lines and groups.
370        for group in &mut self.groups {
371            for line_info in group.lines_mut() {
372                line_info.serves_mut().retain(|&s| s != stop);
373            }
374            group.rebuild_caches();
375        }
376
377        // Remove from SortedStops resource.
378        if let Some(sorted) = self.world.resource_mut::<crate::world::SortedStops>() {
379            sorted.0.retain(|&(_, s)| s != stop);
380        }
381
382        // Remove from stop_lookup.
383        self.stop_lookup.retain(|_, &mut eid| eid != stop);
384
385        self.events.emit(Event::StopRemoved {
386            stop,
387            tick: self.tick,
388        });
389
390        // Despawn from world.
391        self.world.despawn(stop);
392
393        self.mark_topo_dirty();
394        Ok(())
395    }
396
397    /// Create a new dispatch group. Returns the group ID.
398    pub fn add_group(
399        &mut self,
400        name: impl Into<String>,
401        dispatch: impl DispatchStrategy + 'static,
402    ) -> GroupId {
403        let next_id = self
404            .groups
405            .iter()
406            .map(|g| g.id().0)
407            .max()
408            .map_or(0, |m| m + 1);
409        let group_id = GroupId(next_id);
410
411        self.groups
412            .push(ElevatorGroup::new(group_id, name.into(), Vec::new()));
413
414        self.dispatchers.insert(group_id, Box::new(dispatch));
415        self.strategy_ids.insert(group_id, BuiltinStrategy::Scan);
416        self.mark_topo_dirty();
417        group_id
418    }
419
420    /// Reassign a line to a different group. Returns the old `GroupId`.
421    ///
422    /// # Errors
423    ///
424    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
425    /// Returns [`SimError::GroupNotFound`] if `new_group` does not exist.
426    pub fn assign_line_to_group(
427        &mut self,
428        line: EntityId,
429        new_group: GroupId,
430    ) -> Result<GroupId, SimError> {
431        let (old_group_idx, line_idx) = self.find_line(line)?;
432
433        // Verify new group exists.
434        if !self.groups.iter().any(|g| g.id() == new_group) {
435            return Err(SimError::GroupNotFound(new_group));
436        }
437
438        let old_group_id = self.groups[old_group_idx].id();
439
440        // Remove LineInfo from old group.
441        let line_info = self.groups[old_group_idx].lines_mut().remove(line_idx);
442        self.groups[old_group_idx].rebuild_caches();
443
444        // Add LineInfo to new group.
445        // Re-lookup new_group_idx since removal may have shifted indices
446        // (only possible if old and new are different groups; if same group
447        // the line_info was already removed above).
448        let new_group_idx = self
449            .groups
450            .iter()
451            .position(|g| g.id() == new_group)
452            .ok_or(SimError::GroupNotFound(new_group))?;
453        self.groups[new_group_idx].lines_mut().push(line_info);
454        self.groups[new_group_idx].rebuild_caches();
455
456        // Update Line component's group field.
457        if let Some(line_comp) = self.world.line_mut(line) {
458            line_comp.group = new_group;
459        }
460
461        self.mark_topo_dirty();
462        self.events.emit(Event::LineReassigned {
463            line,
464            old_group: old_group_id,
465            new_group,
466            tick: self.tick,
467        });
468
469        Ok(old_group_id)
470    }
471
472    /// Reassign an elevator to a different line (swing-car pattern).
473    ///
474    /// The elevator is moved from its current line to the target line.
475    /// Both lines must be in the same group, or you must reassign the
476    /// line first via [`assign_line_to_group`](Self::assign_line_to_group).
477    ///
478    /// # Errors
479    ///
480    /// Returns [`SimError::EntityNotFound`] if the elevator does not exist.
481    /// Returns [`SimError::LineNotFound`] if the target line is not found in any group.
482    pub fn reassign_elevator_to_line(
483        &mut self,
484        elevator: EntityId,
485        new_line: EntityId,
486    ) -> Result<(), SimError> {
487        let old_line = self
488            .world
489            .elevator(elevator)
490            .ok_or(SimError::EntityNotFound(elevator))?
491            .line();
492
493        if old_line == new_line {
494            return Ok(());
495        }
496
497        // Validate both lines exist BEFORE mutating anything.
498        let (old_group_idx, old_line_idx) = self.find_line(old_line)?;
499        let (new_group_idx, new_line_idx) = self.find_line(new_line)?;
500
501        // Enforce max_cars on target line.
502        if let Some(max) = self.world.line(new_line).and_then(Line::max_cars) {
503            let current_count = self.groups[new_group_idx].lines()[new_line_idx]
504                .elevators()
505                .len();
506            if current_count >= max {
507                return Err(SimError::InvalidConfig {
508                    field: "line.max_cars",
509                    reason: format!("target line already has {current_count} cars (max {max})"),
510                });
511            }
512        }
513
514        let old_group_id = self.groups[old_group_idx].id();
515        let new_group_id = self.groups[new_group_idx].id();
516
517        self.groups[old_group_idx].lines_mut()[old_line_idx]
518            .elevators_mut()
519            .retain(|&e| e != elevator);
520        self.groups[new_group_idx].lines_mut()[new_line_idx]
521            .elevators_mut()
522            .push(elevator);
523
524        if let Some(car) = self.world.elevator_mut(elevator) {
525            car.line = new_line;
526        }
527
528        self.groups[old_group_idx].rebuild_caches();
529        if new_group_idx != old_group_idx {
530            self.groups[new_group_idx].rebuild_caches();
531
532            // Notify the old group's dispatcher so it clears per-elevator
533            // state (ScanDispatch/LookDispatch track direction by
534            // EntityId). Matches the symmetry with `remove_elevator`.
535            if let Some(old_dispatcher) = self.dispatchers.get_mut(&old_group_id) {
536                old_dispatcher.notify_removed(elevator);
537            }
538        }
539
540        self.mark_topo_dirty();
541
542        let _ = new_group_id; // reserved for symmetric notify_added once the trait gains one
543        self.events.emit(Event::ElevatorReassigned {
544            elevator,
545            old_line,
546            new_line,
547            tick: self.tick,
548        });
549
550        Ok(())
551    }
552
553    /// Add a stop to a line's served stops.
554    ///
555    /// # Errors
556    ///
557    /// Returns [`SimError::EntityNotFound`] if the stop does not exist.
558    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
559    pub fn add_stop_to_line(&mut self, stop: EntityId, line: EntityId) -> Result<(), SimError> {
560        // Verify stop exists.
561        if self.world.stop(stop).is_none() {
562            return Err(SimError::EntityNotFound(stop));
563        }
564
565        let (group_idx, line_idx) = self.find_line(line)?;
566
567        let li = &mut self.groups[group_idx].lines_mut()[line_idx];
568        if !li.serves().contains(&stop) {
569            li.serves_mut().push(stop);
570        }
571
572        self.groups[group_idx].push_stop(stop);
573
574        self.mark_topo_dirty();
575        Ok(())
576    }
577
578    /// Remove a stop from a line's served stops.
579    ///
580    /// # Errors
581    ///
582    /// Returns [`SimError::LineNotFound`] if the line is not found in any group.
583    pub fn remove_stop_from_line(
584        &mut self,
585        stop: EntityId,
586        line: EntityId,
587    ) -> Result<(), SimError> {
588        let (group_idx, line_idx) = self.find_line(line)?;
589
590        self.groups[group_idx].lines_mut()[line_idx]
591            .serves_mut()
592            .retain(|&s| s != stop);
593
594        // Rebuild group's stop_entities from all lines.
595        self.groups[group_idx].rebuild_caches();
596
597        self.mark_topo_dirty();
598        Ok(())
599    }
600
601    // ── Line / group queries ────────────────────────────────────────
602
603    /// Get all line entities across all groups.
604    #[must_use]
605    pub fn all_lines(&self) -> Vec<EntityId> {
606        self.groups
607            .iter()
608            .flat_map(|g| g.lines().iter().map(LineInfo::entity))
609            .collect()
610    }
611
612    /// Number of lines in the simulation.
613    #[must_use]
614    pub fn line_count(&self) -> usize {
615        self.groups.iter().map(|g| g.lines().len()).sum()
616    }
617
618    /// Get all line entities in a group.
619    #[must_use]
620    pub fn lines_in_group(&self, group: GroupId) -> Vec<EntityId> {
621        self.groups
622            .iter()
623            .find(|g| g.id() == group)
624            .map_or_else(Vec::new, |g| {
625                g.lines().iter().map(LineInfo::entity).collect()
626            })
627    }
628
629    /// Get elevator entities on a specific line.
630    #[must_use]
631    pub fn elevators_on_line(&self, line: EntityId) -> Vec<EntityId> {
632        self.groups
633            .iter()
634            .flat_map(ElevatorGroup::lines)
635            .find(|li| li.entity() == line)
636            .map_or_else(Vec::new, |li| li.elevators().to_vec())
637    }
638
639    /// Get stop entities served by a specific line.
640    #[must_use]
641    pub fn stops_served_by_line(&self, line: EntityId) -> Vec<EntityId> {
642        self.groups
643            .iter()
644            .flat_map(ElevatorGroup::lines)
645            .find(|li| li.entity() == line)
646            .map_or_else(Vec::new, |li| li.serves().to_vec())
647    }
648
649    /// Get the line entity for an elevator.
650    #[must_use]
651    pub fn line_for_elevator(&self, elevator: EntityId) -> Option<EntityId> {
652        self.groups
653            .iter()
654            .flat_map(ElevatorGroup::lines)
655            .find(|li| li.elevators().contains(&elevator))
656            .map(LineInfo::entity)
657    }
658
659    /// Iterate over elevators currently repositioning.
660    pub fn iter_repositioning_elevators(&self) -> impl Iterator<Item = EntityId> + '_ {
661        self.world
662            .iter_elevators()
663            .filter_map(|(id, _pos, car)| if car.repositioning() { Some(id) } else { None })
664    }
665
666    /// Get all line entities that serve a given stop.
667    #[must_use]
668    pub fn lines_serving_stop(&self, stop: EntityId) -> Vec<EntityId> {
669        self.groups
670            .iter()
671            .flat_map(ElevatorGroup::lines)
672            .filter(|li| li.serves().contains(&stop))
673            .map(LineInfo::entity)
674            .collect()
675    }
676
677    /// Get all group IDs that serve a given stop.
678    #[must_use]
679    pub fn groups_serving_stop(&self, stop: EntityId) -> Vec<GroupId> {
680        self.groups
681            .iter()
682            .filter(|g| g.stop_entities().contains(&stop))
683            .map(ElevatorGroup::id)
684            .collect()
685    }
686
687    // ── Topology queries ─────────────────────────────────────────────
688
689    /// Rebuild the topology graph if any mutation has invalidated it.
690    pub(super) fn ensure_graph_built(&self) {
691        if let Ok(mut graph) = self.topo_graph.lock()
692            && graph.is_dirty()
693        {
694            graph.rebuild(&self.groups);
695        }
696    }
697
698    /// All stops reachable from a given stop through the line/group topology.
699    pub fn reachable_stops_from(&self, stop: EntityId) -> Vec<EntityId> {
700        self.ensure_graph_built();
701        self.topo_graph
702            .lock()
703            .map_or_else(|_| Vec::new(), |g| g.reachable_stops_from(stop))
704    }
705
706    /// Stops that serve as transfer points between groups.
707    pub fn transfer_points(&self) -> Vec<EntityId> {
708        self.ensure_graph_built();
709        TopologyGraph::transfer_points(&self.groups)
710    }
711
712    /// Find the shortest route between two stops, possibly spanning multiple groups.
713    pub fn shortest_route(&self, from: EntityId, to: EntityId) -> Option<Route> {
714        self.ensure_graph_built();
715        self.topo_graph
716            .lock()
717            .ok()
718            .and_then(|g| g.shortest_route(from, to))
719    }
720}