Skip to main content

unlit3dx/
world.rs

1//! Worlds the host reaches through a proxy component: one on its own
2//! thread, or one the host steps itself.
3//!
4//! Both leave the same proxy component on the host entity, so the host's
5//! world carries the same data either way. What differs is who drives the
6//! world: a thread of its own, or the host's own frames.
7//!
8//! Whoever drives a world publishes its subscriptions: a turn of the loop
9//! drains the queue, runs the tick, and then publishes what the world's
10//! collectors returned. A tick body therefore collects nothing by hand and
11//! cannot publish twice.
12
13use async_channel::{Receiver, bounded};
14use unlit_ecs::{Bundle, DynamicBundle, Entity, EntityAccessError, World, bundle};
15
16use crate::channel::publish_collected;
17use crate::executor::LocalTasks;
18use crate::handle::WorldHandle;
19use crate::job::{Job, JobSender, SendError};
20use crate::thread::{ThreadControl, ThreadStatus, WorldThread, panic_message, stepped_control};
21
22/// The proxy component a spawned world leaves on the host.
23///
24/// It owns the world's state - a thread that drives it, or the world itself
25/// when the host steps it - so despawning the entity ends the world. It is
26/// an implementation detail: callers reach the world through the
27/// [`WorldHandle`] the spawn returns, never through this component.
28struct WorldProxy {
29    /// What drives the world.
30    kind: WorldKind,
31}
32
33/// What drives a spawned world.
34enum WorldKind {
35    /// A world on its own thread.
36    Threaded {
37        /// The thread, stopped and joined when the proxy drops.
38        _thread: WorldThread,
39    },
40    /// A world the host steps with [`step_stepped_world`].
41    Stepped(Box<SteppedWorld>),
42}
43
44/// The name a spawned world answers to.
45///
46/// Every spawn leaves this component on the host entity that carries the
47/// world, so a bridge that only walks the host can name the worlds it
48/// reports instead of numbering them.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct WorldName(pub &'static str);
51
52/// The host-side view of a spawned world's command queue.
53///
54/// A spawned world is reached through the [`WorldHandle`] its spawn
55/// returns, but that handle belongs to the caller. Every spawn also leaves
56/// this component on the proxy entity, so a bridge that only has the host
57/// world — the MCP transport, say — can reach the world the same way. A
58/// job posted here is picked up by whatever drives the world: its own
59/// thread, or the host's [`step_stepped_world`] calls.
60#[derive(Clone)]
61pub struct WorldSender {
62    /// The queue the world's jobs are posted through.
63    sender: JobSender,
64    /// The liveness a sender checks before it posts.
65    status: ThreadStatus,
66}
67
68impl WorldSender {
69    /// Post a job to the world this component belongs to.
70    pub fn send(&self, job: Job) -> Result<(), SendError> {
71        self.sender.send(job)
72    }
73
74    /// Whether the world has stopped.
75    ///
76    /// A job sent to a stopped world never runs, so a sender checks this
77    /// first rather than waiting for an answer that cannot come.
78    pub fn is_finished(&self) -> bool {
79        self.status.is_finished()
80    }
81}
82
83/// The tick a world runs, with its one-time state already captured.
84type Tick = Box<dyn FnMut(&mut World, &ThreadControl, f32) -> bool>;
85
86/// A world the host steps: the world, its command queue and its tick.
87///
88/// The world lives here rather than in the caller's own struct, so a host
89/// entity's data does not depend on whether the world has a thread.
90struct SteppedWorld {
91    /// The world itself.
92    world: World,
93    /// The jobs queued for it.
94    inbox: Receiver<Job>,
95    /// The pacing and stop signal its tick reads.
96    control: ThreadControl,
97    /// The state the handle reports.
98    status: ThreadStatus,
99    /// The tick, with its one-time state already captured.
100    tick: Tick,
101    /// The name the world logs under, and the one a bridge reports.
102    name: &'static str,
103}
104
105impl Drop for SteppedWorld {
106    fn drop(&mut self) {
107        // Dropping the proxy entity tears the world down, and a world that is
108        // gone says so: the state it leaves reads `Stopped`, and the queue
109        // closes so a later send is refused instead of landing in a queue no
110        // step will ever drain.
111        self.status.finish();
112        self.inbox.close();
113    }
114}
115
116/// How a world is driven: on its own thread, or by the host's frames.
117///
118/// A runner is a zero-sized choice made at the call site, so a bridge can
119/// offer both a threaded and a stepped world without a feature or a target
120/// check. The stepped runner needs no thread at all, so a target without
121/// threads can still build and drive every world.
122pub trait WorldRunner: 'static {
123    /// Whether the runner drives the world on its own.
124    ///
125    /// A world whose runner drives itself can open devices and sleep between
126    /// ticks; a stepped world does neither, and its owner does both.
127    const SELF_DRIVEN: bool;
128
129    /// Spawn a world driven this way.
130    fn spawn<I, S, T>(
131        host: &mut World,
132        name: &'static str,
133        command_capacity: usize,
134        init: I,
135        tick: T,
136    ) -> WorldHandle
137    where
138        I: FnOnce(&mut World) -> S + Send + 'static,
139        S: 'static,
140        T: FnMut(&mut World, &ThreadControl, &mut S, f32) -> bool + Send + 'static;
141}
142
143/// A world on its own thread.
144pub struct Threaded;
145
146/// A world the host steps, with no thread of its own.
147pub struct Stepped;
148
149impl WorldRunner for Threaded {
150    const SELF_DRIVEN: bool = true;
151
152    fn spawn<I, S, T>(
153        host: &mut World,
154        name: &'static str,
155        command_capacity: usize,
156        init: I,
157        tick: T,
158    ) -> WorldHandle
159    where
160        I: FnOnce(&mut World) -> S + Send + 'static,
161        S: 'static,
162        T: FnMut(&mut World, &ThreadControl, &mut S, f32) -> bool + Send + 'static,
163    {
164        spawn_threaded_world_with_init(host, name, command_capacity, init, tick)
165    }
166}
167
168impl WorldRunner for Stepped {
169    const SELF_DRIVEN: bool = false;
170
171    fn spawn<I, S, T>(
172        host: &mut World,
173        name: &'static str,
174        command_capacity: usize,
175        init: I,
176        tick: T,
177    ) -> WorldHandle
178    where
179        I: FnOnce(&mut World) -> S + Send + 'static,
180        S: 'static,
181        T: FnMut(&mut World, &ThreadControl, &mut S, f32) -> bool + Send + 'static,
182    {
183        spawn_stepped_world_with_init(host, name, command_capacity, init, tick)
184    }
185}
186
187/// Spawn a threaded world on a fresh thread, driven by `tick`.
188///
189/// The thread's loop drains the jobs queued for the world, then runs
190/// `tick` once, then publishes what the world's subscriptions collected;
191/// a tick that returns `false` ends the thread. A job that
192/// panics is a tick panic: it is reported and ends the thread. The handle
193/// returned carries the handle every later job goes through, and
194/// dropping the host entity it names stops the thread.
195///
196/// `tick` takes the whole seconds since the previous tick, so a tick body
197/// can advance by the time that really passed rather than assume the
198/// rate it asked to be woken at.
199///
200/// `name` labels the world for a bridge that lists the host's worlds; it
201/// lands on the proxy entity as a [`WorldName`].
202pub fn spawn_threaded_world<T>(
203    host: &mut World,
204    name: &'static str,
205    command_capacity: usize,
206    mut tick: T,
207) -> WorldHandle
208where
209    T: FnMut(&mut World, &ThreadControl, f32) -> bool + Send + 'static,
210{
211    spawn_threaded_world_with_init(
212        host,
213        name,
214        command_capacity,
215        |_| (),
216        move |world, control, (), delta_seconds| tick(world, control, delta_seconds),
217    )
218}
219
220/// Like [`spawn_threaded_world`], with a one-time setup step.
221///
222/// `init` runs on the world's thread before the first job is drained, so
223/// a world can create the components its jobs assume exist - its physics
224/// world, say - and hand any state they need to the tick. The state it
225/// returns is passed to every tick.
226///
227/// # Panics
228///
229/// Panics when `command_capacity` is zero: a queue that can never hold
230/// a job would silently block every sender.
231pub fn spawn_threaded_world_with_init<I, S, T>(
232    host: &mut World,
233    name: &'static str,
234    command_capacity: usize,
235    init: I,
236    mut tick: T,
237) -> WorldHandle
238where
239    I: FnOnce(&mut World) -> S + Send + 'static,
240    S: 'static,
241    T: FnMut(&mut World, &ThreadControl, &mut S, f32) -> bool + Send + 'static,
242{
243    let (queue, inbox) = bounded::<Job>(command_capacity);
244    let thread = WorldThread::spawn_with(name, init, move |world, control, state| {
245        while let Ok(job) = inbox.try_recv() {
246            job(world);
247        }
248        let running = tick(world, control, state, control.elapsed_seconds());
249        // The turn is over: the driver publishes what the world's collectors
250        // returned, so a tick body collects nothing by hand.
251        publish_collected(world);
252        running
253    });
254    let status: ThreadStatus = thread.status();
255    let sender = JobSender::new(queue);
256    let port = WorldSender {
257        sender: sender.clone(),
258        status: status.clone(),
259    };
260    let entity = host.root_scope_mut().spawn(WorldEntity {
261        kind: WorldKind::Threaded { _thread: thread },
262        name,
263        sender: port,
264    });
265    WorldHandle::with_status(entity, sender, status)
266}
267
268/// Spawn a world the host steps, with a one-time setup step.
269///
270/// The world and its command queue live in the proxy component on the host
271/// entity, exactly as a threaded world's thread does, so the entity carries
272/// the same data either way. Nothing here runs on its own: the host calls
273/// [`step_stepped_world`] to drain the queue and run the tick once.
274///
275/// `init` runs immediately, against the fresh world, before the entity is
276/// spawned, so a job sent right after the spawn already finds what it built.
277///
278/// `name` labels the world for a bridge that lists the host's worlds; it
279/// lands on the proxy entity as a [`WorldName`].
280pub fn spawn_stepped_world_with_init<I, S, T>(
281    host: &mut World,
282    name: &'static str,
283    command_capacity: usize,
284    init: I,
285    mut tick: T,
286) -> WorldHandle
287where
288    I: FnOnce(&mut World) -> S + 'static,
289    S: 'static,
290    T: FnMut(&mut World, &ThreadControl, &mut S, f32) -> bool + 'static,
291{
292    let (queue, inbox) = bounded::<Job>(command_capacity);
293    let mut world = World::new();
294    // The world's async tasks are its own from here on: the setup may spawn
295    // one, and every step drives them.
296    LocalTasks::install(&mut world);
297    let mut state = init(&mut world);
298    let (control, status) = stepped_control();
299    let tick = Box::new(
300        move |world: &mut World, control: &ThreadControl, delta_seconds: f32| {
301            tick(world, control, &mut state, delta_seconds)
302        },
303    );
304    let stepped = SteppedWorld {
305        world,
306        inbox,
307        control,
308        status: status.clone(),
309        tick,
310        name,
311    };
312    let sender = JobSender::new(queue);
313    let port = WorldSender {
314        sender: sender.clone(),
315        status: status.clone(),
316    };
317    let entity = host.root_scope_mut().spawn(WorldEntity {
318        kind: WorldKind::Stepped(Box::new(stepped)),
319        name,
320        sender: port,
321    });
322    WorldHandle::with_status(entity, sender, status)
323}
324
325/// The entity a spawned world lives on: what drives it, the name it answers
326/// to, and the queue its jobs are posted through.
327struct WorldEntity {
328    /// What drives the world.
329    kind: WorldKind,
330    /// The name the world answers to.
331    name: &'static str,
332    /// The host-side view of its command queue.
333    sender: WorldSender,
334}
335
336impl Bundle for WorldEntity {
337    fn name(&self) -> &'static str {
338        self.name
339    }
340
341    fn into_bundle(self) -> DynamicBundle {
342        bundle![
343            WorldProxy { kind: self.kind },
344            WorldName(self.name),
345            self.sender,
346        ]
347    }
348}
349
350/// Why a stepped world could not be driven through the entity that names it.
351#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
352#[non_exhaustive]
353pub enum StepError {
354    /// The entity does not name a world proxy the host can reach.
355    #[error("cannot reach the world proxy: {0}")]
356    NoWorld(#[from] EntityAccessError),
357    /// The entity names a threaded world, which drives itself.
358    #[error("the world is threaded and drives itself")]
359    NotStepped,
360}
361
362/// What one turn of a stepped world did.
363#[derive(Clone, Copy, Debug, PartialEq, Eq)]
364#[non_exhaustive]
365pub enum StepOutcome {
366    /// The tick, or the queued jobs, ran and the world carries on.
367    Ran,
368    /// The tick ended the world.
369    Finished,
370    /// A stop request ended the world before it ticked.
371    Stopped,
372    /// A job or the tick panicked, and the world ended.
373    Panicked,
374}
375
376/// Drain a stepped world's jobs and run its tick once with `delta_seconds`.
377///
378/// A tick that returns `false` stops the world; a panic, in a job or in the
379/// tick, is caught, logged and published as the world's state, and ends the
380/// world too. Both are reported through [StepOutcome] rather than left for the
381/// caller to guess.
382///
383/// The step publishes what the world's subscriptions collected, the same as
384/// a threaded world's loop does, so a tick body never publishes by hand.
385///
386/// # Errors
387///
388/// Returns [StepError::NoWorld] when `entity` names no reachable world proxy,
389/// and [StepError::NotStepped] when it names a threaded world, which drives
390/// itself and must not be stepped from here.
391pub fn step_stepped_world(
392    host: &World,
393    entity: Entity,
394    delta_seconds: f32,
395) -> Result<StepOutcome, StepError> {
396    let mut proxy = host.root_scope().query_one::<&mut WorldProxy>(entity)?;
397    let WorldKind::Stepped(stepped) = &mut proxy.get().kind else {
398        return Err(StepError::NotStepped);
399    };
400    if stepped.status.is_finished() {
401        return Ok(StepOutcome::Finished);
402    }
403    if stepped.control.stop_requested() {
404        // A stop is not a panic, so the world collects one last time: the
405        // state it had reached is the last its readers ever get.
406        publish_collected(&stepped.world);
407        stepped.status.finish();
408        stepped.inbox.close();
409        return Ok(StepOutcome::Stopped);
410    }
411    if !run_queued_jobs(&mut *stepped) {
412        return Ok(StepOutcome::Panicked);
413    }
414    let SteppedWorld {
415        world,
416        inbox,
417        control,
418        tick,
419        status,
420        name,
421    } = &mut **stepped;
422    LocalTasks::pump(world);
423    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
424        tick(world, control, delta_seconds)
425    }));
426    match outcome {
427        Ok(true) => {}
428        Ok(false) => {
429            // A tick that ends the world still publishes: the state it leaves
430            // is the last one its readers ever get.
431            publish_collected(world);
432            status.finish();
433            inbox.close();
434            return Ok(StepOutcome::Finished);
435        }
436        Err(payload) => {
437            let message = panic_message(payload.as_ref());
438            log::error!("the stepped world {name} panicked: {message}");
439            status.record_panic(message);
440            // A collector is arbitrary code: running one now would hand its
441            // panic to the host, so the world stops collecting here.
442            inbox.close();
443            return Ok(StepOutcome::Panicked);
444        }
445    }
446    publish_collected(world);
447    Ok(StepOutcome::Ran)
448}
449
450/// Drain the queued jobs of every world the host steps.
451///
452/// This walks the proxies directly, so a caller that must answer a
453/// command before its next frame does not need to gather entities into
454/// a separate buffer first.
455pub fn drain_all_stepped_worlds(host: &World) {
456    let mut proxies = host.root_scope().query::<&mut WorldProxy>();
457    for proxy in proxies.iter() {
458        let WorldKind::Stepped(stepped) = &mut proxy.kind else {
459            continue;
460        };
461        if stepped.status.is_finished() || stepped.control.stop_requested() {
462            continue;
463        }
464        run_queued_jobs(stepped);
465    }
466}
467
468/// Run the jobs a stepped world has queued, stopping at the first panic.
469fn run_queued_jobs(stepped: &mut SteppedWorld) -> bool {
470    loop {
471        let Ok(job) = stepped.inbox.try_recv() else {
472            return true;
473        };
474        let outcome =
475            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job(&mut stepped.world)));
476        if let Err(payload) = outcome {
477            let message = panic_message(payload.as_ref());
478            log::error!("the stepped world {} panicked: {message}", stepped.name);
479            stepped.status.record_panic(message);
480            // The world is over. Close the queue so a sender learns it from
481            // the send, as a threaded world's departed receiver already
482            // taught it.
483            stepped.inbox.close();
484            return false;
485        }
486    }
487}
488
489/// Run `f` against a stepped world's world.
490///
491/// A host that supplies its own buffers - an offline run filling an audio
492/// buffer frame by frame, say - reads the world through this instead of
493/// through the world's jobs.
494///
495/// # Errors
496///
497/// Returns [StepError::NoWorld] when `entity` names no reachable world proxy,
498/// and [StepError::NotStepped] when it names a threaded world.
499pub fn with_stepped_world<R>(
500    host: &World,
501    entity: Entity,
502    f: impl FnOnce(&World) -> R,
503) -> Result<R, StepError> {
504    let mut proxy = host.root_scope().query_one::<&WorldProxy>(entity)?;
505    let WorldKind::Stepped(stepped) = &proxy.get().kind else {
506        return Err(StepError::NotStepped);
507    };
508    Ok(f(&stepped.world))
509}
510
511/// Run `f` against a stepped world's world mutably.
512///
513/// The mutable twin of [with_stepped_world]: a caller that starts or stops a
514/// recording on the world needs the world itself, not only its components.
515///
516/// # Errors
517///
518/// Returns [StepError::NoWorld] when `entity` names no reachable world proxy,
519/// and [StepError::NotStepped] when it names a threaded world.
520pub fn with_stepped_world_mut<R>(
521    host: &World,
522    entity: Entity,
523    f: impl FnOnce(&mut World) -> R,
524) -> Result<R, StepError> {
525    let mut proxy = host.root_scope().query_one::<&mut WorldProxy>(entity)?;
526    let WorldKind::Stepped(stepped) = &mut proxy.get().kind else {
527        return Err(StepError::NotStepped);
528    };
529    Ok(f(&mut stepped.world))
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::FRAME_SECONDS;
536    use crate::job::SendError;
537    use std::time::Duration;
538    use unlit_ecs::bundle;
539
540    /// Jobs sent through the handle run, and what one leaves behind is
541    /// published to the sender's reader.
542    #[test]
543    fn sent_jobs_run_and_publish_back() {
544        let mut host = World::new();
545        let handle = spawn_threaded_world(&mut host, "test", 8, |_, control, _| {
546            control.wait_timeout(Duration::from_millis(1));
547            true
548        });
549        handle
550            .sender
551            .send(Box::new(|world: &mut World| {
552                world.root_scope_mut().spawn(bundle![Count(0)]);
553            }))
554            .expect("the world accepts jobs");
555        let counted = crate::channel::Snapshot::new();
556        let reader = counted.reader();
557        let publish = counted.clone();
558        handle
559            .sender
560            .send(Box::new(move |world: &mut World| {
561                world
562                    .root_scope()
563                    .query::<&mut Count>()
564                    .iter()
565                    .next()
566                    .expect("the counter")
567                    .0 += 1;
568                let mut count_rows = world.root_scope().query::<&Count>();
569
570                let count = count_rows.iter().next().expect("the counter").0;
571                publish.publish(count);
572            }))
573            .expect("the world accepts jobs");
574        let mut answer = None;
575        for _ in 0..2_000 {
576            answer = reader.latest().map(|count| *count);
577            if answer.is_some() {
578                break;
579            }
580            std::thread::sleep(Duration::from_millis(1));
581        }
582        assert_eq!(answer, Some(1));
583    }
584
585    /// The setup runs before the first job, so a job finds what it built.
586    #[test]
587    fn init_runs_before_the_first_job() {
588        let mut host = World::new();
589        let handle = spawn_threaded_world_with_init(
590            &mut host,
591            "test",
592            8,
593            |world: &mut World| {
594                world.root_scope_mut().spawn(bundle![Count(0)]);
595            },
596            |_, control, (), _| {
597                control.wait_timeout(Duration::from_millis(1));
598                true
599            },
600        );
601        let seeded = crate::channel::Snapshot::new();
602        let reader = seeded.reader();
603        let publish = seeded.clone();
604        handle
605            .sender
606            .send(Box::new(move |world: &mut World| {
607                publish.publish(world.root_scope().query::<&Count>().iter().next().is_some());
608            }))
609            .expect("the world accepts jobs");
610        let mut answered = None;
611        for _ in 0..2_000 {
612            answered = reader.latest().map(|found| *found);
613            if answered.is_some() {
614                break;
615            }
616            std::thread::sleep(Duration::from_millis(1));
617        }
618        assert_eq!(answered, Some(true), "init ran before the job");
619    }
620
621    /// A full queue fails the send instead of blocking the sender.
622    #[test]
623    fn a_full_queue_fails_the_send() {
624        let mut host = World::new();
625        let handle = spawn_threaded_world(&mut host, "test", 1, |_, _, _| true);
626        let mut results = Vec::new();
627        for _ in 0..64 {
628            results.push(handle.sender.send(Box::new(|_| {})));
629        }
630        assert!(
631            results.contains(&Err(SendError::Full)),
632            "a bounded queue must eventually report Full"
633        );
634    }
635
636    /// A stepped world runs its jobs and its tick only when stepped.
637    #[test]
638    fn a_stepped_world_runs_on_its_owner_s_calls() {
639        let mut host = World::new();
640        let handle = spawn_stepped_world_with_init(
641            &mut host,
642            "stepped",
643            8,
644            |world: &mut World| {
645                world.root_scope_mut().spawn(bundle![Count(0)]);
646            },
647            |world: &mut World, _control: &ThreadControl, (), _: f32| {
648                world
649                    .root_scope()
650                    .query::<&mut Count>()
651                    .iter()
652                    .next()
653                    .expect("the counter")
654                    .0 += 1;
655                true
656            },
657        );
658        let count = |host: &World| {
659            with_stepped_world(host, handle.entity, |world| {
660                world
661                    .root_scope()
662                    .query::<&Count>()
663                    .iter()
664                    .next()
665                    .expect("the counter")
666                    .0
667            })
668        };
669        // Nothing has run yet: the host has not stepped the world.
670        assert_eq!(count(&host), Ok(0));
671        assert_eq!(
672            step_stepped_world(&host, handle.entity, FRAME_SECONDS),
673            Ok(StepOutcome::Ran)
674        );
675        assert_eq!(count(&host), Ok(1));
676        assert_eq!(
677            step_stepped_world(&host, handle.entity, FRAME_SECONDS),
678            Ok(StepOutcome::Ran)
679        );
680        assert_eq!(count(&host), Ok(2));
681    }
682
683    /// Draining every stepped world in one pass must not hold a shared
684    /// claim on the proxy that the drain itself needs to write.
685    #[test]
686    fn draining_all_stepped_worlds_does_not_conflict_with_its_own_query() {
687        let mut host = World::new();
688        let first = spawn_stepped_world_with_init(
689            &mut host,
690            "first",
691            8,
692            |world: &mut World| {
693                world.root_scope_mut().spawn(bundle![Count(0)]);
694            },
695            |_, _, (), _| true,
696        );
697        let second = spawn_stepped_world_with_init(
698            &mut host,
699            "second",
700            8,
701            |world: &mut World| {
702                world.root_scope_mut().spawn(bundle![Count(0)]);
703            },
704            |_, _, (), _| true,
705        );
706        for handle in [&first, &second] {
707            handle
708                .sender
709                .send(Box::new(|world: &mut World| {
710                    world
711                        .root_scope()
712                        .query::<&mut Count>()
713                        .iter()
714                        .next()
715                        .expect("the counter")
716                        .0 += 1;
717                }))
718                .expect("the world accepts jobs");
719        }
720        // The single query inside the drain must not trip the borrow ledger.
721        drain_all_stepped_worlds(&host);
722        for handle in [&first, &second] {
723            let count = with_stepped_world(&host, handle.entity, |world| {
724                world
725                    .root_scope()
726                    .query::<&Count>()
727                    .iter()
728                    .next()
729                    .expect("the counter")
730                    .0
731            });
732            assert_eq!(count, Ok(1), "the queued job ran");
733        }
734    }
735
736    /// A stepped world's tick can end the world.
737    #[test]
738    fn a_stepped_tick_ends_the_world() {
739        let mut host = World::new();
740        let handle = spawn_stepped_world_with_init(
741            &mut host,
742            "stepped",
743            8,
744            |_| (),
745            |_, _: &ThreadControl, (), _: f32| false,
746        );
747        assert_eq!(
748            step_stepped_world(&host, handle.entity, FRAME_SECONDS),
749            Ok(StepOutcome::Finished)
750        );
751        assert!(handle.is_finished(), "a false tick ends a stepped world");
752    }
753
754    /// An entity that names no world proxy is refused, not silently skipped.
755    #[test]
756    fn stepping_a_foreign_entity_fails_readably() {
757        let mut host = World::new();
758        let plain = host.root_scope_mut().spawn(bundle![Count(0)]);
759        assert!(matches!(
760            step_stepped_world(&host, plain, 0.0),
761            Err(StepError::NoWorld(_))
762        ));
763    }
764
765    /// A world that drives itself on a thread is refused rather than
766    /// silently ignored, so a caller cannot mistake one mode for the other.
767    #[test]
768    fn stepping_a_threaded_world_is_refused() {
769        let mut host = World::new();
770        let handle = spawn_threaded_world(&mut host, "threaded", 8, |_, control, _| {
771            control.wait_timeout(Duration::from_millis(1));
772            true
773        });
774        assert_eq!(
775            step_stepped_world(&host, handle.entity, FRAME_SECONDS),
776            Err(StepError::NotStepped)
777        );
778        handle.stop();
779    }
780
781    /// The counter the tests share with their worlds.
782    struct Count(u32);
783}