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}