MultiplayerSession.Spawning.cs
using Sandbox;
using System;
namespace PaintballBaddies;

public sealed partial class MultiplayerSession
{
    public sealed class StartPose { public Vector3 Position { get; set; } public Rotation Rotation { get; set; } }
    [Sync(SyncFlags.FromHost)] public string RoundStarts { get; private set; } = "";
    private string observedStarts;
    private StartPose[] cachedStarts = [];

    public bool LocalArenaReady
    {
        get
        {
            if(Scene.GetAllComponents<MapInstance>().Any(m=>m.Active && !m.IsLoaded))return false;
            var venue=Scene.GetAllComponents<RestaurantArena>().FirstOrDefault();
            if(!venue.IsValid())return true;
            return venue.Ready && venue.SourcePrepared && string.IsNullOrEmpty(venue.Error)
                && (!venue.EnableServiceExpansion || Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault()?.Ready==true);
        }
    }

    // Restaurant.Ready precedes the delivery bay's cloud downloads and the
    // doorway navigation scan. Never start the countdown in that interval.
    public string SpawnReadinessMessage
    {
        get
        {
            if(Scene.GetAllComponents<MapInstance>().Any(m=>m.Active && !m.IsLoaded))
                return "The arena is still loading. Play will be ready shortly.";
            var venue=Scene.GetAllComponents<RestaurantArena>().FirstOrDefault();
            if(venue.IsValid())
            {
                if(!string.IsNullOrEmpty(venue.Error))return venue.Error;
                if(!venue.Ready || !venue.SourcePrepared)return "The restaurant is still loading. Play will be ready shortly.";
                if(venue.EnableServiceExpansion)
                {
                    var service=Scene.GetAllComponents<RestaurantServiceExpansion>().FirstOrDefault();
                    if(service.IsValid() && !string.IsNullOrEmpty(service.Error))return service.Error;
                    if(service?.Ready!=true)return "Preparing the kitchen and loading dock…";
                }
                if(Scene.GetAllComponents<KitchenDoorways>().FirstOrDefault()?.Routes.Count is not > 0)
                    return "Preparing the kitchen entrances…";
            }
            return Scene.NavMesh.IsGenerating ? "Preparing safe arena paths…" : "";
        }
    }

    internal StartPose StartFor(int seat)
    {
        if(!IsOverdrive || !InRound || string.IsNullOrEmpty(RoundStarts))return null;
        if(observedStarts!=RoundStarts){observedStarts=RoundStarts;cachedStarts=Json.Deserialize<StartPose[]>(RoundStarts);}
        return seat>=0 && seat<cachedStarts.Length ? cachedStarts[seat] : null;
    }
    private IEnumerable<StartPose> StartCandidates()
    {
        foreach(var p in ArenaMap.Spawns(Scene))yield return new(){Position=p.WorldPosition,Rotation=p.WorldRotation};
        foreach(var p in Scene.GetAllComponents<SpawnPoint>().Where(p=>p.GameObject.Active && p.GameObject.Name.StartsWith("PB Alternate ")))
            yield return new(){Position=p.WorldPosition,Rotation=p.WorldRotation};
    }
    internal bool ClearStart(Vector3 p)
    {
        bool floor=Scene.Trace.Ray(p+Vector3.Up*8,p-Vector3.Up*10).WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit;
        var hit=Scene.Trace.Capsule(new Capsule(Vector3.Up*18,Vector3.Up*58,16),p,p)
            .WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run();
        return floor && !hit.Hit && !hit.StartedSolid;
    }
    private void RollRoundStarts()
    {
        RoundStarts="";if(!IsOverdrive)return;
        var selected=new List<StartPose>();
        foreach(var p in StartCandidates().OrderBy(_=>Game.Random.Float(0,1)))
        {
            if(!ClearStart(p.Position) || selected.Any(s=>(s.Position-p.Position).Length<72))continue;
            selected.Add(p);if(selected.Count==6)break;
        }
        if(selected.Count==6)RoundStarts=Json.Serialize(selected);
        else Log.Warning($"ROUND_STARTS only {selected.Count} clear starts; preserving authored start positions");
    }
    private StartPose JoiningStart(int seat)
    {
        if(Mode!=ArenaGameMode.Overdrive && !(IsOverdrive && InRound))
            return new(){Position=ArenaMap.PlayerSpawn(Scene,seat,Spawns[seat]),Rotation=ArenaMap.SpawnRotation(Scene,seat,Rotation.Identity)};
        if(!string.IsNullOrEmpty(SpawnReadinessMessage))return null;
        // The reserved seat's AI remains alive while this connection waits. It
        // is replaced only after validation, so its current space is available.
        var allies=Scene.GetAllComponents<PaintballCombatant>().Where(a=>a.GameObject.Active
            && a.Components.Get<NetworkBot>()?.Seat!=seat).ToArray();
        var enemies=OverdriveEnemies.ToArray();
        return StartCandidates().Where(p=>ClearStart(p.Position)
                && !allies.Any(a=>(a.WorldPosition-p.Position).Length<72)
                && !enemies.Any(e=>(e.WorldPosition-p.Position).Length<240))
            .OrderBy(p=>enemies.Count(e=>!Scene.Trace.Ray(e.AimPoint,p.Position+Vector3.Up*55)
                .WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run().Hit))
            .ThenBy(p=>allies.Select(a=>(a.WorldPosition-p.Position).Length).DefaultIfEmpty(0).Min())
            .FirstOrDefault();
    }
    private Vector3 SpawnCameraOrigin(PlayerController player,Vector3 offset)
    {
        var eye=player.EyePosition;var look=player.EyeAngles.ToRotation();
        var desired=eye-look.Forward*offset.x+look.Right*offset.y+look.Up*offset.z;
        var trace=Scene.Trace.Sphere(6,eye,desired).WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run();
        return trace.Hit ? trace.EndPosition+trace.Normal*7 : desired;
    }
    private IEnumerable<Vector3> SpawnViewOrigins(PlayerController player)
    {
        var eye=player.EyePosition;
        yield return eye;
        yield return SpawnCameraOrigin(player,player.CameraOffset);
        // A remote pawn does not run its local camera smoothing. Cover both
        // shoulders and focus distances using its synchronized look angles.
        foreach(float shoulder in new[]{-1f,1f})
        {
            yield return SpawnCameraOrigin(player,new Vector3(98,28*shoulder,8));
            yield return SpawnCameraOrigin(player,new Vector3(55,22*shoulder,8));
        }
        if(!player.IsProxy && Scene.Camera.IsValid())yield return Scene.Camera.WorldPosition;
        // Conservative samples also protect a turn made on the spawning frame.
        foreach(var offset in new[]{Vector3.Forward*105,Vector3.Backward*105,Vector3.Left*75,Vector3.Right*75})
        {
            var trace=Scene.Trace.Sphere(6,eye,eye+offset).WithoutTags("paintball_actor","overdrive_enemy","paintball_debris").Run();
            yield return trace.Hit ? trace.EndPosition+trace.Normal*7 : eye+offset;
        }
    }
    // The largest arrival pose (including the hefty body, outstretched arms,
    // plate stack and open egg carton), with room for its first animated frame.
    internal static readonly BBox ArrivalBounds=new(new Vector3(-42,-42,-2),new Vector3(42,42,100));
    private static readonly Vector3[] arrivalSamples=BuildArrivalSamples();
    private static Vector3[] BuildArrivalSamples()
    {
        var points=new List<Vector3>();
        foreach(float z in new[]{-2f,34f,67f,100f})
        foreach(float x in new[]{-42f,0f,42f})
        foreach(float y in new[]{-42f,0f,42f})points.Add(new Vector3(x,y,z));
        return points.ToArray();
    }
    private static bool SeeThroughName(string value)
    {
        value=value?.ToLowerInvariant() ?? "";
        return value.Contains("glass") || value.Contains("window") || value.Contains("fence")
            || value.Contains("chainlink") || value.Contains("chain_link") || value.Contains("grate")
            || value.Contains("invisible") || value.Contains("player barrier");
    }
    private static bool SeeThroughMaterial(Material material)
        =>material is not null && (material.Flags.IsTranslucent || material.Flags.IsAlphaTest || material.Flags.IsSky
            || SeeThroughName(material.Name) || SeeThroughName(material.ShaderName));
    private static bool OpaqueArrivalBlocker(SceneTraceResult hit)
    {
        if(!hit.Hit || hit.StartedSolid || !hit.GameObject.IsValid())return false;
        if(SeeThroughName(hit.Surface?.ResourcePath) || SeeThroughName(hit.GameObject.Name))return false;
        var renderer=hit.Component as ModelRenderer ?? hit.GameObject.Components.Get<ModelRenderer>();
        if(renderer.IsValid())
        {
            if(renderer.Tint.a<.99f || SeeThroughMaterial(renderer.MaterialOverride) || RestaurantArrivalGlass.Contains(renderer,hit.EndPosition))return false;
            // A combined world contains both opaque and glass slots. Its per-hit
            // physics surface (or recorded glass part bounds) selects the window;
            // do not classify its brickwork
            // as glass simply because another triangle has a glass material.
            if(renderer.Materials.Count==1 && SeeThroughMaterial(renderer.Materials.GetOverride(0) ?? renderer.Materials.GetOriginal(0)))return false;
            if(SeeThroughName(renderer.Model?.Name))return false;
        }
        return true;
    }
    private bool ArrivalHiddenFrom(Vector3 origin,Vector3 point,ArenaShield[] shields)
    {
        foreach(var sample in arrivalSamples)
        {
            // Physics surfaces are available in packaged games too. Editor-only
            // render-mesh traces previously gave different concealment results.
            var trace=Scene.Trace.Ray(origin,point+sample)
                .WithoutTags("paintball_actor","overdrive_enemy","paintball_debris","pop_target",RestaurantBathroomEntrance.BarrierTag);
            foreach(var shield in shields)if(shield.IsValid())trace=trace.IgnoreGameObjectHierarchy(shield.GameObject);
            // Reject uncertain or transparent first hits. A solid wall beyond
            // glass may also conceal an arrival, but waiting is preferable to a
            // visible pop-in. Never relax this check after repeated attempts.
            if(!OpaqueArrivalBlocker(trace.Run()))return false;
        }
        return true;
    }
    internal bool SpawnHidden(Vector3 point)
    {
        var humans=Scene.GetAllComponents<PlayerController>().Where(p=>p.Active).ToArray();
        if(humans.Length==0)return false;
        var shields=Scene.GetAllComponents<ArenaShield>().ToArray();
        // Includes host and every remote NetworkPawn's synchronized eye/look.
        // No FOV test: a human can turn freely or change camera shoulder.
        foreach(var p in humans)
        foreach(var origin in SpawnViewOrigins(p))
            if(!ArrivalHiddenFrom(origin,point,shields))return false;
        return true;
    }
}