MultiplayerSession.Overdrive.cs
using Sandbox;

using System;

namespace PaintballBaddies;



public sealed partial class MultiplayerSession

{

    public bool IsOverdrive=>ActiveMode==ArenaGameMode.Overdrive;

    public bool IsCooperative=>IsPopRiot || IsOverdrive;

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

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

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

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

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

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

    [Sync(SyncFlags.FromHost)] public Vector3 ExtractionPoint {get;private set;}

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

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

    internal bool OverdriveReviewPaused {get;set;}

    public bool OverdriveWon=>OverdrivePhase=="COMPLETE";

    private int overdriveSpawned,overdriveQuota,overdriveSeed,restaurantEntrance;



    private float overdriveSpawnClock,overdriveBlocked;
    private int openingSausages;

    public IEnumerable<OverdriveEnemy> OverdriveEnemies=>Scene.GetAllComponents<OverdriveEnemy>().Where(e=>!e.Dead && e.Round==RoundId);

    private void StartOverdriveRound()

    {

        ConcreteFractures="";

        foreach(var e in Scene.GetAllComponents<OverdriveEnemy>().ToArray())e.GameObject.Destroy();

        foreach(var e in Scene.GetAllComponents<SausageEggSlick>().ToArray())e.GameObject.Destroy();

        OverdrivePhase="";OverdriveResult="";OverdriveWave=OverdriveUpgrades=OverdriveRemaining=0;

        OverdriveReviewPaused=false;OverdriveWipe=ExtractionHold=overdriveBlocked=0;

        restaurantEntrance=0;restaurantEntryCounts.Clear();openingSausages=0;overdriveSpawnClock=.3f;recentSpawnPoints.Clear();

        if(!IsOverdrive)return;

        _=OverdriveAssets.Load();

        Remaining=300;ActiveMinutes=5;OverdrivePhase="READY";OverdrivePhaseSeconds=5;

        overdriveSeed=RoundId*101;RiotBreather=0;

    }

    private void BeginOverdriveWave()

    {

        OverdriveWave++;RiotWave=OverdriveWave;OverdrivePhase="FIGHT";

        overdriveSpawned=OverdriveWave==1 ? openingSausages : 0;overdriveQuota=6+OverdriveWave*4;OverdriveRemaining=overdriveQuota;

        overdriveSpawnClock=0;overdriveBlocked=0;

        foreach(var p in Scene.GetAllComponents<PaintballCombatant>())p.HealOverdrive();

    }

    private void TickOverdrive()

    {

        if(!Authority || !IsOverdrive || !InRound || OverdriveReviewPaused || Remaining<=0)return;

        _=OverdriveAssets.Load();

        if(!OverdriveAssets.Ready)

        {if(!string.IsNullOrEmpty(OverdriveAssets.Error)){OverdriveResult=OverdriveAssets.Error;Remaining=0;}return;}

        if(!string.IsNullOrEmpty(SpawnReadinessMessage)){Notice=SpawnReadinessMessage;return;}

        var humans=Players.Select(p=>p.Components.Get<PaintballCombatant>()).Where(p=>p.IsValid()).ToArray();

        bool wiped=humans.Length>0 && humans.All(p=>p.Components.Get<CloseCombat>()?.Incapacitated==true);

        OverdriveWipe=wiped ? OverdriveWipe+Time.Delta : 0;

        if(OverdriveWipe>=12){OverdriveResult="SQUAD DOWN · TRY ANOTHER BUILD";Remaining=0;return;}

        if(OverdrivePhase is "READY" or "BREATHER")

        {

            if(OverdrivePhase=="READY" && openingSausages<3 && !Scene.NavMesh.IsGenerating)
            {
                overdriveSpawnClock-=Time.Delta;
                if(overdriveSpawnClock<=0){if(SpawnOverdriveAttacker())openingSausages++;overdriveSpawnClock=.65f;}
            }
            OverdrivePhaseSeconds=MathF.Max(0,OverdrivePhaseSeconds-Time.Delta);

            if(OverdrivePhaseSeconds<=0)

            {

                BeginOverdriveWave();

            }

            return;

        }

        if(OverdrivePhase!="FIGHT")return;

        int alive=OverdriveEnemies.Count();

        OverdriveRemaining=overdriveQuota-overdriveSpawned+alive;

        if(overdriveSpawned>=overdriveQuota && alive==0 && !RiotTargets.Any() && !Scene.GetAllComponents<PopRiotTarget>().Any(t=>t.Crashing)

            && !Scene.GetAllComponents<OverdriveEnemy>().Any(e=>e.HeadlessStagger))

        {

            if(OverdriveWave<3)

            {

                // Short breathers preserve control. Rewards come from each

                // player's five headshot kills, not a menu or wave transition.

                OverdrivePhase="BREATHER";OverdrivePhaseSeconds=4;

            }

            else

            {

                OverdrivePhase="COMPLETE";OverdriveRemaining=0;

                OverdriveResult="KITCHEN CLEARED · SAUSAGE SURVIVAL COMPLETE";Remaining=0;

            }

            return;

        }

        overdriveSpawnClock-=Time.Delta;

        if(overdriveSpawnClock>0 || overdriveSpawned>=overdriveQuota || alive>=7 || Scene.NavMesh.IsGenerating)return;

        if(SpawnOverdriveAttacker()){overdriveSpawned++;overdriveBlocked=0;}

        else
        {
            // Watching or occupying an entrance is normal play, not a broken
            // round. Keep waiting for an unseen arrival instead of forcing a loss.
            overdriveBlocked=MathF.Min(15,overdriveBlocked+1);
        }

        overdriveSpawnClock=ArenaSelection.Current(Scene)==ArenaSelection.Restaurant ? .85f : 1.2f;

    }

    private Vector3[] OverdrivePatrolPoints()

    {var authored=ArenaMap.PatrolPoints(Scene);return authored.Length>0 ? authored : ArenaOpponent.CreatePatrolRoute();}

    private readonly List<Vector3> recentSpawnPoints=new();

    // Just inside the dining room, past each of the two openings in the kitchen wall.

    internal static readonly Vector3[] RestaurantDoors=[new(400,1360,33),new(400,800,33)];

    internal static readonly Vector3[] KitchenEntrances=[new(560,1800,33),new(560,1480,33),new(560,1160,33),new(560,1000,33),new(640,1320,33)];

    private bool SpawnOverdriveAttacker()

    {

        var actors=Scene.GetAllComponents<PaintballCombatant>().Where(a=>a.AcceptHits).ToArray();

        var humanPositions=actors.Where(a=>a.Components.Get<NetworkPawn>().IsValid()).Select(a=>a.WorldPosition).ToArray();

        bool restaurant=Scene.GetAllComponents<RestaurantArena>().Any();

        // Spread arrivals over every entrance and route node instead of always

        // using the single farthest point. Prefer points far from the squad and

        // out of its sight, and avoid lanes used by the last few spawns, so

        // sausages arrive from several directions and can flank.

        if(restaurant)return SpawnRestaurantAttacker(actors);

        var candidates=(restaurant ? KitchenEntrances.Concat(OverdrivePatrolPoints().Where(p=>p.x>=500)) : OverdrivePatrolPoints()).ToArray();

        float minDistance=restaurant ? 420 : 650;

        var eyes=humanPositions.Select(h=>h+Vector3.Up*64).ToArray();

        var points=candidates.Select(p=>

        {

            float nearest=humanPositions.Select(h=>(h-p).Length).DefaultIfEmpty(900).Min();

            bool seen=eyes.Any(e=>!Scene.Trace.Ray(e,p+Vector3.Up*40).WithoutTags("paintball_actor","paintball_debris","overdrive_enemy","pop_target").Run().Hit);

            int recent=recentSpawnPoints.Count(r=>(r-p).Length<260);

            float weight=Game.Random.Float(.6f,1.4f)*(seen ? 1 : 1.8f)/(1+recent*1.5f);

            return (Point:p,Eligible:nearest>=minDistance,Weight:weight,Nearest:nearest);

        }).OrderByDescending(c=>c.Eligible).ThenByDescending(c=>c.Eligible ? c.Weight : c.Nearest).Select(c=>c.Point).ToArray();

        foreach(var point in points)

        {

            var floor=Scene.NavMesh.GetClosestPoint(point,160);if(!floor.HasValue)continue;

            if(actors.Any(a=>(a.WorldPosition-floor.Value).Length<(a.Components.Get<NetworkPawn>().IsValid() ? (restaurant ? 130 : 300) : 75)))continue;

            if(OverdriveEnemies.Any(e=>(e.WorldPosition-floor.Value).Length<60))continue;

            var position=floor.Value+Vector3.Up*3;
            if(!SpawnHidden(position))continue;

            if(Scene.Trace.Sphere(18,position+Vector3.Up*28,position+Vector3.Up*28)

                .WithoutTags("paintball_debris","overdrive_enemy").Run().Hit)continue;

            var spawned=SpawnOverdriveRobot(position);

            recentSpawnPoints.Add(point);if(recentSpawnPoints.Count>5)recentSpawnPoints.RemoveAt(0);

            return true;

        }

        return false;

    }

    private readonly Dictionary<string,int> restaurantEntryCounts=new();
    public IReadOnlyDictionary<string,int> RestaurantEntryCounts=>restaurantEntryCounts;

    private bool SpawnRestaurantAttacker(PaintballCombatant[] actors)

    {

        var routes=Scene.GetAllComponents<KitchenDoorways>().FirstOrDefault()?.Routes;
        if(routes is null)return false;

        // An entrance skipped while watched gets first refusal once hidden
        // again, instead of permanently losing its share to the nearest door.
        var order=Enumerable.Range(0,routes.Count)
            .OrderBy(i=>restaurantEntryCounts.GetValueOrDefault(KitchenDoorways.EntryName(routes[i])))
            .ThenBy(i=>(i-restaurantEntrance+routes.Count)%routes.Count).ToArray();
        foreach(int index in order)

        {

            var route=routes[index];var entry=KitchenDoorways.EntryName(route);

            // Step sideways behind a jamb or use cover in the delivery yard.
            var candidates=KitchenDoorways.ConcealedEntryCandidates(route);
            foreach(var candidate in candidates.OrderBy(_=>Game.Random.Float(0,1)))
            {
                var floor=Scene.NavMesh.GetClosestPoint(candidate,35);if(!floor.HasValue)continue;
                if(actors.Any(a=>(a.WorldPosition-floor.Value).Length<(a.Components.Get<NetworkPawn>().IsValid() ? 160 : 75)))continue;
                if(OverdriveEnemies.Any(e=>(e.WorldPosition-floor.Value).Length<90))continue;
                var position=floor.Value+Vector3.Up*2;
                if(!SpawnHidden(position))continue;
                if(Scene.Trace.Capsule(new Capsule(Vector3.Up*17,Vector3.Up*57,17),position,position)
                    .WithoutTags("paintball_debris","overdrive_enemy").Run().Hit)continue;
                var path=Scene.NavMesh.CalculatePath(new Sandbox.Navigation.CalculatePathRequest{Start=floor.Value,Target=route.Exit});
                if(!KitchenDoorways.UsesAssignedOpening(path,route))continue;
                var enemy=SpawnOverdriveRobot(position);if(!enemy.IsValid())continue;
                enemy.RouteVia=route.Exit;restaurantEntrance=(index+1)%routes.Count;
                restaurantEntryCounts[entry]=restaurantEntryCounts.GetValueOrDefault(entry)+1;
                Log.Info($"SAUSAGE_ENTRY hidden route={index} entry={entry} count={restaurantEntryCounts[entry]} at={position.x:0},{position.y:0}");return true;
            }

        }

        return false;

    }

    internal OverdriveEnemy SpawnOverdriveRobot(Vector3 point)

    {

        if(!Authority || !IsOverdrive || !InRound)return null;

        var go=new GameObject(false,"Terry sausage raider"){WorldPosition=point};

        var enemy=go.Components.Create<OverdriveEnemy>();enemy.Round=RoundId;
        ++overdriveSeed;
        // One unarmed runner per five arrivals; the round seed shifts his place.
        // The remaining enemies keep their knife/plate/egg roles.
        enemy.AttackStyle=overdriveSeed%5==0 ? 0 : 1+(overdriveSeed-1)%3;

        enemy.SetHefty(!OverdriveReviewPaused && overdriveSeed%5==2);

        // Plate throwers carry their ammunition as a stack in the off hand.

        if(enemy.AttackStyle==2)enemy.SetPlateStack(Game.Random.Int(7,10));

        if(enemy.AttackStyle==3)enemy.SetEggCarton(6);

        // Include components in the spawn snapshot. Adding them independently

        // on both peers in OnStart created duplicate guest presentation rigs.

        enemy.PrepareComponents();go.Enabled=true;

        if(Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}

        return enemy;

    }

    // True when this death empties the encounter: nothing left alive or to spawn.

    internal bool LastSausageOfEncounter(OverdriveEnemy dying)

        =>OverdrivePhase=="FIGHT" && overdriveSpawned>=overdriveQuota && !OverdriveEnemies.Any(e=>e!=dying);

    internal void OverdriveKill(PaintballCombatant shooter,int points,Vector3 point)

    {

        if(!Authority || !IsOverdrive || !InRound)return;

        TeamScore+=points;

        if(shooter.IsValid()){shooter.PopScore+=points;shooter.TargetsPopped++;}

        if((OverdriveUpgrades&4)!=0)

            foreach(var ally in Scene.GetAllComponents<PaintballCombatant>().Where(a=>(a.WorldPosition-point).Length<190 && a.Components.Get<CloseCombat>()?.Incapacitated!=true))ally.HealOverdrive();

    }

}