azalea_client/
client.rs

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