Skip to main content

elevator_core/
builder.rs

1//! Fluent builder for constructing a [`Simulation`] programmatically.
2
3use serde::{Serialize, de::DeserializeOwned};
4
5use crate::config::{
6    BuildingConfig, ElevatorConfig, GroupConfig, LineConfig, PassengerSpawnConfig, SimConfig,
7    SimulationParams,
8};
9use crate::dispatch::scan::ScanDispatch;
10use crate::dispatch::{BuiltinReposition, DispatchStrategy, RepositionStrategy};
11use crate::error::SimError;
12use crate::hooks::{Phase, PhaseHooks};
13use crate::ids::GroupId;
14use crate::sim::Simulation;
15use crate::stop::{StopConfig, StopId};
16use crate::world::World;
17use std::collections::BTreeMap;
18
19/// A deferred extension registration closure.
20type ExtRegistration = Box<dyn FnOnce(&mut World) + Send>;
21
22/// Fluent builder for constructing a [`Simulation`].
23///
24/// Builds a [`SimConfig`] internally and delegates to [`Simulation::new()`].
25/// Provides a more ergonomic API for programmatic construction compared to
26/// assembling a config struct manually.
27///
28/// # Default configuration
29///
30/// `SimulationBuilder::new()` starts with a minimal valid config:
31/// - 2 stops at positions 0.0 and 10.0
32/// - 1 elevator with reasonable defaults
33/// - `ScanDispatch` strategy
34/// - 60 ticks per second
35pub struct SimulationBuilder {
36    /// Simulation configuration (stops, elevators, timing).
37    config: SimConfig,
38    /// Per-group dispatch strategies.
39    dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
40    /// Per-group reposition strategies.
41    repositioners: Vec<(GroupId, Box<dyn RepositionStrategy>, BuiltinReposition)>,
42    /// Lifecycle hooks for before/after each tick phase.
43    hooks: PhaseHooks,
44    /// Deferred extension registrations (applied after build).
45    ext_registrations: Vec<ExtRegistration>,
46}
47
48impl Default for SimulationBuilder {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl SimulationBuilder {
55    /// Create a builder with a minimal valid default configuration.
56    ///
57    /// The default gives you two stops (Ground at 0.0, Top at 10.0) and one
58    /// elevator with SCAN dispatch. Override any part with the fluent methods
59    /// before calling [`build`](Self::build).
60    ///
61    /// ```
62    /// use elevator_core::prelude::*;
63    ///
64    /// let sim = SimulationBuilder::new().build().unwrap();
65    /// assert_eq!(sim.current_tick(), 0);
66    /// ```
67    #[must_use]
68    pub fn new() -> Self {
69        let config = SimConfig {
70            building: BuildingConfig {
71                name: "Default".into(),
72                stops: vec![
73                    StopConfig {
74                        id: StopId(0),
75                        name: "Ground".into(),
76                        position: 0.0,
77                    },
78                    StopConfig {
79                        id: StopId(1),
80                        name: "Top".into(),
81                        position: 10.0,
82                    },
83                ],
84                lines: None,
85                groups: None,
86            },
87            elevators: vec![ElevatorConfig {
88                id: 0,
89                name: "Elevator 1".into(),
90                max_speed: 2.0,
91                acceleration: 1.5,
92                deceleration: 2.0,
93                weight_capacity: 800.0,
94                starting_stop: StopId(0),
95                door_open_ticks: 10,
96                door_transition_ticks: 5,
97                restricted_stops: Vec::new(),
98                #[cfg(feature = "energy")]
99                energy_profile: None,
100                service_mode: None,
101                inspection_speed_factor: 0.25,
102            }],
103            simulation: SimulationParams {
104                ticks_per_second: 60.0,
105            },
106            passenger_spawning: PassengerSpawnConfig {
107                mean_interval_ticks: 120,
108                weight_range: (50.0, 100.0),
109            },
110        };
111
112        let mut dispatchers = BTreeMap::new();
113        dispatchers.insert(
114            GroupId(0),
115            Box::new(ScanDispatch::new()) as Box<dyn DispatchStrategy>,
116        );
117
118        Self {
119            config,
120            dispatchers,
121            repositioners: Vec::new(),
122            hooks: PhaseHooks::default(),
123            ext_registrations: Vec::new(),
124        }
125    }
126
127    /// Create a builder from an existing [`SimConfig`].
128    ///
129    /// Uses `ScanDispatch` as the default strategy. Call [`.dispatch()`](Self::dispatch)
130    /// to override.
131    #[must_use]
132    pub fn from_config(config: SimConfig) -> Self {
133        let mut dispatchers = BTreeMap::new();
134        dispatchers.insert(
135            GroupId(0),
136            Box::new(ScanDispatch::new()) as Box<dyn DispatchStrategy>,
137        );
138
139        Self {
140            config,
141            dispatchers,
142            repositioners: Vec::new(),
143            hooks: PhaseHooks::default(),
144            ext_registrations: Vec::new(),
145        }
146    }
147
148    /// Replace all stops with the given list.
149    ///
150    /// Clears any previously added stops.
151    #[must_use]
152    pub fn stops(mut self, stops: Vec<StopConfig>) -> Self {
153        self.config.building.stops = stops;
154        self
155    }
156
157    /// Add a single stop to the building.
158    #[must_use]
159    pub fn stop(mut self, id: StopId, name: impl Into<String>, position: f64) -> Self {
160        self.config.building.stops.push(StopConfig {
161            id,
162            name: name.into(),
163            position,
164        });
165        self
166    }
167
168    /// Replace all elevators with the given list.
169    ///
170    /// Clears any previously added elevators.
171    #[must_use]
172    pub fn elevators(mut self, elevators: Vec<ElevatorConfig>) -> Self {
173        self.config.elevators = elevators;
174        self
175    }
176
177    /// Add a single elevator configuration.
178    #[must_use]
179    pub fn elevator(mut self, config: ElevatorConfig) -> Self {
180        self.config.elevators.push(config);
181        self
182    }
183
184    /// Add a single line configuration.
185    ///
186    /// Switches from legacy flat-elevator mode to explicit topology.
187    #[must_use]
188    pub fn line(mut self, config: LineConfig) -> Self {
189        self.config
190            .building
191            .lines
192            .get_or_insert_with(Vec::new)
193            .push(config);
194        self
195    }
196
197    /// Replace all lines with the given list.
198    ///
199    /// Switches from legacy flat-elevator mode to explicit topology.
200    #[must_use]
201    pub fn lines(mut self, lines: Vec<LineConfig>) -> Self {
202        self.config.building.lines = Some(lines);
203        self
204    }
205
206    /// Add a single group configuration.
207    #[must_use]
208    pub fn group(mut self, config: GroupConfig) -> Self {
209        self.config
210            .building
211            .groups
212            .get_or_insert_with(Vec::new)
213            .push(config);
214        self
215    }
216
217    /// Replace all groups with the given list.
218    #[must_use]
219    pub fn groups(mut self, groups: Vec<GroupConfig>) -> Self {
220        self.config.building.groups = Some(groups);
221        self
222    }
223
224    /// Set the simulation tick rate (ticks per second).
225    #[must_use]
226    pub const fn ticks_per_second(mut self, tps: f64) -> Self {
227        self.config.simulation.ticks_per_second = tps;
228        self
229    }
230
231    /// Set the building name.
232    #[must_use]
233    pub fn building_name(mut self, name: impl Into<String>) -> Self {
234        self.config.building.name = name.into();
235        self
236    }
237
238    /// Set the default dispatch strategy for the default group.
239    #[must_use]
240    pub fn dispatch(mut self, strategy: impl DispatchStrategy + 'static) -> Self {
241        self.dispatchers.insert(GroupId(0), Box::new(strategy));
242        self
243    }
244
245    /// Set a dispatch strategy for a specific group.
246    #[must_use]
247    pub fn dispatch_for_group(
248        mut self,
249        group: GroupId,
250        strategy: impl DispatchStrategy + 'static,
251    ) -> Self {
252        self.dispatchers.insert(group, Box::new(strategy));
253        self
254    }
255
256    /// Register a hook to run before a simulation phase.
257    #[must_use]
258    pub fn before(
259        mut self,
260        phase: Phase,
261        hook: impl Fn(&mut World) + Send + Sync + 'static,
262    ) -> Self {
263        self.hooks.add_before(phase, Box::new(hook));
264        self
265    }
266
267    /// Register a hook to run after a simulation phase.
268    #[must_use]
269    pub fn after(
270        mut self,
271        phase: Phase,
272        hook: impl Fn(&mut World) + Send + Sync + 'static,
273    ) -> Self {
274        self.hooks.add_after(phase, Box::new(hook));
275        self
276    }
277
278    /// Register a hook to run before a phase for a specific group.
279    #[must_use]
280    pub fn before_group(
281        mut self,
282        phase: Phase,
283        group: GroupId,
284        hook: impl Fn(&mut World) + Send + Sync + 'static,
285    ) -> Self {
286        self.hooks.add_before_group(phase, group, Box::new(hook));
287        self
288    }
289
290    /// Register a hook to run after a phase for a specific group.
291    #[must_use]
292    pub fn after_group(
293        mut self,
294        phase: Phase,
295        group: GroupId,
296        hook: impl Fn(&mut World) + Send + Sync + 'static,
297    ) -> Self {
298        self.hooks.add_after_group(phase, group, Box::new(hook));
299        self
300    }
301
302    /// Set a reposition strategy for the default group.
303    ///
304    /// Enables the reposition phase, which runs after dispatch to
305    /// move idle elevators for better coverage.
306    #[must_use]
307    pub fn reposition(
308        self,
309        strategy: impl RepositionStrategy + 'static,
310        id: BuiltinReposition,
311    ) -> Self {
312        self.reposition_for_group(GroupId(0), strategy, id)
313    }
314
315    /// Set a reposition strategy for a specific group.
316    #[must_use]
317    pub fn reposition_for_group(
318        mut self,
319        group: GroupId,
320        strategy: impl RepositionStrategy + 'static,
321        id: BuiltinReposition,
322    ) -> Self {
323        self.repositioners.push((group, Box::new(strategy), id));
324        self
325    }
326
327    /// Pre-register an extension type for snapshot deserialization.
328    ///
329    /// Extensions registered here will be available immediately after [`build()`](Self::build)
330    /// without needing to call `register_ext` manually.
331    #[must_use]
332    pub fn with_ext<T: 'static + Send + Sync + Serialize + DeserializeOwned>(
333        mut self,
334        name: &str,
335    ) -> Self {
336        let name = name.to_owned();
337        self.ext_registrations
338            .push(Box::new(move |world: &mut World| {
339                world.register_ext::<T>(&name);
340            }));
341        self
342    }
343
344    /// Validate the configuration without building the simulation.
345    ///
346    /// Runs the same validation as [`build()`](Self::build) but does not
347    /// allocate entities or construct the simulation. Useful for CLI tools,
348    /// config editors, and dry-run checks.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`SimError::InvalidConfig`] if the configuration is invalid.
353    pub fn validate(&self) -> Result<(), SimError> {
354        Simulation::validate_config(&self.config)
355    }
356
357    /// Build the simulation, validating the configuration.
358    ///
359    /// Returns `Err(SimError)` if the configuration is invalid.
360    ///
361    /// # Errors
362    ///
363    /// Returns [`SimError::InvalidConfig`] if the assembled configuration is invalid.
364    ///
365    /// # Examples
366    ///
367    /// ```
368    /// use elevator_core::prelude::*;
369    /// use elevator_core::stop::StopConfig;
370    ///
371    /// let mut sim = SimulationBuilder::new()
372    ///     .stops(vec![
373    ///         StopConfig { id: StopId(0), name: "Lobby".into(), position: 0.0 },
374    ///         StopConfig { id: StopId(1), name: "Roof".into(), position: 20.0 },
375    ///     ])
376    ///     .build()
377    ///     .unwrap();
378    ///
379    /// sim.spawn_rider_by_stop_id(StopId(0), StopId(1), 75.0).unwrap();
380    ///
381    /// for _ in 0..1000 {
382    ///     sim.step();
383    /// }
384    ///
385    /// assert!(sim.metrics().total_delivered() > 0);
386    /// ```
387    pub fn build(self) -> Result<Simulation, SimError> {
388        let mut sim = Simulation::new_with_hooks(&self.config, self.dispatchers, self.hooks)?;
389
390        for (group, strategy, id) in self.repositioners {
391            sim.set_reposition(group, strategy, id);
392        }
393
394        for register in self.ext_registrations {
395            register(sim.world_mut());
396        }
397
398        Ok(sim)
399    }
400}