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