1use std::{
2 collections::HashMap,
3 fmt::Debug,
4 io,
5 net::SocketAddr,
6 sync::Arc,
7 thread,
8 time::{Duration, Instant},
9};
10
11use azalea_auth::{game_profile::GameProfile, sessionserver::ClientSessionServerError};
12use azalea_chat::FormattedText;
13use azalea_core::{
14 data_registry::ResolvableDataRegistry, position::Vec3, resource_location::ResourceLocation,
15 tick::GameTick,
16};
17use azalea_entity::{
18 EntityPlugin, EntityUpdateSet, EyeHeight, LocalEntity, Position,
19 indexing::{EntityIdIndex, EntityUuidIndex},
20 metadata::Health,
21};
22use azalea_physics::PhysicsPlugin;
23use azalea_protocol::{
24 ServerAddress,
25 common::client_information::ClientInformation,
26 connect::{Connection, ConnectionError, Proxy},
27 packets::{
28 self, ClientIntention, ConnectionProtocol, PROTOCOL_VERSION, Packet,
29 config::{ClientboundConfigPacket, ServerboundConfigPacket},
30 game::ServerboundGamePacket,
31 handshake::{
32 ClientboundHandshakePacket, ServerboundHandshakePacket,
33 s_intention::ServerboundIntention,
34 },
35 login::{
36 ClientboundLoginPacket, s_hello::ServerboundHello, s_key::ServerboundKey,
37 s_login_acknowledged::ServerboundLoginAcknowledged,
38 },
39 },
40 resolver,
41};
42use azalea_world::{Instance, InstanceContainer, InstanceName, PartialInstance};
43use bevy_app::{App, Plugin, PluginGroup, PluginGroupBuilder, PluginsState, Update};
44use bevy_ecs::{
45 bundle::Bundle,
46 component::Component,
47 entity::Entity,
48 schedule::{InternedScheduleLabel, IntoSystemConfigs, LogLevel, ScheduleBuildSettings},
49 system::{ResMut, Resource},
50 world::World,
51};
52use bevy_time::TimePlugin;
53use derive_more::Deref;
54use parking_lot::{Mutex, RwLock};
55use simdnbt::owned::NbtCompound;
56use thiserror::Error;
57use tokio::{
58 sync::{
59 broadcast,
60 mpsc::{self, error::TrySendError},
61 },
62 time,
63};
64use tracing::{debug, error, info};
65use uuid::Uuid;
66
67use crate::{
68 Account, PlayerInfo,
69 attack::{self, AttackPlugin},
70 brand::BrandPlugin,
71 chat::ChatPlugin,
72 chunks::{ChunkBatchInfo, ChunksPlugin},
73 disconnect::{DisconnectEvent, DisconnectPlugin},
74 events::{Event, EventsPlugin, LocalPlayerEvents},
75 interact::{CurrentSequenceNumber, InteractPlugin},
76 inventory::{Inventory, InventoryPlugin},
77 local_player::{
78 GameProfileComponent, Hunger, InstanceHolder, PermissionLevel, PlayerAbilities, TabList,
79 },
80 mining::{self, MiningPlugin},
81 movement::{LastSentLookDirection, MovementPlugin, PhysicsState},
82 packet::{
83 PacketPlugin,
84 login::{self, InLoginState, LoginSendPacketQueue},
85 },
86 player::retroactively_add_game_profile_component,
87 pong::PongPlugin,
88 raw_connection::RawConnection,
89 respawn::RespawnPlugin,
90 task_pool::TaskPoolPlugin,
91 tick_end::TickEndPlugin,
92};
93
94#[derive(Clone)]
104pub struct Client {
105 pub profile: GameProfile,
114 pub entity: Entity,
116
117 pub ecs: Arc<Mutex<World>>,
121
122 pub run_schedule_sender: mpsc::Sender<()>,
124}
125
126#[derive(Error, Debug)]
128pub enum JoinError {
129 #[error("{0}")]
130 Resolver(#[from] resolver::ResolverError),
131 #[error("{0}")]
132 Connection(#[from] ConnectionError),
133 #[error("{0}")]
134 ReadPacket(#[from] Box<azalea_protocol::read::ReadPacketError>),
135 #[error("{0}")]
136 Io(#[from] io::Error),
137 #[error("{0}")]
138 SessionServer(#[from] azalea_auth::sessionserver::ClientSessionServerError),
139 #[error("The given address could not be parsed into a ServerAddress")]
140 InvalidAddress,
141 #[error("Couldn't refresh access token: {0}")]
142 Auth(#[from] azalea_auth::AuthError),
143 #[error("Disconnected: {reason}")]
144 Disconnect { reason: FormattedText },
145}
146
147pub struct StartClientOpts<'a> {
148 pub ecs_lock: Arc<Mutex<World>>,
149 pub account: &'a Account,
150 pub address: &'a ServerAddress,
151 pub resolved_address: &'a SocketAddr,
152 pub proxy: Option<Proxy>,
153 pub run_schedule_sender: mpsc::Sender<()>,
154 pub event_sender: Option<mpsc::UnboundedSender<Event>>,
155}
156
157impl<'a> StartClientOpts<'a> {
158 pub fn new(
159 account: &'a Account,
160 address: &'a ServerAddress,
161 resolved_address: &'a SocketAddr,
162 event_sender: Option<mpsc::UnboundedSender<Event>>,
163 ) -> StartClientOpts<'a> {
164 let (run_schedule_sender, run_schedule_receiver) = mpsc::channel(1);
166
167 let mut app = App::new();
168 app.add_plugins(DefaultPlugins);
169
170 let ecs_lock = start_ecs_runner(app, run_schedule_receiver, run_schedule_sender.clone());
171
172 Self {
173 ecs_lock,
174 account,
175 address,
176 resolved_address,
177 proxy: None,
178 run_schedule_sender,
179 event_sender,
180 }
181 }
182
183 pub fn proxy(mut self, proxy: Proxy) -> Self {
184 self.proxy = Some(proxy);
185 self
186 }
187}
188
189impl Client {
190 pub fn new(
195 profile: GameProfile,
196 entity: Entity,
197 ecs: Arc<Mutex<World>>,
198 run_schedule_sender: mpsc::Sender<()>,
199 ) -> Self {
200 Self {
201 profile,
202 entity,
204
205 ecs,
206
207 run_schedule_sender,
208 }
209 }
210
211 pub async fn join(
232 account: &Account,
233 address: impl TryInto<ServerAddress>,
234 ) -> Result<(Self, mpsc::UnboundedReceiver<Event>), JoinError> {
235 let address: ServerAddress = address.try_into().map_err(|_| JoinError::InvalidAddress)?;
236 let resolved_address = resolver::resolve_address(&address).await?;
237 let (tx, rx) = mpsc::unbounded_channel();
238
239 let client = Self::start_client(StartClientOpts::new(
240 account,
241 &address,
242 &resolved_address,
243 Some(tx),
244 ))
245 .await?;
246 Ok((client, rx))
247 }
248
249 pub async fn join_with_proxy(
250 account: &Account,
251 address: impl TryInto<ServerAddress>,
252 proxy: Proxy,
253 ) -> Result<(Self, mpsc::UnboundedReceiver<Event>), JoinError> {
254 let address: ServerAddress = address.try_into().map_err(|_| JoinError::InvalidAddress)?;
255 let resolved_address = resolver::resolve_address(&address).await?;
256 let (tx, rx) = mpsc::unbounded_channel();
257
258 let client = Self::start_client(
259 StartClientOpts::new(account, &address, &resolved_address, Some(tx)).proxy(proxy),
260 )
261 .await?;
262 Ok((client, rx))
263 }
264
265 pub async fn start_client(
268 StartClientOpts {
269 ecs_lock,
270 account,
271 address,
272 resolved_address,
273 proxy,
274 run_schedule_sender,
275 event_sender,
276 }: StartClientOpts<'_>,
277 ) -> Result<Self, JoinError> {
278 let entity = {
281 let mut ecs = ecs_lock.lock();
282
283 let entity_uuid_index = ecs.resource::<EntityUuidIndex>();
284 let uuid = account.uuid_or_offline();
285 let entity = if let Some(entity) = entity_uuid_index.get(&account.uuid_or_offline()) {
286 debug!("Reusing entity {entity:?} for client");
287 entity
288 } else {
289 let entity = ecs.spawn_empty().id();
290 debug!("Created new entity {entity:?} for client");
291 let mut entity_uuid_index = ecs.resource_mut::<EntityUuidIndex>();
293 entity_uuid_index.insert(uuid, entity);
294 entity
295 };
296
297 ecs.entity_mut(entity).insert(account.to_owned());
299
300 entity
301 };
302
303 let conn = if let Some(proxy) = proxy {
304 Connection::new_with_proxy(resolved_address, proxy).await?
305 } else {
306 Connection::new(resolved_address).await?
307 };
308 let (conn, game_profile) =
309 Self::handshake(ecs_lock.clone(), entity, conn, account, address).await?;
310
311 let (read_conn, write_conn) = conn.into_split();
315 let (read_conn, write_conn) = (read_conn.raw, write_conn.raw);
316
317 let mut ecs = ecs_lock.lock();
320
321 let client = Client::new(
323 game_profile.clone(),
324 entity,
325 ecs_lock.clone(),
326 run_schedule_sender.clone(),
327 );
328
329 let instance = Instance::default();
330 let instance_holder = crate::local_player::InstanceHolder::new(
331 entity,
332 Arc::new(RwLock::new(instance)),
335 );
336
337 let mut entity = ecs.entity_mut(entity);
338 entity.insert((
339 LocalPlayerBundle {
341 raw_connection: RawConnection::new(
342 run_schedule_sender,
343 ConnectionProtocol::Configuration,
344 read_conn,
345 write_conn,
346 ),
347 game_profile: GameProfileComponent(game_profile),
348 client_information: crate::ClientInformation::default(),
349 instance_holder,
350 metadata: azalea_entity::metadata::PlayerMetadataBundle::default(),
351 },
352 InConfigState,
353 LocalEntity,
355 ));
356 if let Some(event_sender) = event_sender {
357 entity.insert(LocalPlayerEvents(event_sender));
359 }
360
361 Ok(client)
362 }
363
364 pub async fn handshake(
370 ecs_lock: Arc<Mutex<World>>,
371 entity: Entity,
372 mut conn: Connection<ClientboundHandshakePacket, ServerboundHandshakePacket>,
373 account: &Account,
374 address: &ServerAddress,
375 ) -> Result<
376 (
377 Connection<ClientboundConfigPacket, ServerboundConfigPacket>,
378 GameProfile,
379 ),
380 JoinError,
381 > {
382 conn.write(ServerboundIntention {
384 protocol_version: PROTOCOL_VERSION,
385 hostname: address.host.clone(),
386 port: address.port,
387 intention: ClientIntention::Login,
388 })
389 .await?;
390 let mut conn = conn.login();
391
392 let (ecs_packets_tx, mut ecs_packets_rx) = mpsc::unbounded_channel();
395 ecs_lock.lock().entity_mut(entity).insert((
396 LoginSendPacketQueue { tx: ecs_packets_tx },
397 crate::packet::login::IgnoreQueryIds::default(),
398 InLoginState,
399 ));
400
401 conn.write(ServerboundHello {
403 name: account.username.clone(),
404 profile_id: account.uuid.unwrap_or_default(),
407 })
408 .await?;
409
410 let (conn, profile) = loop {
411 let packet = tokio::select! {
412 packet = conn.read() => packet?,
413 Some(packet) = ecs_packets_rx.recv() => {
414 conn.write(packet).await?;
416 continue;
417 }
418 };
419
420 ecs_lock.lock().send_event(login::LoginPacketEvent {
421 entity,
422 packet: Arc::new(packet.clone()),
423 });
424
425 match packet {
426 ClientboundLoginPacket::Hello(p) => {
427 debug!("Got encryption request");
428 let e = azalea_crypto::encrypt(&p.public_key, &p.challenge).unwrap();
429
430 if let Some(access_token) = &account.access_token {
431 let mut attempts: usize = 1;
434
435 while let Err(e) = {
436 let access_token = access_token.lock().clone();
437 conn.authenticate(
438 &access_token,
439 &account
440 .uuid
441 .expect("Uuid must be present if access token is present."),
442 e.secret_key,
443 &p,
444 )
445 .await
446 } {
447 if attempts >= 2 {
448 return Err(e.into());
451 }
452 if matches!(
453 e,
454 ClientSessionServerError::InvalidSession
455 | ClientSessionServerError::ForbiddenOperation
456 ) {
457 account.refresh().await?;
460 } else {
461 return Err(e.into());
462 }
463 attempts += 1;
464 }
465 }
466
467 conn.write(ServerboundKey {
468 key_bytes: e.encrypted_public_key,
469 encrypted_challenge: e.encrypted_challenge,
470 })
471 .await?;
472
473 conn.set_encryption_key(e.secret_key);
474 }
475 ClientboundLoginPacket::LoginCompression(p) => {
476 debug!("Got compression request {:?}", p.compression_threshold);
477 conn.set_compression_threshold(p.compression_threshold);
478 }
479 ClientboundLoginPacket::LoginFinished(p) => {
480 debug!(
481 "Got profile {:?}. handshake is finished and we're now switching to the configuration state",
482 p.game_profile
483 );
484 conn.write(ServerboundLoginAcknowledged {}).await?;
485
486 break (conn.config(), p.game_profile);
487 }
488 ClientboundLoginPacket::LoginDisconnect(p) => {
489 debug!("Got disconnect {:?}", p);
490 return Err(JoinError::Disconnect { reason: p.reason });
491 }
492 ClientboundLoginPacket::CustomQuery(p) => {
493 debug!("Got custom query {:?}", p);
494 }
497 ClientboundLoginPacket::CookieRequest(p) => {
498 debug!("Got cookie request {:?}", p);
499
500 conn.write(packets::login::ServerboundCookieResponse {
501 key: p.key,
502 payload: None,
504 })
505 .await?;
506 }
507 }
508 };
509
510 ecs_lock
511 .lock()
512 .entity_mut(entity)
513 .remove::<login::IgnoreQueryIds>()
514 .remove::<LoginSendPacketQueue>()
515 .remove::<InLoginState>();
516
517 Ok((conn, profile))
518 }
519
520 pub fn write_packet(
522 &self,
523 packet: impl Packet<ServerboundGamePacket>,
524 ) -> Result<(), crate::raw_connection::WritePacketError> {
525 let packet = packet.into_variant();
526 self.raw_connection_mut(&mut self.ecs.lock())
527 .write_packet(packet)
528 }
529
530 pub fn disconnect(&self) {
535 self.ecs.lock().send_event(DisconnectEvent {
536 entity: self.entity,
537 reason: None,
538 });
539 }
540
541 pub fn raw_connection<'a>(&'a self, ecs: &'a mut World) -> &'a RawConnection {
542 self.query::<&RawConnection>(ecs)
543 }
544 pub fn raw_connection_mut<'a>(
545 &'a self,
546 ecs: &'a mut World,
547 ) -> bevy_ecs::world::Mut<'a, RawConnection> {
548 self.query::<&mut RawConnection>(ecs)
549 }
550
551 pub fn component<T: Component + Clone>(&self) -> T {
574 self.query::<&T>(&mut self.ecs.lock()).clone()
575 }
576
577 pub fn get_component<T: Component + Clone>(&self) -> Option<T> {
582 self.query::<Option<&T>>(&mut self.ecs.lock()).cloned()
583 }
584
585 pub fn resource<T: Resource + Clone>(&self) -> T {
587 self.ecs.lock().resource::<T>().clone()
588 }
589
590 pub fn map_component<T: Component, R>(&self, f: impl FnOnce(&T) -> R) -> R {
610 let mut ecs = self.ecs.lock();
611 let value = self.query::<&T>(&mut ecs);
612 f(value)
613 }
614
615 pub fn map_get_component<T: Component, R>(&self, f: impl FnOnce(Option<&T>) -> R) -> R {
628 let mut ecs = self.ecs.lock();
629 let value = self.query::<Option<&T>>(&mut ecs);
630 f(value)
631 }
632
633 pub fn world(&self) -> Arc<RwLock<Instance>> {
640 let instance_holder = self.component::<InstanceHolder>();
641 instance_holder.instance.clone()
642 }
643
644 pub fn partial_world(&self) -> Arc<RwLock<PartialInstance>> {
654 let instance_holder = self.component::<InstanceHolder>();
655 instance_holder.partial_instance.clone()
656 }
657
658 pub fn logged_in(&self) -> bool {
660 self.query::<Option<&InstanceName>>(&mut self.ecs.lock())
662 .is_some()
663 }
664
665 pub async fn set_client_information(
681 &self,
682 client_information: ClientInformation,
683 ) -> Result<(), crate::raw_connection::WritePacketError> {
684 {
685 let mut ecs = self.ecs.lock();
686 let mut client_information_mut = self.query::<&mut ClientInformation>(&mut ecs);
687 *client_information_mut = client_information.clone();
688 }
689
690 if self.logged_in() {
691 debug!(
692 "Sending client information (already logged in): {:?}",
693 client_information
694 );
695 self.write_packet(azalea_protocol::packets::game::s_client_information::ServerboundClientInformation { information: client_information.clone() })?;
696 }
697
698 Ok(())
699 }
700}
701
702impl Client {
703 pub fn position(&self) -> Vec3 {
711 Vec3::from(
712 &self
713 .get_component::<Position>()
714 .expect("the client's position hasn't been initialized yet"),
715 )
716 }
717
718 pub fn eye_position(&self) -> Vec3 {
723 self.position().up((*self.component::<EyeHeight>()) as f64)
724 }
725
726 pub fn health(&self) -> f32 {
730 *self.component::<Health>()
731 }
732
733 pub fn hunger(&self) -> Hunger {
738 self.component::<Hunger>().to_owned()
739 }
740
741 pub fn username(&self) -> String {
746 self.component::<GameProfileComponent>().name.to_owned()
747 }
748
749 pub fn uuid(&self) -> Uuid {
753 self.component::<GameProfileComponent>().uuid
754 }
755
756 pub fn tab_list(&self) -> HashMap<Uuid, PlayerInfo> {
760 (*self.component::<TabList>()).clone()
761 }
762
763 pub fn with_registry_holder<R>(
771 &self,
772 f: impl FnOnce(&azalea_core::registry_holder::RegistryHolder) -> R,
773 ) -> R {
774 let instance = self.world();
775 let registries = &instance.read().registries;
776 f(registries)
777 }
778
779 pub fn resolve_registry_name(
785 &self,
786 registry: &impl ResolvableDataRegistry,
787 ) -> Option<ResourceLocation> {
788 self.with_registry_holder(|registries| registry.resolve_name(registries))
789 }
790 pub fn with_resolved_registry<R>(
800 &self,
801 registry: impl ResolvableDataRegistry,
802 f: impl FnOnce(&ResourceLocation, &NbtCompound) -> R,
803 ) -> Option<R> {
804 self.with_registry_holder(|registries| {
805 registry
806 .resolve(registries)
807 .map(|(name, data)| f(name, data))
808 })
809 }
810}
811
812#[derive(Bundle)]
818pub struct LocalPlayerBundle {
819 pub raw_connection: RawConnection,
820 pub game_profile: GameProfileComponent,
821 pub client_information: ClientInformation,
822 pub instance_holder: InstanceHolder,
823
824 pub metadata: azalea_entity::metadata::PlayerMetadataBundle,
825}
826
827#[derive(Bundle, Default)]
831pub struct JoinedClientBundle {
832 pub physics_state: PhysicsState,
834 pub inventory: Inventory,
835 pub tab_list: TabList,
836 pub current_sequence_number: CurrentSequenceNumber,
837 pub last_sent_direction: LastSentLookDirection,
838 pub abilities: PlayerAbilities,
839 pub permission_level: PermissionLevel,
840 pub chunk_batch_info: ChunkBatchInfo,
841 pub hunger: Hunger,
842
843 pub entity_id_index: EntityIdIndex,
844
845 pub mining: mining::MineBundle,
846 pub attack: attack::AttackBundle,
847
848 pub in_game_state: InGameState,
849}
850
851#[derive(Component, Clone, Debug, Default)]
854pub struct InGameState;
855#[derive(Component, Clone, Debug, Default)]
858pub struct InConfigState;
859
860pub struct AzaleaPlugin;
861impl Plugin for AzaleaPlugin {
862 fn build(&self, app: &mut App) {
863 app.add_systems(
864 Update,
865 (
866 retroactively_add_game_profile_component.after(EntityUpdateSet::Index),
868 ),
869 )
870 .init_resource::<InstanceContainer>()
871 .init_resource::<TabList>();
872 }
873}
874
875#[doc(hidden)]
880pub fn start_ecs_runner(
881 mut app: App,
882 run_schedule_receiver: mpsc::Receiver<()>,
883 run_schedule_sender: mpsc::Sender<()>,
884) -> Arc<Mutex<World>> {
885 if app.plugins_state() != PluginsState::Cleaned {
888 if app.plugins_state() == PluginsState::Adding {
890 info!("Waiting for plugins to load ...");
891 while app.plugins_state() == PluginsState::Adding {
892 thread::yield_now();
893 }
894 }
895 app.finish();
897 app.cleanup();
898 }
899
900 let ecs = Arc::new(Mutex::new(std::mem::take(app.world_mut())));
903
904 tokio::spawn(run_schedule_loop(
905 ecs.clone(),
906 *app.main().update_schedule.as_ref().unwrap(),
907 run_schedule_receiver,
908 ));
909 tokio::spawn(tick_run_schedule_loop(run_schedule_sender));
910
911 ecs
912}
913
914async fn run_schedule_loop(
915 ecs: Arc<Mutex<World>>,
916 outer_schedule_label: InternedScheduleLabel,
917 mut run_schedule_receiver: mpsc::Receiver<()>,
918) {
919 let mut last_tick: Option<Instant> = None;
920 loop {
921 run_schedule_receiver.recv().await;
923
924 let mut ecs = ecs.lock();
925
926 ecs.run_schedule(outer_schedule_label);
928 if last_tick
929 .map(|last_tick| last_tick.elapsed() > Duration::from_millis(50))
930 .unwrap_or(true)
931 {
932 if let Some(last_tick) = &mut last_tick {
933 *last_tick += Duration::from_millis(50);
934 } else {
935 last_tick = Some(Instant::now());
936 }
937 ecs.run_schedule(GameTick);
938 }
939
940 ecs.clear_trackers();
941 }
942}
943
944pub async fn tick_run_schedule_loop(run_schedule_sender: mpsc::Sender<()>) {
947 let mut game_tick_interval = time::interval(Duration::from_millis(50));
948 game_tick_interval.set_missed_tick_behavior(time::MissedTickBehavior::Burst);
950
951 loop {
952 game_tick_interval.tick().await;
953 if let Err(TrySendError::Closed(())) = run_schedule_sender.try_send(()) {
954 error!("tick_run_schedule_loop failed because run_schedule_sender was closed");
955 return;
957 }
958 }
959}
960
961#[derive(Resource, Deref)]
980pub struct TickBroadcast(broadcast::Sender<()>);
981
982pub fn send_tick_broadcast(tick_broadcast: ResMut<TickBroadcast>) {
983 let _ = tick_broadcast.0.send(());
984}
985pub struct TickBroadcastPlugin;
987impl Plugin for TickBroadcastPlugin {
988 fn build(&self, app: &mut App) {
989 app.insert_resource(TickBroadcast(broadcast::channel(1).0))
990 .add_systems(GameTick, send_tick_broadcast);
991 }
992}
993
994pub struct AmbiguityLoggerPlugin;
995impl Plugin for AmbiguityLoggerPlugin {
996 fn build(&self, app: &mut App) {
997 app.edit_schedule(Update, |schedule| {
998 schedule.set_build_settings(ScheduleBuildSettings {
999 ambiguity_detection: LogLevel::Warn,
1000 ..Default::default()
1001 });
1002 });
1003 app.edit_schedule(GameTick, |schedule| {
1004 schedule.set_build_settings(ScheduleBuildSettings {
1005 ambiguity_detection: LogLevel::Warn,
1006 ..Default::default()
1007 });
1008 });
1009 }
1010}
1011
1012pub struct DefaultPlugins;
1015
1016impl PluginGroup for DefaultPlugins {
1017 fn build(self) -> PluginGroupBuilder {
1018 #[allow(unused_mut)]
1019 let mut group = PluginGroupBuilder::start::<Self>()
1020 .add(AmbiguityLoggerPlugin)
1021 .add(TimePlugin)
1022 .add(PacketPlugin)
1023 .add(AzaleaPlugin)
1024 .add(EntityPlugin)
1025 .add(PhysicsPlugin)
1026 .add(EventsPlugin)
1027 .add(TaskPoolPlugin::default())
1028 .add(InventoryPlugin)
1029 .add(ChatPlugin)
1030 .add(DisconnectPlugin)
1031 .add(MovementPlugin)
1032 .add(InteractPlugin)
1033 .add(RespawnPlugin)
1034 .add(MiningPlugin)
1035 .add(AttackPlugin)
1036 .add(ChunksPlugin)
1037 .add(TickEndPlugin)
1038 .add(BrandPlugin)
1039 .add(TickBroadcastPlugin)
1040 .add(PongPlugin);
1041 #[cfg(feature = "log")]
1042 {
1043 group = group.add(bevy_log::LogPlugin::default());
1044 }
1045 group
1046 }
1047}