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}