Skip to main content

elevator_core/
builder.rs

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