pebble/app.rs
1use crate::{
2 assets::required::RequiredResources,
3 ecs::{
4 events::{AsyncEventChannel, Events, drain_async_events},
5 plugin::Plugin,
6 resources::Resources,
7 system::{IntoSystem, System},
8 system_set::IntoSystemSet,
9 },
10};
11use std::collections::{BTreeMap, BinaryHeap, HashMap};
12
13/// Determines when during a frame a system is executed.
14///
15/// There's no dedicated "run once at startup" stage — instead, any system on
16/// any stage can be made to run at most once with [`.once()`](crate::ecs::system::OnceExt::once),
17/// which turns "have I already done this" into the function's own return
18/// value (`Some(())` = done, retire; `None` = not ready, try again next
19/// tick) instead of a special stage with its own rules. A `.once()` system
20/// naturally waits as many ticks as it needs to (an async GPU backend, a
21/// `LazyResource` that isn't built yet) using the exact same requirement
22/// checks as every other system on its stage.
23///
24/// [`AssetSync`](SystemStage::AssetSync)/[`AssetSyncDeps`](SystemStage::AssetSyncDeps)
25/// are prioritized: they're re-run to convergence (repeated until a full
26/// pass produces no new resources) at the front of every tick and again
27/// after every other stage, so newly queued asset/resource work is drained
28/// before gameplay stages continue rather than waiting for the next tick's
29/// front pass. All other stages run once per [`App::update`] tick, in the
30/// order declared below.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
32pub enum SystemStage {
33 /// Before the main update.
34 PreUpdate,
35 /// Main game-logic update.
36 Update,
37 /// After the main update.
38 PostUpdate,
39 /// Prepare rendering data and poll for the GPU backend.
40 /// The backend resource becomes available here on the tick it finishes
41 /// initialising, making it visible to the asset sync stages.
42 PreRender,
43 /// Upload CPU-side source assets to the GPU backend.
44 AssetSync,
45 /// Construct lazy GPU resources and upload assets that depend on other
46 /// processed assets. Runs in a convergence loop so dependency chains
47 /// (e.g. LazyResource A → LazyResource B) resolve within a single tick.
48 AssetSyncDeps,
49 /// Issue draw calls.
50 Render,
51 /// Cleanup or post-processing after rendering.
52 PostRender,
53}
54
55impl SystemStage {
56 /// Returns `true` for stages that are prioritized and re-run until a
57 /// full pass produces no new resources, instead of running once in
58 /// their declared position in the tick order. See the type-level docs
59 /// on [`SystemStage`].
60 pub fn is_convergent(self) -> bool {
61 matches!(self, Self::AssetSync | Self::AssetSyncDeps)
62 }
63}
64
65/// Fixed per-tick order for every stage *except* the convergent ones
66/// (`AssetSync`, `AssetSyncDeps`), which are driven separately by
67/// [`App::reconverge`] — at the front of the tick and again after each of
68/// these — rather than appearing in this list.
69const TICK_STAGES: [SystemStage; 6] = [
70 SystemStage::PreUpdate,
71 SystemStage::Update,
72 SystemStage::PostUpdate,
73 SystemStage::PreRender,
74 SystemStage::Render,
75 SystemStage::PostRender,
76];
77
78/// Whether a system is safe to run right now, given its declared
79/// [`System::requires`]. See [`App::check_readiness`].
80enum Readiness {
81 /// No unmet requirement — go ahead and run it.
82 Ready,
83 /// Missing a resource that some plugin has declared (via
84 /// [`RequiredResources::provides`]) it eventually provides — wait
85 /// quietly, no error, and try again next pass/tick.
86 WaitingOnLazy,
87 /// Missing a resource nothing has ever declared it will provide —
88 /// almost certainly a genuine oversight, not a timing issue.
89 MissingUnprovided {
90 system: &'static str,
91 resource: &'static str,
92 hint: Option<&'static str>,
93 },
94}
95
96/// Callback used to drive the application's main loop.
97///
98/// Set with [`App::set_runner`]. The default runner calls [`App::update`] in
99/// an infinite loop.
100pub type AppRunner = Box<dyn FnOnce(App)>;
101
102/// The central application object.
103///
104/// `App` owns the ECS world, resources, plugins, and systems. The typical
105/// lifecycle is:
106///
107/// 1. Create with [`App::new`].
108/// 2. Register plugins with [`add_plugin`](App::add_plugin).
109/// 3. Call [`build`](App::build) to run all plugin registrations, execute
110/// validate required resources, and settle `AssetSync`/`AssetSyncDeps`
111/// as far as they can go synchronously.
112/// 4. Call [`run`](App::run) to hand control to the runner.
113pub struct App {
114 pub(crate) world: hecs::World,
115 pub(crate) resources: Resources,
116 plugins: Vec<Box<dyn Plugin>>,
117 systems: BTreeMap<SystemStage, Vec<Box<dyn System>>>,
118 runner: Option<AppRunner>,
119 pub(crate) required: RequiredResources,
120 /// One closure per event type registered via [`add_event`](App::add_event),
121 /// each calling that type's [`Events::update`] to age its buffers. Run
122 /// at the front of every [`update`](App::update) tick, before any user
123 /// system, so a reader anywhere in the tick sees a consistent view. Kept
124 /// here rather than as regular systems because they must run before
125 /// every stage, not just one, and ordering that generically against
126 /// arbitrary user systems isn't worth the complexity.
127 event_updaters: Vec<Box<dyn FnMut(&hecs::World, &Resources)>>,
128}
129
130impl Default for App {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136impl App {
137 /// Create a new `App` with an empty world and a default infinite-loop runner.
138 ///
139 /// Builds in [`TimePlugin`](crate::time::TimePlugin) — `Res<Time>` works
140 /// without registering anything yourself.
141 pub fn new() -> Self {
142 let mut world = hecs::World::default();
143 let mut resources = Resources::new(&mut world);
144 resources.insert_resource(&mut world, ());
145
146 let mut app = Self {
147 world: world,
148 resources: resources,
149 plugins: Vec::new(),
150 systems: BTreeMap::new(),
151 runner: Some(Box::new(|mut app| {
152 loop {
153 app.update();
154 }
155 })),
156 required: RequiredResources::new(),
157 event_updaters: Vec::new(),
158 };
159 app.add_plugin(crate::time::TimePlugin);
160 app
161 }
162
163 /// Check `system` against `required` without running it. See
164 /// [`Readiness`]. Used by [`run_stage_once`](App::run_stage_once) for
165 /// every stage.
166 ///
167 /// A free function (rather than a `&self` method) so it only borrows
168 /// `world`/`resources`/`required` — the specific fields still available
169 /// while a caller holds a `&mut` borrow of `self.systems` to iterate the
170 /// very system being checked.
171 fn check_readiness(
172 world: &hecs::World,
173 resources: &Resources,
174 required: &RequiredResources,
175 system: &dyn System,
176 ) -> Readiness {
177 for req in system.requires() {
178 if (req.present)(world, resources) {
179 continue;
180 }
181 if required.is_provided(req.type_id) {
182 return Readiness::WaitingOnLazy;
183 }
184 return Readiness::MissingUnprovided {
185 system: system.name(),
186 resource: req.name,
187 hint: req.hint,
188 };
189 }
190 Readiness::Ready
191 }
192
193 /// The advice appended to a "missing resource" panic when the
194 /// [`RequiredResource`](crate::ecs::system::RequiredResource) didn't
195 /// supply its own more specific `hint` — the generic fallback,
196 /// appropriate for a plain `Res<T>`/`ResMut<T>` on an arbitrary
197 /// resource type with no dedicated registration method of its own.
198 fn generic_missing_resource_hint(resource: &'static str) -> String {
199 format!(
200 "If `{resource}` genuinely arrives later (an async backend, a LazyResource, \
201 an Asset upload), call `app.required.provides::<{resource}>()` in whichever \
202 plugin inserts it, and this will wait instead of erroring. Otherwise, insert \
203 it via App::add_resource before this stage runs."
204 )
205 }
206
207 /// Panic with a message naming both the offending system and resource,
208 /// plus either its param-specific `hint` (e.g. "call `app.add_event::<T>()`")
209 /// or, absent that, the generic fallback advice.
210 fn panic_missing_unprovided(
211 stage: SystemStage,
212 system: &'static str,
213 resource: &'static str,
214 hint: Option<&'static str>,
215 ) -> ! {
216 let advice = hint
217 .map(str::to_string)
218 .unwrap_or_else(|| Self::generic_missing_resource_hint(resource));
219 panic!(
220 "{stage:?}: system `{system}` requires `{resource}`, which nothing has \
221 registered as provided.\n\n{advice}"
222 );
223 }
224
225 /// Pre-flight check, run once at the end of [`build`](Self::build): walk
226 /// every registered system in every stage and evaluate its
227 /// [`System::requires`] via [`check_readiness`](Self::check_readiness),
228 /// the same logic [`run_stage_once`](Self::run_stage_once) applies lazily
229 /// as each stage actually runs. A system waiting on a resource that
230 /// something else has [declared it provides](RequiredResources::provides)
231 /// is left alone — it'll show up once that plugin's async/lazy work
232 /// settles. A system requiring a resource that *nothing* provides and
233 /// that isn't already present is a genuine configuration mistake, and
234 /// every such mistake across the whole app is collected into one panic
235 /// here — instead of each one surfacing separately, one at a time, the
236 /// first time its particular stage happens to run.
237 fn validate_requirements(&self) {
238 let mut missing = Vec::new();
239
240 for (stage, systems) in self.systems.iter() {
241 for system in systems.iter() {
242 if let Readiness::MissingUnprovided { system, resource, hint } =
243 Self::check_readiness(&self.world, &self.resources, &self.required, system.as_ref())
244 {
245 missing.push((*stage, system, resource, hint));
246 }
247 }
248 }
249
250 if missing.is_empty() {
251 return;
252 }
253
254 let mut message = String::from(
255 "Pebble startup validation failed — the following systems require resources \
256 that nothing has registered as provided:\n",
257 );
258 for (stage, system, resource, hint) in &missing {
259 let advice = hint
260 .map(str::to_string)
261 .unwrap_or_else(|| Self::generic_missing_resource_hint(resource));
262 message.push_str(&format!("\n{stage:?}: system `{system}` requires `{resource}`\n {advice}\n"));
263 }
264 panic!("{message}");
265 }
266
267 /// Run every system in `stage` once, flush the command buffer, and return
268 /// `true` if any resource was newly inserted during this pass.
269 ///
270 /// A system with an unmet hard [`Res`](crate::ecs::system::Res)/[`ResMut`](crate::ecs::system::ResMut)
271 /// requirement is skipped for this pass if the resource is registered as
272 /// [provided](RequiredResources::provides) somewhere (it'll get there —
273 /// just not yet), or panics immediately, naming the system and resource,
274 /// if nothing ever declared it would provide that resource at all.
275 ///
276 /// [`Commands::insert_resource`](crate::ecs::system::Commands::insert_resource)
277 /// bumps the generation counter at queue time, so both direct inserts and
278 /// deferred command-buffer inserts are detected here with no world
279 /// introspection needed after the flush.
280 fn run_stage_once(&mut self, stage: SystemStage) -> bool {
281 let gen_before = self.resources.generation();
282
283 if let Some(systems) = self.systems.get_mut(&stage) {
284 for system in systems.iter_mut() {
285 match Self::check_readiness(&self.world, &self.resources, &self.required, system.as_ref()) {
286 Readiness::Ready => {}
287 Readiness::WaitingOnLazy => continue,
288 Readiness::MissingUnprovided { system, resource, hint } => {
289 Self::panic_missing_unprovided(stage, system, resource, hint)
290 }
291 }
292 let _guard = crate::ecs::resources::set_current_system(system.name());
293 system.run(&self.world, &self.resources);
294 }
295 }
296 self.resources.get_command_buffer().run_on(&mut self.world);
297
298 self.resources.generation() != gen_before
299 }
300
301 /// Run `AssetSync`, then `AssetSyncDeps`, repeating both until a full
302 /// pass produces no new resources, up to `max_passes`. Logs a warning if
303 /// the limit is reached — that usually means a [`LazyResource`](crate::assets::singleton_asset::LazyResource)
304 /// whose `construct()` or an [`Asset`](crate::assets::upload::Asset)
305 /// whose `upload()` always returns `None`.
306 ///
307 /// Called at the front of every tick and again after every stage in
308 /// [`update`](App::update) (and once during [`build`](App::build)), so
309 /// newly-queued asset/resource work is drained immediately instead of
310 /// waiting for the next tick's front pass.
311 fn reconverge(&mut self, max_passes: u32) {
312 for pass in 0..max_passes {
313 let gen_before = self.resources.generation();
314
315 self.run_stage_once(SystemStage::AssetSync);
316 self.run_stage_once(SystemStage::AssetSyncDeps);
317
318 if self.resources.generation() == gen_before {
319 return;
320 }
321 if pass == max_passes - 1 {
322 tracing::warn!(
323 "AssetSync/AssetSyncDeps did not settle after {max_passes} passes — a \
324 dependency may be permanently unsatisfiable. Check for a LazyResource \
325 whose construct() or an Asset whose upload() always returns None."
326 );
327 }
328 }
329 }
330
331 /// Queue a plugin to be built during [`build`](App::build).
332 pub fn add_plugin(&mut self, plugin: impl Plugin) -> &mut Self {
333 self.plugins.push(Box::new(plugin));
334 self
335 }
336
337 /// Insert a resource into the world immediately.
338 pub fn add_resource(&mut self, res: impl hecs::Component) -> &mut Self {
339 self.resources.insert_resource(&mut self.world, res);
340 self
341 }
342
343 /// Borrow resource `T`, panicking if it is absent.
344 pub fn get_resource<'a, T: hecs::Component>(&'a self) -> hecs::Ref<'a, T> {
345 self.resources.get_resource(&self.world)
346 }
347
348 /// Mutably borrow resource `T`, panicking if it is absent.
349 pub fn get_resource_mut<'a, T: hecs::Component>(&'a self) -> hecs::RefMut<'a, T> {
350 self.resources.get_resource_mut(&self.world)
351 }
352
353 /// Insert resource `T` only if it is not already present.
354 ///
355 /// Returns `true` if the resource was inserted.
356 pub fn try_insert_resource<T: hecs::Component>(&mut self, res: T) -> bool {
357 self.resources.try_insert(&mut self.world, res)
358 }
359
360 /// Declare that resource type `T` is expected to be inserted later —
361 /// possibly asynchronously (a background thread's result, a hand-rolled
362 /// lazy resource) rather than up front. A system elsewhere with a hard
363 /// `Res<T>`/`ResMut<T>` requirement on `T` will then wait quietly for it
364 /// instead of `App` treating the absence as a configuration mistake and
365 /// panicking.
366 ///
367 /// [`GraphicsPlugin`](crate::rendering::graphics_plugin::GraphicsPlugin)
368 /// and [`LazyResourcePlugin`](crate::assets::singleton_asset::LazyResourcePlugin)
369 /// already call this for the backend and lazy resource types they
370 /// manage — reach for this directly only for your own resource types
371 /// that arrive outside of those.
372 pub fn provides<T: 'static>(&mut self) -> &mut Self {
373 self.required.provides::<T>();
374 self
375 }
376
377 /// Register event type `T`, making [`EventWriter<T>`](crate::ecs::events::EventWriter)
378 /// and [`EventReader<T>`](crate::ecs::events::EventReader) usable as
379 /// system parameters.
380 ///
381 /// Inserts the backing [`Events<T>`] resource (a no-op if `T` was
382 /// already registered) and schedules its per-tick aging, which is what
383 /// gives events sent during tick `N` a consistent two-tick lifetime —
384 /// visible for the rest of `N` and all of `N + 1` — regardless of which
385 /// stage the writer or reader runs in.
386 pub fn add_event<T: hecs::Component>(&mut self) -> &mut Self {
387 self.try_insert_resource(Events::<T>::default());
388 self.event_updaters.push(Box::new(|world, resources| {
389 resources.get_resource_mut::<Events<T>>(world).update();
390 }));
391 self
392 }
393
394 /// Register event type `T` as in [`add_event`](Self::add_event), and
395 /// additionally make [`AsyncEventWriter<T>`](crate::ecs::events::AsyncEventWriter)
396 /// usable as a system parameter — the friendly way to turn a background
397 /// task's result into a `T` event once it resolves, instead of hand-
398 /// rolling a pending-task resource and poll system yourself.
399 ///
400 /// Requires [`BackgroundTasksPlugin`](crate::threading::BackgroundTasksPlugin)
401 /// to be registered before any system using `AsyncEventWriter<T>` runs —
402 /// that's what [`AsyncEventWriter::spawn`](crate::ecs::events::AsyncEventWriter::spawn)
403 /// drives the future through.
404 pub fn add_async_event<T: hecs::Component>(&mut self) -> &mut Self {
405 self.add_event::<T>();
406 self.try_insert_resource(AsyncEventChannel::<T>::new());
407 self.add_system(SystemStage::PreUpdate, drain_async_events::<T>);
408 self
409 }
410
411 /// Register a single system to run at `stage`.
412 pub fn add_system<Marker>(
413 &mut self,
414 stage: SystemStage,
415 system: impl IntoSystem<Marker> + 'static,
416 ) -> &mut Self {
417 self.systems
418 .entry(stage)
419 .or_default()
420 .push(Box::new(system.into_system()));
421 self
422 }
423
424 /// Register multiple systems to run at `stage`.
425 ///
426 /// Accepts a tuple of systems via [`IntoSystemSet`].
427 pub fn add_systems<Marker>(
428 &mut self,
429 stage: SystemStage,
430 systems: impl IntoSystemSet<Marker>,
431 ) -> &mut Self {
432 let entry = self.systems.entry(stage).or_default();
433 entry.extend(systems.into_system_set());
434 self
435 }
436
437 /// Topologically sort `systems` by each system's [`System::after_ids`]/[`System::before_ids`]
438 /// constraints (referencing other systems' [`System::ordering_id`] within
439 /// the same stage), breaking ties by original registration order.
440 ///
441 /// Panics if the constraints form a cycle, naming every system still
442 /// stuck once no more zero-dependency systems remain.
443 fn sort_stage(stage: SystemStage, systems: &mut Vec<Box<dyn System>>) {
444 let id_index: HashMap<std::any::TypeId, usize> = systems
445 .iter()
446 .enumerate()
447 .map(|(i, s)| (s.ordering_id(), i))
448 .collect();
449
450 let n = systems.len();
451 let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); n];
452 let mut in_degree = vec![0usize; n];
453
454 for (i, system) in systems.iter().enumerate() {
455 for id in system.after_ids() {
456 if let Some(&dep) = id_index.get(id) {
457 adjacency[dep].push(i);
458 in_degree[i] += 1;
459 }
460 }
461 for id in system.before_ids() {
462 if let Some(&dependent) = id_index.get(id) {
463 adjacency[i].push(dependent);
464 in_degree[dependent] += 1;
465 }
466 }
467 }
468
469 // Min-heap on original index so ties resolve to registration order.
470 let mut ready: BinaryHeap<std::cmp::Reverse<usize>> = in_degree
471 .iter()
472 .enumerate()
473 .filter(|(_, d)| **d == 0)
474 .map(|(i, _)| std::cmp::Reverse(i))
475 .collect();
476
477 let mut order = Vec::with_capacity(n);
478 while let Some(std::cmp::Reverse(u)) = ready.pop() {
479 order.push(u);
480 for &v in &adjacency[u] {
481 in_degree[v] -= 1;
482 if in_degree[v] == 0 {
483 ready.push(std::cmp::Reverse(v));
484 }
485 }
486 }
487
488 if order.len() != n {
489 let stuck: Vec<&'static str> = (0..n)
490 .filter(|i| in_degree[*i] > 0)
491 .map(|i| systems[i].name())
492 .collect();
493 panic!(
494 "{stage:?}: system ordering constraints form a cycle among: {stuck:?}"
495 );
496 }
497
498 let mut taken: Vec<Option<Box<dyn System>>> = systems.drain(..).map(Some).collect();
499 for i in order {
500 systems.push(taken[i].take().unwrap());
501 }
502 }
503
504 /// Build all plugins and validate required resources.
505 ///
506 /// Plugins may register additional plugins during their `build` call; this
507 /// repeats until no new plugins are added, up to a hard limit of 64 passes
508 /// to catch accidental infinite registration cycles.
509 pub fn build(&mut self) -> &mut Self {
510 let mut iterations = 0;
511 const MAX_PLUGIN_BUILD_ITERATIONS: u32 = 64;
512
513 while !self.plugins.is_empty() {
514 iterations += 1;
515 if iterations > MAX_PLUGIN_BUILD_ITERATIONS {
516 panic!(
517 "App::build() exceeded {MAX_PLUGIN_BUILD_ITERATIONS} plugin-registration passes — \
518 likely a cycle where plugins keep registering each other. Check for a plugin whose \
519 build() unconditionally re-adds itself or another plugin that re-adds it."
520 );
521 }
522 let plugins: Vec<_> = self.plugins.drain(..).collect();
523 for plugin in plugins {
524 plugin.build(self);
525 }
526 }
527
528 for (stage, systems) in self.systems.iter_mut() {
529 Self::sort_stage(*stage, systems);
530 }
531
532 // Resolve as much as possible synchronously (headless/CPU-only
533 // backends, tests) so resources are ready immediately after
534 // build(). Anything still pending (an async GPU backend, say)
535 // keeps getting retried every tick by update().
536 self.reconverge(64);
537
538 self.validate_requirements();
539
540 self
541 }
542
543 /// Run every stage once per tick, in [`TICK_STAGES`] order. Before every
544 /// tick, and again after every stage, [`reconverge`](App::reconverge)
545 /// drains `AssetSync`/`AssetSyncDeps` — so newly-queued asset or
546 /// resource work is handled immediately rather than waiting for the
547 /// next tick's front pass.
548 pub fn update(&mut self) {
549 for updater in self.event_updaters.iter_mut() {
550 updater(&self.world, &self.resources);
551 }
552
553 self.reconverge(64);
554
555 for stage in TICK_STAGES {
556 self.run_stage_once(stage);
557 self.reconverge(64);
558 }
559 }
560
561 /// Replace the default runner with a custom one.
562 ///
563 /// The runner receives ownership of the `App` and is responsible for
564 /// calling [`update`](App::update) at the appropriate cadence (e.g. driven
565 /// by a window event loop).
566 pub fn set_runner<F>(&mut self, runner: F) -> &mut Self
567 where
568 F: FnOnce(App) + 'static,
569 {
570 self.runner = Some(Box::new(runner));
571 self
572 }
573
574 /// Consume the app and hand it to the configured runner.
575 ///
576 /// Panics if no runner has been set.
577 pub fn run(&mut self) {
578 let mut owned_app = std::mem::take(self);
579 let runner = owned_app.runner.take().expect("No runner found!");
580 runner(owned_app);
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use crate::ecs::system::{ResMut, SystemOrderingExt};
588
589 struct Order(Vec<&'static str>);
590
591 fn sys_a(mut o: ResMut<Order>) {
592 o.0.push("a");
593 }
594 fn sys_b(mut o: ResMut<Order>) {
595 o.0.push("b");
596 }
597 fn sys_c(mut o: ResMut<Order>) {
598 o.0.push("c");
599 }
600
601 #[test]
602 fn systems_run_in_declared_order() {
603 let mut app = App::new();
604 app.add_resource(Order(Vec::new()));
605
606 // Registered in a-b-c order, but both a and b declare they must run
607 // after c — the sort should move c first while leaving a before b
608 // (their relative registration order) intact.
609 app.add_system(SystemStage::Update, sys_a.after(sys_c));
610 app.add_system(SystemStage::Update, sys_b.after(sys_c));
611 app.add_system(SystemStage::Update, sys_c);
612
613 app.build();
614 app.update();
615
616 let order = app.get_resource::<Order>();
617 assert_eq!(order.0, vec!["c", "a", "b"]);
618 }
619
620 #[test]
621 #[should_panic(expected = "cycle")]
622 fn cyclic_ordering_constraints_panic() {
623 let mut app = App::new();
624 app.add_resource(Order(Vec::new()));
625
626 app.add_system(SystemStage::Update, sys_a.after(sys_b));
627 app.add_system(SystemStage::Update, sys_b.after(sys_a));
628
629 app.build();
630 }
631
632 #[test]
633 fn time_plugin_is_already_built_into_a_fresh_app() {
634 let mut app = App::new();
635 app.build();
636
637 // Doesn't panic — `Time` exists without anyone calling
638 // `add_plugin(TimePlugin)` themselves.
639 let _ = app.get_resource::<crate::time::Time>();
640 }
641
642 #[test]
643 fn registering_time_plugin_again_does_not_double_register_its_system() {
644 let mut app = App::new();
645 app.add_plugin(crate::time::TimePlugin); // redundant - App::new() already built it in
646 app.build();
647
648 // A fresh App's PreUpdate stage contains only Time's own tick
649 // system; if TimePlugin weren't idempotent, this would be 2.
650 let tick_systems = app.systems.get(&SystemStage::PreUpdate).map_or(0, Vec::len);
651 assert_eq!(tick_systems, 1);
652 }
653}