MultiplayerSession.cs
using Sandbox;

using Sandbox.Network;

using System;

namespace PaintballBaddies;



// Native lobby, connection ownership and host-owned six-seat match.

// Ordinary comment avoids duplicate Description metadata across generated partial declarations.

public sealed partial class MultiplayerSession : Component, Component.INetworkListener

{

    public static MultiplayerSession Find(Scene scene)=>scene.GetAllComponents<MultiplayerSession>().FirstOrDefault();

    public static bool Online=>Networking.IsActive;

    public static bool Authority=>!Online || Networking.IsHost;

    public static PlayerController LocalPlayer(Scene scene)=>scene.GetAllComponents<PlayerController>()

        .FirstOrDefault(x=>!x.IsProxy && (!Online || x.Components.Get<NetworkPawn>().IsValid()));

    [Sync(SyncFlags.FromHost)] public bool InRound { get; private set; }

    [Sync(SyncFlags.FromHost)] public float Remaining { get; private set; }

    [Sync(SyncFlags.FromHost)] public int Minutes { get; private set; }=3;

    [Sync(SyncFlags.FromHost)] public int ActiveMinutes { get; private set; }=3;

    [Sync(SyncFlags.FromHost)] public string Status { get; private set; }="LOBBY";

    [Sync(SyncFlags.FromHost)] public int RoundId { get; private set; }

    [Sync(SyncFlags.FromHost)] public int MarkerSoundAssignments { get; private set; }=-1;

    public const string Protocol="bvs-beta-1";

    [Sync(SyncFlags.FromHost)] public bool PublicLobby {get;private set;}=true;

    public string Notice { get; private set; }="";

    public string JoinAddress { get; set; }="";

    public List<LobbyInformation> Lobbies { get; private set; }=new();

    private bool registered;

    private bool hadNetworkSession;

    private float reconcileTime;

    private bool pendingRoundStart;

    private readonly HashSet<Guid> disconnected=new();

    private static readonly Vector3[] Spawns={new(-630,0,4),new(570,-400,4),new(570,0,4),new(570,400,4),new(-650,1250,4),new(650,1250,4)};

    public IEnumerable<NetworkPawn> Players=>Scene.GetAllComponents<NetworkPawn>().Where(x=>x.GameObject.Active);

    public IEnumerable<NetworkBot> Bots=>Scene.GetAllComponents<NetworkBot>().Where(x=>x.GameObject.Active);

    public ArenaMatch.Standing[] Standings=>Scene.GetAllComponents<PaintballCombatant>()

        .Where(x=>x.Components.Get<NetworkPawn>().IsValid() || x.Components.Get<NetworkBot>().IsValid())

        .Select(x=>new ArenaMatch.Standing(x.Components.Get<PlayerProfile>()?.DisplayName ??

            RosterSelection.Names[x.Components.Get<ArenaOpponent>()?.CharacterIndex ?? 0],IsCooperative ? x.PopHits : x.HitsLanded,x.PaintHits,

            x.GameObject==LocalPlayer(Scene)?.GameObject,x.KnockdownsLanded,x.KnockdownsReceived,x.Components.Get<NetworkBot>().IsValid(),

            x.TargetsPopped,x.PopScore,IsCooperative,x.SausageKills,x.SausageHeadshots,x.OverdriveDowns,IsOverdrive)).OrderByDescending(x=>x.Points).ThenBy(x=>IsCooperative ? 0 : x.Received).ThenBy(x=>x.Name).ToArray();



    protected override void OnStart()

    {

        ArenaSelection.Restore(this);

        if(Authority && MarkerSoundAssignments<0)MarkerSoundAssignments=CharacterMarkerSound.Roll();

        if(!PaintImpactSystem.Find(Scene).IsValid())Components.Create<PaintImpactSystem>();

        if(Online)TrainingRange.CreateLocalHud(Scene);

    }

    public void Host(bool publicGame=true)

    {

        if(Online)return;

        CancelPlaySearch();

        var controls=Scene.GetAllComponents<PaintballControls>().FirstOrDefault();

        controls?.StartPractice();

        Minutes=(int)(LocalPlayer(Scene)?.Components.Get<ArenaMatch>()?.RoundDuration ?? 180)/60;

        try

        {

            PublicLobby=publicGame;

            Networking.CreateLobby(new LobbyConfig{Name=$"Baddies vs Sausages / {ArenaMap.DisplayTitle(Scene)}",MaxPlayers=6,Hidden=false,Privacy=publicGame ? LobbyPrivacy.Public : LobbyPrivacy.FriendsOnly,

                DestroyWhenHostLeaves=true});

            Networking.SetData("pb_protocol",Protocol);

            Networking.SetData("pb_access",publicGame ? "public" : "friends");

        }

        catch(Exception){Notice="Could not create a lobby. Check the Steam connection and try again.";return;}

        Scene.GetAllComponents<TrainingRange>().FirstOrDefault()?.SetTargetsVisible(false);

        foreach(var old in Scene.GetAllComponents<PlayerController>().Where(x=>!x.Components.Get<NetworkPawn>().IsValid()).ToArray())old.GameObject.Destroy();

        foreach(var shield in Scene.GetAllComponents<ArenaShield>().ToArray())shield.GameObject.Destroy();

        Notice=publicGame ? "Starting your public match. AI fills empty seats; people can join during play."

            : "Starting your friends-only match. AI fills empty seats.";

    }

    public void Join(ulong id)

    {

        if((Online && !CanQuickPlay) || Networking.IsConnecting)return;

        if(id==0){Notice="Enter a valid host Steam ID or lobby ID.";return;}

        CancelPlaySearch();

        // Keep the menu visible while connecting so Cancel and Quit stay reachable.

        var controls=Scene.GetAllComponents<PaintballControls>().FirstOrDefault();

        if(controls.IsValid() && !controls.Open)controls.Toggle();

        Notice="Connecting to the host...";

        Networking.Connect(id);

    }

    public void JoinTyped()

    {

        if(ulong.TryParse(JoinAddress.Trim(),out var id)){Join(id);return;}

        Notice="Enter the host Steam ID or a lobby ID.";

    }

    public void Leave()=>Game.Disconnect();

    public bool AcceptConnection(Connection channel,ref string reason)

    {

        if(Players.Count()+pendingSpawnSeats.Count>=6){reason="All six player seats are occupied.";return false;}

        return true;

    }

    public void OnActive(Connection channel)

    {

        if(!Networking.IsHost)return;

        disconnected.Remove(channel.Id);

        SpawnPlayer(channel);

    }

    public void OnDisconnected(Connection channel)

    {

        if(!Networking.IsHost)return;

        disconnected.Add(channel.Id);

        pendingSpawnSeats.Remove(channel.Id);spawnRetryAfter.Remove(channel.Id);

        foreach(var pawn in Players.Where(x=>x.ConnectionId==channel.Id).ToArray())pawn.GameObject.Destroy();

        reconcileTime=0;

    }

    private readonly Dictionary<Guid,float> spawnRetryAfter=new();

    private readonly Dictionary<Guid,int> pendingSpawnSeats=new();

    private void SpawnPlayer(Connection channel)

    {

        if(Players.Any(x=>x.ConnectionId==channel.Id) || spawnRetryAfter.GetValueOrDefault(channel.Id)>RealTime.Now)return;

        if(channel!=Connection.Host)channel.CanSpawnObjects=false;

        if(Players.Count()>=6)return;

        var occupied=Players.Select(x=>x.Seat).Concat(pendingSpawnSeats.Where(x=>x.Key!=channel.Id).Select(x=>x.Value)).ToArray();

        // The host rolls once from characters not held by another human.

        // Seat replication keeps outfit, paint, AI replacement and spawning consistent.

        var available=Enumerable.Range(0,RosterSelection.Names.Length).Where(x=>!occupied.Contains(x)).ToArray();

        if(available.Length==0)return;

        if(!pendingSpawnSeats.TryGetValue(channel.Id,out int seat))
            pendingSpawnSeats[channel.Id]=seat=available[Game.Random.Int(0,available.Length-1)];

        var placement=JoiningStart(seat);
        if(placement is null)
        {
            // Keep its AI active and retain this character reservation. The
            // normal connection reconciliation retries when a safe start clears.
            spawnRetryAfter[channel.Id]=RealTime.Now+.5f;
            return;
        }
        var spawn=placement.Position;

        var go=GameObject.Clone("prefabs/network_baddie.prefab",new Transform(spawn),startEnabled:false,name:$"Player {seat+1}");

        // Initialize ownership and game state before enabling OnStart handlers.
        // A failed editor hotload can leave an incomplete cached prefab. Do not
        // enable half a pawn or retry every frame, flashing camera ownership.
        // The clone is still disabled here, so look components up including disabled ones:
        // enabled-only lookups return nothing on an inactive object.
        var pawn=go.Components.Get<NetworkPawn>(true);
        if(!pawn.IsValid() || !go.Components.Get<PlayerController>(true).IsValid() || !go.Components.Get<PaintballCombatant>(true).IsValid() || !go.Components.Get<PlayerProfile>(true).IsValid())
        {
            spawnRetryAfter[channel.Id]=RealTime.Now+5;
            Notice="Player could not load. Restart the local game to retry.";
            Log.Warning("PLAYER_PREFAB_INCOMPLETE: player creation deferred; no partial pawn enabled.");go.Destroy();return;
        }
        foreach(var bot in Bots.Where(x=>x.Seat==seat).ToArray())bot.GameObject.Destroy();
        pawn.Seat=seat;pawn.ConnectionId=channel.Id;
        pawn.JoinPosition=spawn;pawn.JoinFacing=placement.Rotation.Angles();

        go.Components.Get<PaintballCombatant>(true).Team=seat+10;

        go.Components.Get<PaintballCombatant>(true).AcceptHits=InRound || Status=="LOBBY";

        go.Components.Get<PlayerProfile>(true).DisplayName=PlayerProfile.CleanName(channel.DisplayName);

        go.NetworkMode=NetworkMode.Object;go.Enabled=true;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn(channel);

        pendingSpawnSeats.Remove(channel.Id);spawnRetryAfter.Remove(channel.Id);

        if(InRound)pawn.ResetForRound(spawn,RoundId,placement.Rotation.Angles());

        reconcileTime=0;

    }

    protected override void OnUpdate()

    {

        TickPopRiot();TickOverdrive();

        if(Online)hadNetworkSession=true;

        else if(hadNetworkSession && !Networking.IsConnecting)

        {

            // Client AI is simulated by its host. Once that connection ends,

            // exit the orphaned scene rather than leave stationary replicas.

            hadNetworkSession=false;

            Log.Info("PAINTBALL_CONNECTION_ENDED returning to game menu");

            Game.Disconnect();

            return;

        }

        if(!Online || !Networking.IsHost)return;

        if(!registered)

        {

            registered=true;

            foreach(var old in Scene.GetAllComponents<PlayerController>().Where(x=>!x.Components.Get<NetworkPawn>().IsValid()).ToArray())old.GameObject.Destroy();

            Scene.GetAllComponents<TrainingRange>().FirstOrDefault()?.SetTargetsVisible(false);

            // Platform-created lobbies need the same discovery data as in-game hosts.

            Networking.SetData("pb_protocol",Protocol);

            Networking.SetData("pb_access",PublicLobby ? "public" : "friends");

            Networking.SetData("pb_mode",ModeKey(Mode));

            Networking.SetData("pb_minutes",Minutes.ToString());

            Networking.SetData("pb_arena",ArenaSelection.Current(Scene));

            GameObject.NetworkMode=NetworkMode.Object;GameObject.Network.SetOwnerTransfer(OwnerTransfer.Fixed);GameObject.NetworkSpawn();

        }

        foreach(var connection in Connection.All.Where(x=>x.IsActive && !disconnected.Contains(x.Id)))SpawnPlayer(connection);

        if(pendingRoundStart && string.IsNullOrEmpty(SpawnReadinessMessage))BeginRound();

        // Hosting is asynchronous. Start only after the local pawn and welcome

        // UI have initialized, including lobbies created by s&box's game browser.

        // RoundId keeps time-up/results and subsequent restarts host-controlled.

        if(RoundId==0 && Status=="LOBBY")

        {

            var host=Players.FirstOrDefault(x=>x.ConnectionId==Connection.Host?.Id);

            var controls=Scene.GetAllComponents<PaintballControls>().FirstOrDefault();

            if(controls.IsValid() && !controls.Open && host.IsValid()

                && host.Components.Get<CitizenPlayerPresentation>()?.IsReady==true

                && host.Components.Get<ArenaMatch>()?.PlayerState.IsValid()==true)

                BeginRound();

        }

        reconcileTime-=Time.Delta;

        if(reconcileTime<=0){reconcileTime=1;ReconcileBots();EnsureShields();}

        if(!InRound)return;

        // Cold cloud assets and a rebuilding doorway navmesh must not consume
        // the match timer before enemies can enter the arena safely.
        if(IsOverdrive && (!OverdriveAssets.Ready || !string.IsNullOrEmpty(SpawnReadinessMessage)) && Remaining>0)return;

        var previous=Remaining;Remaining=MathF.Max(0,Remaining-Time.Delta);

        if(ArenaMatch.CountdownCue(previous,Remaining) is {} cue)NetworkEffects.Sound($"sounds/paintball/{cue}.sound",Vector3.Zero,Guid.Empty);

        if(Remaining<=0)

        {

            InRound=false;Status="TIME UP";

            if(IsPopRiot && string.IsNullOrEmpty(RiotResult))RiotResult=RiotLives>0
                ? $"TEAM SURVIVED · {Math.Max(0,RiotWave-(RiotBreather>0 ? 0 : 1))} WAVES CLEARED!" : "TEAM OUT OF LIVES — ONE MORE ROUND?";
            ClearRiotTargets();
            foreach(var enemy in Scene.GetAllComponents<OverdriveEnemy>().ToArray())enemy.GameObject.Destroy();
            if(IsOverdrive && string.IsNullOrEmpty(OverdriveResult))OverdriveResult="OUT OF TIME · TRY ANOTHER BUILD";

            foreach(var state in Scene.GetAllComponents<PaintballCombatant>())state.AcceptHits=false;

            foreach(var pawn in Players)pawn.EndRound();

            foreach(var bot in Bots){bot.Components.Get<NavMeshAgent>()?.Stop();bot.Components.Get<ArenaOpponent>().CombatEnabled=false;}

            foreach(var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray())ball.GameObject.Destroy();

        }

    }

    [Rpc.Host]

    public async void SetMinutes(int minutes)

    {

        var caller=Rpc.Caller;

        await GameTask.MainThread();

        if(!this.IsValid() || caller!=Connection.Host || !ArenaMatch.SupportsRoundMinutes(minutes))return;

        Minutes=minutes;

        Networking.SetData("pb_minutes",Minutes.ToString());

    }

    [Rpc.Host]

    public async void StartRound()

    {

        var caller=Rpc.Caller;

        await GameTask.MainThread();

        if(!this.IsValid() || caller!=Connection.Host)return;

        BeginRound();

    }

    private void BeginRound()

    {

        if(!Networking.IsHost)return;

        if(Mode==ArenaGameMode.Overdrive && !string.IsNullOrEmpty(SpawnReadinessMessage))
        {pendingRoundStart=true;Notice=SpawnReadinessMessage;return;}

        pendingRoundStart=false;

        MarkerSoundAssignments=CharacterMarkerSound.Roll(MarkerSoundAssignments);

        RoundId++;ActiveMinutes=Minutes;ActiveMode=Mode;Remaining=ActiveMinutes*60;Status=ModeTitle(ActiveMode);InRound=true;

        StartPopRiotRound();StartOverdriveRound();RollRoundStarts();

        foreach(var bot in Bots.ToArray())bot.GameObject.Destroy();

        foreach(var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray())ball.GameObject.Destroy();

        foreach(var shield in Scene.GetAllComponents<ArenaShield>().ToArray())shield.GameObject.Destroy();

        foreach(var pawn in Players)

        {

            pawn.Components.Get<PaintballCombatant>().ResetPaintHits();

            pawn.ResetForRound(ArenaMap.PlayerSpawn(Scene,pawn.Seat,Spawns[pawn.Seat]),RoundId,ArenaMap.SpawnRotation(Scene,pawn.Seat,Rotation.Identity).Angles());

        }

        Scene.NavMesh.IsEnabled=true;Scene.NavMesh.IncludeStaticBodies=true;Scene.NavMesh.IncludeKeyframedBodies=false;

        Scene.NavMesh.AgentHeight=67;Scene.NavMesh.AgentRadius=17;Scene.NavMesh.SetDirty();

        ReconcileBots();EnsureShields();

        Notice="AI fills empty seats. Joining players replace AI; departing players are replaced by AI.";

        Log.Info($"PAINTBALL_ROUND_STARTED round={RoundId} humans={Players.Count()} ai={Bots.Count()} minutes={Minutes}");

        NetworkEffects.ClearPaint();NetworkEffects.Sound("sounds/paintball/round_start.sound",Vector3.Zero,Guid.Empty);

    }

    internal void RestoreArenaSelection(ArenaGameMode mode,int minutes,bool publicLobby)

    {

        Mode=mode;Minutes=ArenaMatch.SupportsRoundMinutes(minutes) ? minutes : 3;PublicLobby=publicLobby;

    }

    private void ReconcileBots()

    {

        if(!InRound)return;

        var humanSeats=Players.Select(x=>x.Seat).ToArray();

        foreach(var bot in Bots.Where(x=>humanSeats.Contains(x.Seat)).ToArray())bot.GameObject.Destroy();

        foreach(int seat in Enumerable.Range(0,6).Where(x=>!humanSeats.Contains(x)))

        {

            if(Bots.Any(x=>x.Seat==seat))continue;

            var go=new GameObject(false,$"Opponent {RosterSelection.Names[seat].ToLowerInvariant()}"){WorldPosition=ArenaMap.PlayerSpawn(Scene,seat,Spawns[seat])};

            go.Components.Create<NetworkBot>().Seat=seat;

            var state=go.Components.Create<PaintballCombatant>();state.Team=seat+10;state.StayInMatch=true;

            go.Components.Create<CloseCombat>();go.Components.Create<CharacterPaint>();go.Components.Create<CharacterVoice>();

            var bot=go.Components.Create<ArenaOpponent>();bot.Character=RosterSelection.Names[seat].ToLowerInvariant();bot.CombatEnabled=true;bot.ContestMode=true;

            go.Components.Create<CitizenOpponentPresentation>();

            go.NetworkMode=NetworkMode.Object;go.Enabled=true;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();

        }

    }

    private void EnsureShields()

    {

        for(int i=Scene.GetAllComponents<ArenaShield>().Count();i<2;i++)

        {

            var go=new GameObject(false,"Arena shield");go.Components.Create<ArenaShield>();

            go.NetworkMode=NetworkMode.Object;go.Enabled=true;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();

        }

    }

}