Core/PrototypeGame.Lobbies.cs
using Sandbox.Network;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Prototype;

public sealed partial class PrototypeGame
{
    private const string LobbyProtocol = "3";
    private static Task<List<LobbyInformation>> QueryGameLobbies() => Networking.QueryLobbies(
        new Dictionary<string, string> { ["game"] = Sandbox.Game.Ident, ["dsver"] = LobbyProtocol, ["hidden"] = "true" },
        includeServers: false);
    // UserInfo reaches the host during the connection handshake, before admission.
    [ConVar("dasher_join_key", ConVarFlags.UserInfo)]
    public static string JoinKey { get; set; } = "";

    [Sync(SyncFlags.FromHost)] public string ServerPartyId { get; private set; } = "";
    public IReadOnlyList<PartyLobbyView> ServerLobbies { get; private set; } = Array.Empty<PartyLobbyView>();
    public bool LobbySearchBusy { get; private set; }
    public string LobbySearchStatus { get; private set; } = "Searching for lobbies...";
    public bool SessionBusy { get; private set; }
    public string SessionStatus { get; private set; } = "";
    private float _nextLobbySearch;
    private string _publishedLobbyState = "";
    private bool _wasNetworked;
    private bool _returningToSolo;
    private static string _localEditorHostId = "";
    // Local editor clients use anonymous (type 4) IDs, not individual Steam accounts.
    private static bool IsSimulatedClient => Connection.Local is not null && (((ulong)Connection.Local.SteamId >> 52) & 15UL) == 4UL;
    public string ConnectionError { get; private set; } = "";
    public void ClearConnectionError() => ConnectionError = "";

    public async Task RefreshServerLobbies()
    {
        if (LobbySearchBusy) return;
        LobbySearchBusy = true;
        LobbySearchStatus = "Searching for lobbies...";
        try
        {
            var lobbies = await QueryGameLobbies();
            if (!IsValid) return;
            ServerLobbies = lobbies.Where(l => l.Get("dsver", "") == LobbyProtocol)
                .Select(l => new PartyLobbyView(l.Get("dsteam", ""), l.Name, l.OwnerId.ToString(),
                    l.Get("dslock", "0") == "1", l.Get("dsstate", "run") != "lobby", Array.Empty<string>())
                {
                    ServerAddress = l.LobbyId.ToString(),
                    AdvertisedMembers = Math.Clamp(l.Members, 0, 4)
                })
                .Where(l => !string.IsNullOrEmpty(l.Id)).OrderBy(l => l.Started).ThenBy(l => l.IsFull).ThenBy(l => l.Name).ToArray();
            LobbySearchStatus = ServerLobbies.Count == 0 ? "No lobbies found." : $"{ServerLobbies.Count} lobbies found";
        }
        catch (Exception)
        {
            if (IsValid) { ServerLobbies = Array.Empty<PartyLobbyView>(); LobbySearchStatus = "Could not refresh lobbies. Try again."; }
        }
        finally
        {
            if (IsValid) { LobbySearchBusy = false; _nextLobbySearch = Time.Now + 10f; }
        }
    }

    private void UpdateServerSession()
    {
        if (_returningToSolo) return;
        if (IsSimulatedClient && Networking.IsClient && !Networking.IsConnecting && Connection.Host is not null)
            _localEditorHostId = Connection.Host.SteamId.ToString();
        // Editor test windows connect to the editor transport automatically. A snapshot
        // without a created team is a solo preview, not a multiplayer lobby to join.
        if (IsSimulatedClient && Networking.IsClient && !Networking.IsConnecting && Phase == RunPhase.Lobby && string.IsNullOrEmpty(ServerPartyId))
        {
            _ = ReturnToSolo();
            return;
        }
        if (LocalPhase == RunPhase.Lobby && !SessionBusy && !LobbySearchBusy && Time.Now >= _nextLobbySearch)
            _ = RefreshServerLobbies();

        if (Networking.IsActive && !Networking.IsConnecting) _wasNetworked = true;
        // A lost connection must never turn the old replicated match into an offline run.
        if (_wasNetworked && !Networking.IsActive && !SessionBusy)
        {
            _ = ReturnToSolo();
            return;
        }
        if (IsHost && Networking.IsActive && !string.IsNullOrEmpty(ServerPartyId)) PublishLobbyState();
    }

    private async Task CreateServerLobby(string name, string password)
    {
        if (SessionBusy || Phase != RunPhase.Lobby) return;
        if (IsSimulatedClient)
        {
            ConnectionError = "This Editor test window uses a simulated Steam account. Host the test lobby in the Editor. To test independent Steam servers, launch standalone game clients with real Steam accounts.";
            return;
        }
        if (Networking.IsActive && (!Networking.IsHost || !string.IsNullOrEmpty(ServerPartyId))) return;
        SessionBusy = true; SessionStatus = "Creating lobby...";
        // Defer network mutation until after the UI click callback.
        await Task.DelayRealtimeSeconds(.05f);
        if (!IsValid) return;
        try
        {
            // The editor may have started its test transport before Host Lobby was
            // clicked. Replace it explicitly; CreateLobby otherwise silently does nothing.
            if (Networking.IsActive)
            {
                Networking.Disconnect();
                _wasNetworked = false;
            }
            RemoveOfflinePlayers();
            PartyRooms.Clear(); PlayerParties.Clear(); LobbyReady.Clear(); LobbyCountdown.Clear(); PrivateLobbies.Clear();
            ServerPartyId = Guid.NewGuid().ToString("N");
            var room = new PartyLobbyView(ServerPartyId, name, LocalConnectionId, !string.IsNullOrEmpty(password), false, Array.Empty<string>());
            PartyRooms[ServerPartyId] = PartyLobbyView.Encode(room, PasswordToken(password));
            JoinKey = PasswordToken(password);
            Networking.CreateLobby(new LobbyConfig
            {
                Name = name, MaxPlayers = MaxPlayers, Privacy = LobbyPrivacy.Public, Hidden = true,
                AutoSwitchToBestHost = false, DestroyWhenHostLeaves = false
            });
            // CreateLobby starts asynchronously. Wait for the host connection and roster.
            for (var i = 0; i < 200; i++)
            {
                await Task.DelayRealtimeSeconds(.1f);
                if (!IsValid) return;
                if (Networking.IsActive && Networking.IsHost && Connection.Local is not null)
                {
                    AddServerMember(Connection.Local);
                    if (LocalPlayer.IsValid())
                    {
                        PublishLobbyState();
                        Status = "Lobby created";
                        return;
                    }
                }
            }
            Status = "Could not create lobby. Check your Steam connection.";
            await ReturnToSolo();
        }
        catch (Exception)
        {
            Status = "Could not create lobby.";
            await ReturnToSolo();
        }
        finally { if (IsValid) SessionBusy = false; }
    }

    private async Task ReadyInSoloPreview()
    {
        if (Phase != RunPhase.Lobby || SessionBusy) return;
        SessionBusy = true; SessionStatus = "Preparing solo run...";
        await Task.DelayRealtimeSeconds(.05f);
        if (!IsValid) return;
        Networking.Disconnect();
        _wasNetworked = false;
        foreach (var pawn in Scene.GetAll<FpsPlayer>().ToArray()) pawn.GameObject.Destroy();
        PartyRooms.Clear(); PlayerParties.Clear(); LobbyReady.Clear(); LobbyCountdown.Clear(); PrivateLobbies.Clear();
        SpawnPlayer(null);
        SessionBusy = false;
        ToggleLobbyReadyHost();
    }

    private void AddServerMember(Connection connection)
    {
        if (!IsHost || Phase != RunPhase.Lobby || connection is null || string.IsNullOrEmpty(ServerPartyId)) return;
        if (!PartyRooms.TryGetValue(ServerPartyId, out var encoded) || !PartyLobbyView.TryDecode(ServerPartyId, encoded, out var room, out var password)) return;
        var id = connection.Id.ToString();
        if (!room.Members.Contains(id))
        {
            if (room.IsFull) { connection.Kick("Lobby is full."); return; }
            var members = room.Members.Append(id).ToArray();
            PartyRooms[room.Id] = PartyLobbyView.Encode(room with { Members = members, OwnerId = Connection.Host?.Id.ToString() ?? room.OwnerId }, password);
            PlayerParties[id] = room.Id;
            LobbyReady[id] = false;
            LobbyCountdown.Clear();
        }
        SpawnPlayer(connection);
    }

    private void PublishLobbyState()
    {
        if (!PartyRooms.TryGetValue(ServerPartyId, out var encoded) || !PartyLobbyView.TryDecode(ServerPartyId, encoded, out var room, out _)) return;
        var state = Phase == RunPhase.Lobby ? "lobby" : "run";
        var signature = $"{ServerPartyId}/{state}/{room.Locked}/{room.Name}/{room.MemberCount}";
        if (_publishedLobbyState == signature) return;
        _publishedLobbyState = signature;
        Networking.ServerName = room.Name;
        Networking.SetData("dsver", LobbyProtocol);
        Networking.SetData("dsteam", ServerPartyId);
        Networking.SetData("dslock", room.Locked ? "1" : "0");
        Networking.SetData("dsstate", state);
    }

    private async Task ConnectToServer(PartyLobbyView lobby, string password)
    {
        if (SessionBusy || Phase != RunPhase.Lobby || lobby.Started || lobby.IsFull || lobby.Id == ServerPartyId) return;
        if (Application.IsEditor)
        {
            ConnectionError = "For local multiplayer testing, host in the Editor and join from the test window. Joining from the Editor can stop the Play scene. Use standalone game clients to test other hosting configurations.";
            return;
        }
        var joinEditor = IsSimulatedClient && !string.IsNullOrEmpty(_localEditorHostId) && lobby.OwnerId == _localEditorHostId;
        if (IsSimulatedClient && !joinEditor)
        {
            ConnectionError = "This test window uses a simulated Steam account. Join its Editor host, or use standalone clients with real Steam accounts to test Steam lobbies.";
            return;
        }
        if (!ulong.TryParse(lobby.ServerAddress, out var address)) { Status = "Refresh the lobby list and try again."; return; }
        SessionBusy = true; SessionStatus = $"Connecting to {lobby.Name}...";
        await Task.DelayRealtimeSeconds(.05f);
        if (!IsValid) return;
        var connectionStarted = false;
        try
        {
            if (!joinEditor)
            {
                SessionStatus = "Checking lobby availability...";
                var latest = await QueryGameLobbies();
                if (!IsValid) return;
                var target = latest.FirstOrDefault(l => l.LobbyId.ToString() == lobby.ServerAddress && l.Get("dsteam", "") == lobby.Id);
                if (target.LobbyId == 0)
                {
                    ConnectionError = "This lobby is no longer available. Refresh the list or host your own lobby.";
                    return;
                }
                if (target.IsFull || target.Members >= MaxPlayers)
                {
                    ConnectionError = "This lobby is full. Choose another lobby or host your own.";
                    return;
                }
                if (target.Get("dsstate", "run") != "lobby")
                {
                    ConnectionError = "This team has started its run. Choose another lobby or host your own.";
                    return;
                }
                SessionStatus = $"Connecting to {lobby.Name}...";
            }
            connectionStarted = true;
            JoinKey = PasswordToken(password);
            if (joinEditor)
            {
                Networking.Connect("local");
                return;
            }
            // Engine owns the connection/loading screen and replaces the local scene with the server snapshot.
            Log.Info($"[Dasher lobby] Connecting to lobby {address}, team {lobby.Id}");
            var connected = await Networking.TryConnectSteamId(address);
            Log.Info($"[Dasher lobby] Connection result: {connected}; active={Networking.IsActive}; connecting={Networking.IsConnecting}");
            if (!connected && IsValid) await ReturnToSolo();
        }
        catch (Exception exception)
        {
            Log.Warning($"[Dasher lobby] Connection failed: {exception}");
            if (IsValid)
            {
                ConnectionError = "Connection failed. Refresh the lobby list and try again.";
                if (connectionStarted) await ReturnToSolo();
            }
        }
        finally { if (IsValid) SessionBusy = false; }
    }

    private async Task ReturnToSolo()
    {
        if (!IsValid || _returningToSolo) return;
        _returningToSolo = true; SessionBusy = true; SessionStatus = "Returning to your solo lobby...";
        await Task.DelayRealtimeSeconds(.05f);
        if (!IsValid) return;
        // Disconnect before reloading: never broadcast the solo scene to the remaining team.
        Networking.Disconnect();
        JoinKey = "";
        var options = new SceneLoadOptions { ShowLoadingScreen = true, DeleteEverything = true };
        if (options.SetScene("scenes/main.scene")) Sandbox.Game.ChangeScene(options);
        else { _returningToSolo = false; SessionBusy = false; Status = "Could not load the main menu."; }
    }
}