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    ///         bypass_load_up_pct: None,
101    ///         bypass_load_down_pct: None,
102    ///     })
103    ///     .build()
104    ///     .unwrap();
105    /// assert_eq!(sim.current_tick(), 0);
106    /// ```
107    #[must_use]
108    pub fn new() -> Self {
109        let config = SimConfig {
110            schema_version: crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
111            building: BuildingConfig {
112                name: "Untitled".into(),
113                stops: Vec::new(),
114                lines: None,
115                groups: None,
116            },
117            elevators: Vec::new(),
118            simulation: SimulationParams {
119                ticks_per_second: 60.0,
120            },
121            passenger_spawning: PassengerSpawnConfig {
122                mean_interval_ticks: 120,
123                weight_range: (50.0, 100.0),
124            },
125        };
126
127        let mut dispatchers = BTreeMap::new();
128        dispatchers.insert(
129            GroupId(0),
130            Box::new(ScanDispatch::new()) as Box<dyn DispatchStrategy>,
131        );
132
133        Self {
134            config,
135            dispatchers,
136            repositioners: Vec::new(),
137            hooks: PhaseHooks::default(),
138            ext_registrations: Vec::new(),
139        }
140    }
141
142    /// Pre-populated builder for zero-config examples, doctests, and quick
143    /// prototyping where the building layout isn't the point.
144    ///
145    /// Provides two stops (Ground at 0.0, Top at 10.0) and one elevator
146    /// with SCAN dispatch. Use this when you want a working `Simulation`
147    /// in one call and don't care about the specific stops.
148    ///
149    /// ```
150    /// use elevator_core::prelude::*;
151    ///
152    /// let sim = SimulationBuilder::demo().build().unwrap();
153    /// assert_eq!(sim.current_tick(), 0);
154    /// ```
155    ///
156    /// If you need a specific stop layout or elevator physics, use
157    /// [`new`](Self::new) and configure every field explicitly — it reads
158    /// more clearly than threading overrides on top of `demo`'s defaults.
159    /// [`.stop()`](Self::stop) is a *push* onto the current stops list,
160    /// so calling it after `demo()` appends to the two defaults rather
161    /// than replacing them.
162    #[must_use]
163    pub fn demo() -> Self {
164        let mut b = Self::new();
165        b.config.building.name = "Demo".into();
166        b.config.building.stops = vec![
167            StopConfig {
168                id: StopId(0),
169                name: "Ground".into(),
170                position: 0.0,
171            },
172            StopConfig {
173                id: StopId(1),
174                name: "Top".into(),
175                position: 10.0,
176            },
177        ];
178        b.config.elevators = vec![ElevatorConfig {
179            id: 0,
180            name: "Elevator 1".into(),
181            max_speed: Speed::from(2.0),
182            acceleration: Accel::from(1.5),
183            deceleration: Accel::from(2.0),
184            weight_capacity: Weight::from(800.0),
185            starting_stop: StopId(0),
186            door_open_ticks: 10,
187            door_transition_ticks: 5,
188            restricted_stops: Vec::new(),
189            #[cfg(feature = "energy")]
190            energy_profile: None,
191            service_mode: None,
192            inspection_speed_factor: 0.25,
193            bypass_load_up_pct: None,
194            bypass_load_down_pct: None,
195        }];
196        b
197    }
198
199    /// Create a builder from an existing [`SimConfig`].
200    ///
201    /// Honours the `dispatch` field on each `GroupConfig` from the config —
202    /// no default is pre-seeded. Call [`.dispatch()`](Self::dispatch) or
203    /// [`.dispatch_for_group()`](Self::dispatch_for_group) to override the
204    /// per-group strategy from code; otherwise the config's choice (or
205    /// `ScanDispatch` if neither config nor builder specifies) is used.
206    /// Pre-fix this function unconditionally seeded `ScanDispatch` for
207    /// `GroupId(0)` and the override loop in construction stomped any
208    /// config-supplied strategy for that group (#287).
209    #[must_use]
210    pub fn from_config(config: SimConfig) -> Self {
211        Self {
212            config,
213            dispatchers: BTreeMap::new(),
214            repositioners: Vec::new(),
215            hooks: PhaseHooks::default(),
216            ext_registrations: Vec::new(),
217        }
218    }
219
220    /// Replace all stops with the given list.
221    ///
222    /// Clears any previously added stops.
223    #[must_use]
224    pub fn stops(mut self, stops: Vec<StopConfig>) -> Self {
225        self.config.building.stops = stops;
226        self
227    }
228
229    /// Add a single stop to the building.
230    #[must_use]
231    pub fn stop(mut self, id: StopId, name: impl Into<String>, position: f64) -> Self {
232        self.config.building.stops.push(StopConfig {
233            id,
234            name: name.into(),
235            position,
236        });
237        self
238    }
239
240    /// Replace all elevators with the given list.
241    ///
242    /// Clears any previously added elevators.
243    #[must_use]
244    pub fn elevators(mut self, elevators: Vec<ElevatorConfig>) -> Self {
245        self.config.elevators = elevators;
246        self
247    }
248
249    /// Add a single elevator configuration.
250    #[must_use]
251    pub fn elevator(mut self, config: ElevatorConfig) -> Self {
252        self.config.elevators.push(config);
253        self
254    }
255
256    /// Add a single line configuration.
257    ///
258    /// Switches from legacy flat-elevator mode to explicit topology.
259    #[must_use]
260    pub fn line(mut self, config: LineConfig) -> Self {
261        self.config
262            .building
263            .lines
264            .get_or_insert_with(Vec::new)
265            .push(config);
266        self
267    }
268
269    /// Replace all lines with the given list.
270    ///
271    /// Switches from legacy flat-elevator mode to explicit topology.
272    #[must_use]
273    pub fn lines(mut self, lines: Vec<LineConfig>) -> Self {
274        self.config.building.lines = Some(lines);
275        self
276    }
277
278    /// Add a single group configuration.
279    #[must_use]
280    pub fn group(mut self, config: GroupConfig) -> Self {
281        self.config
282            .building
283            .groups
284            .get_or_insert_with(Vec::new)
285            .push(config);
286        self
287    }
288
289    /// Replace all groups with the given list.
290    #[must_use]
291    pub fn groups(mut self, groups: Vec<GroupConfig>) -> Self {
292        self.config.building.groups = Some(groups);
293        self
294    }
295
296    /// Set the simulation tick rate (ticks per second).
297    #[must_use]
298    pub const fn ticks_per_second(mut self, tps: f64) -> Self {
299        self.config.simulation.ticks_per_second = tps;
300        self
301    }
302
303    /// Set the building name.
304    #[must_use]
305    pub fn building_name(mut self, name: impl Into<String>) -> Self {
306        self.config.building.name = name.into();
307        self
308    }
309
310    /// Set the default dispatch strategy for the default group.
311    #[must_use]
312    pub fn dispatch(mut self, strategy: impl DispatchStrategy + 'static) -> Self {
313        self.dispatchers.insert(GroupId(0), Box::new(strategy));
314        self
315    }
316
317    /// Set a dispatch strategy for a specific group.
318    #[must_use]
319    pub fn dispatch_for_group(
320        mut self,
321        group: GroupId,
322        strategy: impl DispatchStrategy + 'static,
323    ) -> Self {
324        self.dispatchers.insert(group, Box::new(strategy));
325        self
326    }
327
328    /// Register a hook to run before a simulation phase.
329    #[must_use]
330    pub fn before(
331        mut self,
332        phase: Phase,
333        hook: impl Fn(&mut World) + Send + Sync + 'static,
334    ) -> Self {
335        self.hooks.add_before(phase, Box::new(hook));
336        self
337    }
338
339    /// Register a hook to run after a simulation phase.
340    #[must_use]
341    pub fn after(
342        mut self,
343        phase: Phase,
344        hook: impl Fn(&mut World) + Send + Sync + 'static,
345    ) -> Self {
346        self.hooks.add_after(phase, Box::new(hook));
347        self
348    }
349
350    /// Register a hook to run before a phase for a specific group.
351    #[must_use]
352    pub fn before_group(
353        mut self,
354        phase: Phase,
355        group: GroupId,
356        hook: impl Fn(&mut World) + Send + Sync + 'static,
357    ) -> Self {
358        self.hooks.add_before_group(phase, group, Box::new(hook));
359        self
360    }
361
362    /// Register a hook to run after a phase for a specific group.
363    #[must_use]
364    pub fn after_group(
365        mut self,
366        phase: Phase,
367        group: GroupId,
368        hook: impl Fn(&mut World) + Send + Sync + 'static,
369    ) -> Self {
370        self.hooks.add_after_group(phase, group, Box::new(hook));
371        self
372    }
373
374    /// Set a reposition strategy for the default group.
375    ///
376    /// Enables the reposition phase, which runs after dispatch to
377    /// move idle elevators for better coverage.
378    #[must_use]
379    pub fn reposition(
380        self,
381        strategy: impl RepositionStrategy + 'static,
382        id: BuiltinReposition,
383    ) -> Self {
384        self.reposition_for_group(GroupId(0), strategy, id)
385    }
386
387    /// Set a reposition strategy for a specific group.
388    #[must_use]
389    pub fn reposition_for_group(
390        mut self,
391        group: GroupId,
392        strategy: impl RepositionStrategy + 'static,
393        id: BuiltinReposition,
394    ) -> Self {
395        self.repositioners.push((group, Box::new(strategy), id));
396        self
397    }
398
399    /// Pre-register an extension type for snapshot deserialization.
400    ///
401    /// Extensions registered here will be available immediately after [`build()`](Self::build)
402    /// without needing to call `register_ext` manually.
403    #[must_use]
404    pub fn with_ext<T: 'static + Send + Sync + Serialize + DeserializeOwned>(mut self) -> Self {
405        self.ext_registrations
406            .push(Box::new(move |world: &mut World| {
407                world.register_ext::<T>(crate::world::ExtKey::from_type_name());
408            }));
409        self
410    }
411
412    /// Validate the configuration without building the simulation.
413    ///
414    /// Runs the same validation as [`build()`](Self::build) but does not
415    /// allocate entities or construct the simulation. Useful for CLI tools,
416    /// config editors, and dry-run checks.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`SimError::InvalidConfig`] if the configuration is invalid.
421    pub fn validate(&self) -> Result<(), SimError> {
422        Simulation::validate_config(&self.config)
423    }
424
425    /// Build the simulation, validating the configuration.
426    ///
427    /// Returns `Err(SimError)` if the configuration is invalid.
428    ///
429    /// # Errors
430    ///
431    /// Returns [`SimError::InvalidConfig`] if the assembled configuration is invalid.
432    ///
433    /// # Examples
434    ///
435    /// ```
436    /// use elevator_core::prelude::*;
437    /// use elevator_core::stop::StopConfig;
438    ///
439    /// let mut sim = SimulationBuilder::demo()
440    ///     .stops(vec![
441    ///         StopConfig { id: StopId(0), name: "Lobby".into(), position: 0.0 },
442    ///         StopConfig { id: StopId(1), name: "Roof".into(), position: 20.0 },
443    ///     ])
444    ///     .build()
445    ///     .unwrap();
446    ///
447    /// sim.spawn_rider(StopId(0), StopId(1), 75.0).unwrap();
448    ///
449    /// for _ in 0..1000 {
450    ///     sim.step();
451    /// }
452    ///
453    /// assert!(sim.metrics().total_delivered() > 0);
454    /// ```
455    pub fn build(self) -> Result<Simulation, SimError> {
456        let mut sim = Simulation::new_with_hooks(&self.config, self.dispatchers, self.hooks)?;
457
458        for (group, strategy, id) in self.repositioners {
459            sim.set_reposition(group, strategy, id);
460        }
461
462        for register in self.ext_registrations {
463            register(sim.world_mut());
464        }
465
466        Ok(sim)
467    }
468}