From ae6e877f1716b15bceb6fa9081394ba7016ff07f Mon Sep 17 00:00:00 2001
From: timvisee <tim@visee.me>
Date: Mon, 15 Nov 2021 17:30:59 +0100
Subject: [PATCH] Add lobby method to configuration

---
 res/lazymc.toml | 29 +++++++++++++++++++++++-
 src/config.rs   | 31 +++++++++++++++++++++++++
 src/lobby.rs    | 60 ++++++++++++++++++++++++++++++-------------------
 src/status.rs   | 32 +++++++++++++-------------
 4 files changed, 112 insertions(+), 40 deletions(-)

diff --git a/res/lazymc.toml b/res/lazymc.toml
index 64191c9..cfcd260 100644
--- a/res/lazymc.toml
+++ b/res/lazymc.toml
@@ -97,6 +97,33 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
 # Forwarded-to server will receive original client handshake and login request as received by lazymc.
 #address = "127.0.0.1:25565"
 
+[join.lobby]
+# Lobby occupation method.
+# The client joins a fake lobby server with an empty world, floating in space.
+# A message is overlayed on screen to notify the server is starting.
+# The client will be teleported to the real server once it is ready.
+# This may keep the client occupied forever if no timeout is set.
+# Consumes client, not allowing other join methods afterwards.
+
+# !!! WARNING !!!
+# This is highly experimental and unstable.
+# This may break the game and crash clients.
+# Don't enable this unless you know what you're doing.
+#
+# - Only works with offline mode
+# - Only works with server packet compression disabled
+# - Only works with vanilla Minecraft clients, does not work with modded
+# - Only tested with Minecraft 1.17.1
+
+# Maxiumum time in seconds in the lobby while the server starts.
+#timeout = 600
+
+# Message banner in lobby shown to client.
+#message = "§2Server is starting\n§7⌛ Please wait..."
+
+# Sound effect to play when server is ready.
+#ready_sound = "block.note_block.chime"
+
 [lockout]
 # Enable to prevent everybody from connecting through lazymc. Instantly kicks player.
 #enabled = false
@@ -123,5 +150,5 @@ command = "java -Xmx1G -Xms1G -jar server.jar --nogui"
 
 [config]
 # lazymc version this configuration is for.
-# Do not change unless you know what you're doing.
+# Don't change unless you know what you're doing.
 version = "0.2.0"
diff --git a/src/config.rs b/src/config.rs
index dbd7dbe..8bc7618 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -238,6 +238,9 @@ pub enum Method {
 
     /// Forward connection to another host.
     Forward,
+
+    /// Keep client in temporary fake lobby until server is ready.
+    Lobby,
 }
 
 /// Join configuration.
@@ -258,6 +261,10 @@ pub struct Join {
     /// Join forward configuration.
     #[serde(default)]
     pub forward: JoinForward,
+
+    /// Join lobby configuration.
+    #[serde(default)]
+    pub lobby: JoinLobby,
 }
 
 impl Default for Join {
@@ -267,6 +274,7 @@ impl Default for Join {
             kick: Default::default(),
             hold: Default::default(),
             forward: Default::default(),
+            lobby: Default::default(),
         }
     }
 }
@@ -320,6 +328,29 @@ impl Default for JoinForward {
         }
     }
 }
+/// Join lobby configuration.
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct JoinLobby {
+    /// Hold client in lobby for number of seconds on connect while server starts.
+    pub timeout: u32,
+
+    /// Message banner in lobby shown to client.
+    pub message: String,
+
+    /// Sound effect to play when server is ready.
+    pub ready_sound: Option<String>,
+}
+
+impl Default for JoinLobby {
+    fn default() -> Self {
+        Self {
+            timeout: 10 * 60,
+            message: "§2Server is starting\n§7⌛ Please wait...".into(),
+            ready_sound: Some("block.note_block.chime".into()),
+        }
+    }
+}
 
 /// Lockout configuration.
 #[derive(Debug, Deserialize)]
diff --git a/src/lobby.rs b/src/lobby.rs
index e5c5979..1fa0dbf 100644
--- a/src/lobby.rs
+++ b/src/lobby.rs
@@ -30,11 +30,7 @@ use crate::proxy;
 use crate::server::{Server, State};
 
 // TODO: remove this before releasing feature
-pub const USE_LOBBY: bool = true;
 pub const DONT_START_SERVER: bool = false;
-const STARTING_BANNER: &str = "§2Server is starting\n§7⌛ Please wait...";
-const JOIN_SOUND: bool = true;
-const JOIN_SOUND_NAME: &str = "block.note_block.chime";
 
 /// Interval to send keep-alive packets at.
 const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
@@ -42,9 +38,6 @@ const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
 /// Auto incrementing ID source for keep alive packets.
 const KEEP_ALIVE_ID: AtomicU64 = AtomicU64::new(0);
 
-/// Lobby clients may wait a maximum of 10 minutes for the server to come online.
-const SERVER_WAIT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
-
 /// Timeout for creating new server connection for lobby client.
 const SERVER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2 * 60);
 
@@ -129,18 +122,17 @@ pub async fn serve(
             send_lobby_play_packets(&mut writer, &server).await?;
 
             // Wait for server to come online, then set up new connection to it
-            stage_wait(server.clone(), &mut writer).await?;
+            stage_wait(&server, &config, &mut writer).await?;
             let (mut outbound, mut server_buf) = connect_to_server(client_info, &config).await?;
 
             // Grab join game packet from server
             let join_game = wait_for_server_join_game(&mut outbound, &mut server_buf).await?;
 
-            // Reset lobby title, player position and play sound effect
+            // Reset lobby title
             send_lobby_title(&mut writer, "").await?;
-            if JOIN_SOUND {
-                send_lobby_player_pos(&mut writer).await?;
-                send_lobby_sound_effect(&mut writer).await?;
-            }
+
+            // Play ready sound if configured
+            play_lobby_ready_sound(&mut writer, &config).await?;
 
             // Wait a second because Notchian servers are slow
             // See: https://wiki.vg/Protocol#Login_Success
@@ -205,6 +197,23 @@ async fn respond_login_success(
     Ok(())
 }
 
+/// Play lobby ready sound effect if configured.
+async fn play_lobby_ready_sound(writer: &mut WriteHalf<'_>, config: &Config) -> Result<(), ()> {
+    if let Some(sound_name) = config.join.lobby.ready_sound.as_ref() {
+        // Must not be empty string
+        if sound_name.trim().is_empty() {
+            warn!(target: "lazymc::lobby", "Lobby ready sound effect is an empty string, you should remove the configuration item instead");
+            return Ok(());
+        }
+
+        // Play sound effect
+        send_lobby_player_pos(writer).await?;
+        send_lobby_sound_effect(writer, sound_name).await?;
+    }
+
+    Ok(())
+}
+
 /// Send packets to client to get workable play state for lobby world.
 async fn send_lobby_play_packets(writer: &mut WriteHalf<'_>, server: &Server) -> Result<(), ()> {
     // See: https://wiki.vg/Protocol_FAQ#What.27s_the_normal_login_sequence_for_a_client.3F
@@ -405,9 +414,9 @@ async fn send_lobby_title(writer: &mut WriteHalf<'_>, text: &str) -> Result<(),
 }
 
 /// Send lobby ready sound effect to client.
-async fn send_lobby_sound_effect(writer: &mut WriteHalf<'_>) -> Result<(), ()> {
+async fn send_lobby_sound_effect(writer: &mut WriteHalf<'_>, sound_name: &str) -> Result<(), ()> {
     let packet = NamedSoundEffect {
-        sound_name: JOIN_SOUND_NAME.into(),
+        sound_name: sound_name.into(),
         sound_category: 0,
         effect_pos_x: 0,
         effect_pos_y: 0,
@@ -453,7 +462,7 @@ async fn send_respawn_from_join(writer: &mut WriteHalf<'_>, join_game: JoinGame)
 /// An infinite keep-alive loop.
 ///
 /// This will keep sending keep-alive and title packets to the client until it is dropped.
-async fn keep_alive_loop(writer: &mut WriteHalf<'_>) -> Result<(), ()> {
+async fn keep_alive_loop(writer: &mut WriteHalf<'_>, config: &Config) -> Result<(), ()> {
     let mut interval = time::interval(KEEP_ALIVE_INTERVAL);
 
     loop {
@@ -463,7 +472,7 @@ async fn keep_alive_loop(writer: &mut WriteHalf<'_>) -> Result<(), ()> {
 
         // Send keep alive and title packets
         send_keep_alive(writer).await?;
-        send_lobby_title(writer, STARTING_BANNER).await?;
+        send_lobby_title(writer, &config.join.lobby.message).await?;
     }
 }
 
@@ -472,17 +481,21 @@ async fn keep_alive_loop(writer: &mut WriteHalf<'_>) -> Result<(), ()> {
 /// In this stage we wait for the server to come online.
 ///
 /// During this stage we keep sending keep-alive and title packets to the client to keep it active.
-async fn stage_wait<'a>(server: Arc<Server>, writer: &mut WriteHalf<'a>) -> Result<(), ()> {
+async fn stage_wait<'a>(
+    server: &Server,
+    config: &Config,
+    writer: &mut WriteHalf<'a>,
+) -> Result<(), ()> {
     select! {
-        a = keep_alive_loop(writer) => a,
-        b = wait_for_server(server) => b,
+        a = keep_alive_loop(writer, config) => a,
+        b = wait_for_server(server, config) => b,
     }
 }
 
 /// Wait for the server to come online.
 ///
 /// Returns `Ok(())` once the server is online, returns `Err(())` if waiting failed.
-async fn wait_for_server<'a>(server: Arc<Server>) -> Result<(), ()> {
+async fn wait_for_server<'a>(server: &Server, config: &Config) -> Result<(), ()> {
     debug!(target: "lazymc::lobby", "Waiting on server to come online...");
 
     // A task to wait for suitable server state
@@ -514,7 +527,8 @@ async fn wait_for_server<'a>(server: Arc<Server>) -> Result<(), ()> {
     };
 
     // Wait for server state with timeout
-    match time::timeout(SERVER_WAIT_TIMEOUT, task_wait).await {
+    let timeout = Duration::from_secs(config.join.lobby.timeout as u64);
+    match time::timeout(timeout, task_wait).await {
         // Relay client to proxy
         Ok(true) => {
             debug!(target: "lazymc::lobby", "Server ready for lobby client");
@@ -526,7 +540,7 @@ async fn wait_for_server<'a>(server: Arc<Server>) -> Result<(), ()> {
 
         // Timeout reached, disconnect
         Err(_) => {
-            warn!(target: "lazymc::lobby", "Lobby client waiting for server to come online reached timeout of {}s", SERVER_WAIT_TIMEOUT.as_secs());
+            warn!(target: "lazymc::lobby", "Lobby client waiting for server to come online reached timeout of {}s", timeout.as_secs());
         }
     }
 
diff --git a/src/status.rs b/src/status.rs
index fa98168..fde52d0 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -140,22 +140,6 @@ pub async fn serve(
                 Server::start(config.clone(), server.clone(), username).await;
             }
 
-            // Lobby mode
-            if lobby::USE_LOBBY {
-                // // Hold login packet and remaining read bytes
-                // hold_queue.extend(raw);
-                // hold_queue.extend(buf.split_off(0));
-
-                // Build queue with login packet and any additionally received
-                let mut queue = BytesMut::with_capacity(raw.len() + buf.len());
-                queue.extend(raw);
-                queue.extend(buf.split_off(0));
-
-                // Start lobby
-                lobby::serve(client, client_info, inbound, config, server, queue).await?;
-                return Ok(());
-            }
-
             // Use join occupy methods
             for method in &config.join.methods {
                 match method {
@@ -214,6 +198,22 @@ pub async fn serve(
 
                         // TODO: do not consume client here, allow other join method on fail
                     }
+
+                    // Lobby method, keep client in lobby while server starts
+                    Method::Lobby => {
+                        trace!(target: "lazymc", "Using lobby method to occupy joining client");
+
+                        // Build queue with login packet and any additionally received
+                        let mut queue = BytesMut::with_capacity(raw.len() + buf.len());
+                        queue.extend(raw);
+                        queue.extend(buf.split_off(0));
+
+                        // Start lobby
+                        lobby::serve(client, client_info, inbound, config, server, queue).await?;
+                        return Ok(());
+
+                        // TODO: do not consume client here, allow other join method on fail
+                    }
                 }
             }