azalea_client/
client.rs

1use std::{
2    collections::HashMap,
3    fmt::Debug,
4    mem,
5    net::SocketAddr,
6    sync::Arc,
7    thread,
8    time::{Duration, Instant},
9};
10
11use azalea_auth::game_profile::GameProfile;
12use azalea_core::{
13    data_registry::ResolvableDataRegistry, identifier::Identifier, position::Vec3, tick::GameTick,
14};
15use azalea_entity::{
16    Attributes, EntityUpdateSystems, PlayerAbilities, Position,
17    dimensions::EntityDimensions,
18    indexing::{EntityIdIndex, EntityUuidIndex},
19    inventory::Inventory,
20    metadata::Health,
21};
22use azalea_physics::local_player::PhysicsState;
23use azalea_protocol::{
24    ServerAddress,
25    connect::Proxy,
26    packets::{Packet, game::ServerboundGamePacket},
27    resolve,
28};
29use azalea_world::{Instance, InstanceContainer, InstanceName, MinecraftEntityId, PartialInstance};
30use bevy_app::{App, AppExit, Plugin, PluginsState, SubApp, Update};
31use bevy_ecs::{
32    message::MessageCursor,
33    prelude::*,
34    schedule::{InternedScheduleLabel, LogLevel, ScheduleBuildSettings},
35};
36use parking_lot::{Mutex, RwLock};
37use thiserror::Error;
38use tokio::{
39    sync::{
40        mpsc::{self},
41        oneshot,
42    },
43    time,
44};
45use tracing::{info, warn};
46use uuid::Uuid;
47
48use crate::{
49    Account, DefaultPlugins,
50    attack::{self},
51    block_update::QueuedServerBlockUpdates,
52    chunks::ChunkBatchInfo,
53    connection::RawConnection,
54    disconnect::DisconnectEvent,
55    events::Event,
56    interact::BlockStatePredictionHandler,
57    join::{ConnectOpts, StartJoinServerEvent},
58    local_player::{Hunger, InstanceHolder, PermissionLevel, TabList},
59    mining::{self},
60    movement::LastSentLookDirection,
61    packet::game::SendGamePacketEvent,
62    player::{GameProfileComponent, PlayerInfo, retroactively_add_game_profile_component},
63};
64
65/// A Minecraft client instance that can interact with the world.
66///
67/// To make a new client, use either [`azalea::ClientBuilder`] or
68/// [`Client::join`].
69///
70/// Note that `Client` is inaccessible from systems (i.e. plugins), but you can
71/// achieve everything that client can do with ECS events.
72///
73/// [`azalea::ClientBuilder`]: https://docs.rs/azalea/latest/azalea/struct.ClientBuilder.html
74#[derive(Clone)]
75pub struct Client {
76    /// The entity for this client in the ECS.
77    pub entity: Entity,
78
79    /// A mutually exclusive reference to the entity component system (ECS).
80    ///
81    /// You probably don't need to access this directly. Note that if you're
82    /// using a shared world (i.e. a swarm), the ECS will contain all entities
83    /// in all instances/dimensions.
84    pub ecs: Arc<Mutex<World>>,
85}
86
87/// An error that happened while joining the server.
88#[derive(Error, Debug)]
89pub enum JoinError {
90    #[error(transparent)]
91    Resolver(#[from] resolve::ResolveError),
92    #[error("The given address could not be parsed into a ServerAddress")]
93    InvalidAddress,
94}
95
96pub struct StartClientOpts {
97    pub ecs_lock: Arc<Mutex<World>>,
98    pub account: Account,
99    pub connect_opts: ConnectOpts,
100    pub event_sender: Option<mpsc::UnboundedSender<Event>>,
101}
102
103impl StartClientOpts {
104    pub fn new(
105        account: Account,
106        address: ServerAddress,
107        resolved_address: SocketAddr,
108        event_sender: Option<mpsc::UnboundedSender<Event>>,
109    ) -> StartClientOpts {
110        let mut app = App::new();
111        app.add_plugins(DefaultPlugins);
112
113        // appexit_rx is unused here since the user should be able to handle it
114        // themselves if they're using StartClientOpts::new
115        let (ecs_lock, start_running_systems, _appexit_rx) = start_ecs_runner(app.main_mut());
116        start_running_systems();
117
118        Self {
119            ecs_lock,
120            account,
121            connect_opts: ConnectOpts {
122                address,
123                resolved_address,
124                proxy: None,
125            },
126            event_sender,
127        }
128    }
129
130    pub fn proxy(mut self, proxy: Proxy) -> Self {
131        self.connect_opts.proxy = Some(proxy);
132        self
133    }
134}
135
136impl Client {
137    /// Create a new client from the given [`GameProfile`], ECS Entity, ECS
138    /// World, and schedule runner function.
139    /// You should only use this if you want to change these fields from the
140    /// defaults, otherwise use [`Client::join`].
141    pub fn new(entity: Entity, ecs: Arc<Mutex<World>>) -> Self {
142        Self {
143            // default our id to 0, it'll be set later
144            entity,
145
146            ecs,
147        }
148    }
149
150    /// Connect to a Minecraft server.
151    ///
152    /// To change the render distance and other settings, use
153    /// [`Client::set_client_information`]. To watch for events like packets
154    /// sent by the server, use the `rx` variable this function returns.
155    ///
156    /// # Examples
157    ///
158    /// ```rust,no_run
159    /// use azalea_client::{Account, Client};
160    ///
161    /// #[tokio::main(flavor = "current_thread")]
162    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
163    ///     let account = Account::offline("bot");
164    ///     let (client, rx) = Client::join(account, "localhost").await?;
165    ///     client.chat("Hello, world!");
166    ///     client.disconnect();
167    ///     Ok(())
168    /// }
169    /// ```
170    pub async fn join(
171        account: Account,
172        address: impl TryInto<ServerAddress>,
173    ) -> Result<(Self, mpsc::UnboundedReceiver<Event>), JoinError> {
174        let address: ServerAddress = address.try_into().map_err(|_| JoinError::InvalidAddress)?;
175        let resolved_address = resolve::resolve_address(&address).await?;
176        let (tx, rx) = mpsc::unbounded_channel();
177
178        let client = Self::start_client(StartClientOpts::new(
179            account,
180            address,
181            resolved_address,
182            Some(tx),
183        ))
184        .await;
185        Ok((client, rx))
186    }
187
188    pub async fn join_with_proxy(
189        account: Account,
190        address: impl TryInto<ServerAddress>,
191        proxy: Proxy,
192    ) -> Result<(Self, mpsc::UnboundedReceiver<Event>), JoinError> {
193        let address: ServerAddress = address.try_into().map_err(|_| JoinError::InvalidAddress)?;
194        let resolved_address = resolve::resolve_address(&address).await?;
195        let (tx, rx) = mpsc::unbounded_channel();
196
197        let client = Self::start_client(
198            StartClientOpts::new(account, address, resolved_address, Some(tx)).proxy(proxy),
199        )
200        .await;
201        Ok((client, rx))
202    }
203
204    /// Create a [`Client`] when you already have the ECS made with
205    /// [`start_ecs_runner`]. You'd usually want to use [`Self::join`] instead.
206    pub async fn start_client(
207        StartClientOpts {
208            ecs_lock,
209            account,
210            connect_opts,
211            event_sender,
212        }: StartClientOpts,
213    ) -> Self {
214        // send a StartJoinServerEvent
215
216        let (start_join_callback_tx, mut start_join_callback_rx) =
217            mpsc::unbounded_channel::<Entity>();
218
219        ecs_lock.lock().write_message(StartJoinServerEvent {
220            account,
221            connect_opts,
222            event_sender,
223            start_join_callback_tx: Some(start_join_callback_tx),
224        });
225
226        let entity = start_join_callback_rx.recv().await.expect(
227            "start_join_callback should not be dropped before sending a message, this is a bug in Azalea",
228        );
229
230        Client::new(entity, ecs_lock)
231    }
232
233    /// Write a packet directly to the server.
234    pub fn write_packet(&self, packet: impl Packet<ServerboundGamePacket>) {
235        let packet = packet.into_variant();
236        self.ecs
237            .lock()
238            .commands()
239            .trigger(SendGamePacketEvent::new(self.entity, packet));
240    }
241
242    /// Disconnect this client from the server by ending all tasks.
243    ///
244    /// The OwnedReadHalf for the TCP connection is in one of the tasks, so it
245    /// automatically closes the connection when that's dropped.
246    pub fn disconnect(&self) {
247        self.ecs.lock().write_message(DisconnectEvent {
248            entity: self.entity,
249            reason: None,
250        });
251    }
252
253    pub fn with_raw_connection<R>(&self, f: impl FnOnce(&RawConnection) -> R) -> R {
254        self.query_self::<&RawConnection, _>(f)
255    }
256    pub fn with_raw_connection_mut<R>(&self, f: impl FnOnce(Mut<'_, RawConnection>) -> R) -> R {
257        self.query_self::<&mut RawConnection, _>(f)
258    }
259
260    /// Get a component from this client. This will clone the component and
261    /// return it.
262    ///
263    ///
264    /// If the component can't be cloned, try [`Self::query_self`] instead.
265    /// If it isn't guaranteed to be present, you can use
266    /// [`Self::get_component`] or [`Self::query_self`].
267    ///
268    ///
269    /// You may also use [`Self::ecs`] directly if you need more control over
270    /// when the ECS is locked.
271    ///
272    /// # Panics
273    ///
274    /// This will panic if the component doesn't exist on the client.
275    ///
276    /// # Examples
277    ///
278    /// ```
279    /// # use azalea_world::InstanceName;
280    /// # fn example(client: &azalea_client::Client) {
281    /// let world_name = client.component::<InstanceName>();
282    /// # }
283    pub fn component<T: Component + Clone>(&self) -> T {
284        self.query_self::<&T, _>(|t| t.clone())
285    }
286
287    /// Get a component from this client, or `None` if it doesn't exist.
288    ///
289    /// If the component can't be cloned, consider using [`Self::query_self`]
290    /// with `Option<&T>` instead.
291    ///
292    /// You may also have to use [`Self::query_self`] directly.
293    pub fn get_component<T: Component + Clone>(&self) -> Option<T> {
294        self.query_self::<Option<&T>, _>(|t| t.cloned())
295    }
296
297    /// Get a resource from the ECS. This will clone the resource and return it.
298    pub fn resource<T: Resource + Clone>(&self) -> T {
299        self.ecs.lock().resource::<T>().clone()
300    }
301
302    /// Get a required ECS resource and call the given function with it.
303    pub fn map_resource<T: Resource, R>(&self, f: impl FnOnce(&T) -> R) -> R {
304        let ecs = self.ecs.lock();
305        let value = ecs.resource::<T>();
306        f(value)
307    }
308
309    /// Get an optional ECS resource and call the given function with it.
310    pub fn map_get_resource<T: Resource, R>(&self, f: impl FnOnce(Option<&T>) -> R) -> R {
311        let ecs = self.ecs.lock();
312        let value = ecs.get_resource::<T>();
313        f(value)
314    }
315
316    /// Get an `RwLock` with a reference to our (potentially shared) world.
317    ///
318    /// This gets the [`Instance`] from the client's [`InstanceHolder`]
319    /// component. If it's a normal client, then it'll be the same as the
320    /// world the client has loaded. If the client is using a shared world,
321    /// then the shared world will be a superset of the client's world.
322    pub fn world(&self) -> Arc<RwLock<Instance>> {
323        let instance_holder = self.component::<InstanceHolder>();
324        instance_holder.instance.clone()
325    }
326
327    /// Get an `RwLock` with a reference to the world that this client has
328    /// loaded.
329    ///
330    /// ```
331    /// # use azalea_core::position::ChunkPos;
332    /// # fn example(client: &azalea_client::Client) {
333    /// let world = client.partial_world();
334    /// let is_0_0_loaded = world.read().chunks.limited_get(&ChunkPos::new(0, 0)).is_some();
335    /// # }
336    pub fn partial_world(&self) -> Arc<RwLock<PartialInstance>> {
337        let instance_holder = self.component::<InstanceHolder>();
338        instance_holder.partial_instance.clone()
339    }
340
341    /// Returns whether we have a received the login packet yet.
342    pub fn logged_in(&self) -> bool {
343        // the login packet tells us the world name
344        self.query_self::<Option<&InstanceName>, _>(|ins| ins.is_some())
345    }
346}
347
348impl Client {
349    /// Get the position of this client.
350    ///
351    /// This is a shortcut for `Vec3::from(&bot.component::<Position>())`.
352    ///
353    /// Note that this value is given a default of [`Vec3::ZERO`] when it
354    /// receives the login packet, its true position may be set ticks
355    /// later.
356    pub fn position(&self) -> Vec3 {
357        Vec3::from(
358            &self
359                .get_component::<Position>()
360                .expect("the client's position hasn't been initialized yet"),
361        )
362    }
363
364    /// Get the bounding box dimensions for our client, which contains our
365    /// width, height, and eye height.
366    ///
367    /// This is a shortcut for
368    /// `self.component::<EntityDimensions>()`.
369    pub fn dimensions(&self) -> EntityDimensions {
370        self.component::<EntityDimensions>()
371    }
372
373    /// Get the position of this client's eyes.
374    ///
375    /// This is a shortcut for
376    /// `bot.position().up(bot.dimensions().eye_height)`.
377    pub fn eye_position(&self) -> Vec3 {
378        self.query_self::<(&Position, &EntityDimensions), _>(|(pos, dim)| {
379            pos.up(dim.eye_height as f64)
380        })
381    }
382
383    /// Get the health of this client.
384    ///
385    /// This is a shortcut for `*bot.component::<Health>()`.
386    pub fn health(&self) -> f32 {
387        *self.component::<Health>()
388    }
389
390    /// Get the hunger level of this client, which includes both food and
391    /// saturation.
392    ///
393    /// This is a shortcut for `self.component::<Hunger>().to_owned()`.
394    pub fn hunger(&self) -> Hunger {
395        self.component::<Hunger>().to_owned()
396    }
397
398    /// Get the username of this client.
399    ///
400    /// This is a shortcut for
401    /// `bot.component::<GameProfileComponent>().name.to_owned()`.
402    pub fn username(&self) -> String {
403        self.profile().name.to_owned()
404    }
405
406    /// Get the Minecraft UUID of this client.
407    ///
408    /// This is a shortcut for `bot.component::<GameProfileComponent>().uuid`.
409    pub fn uuid(&self) -> Uuid {
410        self.profile().uuid
411    }
412
413    /// Get a map of player UUIDs to their information in the tab list.
414    ///
415    /// This is a shortcut for `*bot.component::<TabList>()`.
416    pub fn tab_list(&self) -> HashMap<Uuid, PlayerInfo> {
417        (*self.component::<TabList>()).clone()
418    }
419
420    /// Returns the [`GameProfile`] for our client. This contains your username,
421    /// UUID, and skin data.
422    ///
423    /// These values are set by the server upon login, which means they might
424    /// not match up with your actual game profile. Also, note that the username
425    /// and skin that gets displayed in-game will actually be the ones from
426    /// the tab list, which you can get from [`Self::tab_list`].
427    ///
428    /// This as also available from the ECS as [`GameProfileComponent`].
429    pub fn profile(&self) -> GameProfile {
430        (*self.component::<GameProfileComponent>()).clone()
431    }
432
433    /// Returns the attribute values of our player, which can be used to
434    /// determine things like our movement speed.
435    pub fn attributes(&self) -> Attributes {
436        self.component::<Attributes>()
437    }
438
439    /// A convenience function to get the Minecraft Uuid of a player by their
440    /// username, if they're present in the tab list.
441    ///
442    /// You can chain this with [`Client::entity_by_uuid`] to get the ECS
443    /// `Entity` for the player.
444    pub fn player_uuid_by_username(&self, username: &str) -> Option<Uuid> {
445        self.tab_list()
446            .values()
447            .find(|player| player.profile.name == username)
448            .map(|player| player.profile.uuid)
449    }
450
451    /// Get an ECS `Entity` in the world by its Minecraft UUID, if it's within
452    /// render distance.
453    pub fn entity_by_uuid(&self, uuid: Uuid) -> Option<Entity> {
454        self.map_resource::<EntityUuidIndex, _>(|entity_uuid_index| entity_uuid_index.get(&uuid))
455    }
456
457    /// Convert an ECS `Entity` to a [`MinecraftEntityId`].
458    pub fn minecraft_entity_by_ecs_entity(&self, entity: Entity) -> Option<MinecraftEntityId> {
459        self.query_self::<&EntityIdIndex, _>(|entity_id_index| {
460            entity_id_index.get_by_ecs_entity(entity)
461        })
462    }
463    /// Convert a [`MinecraftEntityId`] to an ECS `Entity`.
464    pub fn ecs_entity_by_minecraft_entity(&self, entity: MinecraftEntityId) -> Option<Entity> {
465        self.query_self::<&EntityIdIndex, _>(|entity_id_index| {
466            entity_id_index.get_by_minecraft_entity(entity)
467        })
468    }
469
470    /// Call the given function with the client's [`RegistryHolder`].
471    ///
472    /// The player's instance (aka world) will be locked during this time, which
473    /// may result in a deadlock if you try to access the instance again while
474    /// in the function.
475    ///
476    /// [`RegistryHolder`]: azalea_core::registry_holder::RegistryHolder
477    pub fn with_registry_holder<R>(
478        &self,
479        f: impl FnOnce(&azalea_core::registry_holder::RegistryHolder) -> R,
480    ) -> R {
481        let instance = self.world();
482        let registries = &instance.read().registries;
483        f(registries)
484    }
485
486    /// Resolve the given registry to its name.
487    ///
488    /// This is necessary for data-driven registries like [`Enchantment`].
489    ///
490    /// [`Enchantment`]: azalea_registry::Enchantment
491    pub fn resolve_registry_name(
492        &self,
493        registry: &impl ResolvableDataRegistry,
494    ) -> Option<Identifier> {
495        self.with_registry_holder(|registries| registry.resolve_name(registries).cloned())
496    }
497    /// Resolve the given registry to its name and data and call the given
498    /// function with it.
499    ///
500    /// This is necessary for data-driven registries like [`Enchantment`].
501    ///
502    /// If you just want the value name, use [`Self::resolve_registry_name`]
503    /// instead.
504    ///
505    /// [`Enchantment`]: azalea_registry::Enchantment
506    pub fn with_resolved_registry<R: ResolvableDataRegistry, Ret>(
507        &self,
508        registry: R,
509        f: impl FnOnce(&Identifier, &R::DeserializesTo) -> Ret,
510    ) -> Option<Ret> {
511        self.with_registry_holder(|registries| {
512            registry
513                .resolve(registries)
514                .map(|(name, data)| f(name, data))
515        })
516    }
517}
518
519/// A bundle of components that's inserted right when we switch to the `login`
520/// state and stay present on our clients until we disconnect.
521///
522/// For the components that are only present in the `game` state, see
523/// [`JoinedClientBundle`].
524#[derive(Bundle)]
525pub struct LocalPlayerBundle {
526    pub raw_connection: RawConnection,
527    pub instance_holder: InstanceHolder,
528
529    pub metadata: azalea_entity::metadata::PlayerMetadataBundle,
530}
531
532/// A bundle for the components that are present on a local player that is
533/// currently in the `game` protocol state.
534///
535/// If you want to filter for this, use [`InGameState`].
536#[derive(Bundle, Default)]
537pub struct JoinedClientBundle {
538    // note that InstanceHolder isn't here because it's set slightly before we fully join the world
539    pub physics_state: PhysicsState,
540    pub inventory: Inventory,
541    pub tab_list: TabList,
542    pub block_state_prediction_handler: BlockStatePredictionHandler,
543    pub queued_server_block_updates: QueuedServerBlockUpdates,
544    pub last_sent_direction: LastSentLookDirection,
545    pub abilities: PlayerAbilities,
546    pub permission_level: PermissionLevel,
547    pub chunk_batch_info: ChunkBatchInfo,
548    pub hunger: Hunger,
549
550    pub entity_id_index: EntityIdIndex,
551
552    pub mining: mining::MineBundle,
553    pub attack: attack::AttackBundle,
554
555    pub in_game_state: InGameState,
556}
557
558/// A marker component for local players that are currently in the
559/// `game` state.
560#[derive(Component, Clone, Debug, Default)]
561pub struct InGameState;
562/// A marker component for local players that are currently in the
563/// `configuration` state.
564#[derive(Component, Clone, Debug, Default)]
565pub struct InConfigState;
566
567pub struct AzaleaPlugin;
568impl Plugin for AzaleaPlugin {
569    fn build(&self, app: &mut App) {
570        app.add_systems(
571            Update,
572            (
573                // add GameProfileComponent when we get an AddPlayerEvent
574                retroactively_add_game_profile_component
575                    .after(EntityUpdateSystems::Index)
576                    .after(crate::join::handle_start_join_server_event),
577            ),
578        )
579        .init_resource::<InstanceContainer>()
580        .init_resource::<TabList>();
581    }
582}
583
584/// Create the ECS world, and return a function that begins running systems.
585/// This exists to allow you to make last-millisecond updates to the world
586/// before any systems start running.
587///
588/// You can create your app with `App::new()`, but don't forget to add
589/// [`DefaultPlugins`].
590#[doc(hidden)]
591pub fn start_ecs_runner(
592    app: &mut SubApp,
593) -> (Arc<Mutex<World>>, impl FnOnce(), oneshot::Receiver<AppExit>) {
594    // this block is based on Bevy's default runner:
595    // https://github.com/bevyengine/bevy/blob/390877cdae7a17095a75c8f9f1b4241fe5047e83/crates/bevy_app/src/schedule_runner.rs#L77-L85
596    if app.plugins_state() != PluginsState::Cleaned {
597        // Wait for plugins to load
598        if app.plugins_state() == PluginsState::Adding {
599            info!("Waiting for plugins to load ...");
600            while app.plugins_state() == PluginsState::Adding {
601                thread::yield_now();
602            }
603        }
604        // Finish adding plugins and cleanup
605        app.finish();
606        app.cleanup();
607    }
608
609    // all resources should have been added by now so we can take the ecs from the
610    // app
611    let ecs = Arc::new(Mutex::new(mem::take(app.world_mut())));
612
613    let ecs_clone = ecs.clone();
614    let outer_schedule_label = *app.update_schedule.as_ref().unwrap();
615
616    let (appexit_tx, appexit_rx) = oneshot::channel();
617    let start_running_systems = move || {
618        tokio::spawn(async move {
619            let appexit = run_schedule_loop(ecs_clone, outer_schedule_label).await;
620            appexit_tx.send(appexit)
621        });
622    };
623
624    (ecs, start_running_systems, appexit_rx)
625}
626
627/// Runs the `Update` schedule 60 times per second and the `GameTick` schedule
628/// 20 times per second.
629///
630/// Exits when we receive an `AppExit` event.
631async fn run_schedule_loop(
632    ecs: Arc<Mutex<World>>,
633    outer_schedule_label: InternedScheduleLabel,
634) -> AppExit {
635    let mut last_update: Option<Instant> = None;
636    let mut last_tick: Option<Instant> = None;
637
638    // azalea runs the Update schedule at most 60 times per second to simulate
639    // framerate. unlike vanilla though, we also only handle packets during Updates
640    // due to everything running in ecs systems.
641    const UPDATE_DURATION_TARGET: Duration = Duration::from_micros(1_000_000 / 60);
642    // minecraft runs at 20 tps
643    const GAME_TICK_DURATION_TARGET: Duration = Duration::from_micros(1_000_000 / 20);
644
645    loop {
646        // sleep until the next update if necessary
647        let now = Instant::now();
648        if let Some(last_update) = last_update {
649            let elapsed = now.duration_since(last_update);
650            if elapsed < UPDATE_DURATION_TARGET {
651                time::sleep(UPDATE_DURATION_TARGET - elapsed).await;
652            }
653        }
654        last_update = Some(now);
655
656        let mut ecs = ecs.lock();
657
658        // if last tick is None or more than 50ms ago, run the GameTick schedule
659        ecs.run_schedule(outer_schedule_label);
660        if last_tick
661            .map(|last_tick| last_tick.elapsed() > GAME_TICK_DURATION_TARGET)
662            .unwrap_or(true)
663        {
664            if let Some(last_tick) = &mut last_tick {
665                *last_tick += GAME_TICK_DURATION_TARGET;
666
667                // if we're more than 10 ticks behind, set last_tick to now.
668                // vanilla doesn't do it in exactly the same way but it shouldn't really matter
669                if (now - *last_tick) > GAME_TICK_DURATION_TARGET * 10 {
670                    warn!(
671                        "GameTick is more than 10 ticks behind, skipping ticks so we don't have to burst too much"
672                    );
673                    *last_tick = now;
674                }
675            } else {
676                last_tick = Some(now);
677            }
678            ecs.run_schedule(GameTick);
679        }
680
681        ecs.clear_trackers();
682        if let Some(exit) = should_exit(&mut ecs) {
683            // it's possible for references to the World to stay around, so we clear the ecs
684            ecs.clear_all();
685            // ^ note that this also forcefully disconnects all of our bots without sending
686            // a disconnect packet (which is fine because we want to disconnect immediately)
687
688            return exit;
689        }
690    }
691}
692
693/// Checks whether the [`AppExit`] event was sent, and if so returns it.
694///
695/// This is based on Bevy's `should_exit` function: https://github.com/bevyengine/bevy/blob/b9fd7680e78c4073dfc90fcfdc0867534d92abe0/crates/bevy_app/src/app.rs#L1292
696fn should_exit(ecs: &mut World) -> Option<AppExit> {
697    let mut reader = MessageCursor::default();
698
699    let events = ecs.get_resource::<Messages<AppExit>>()?;
700    let mut events = reader.read(events);
701
702    if events.len() != 0 {
703        return Some(
704            events
705                .find(|exit| exit.is_error())
706                .cloned()
707                .unwrap_or(AppExit::Success),
708        );
709    }
710
711    None
712}
713
714pub struct AmbiguityLoggerPlugin;
715impl Plugin for AmbiguityLoggerPlugin {
716    fn build(&self, app: &mut App) {
717        app.edit_schedule(Update, |schedule| {
718            schedule.set_build_settings(ScheduleBuildSettings {
719                ambiguity_detection: LogLevel::Warn,
720                ..Default::default()
721            });
722        });
723        app.edit_schedule(GameTick, |schedule| {
724            schedule.set_build_settings(ScheduleBuildSettings {
725                ambiguity_detection: LogLevel::Warn,
726                ..Default::default()
727            });
728        });
729    }
730}