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

// Slot 1 replaces the former coin mode; the existing lobby/timer handles both modes.
public enum ArenaGameMode { PaintRush, PopRiot, Overdrive }
public sealed class PopRiotSpawnPoint : Component { }

public sealed partial class MultiplayerSession
{
    [Sync(SyncFlags.FromHost)] public ArenaGameMode Mode {get;private set;}=ArenaGameMode.Overdrive;
    [Sync(SyncFlags.FromHost)] public ArenaGameMode ActiveMode {get;private set;}=ArenaGameMode.Overdrive;
    [Sync(SyncFlags.FromHost)] public int RiotWave {get;private set;}
    [Sync(SyncFlags.FromHost)] public int TeamScore {get;private set;}
    [Sync(SyncFlags.FromHost)] public int RiotCombo {get;private set;}
    [Sync(SyncFlags.FromHost)] public float ComboSeconds {get;private set;}
    [Sync(SyncFlags.FromHost)] public float RiotBreather {get;private set;}
    [Sync(SyncFlags.FromHost)] public float RiotOverflow {get;private set;}
    [Sync(SyncFlags.FromHost)] public int RiotAlive {get;private set;}
    [Sync(SyncFlags.FromHost)] public bool RiotFinale {get;private set;}
    [Sync(SyncFlags.FromHost)] public int RiotGiantHealth {get;private set;}
    [Sync(SyncFlags.FromHost)] public int RiotChambers {get;private set;}
    [Sync(SyncFlags.FromHost)] public bool RiotChambersOpen {get;private set;}
    [Sync(SyncFlags.FromHost)] public Vector3 RiotGiantPosition {get;private set;}
    [Sync(SyncFlags.FromHost)] public string RiotResult {get;private set;}="";
    [Sync(SyncFlags.FromHost)] public int RiotLives {get;private set;}=RiotMaximumLives;
    [Sync(SyncFlags.FromHost)] public int RiotWaveTotal {get;private set;}
    [Sync(SyncFlags.FromHost)] public int RiotWaveCleared {get;private set;}
    [Sync(SyncFlags.FromHost)] public string RiotNotice {get;private set;}="";
    [Sync(SyncFlags.FromHost)] public float RiotNoticeSeconds {get;private set;}
    public const int RiotMaximumLives=5;
    public const int RiotCapacity=12;
    public bool RiotWon=>!string.IsNullOrEmpty(RiotResult) && RiotLives>0 && RiotResult.StartsWith("TEAM SURVIVED");
    public bool IsPopRiot=>ActiveMode==ArenaGameMode.PopRiot;
    public int RiotMultiplier=>1+Math.Min(4,RiotCombo/8);
    public static string ModeTitle(ArenaGameMode mode)=>mode==ArenaGameMode.Overdrive ? "SAUSAGE SURVIVAL" : mode==ArenaGameMode.PopRiot ? "POP RIOT" : "PAINT RUSH";
    public static string ModeKey(ArenaGameMode mode)=>mode==ArenaGameMode.Overdrive ? "overdrive" : mode==ArenaGameMode.PopRiot ? "pop_riot" : "paint_rush";
    public string ModeHelp=>HelpFor(Mode);
    public static string HelpFor(ArenaGameMode mode)=>mode==ArenaGameMode.Overdrive ? "Survive three waves together. Use dual pistols and explosive bolts, intercept flying kitchenware, and earn specials with headshot kills. Clear the final wave to win. Friendly fire is off." : mode==ArenaGameMode.PopRiot
        ? "Shoot moving drones before they shoot you. Intercept their large paintballs, dodge or take cover. Five shared team lives; each wave cleared restores one. Survive until the timer ends. Friendly fire is off."
        : "Land paint. Avoid hits. Most points wins. Everyone stays in the fight.";
    private Vector3[] riotLocations=Array.Empty<Vector3>();
    private float riotSpawnClock,riotSpawnBlocked;
    private int riotSpawned,riotQuota,riotSeed;
    internal bool RiotReviewPaused {get;set;}
    public IEnumerable<PopRiotTarget> RiotTargets=>Scene.GetAllComponents<PopRiotTarget>().Where(t=>!t.Popped && t.Round==RoundId);

    public void SelectMode(ArenaGameMode mode)
    {
        if(!Enum.IsDefined(mode))return;
        if(Online){RequestMode(mode);return;}
        Mode=mode; if(mode==ArenaGameMode.Overdrive)_=OverdriveAssets.Load();
    }
    [Rpc.Host]
    private async void RequestMode(ArenaGameMode mode)
    {
        var caller=Rpc.Caller;await GameTask.MainThread();
        if(!IsValid || caller!=Connection.Host || !Enum.IsDefined(mode))return;
        Mode=mode; if(mode==ArenaGameMode.Overdrive)_=OverdriveAssets.Load();
        if(!InRound)Networking.SetData("pb_mode",ModeKey(Mode));
    }
    private void StartPopRiotRound()
    {
        ClearRiotTargets();riotLocations=Array.Empty<Vector3>();
        RiotWave=TeamScore=RiotCombo=RiotAlive=0;ComboSeconds=RiotOverflow=0;
        RiotFinale=false;RiotGiantHealth=RiotChambers=0;RiotChambersOpen=false;RiotGiantPosition=Vector3.Zero;RiotResult="";RiotBreather=3;riotSpawnClock=0;riotSpawned=riotQuota=0;
        RiotLives=RiotMaximumLives;RiotWaveTotal=RiotWaveCleared=0;RiotNotice="";RiotNoticeSeconds=riotSpawnBlocked=0;
        RiotReviewPaused=false;
        if(Online)Networking.SetData("pb_mode",ModeKey(ActiveMode));
    }
    private void ClearRiotTargets()
    {
        foreach(var t in Scene.GetAllComponents<PopRiotTarget>().ToArray())t.GameObject.Destroy();
        RiotAlive=0;
    }
    private void TickPopRiot()
    {
        if(!Authority || !IsPopRiot || !InRound || RiotReviewPaused || Remaining<=0)return;
        ComboSeconds=MathF.Max(0,ComboSeconds-Time.Delta);
        RiotNoticeSeconds=MathF.Max(0,RiotNoticeSeconds-Time.Delta);
        if(ComboSeconds<=0)RiotCombo=0;
        if(riotLocations.Length==0)
        {
            if(Scene.NavMesh.IsGenerating)return;
            var authored=Scene.GetAllComponents<PopRiotSpawnPoint>().Select(x=>x.WorldPosition).ToArray();
            bool foundry=ArenaSelection.Current(Scene)==ArenaSelection.Foundry;
            var candidates=foundry
                ? new[]{0f,1181.1024f}.SelectMany(y=>Enumerable.Range(-4,9).Select(x=>new Vector3(x*240,y,0)))
                : (authored.Length>0 ? authored : ArenaMap.PatrolPoints(Scene))
                    .SelectMany(p=>new[]{p,p+Vector3.Forward*160,p-Vector3.Forward*160,p+Vector3.Right*160,p-Vector3.Right*160});
            riotLocations=candidates.Select(p=>new { Point=p, Floor=Scene.NavMesh.GetClosestPoint(p,150) })
                .Where(p=>p.Floor.HasValue).Select(p=>(foundry ? new Vector3(p.Point.x,p.Point.y,p.Floor.Value.z) : p.Floor.Value)+Vector3.Up*156)
                .Where(p=>!Scene.Trace.Sphere(42,p+Vector3.Up*(foundry ? 230 : 420),p)
                    .WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit)
                .OrderBy(_=>Game.Random.Float()).ToArray();
            if(riotLocations.Length==0)
            {
                riotSpawnBlocked+=Time.Delta;
                if(riotSpawnBlocked>12){RiotResult="NO CLEAR TARGET LANES — RESTART OR CHOOSE ANOTHER ARENA";Remaining=0;}
                return;
            }
        }
        RiotAlive=RiotTargets.Count(t=>t.Kind!=PopKind.Glob);
        if(RiotBreather>0)
        {
            RiotBreather=MathF.Max(0,RiotBreather-Time.Delta);
            if(RiotBreather<=0)BeginRiotWave();
            return;
        }
        if(riotSpawned>=riotQuota && !RiotTargets.Any() && !Scene.GetAllComponents<PopRiotTarget>().Any(t=>t.Crashing && t.Round==RoundId))
        {
            CompleteRiotWave();return;
        }
        riotSpawnClock-=Time.Delta;
        int concurrent=riotQuota;
        // Never keep spawning beyond the wave quota: the previous mode could not clear naturally.
        if(riotSpawnClock>0 || riotSpawned>=riotQuota || RiotAlive>=concurrent)return;
        if(SpawnWaveDrone())riotSpawnBlocked=0;
        else
        {
            riotSpawnBlocked+=.65f;
            if(riotSpawnBlocked>12 && RiotAlive==0)
            {RiotResult="NO CLEAR TARGET LANES — RESTART OR CHOOSE ANOTHER ARENA";Remaining=0;}
        }
        riotSpawnClock=.9f;
    }
    private bool SpawnWaveDrone()
    {
        var actors=Scene.GetAllComponents<PaintballCombatant>().Where(a=>a.AcceptHits).ToArray();
        var humans=actors.Where(a=>a.Components.Get<NetworkPawn>().IsValid()).ToArray();
        var viewers=humans.Length>0 ? humans : actors;
        // Prefer lanes players can actually see, without appearing at their feet or inside another target.
        foreach(var p in riotLocations.OrderBy(_=>Game.Random.Float()).OrderBy(p=>
            viewers.Any(a=>(a.WorldPosition-p).Length<1500 && !Scene.Trace.Ray(p,a.EyePosition)
                .WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit) ? 0 : 1))
        {
            var point=p;
            if(actors.Any(a=>(a.EyePosition-p).Length<75) || RiotTargets.Any(t=>(t.WorldPosition-point).Length<130))continue;
            var entry=point+Vector3.Up*(ArenaSelection.Current(Scene)==ArenaSelection.Foundry ? 230 : 420);
            if(RiotTargets.Any(t=>(t.WorldPosition-entry).Length<100))continue;
            var drone=SpawnRiotTarget(entry,PopKind.Blob);
            if(drone.IsValid()){drone.Anchor=point;drone.Entering=true;riotSpawned++;return true;}
        }
        return false;
    }
    private void BeginRiotWave()
    {
        RiotWave++;riotSpawned=0;riotQuota=Math.Min(6,3+RiotWave/2);
        RiotWaveTotal=riotQuota;RiotWaveCleared=0;riotSpawnClock=riotSpawnBlocked=0;
        SpawnWaveDrone();riotSpawnClock=.9f;
    }
    internal void CompleteRiotWave()
    {
        if(!Authority || !IsPopRiot || !InRound || Remaining<=0 || RiotBreather>0)return;
        bool restored=RiotLives<RiotMaximumLives;
        RiotLives=Math.Min(RiotMaximumLives,RiotLives+1);
        RiotNotice=restored ? "WAVE CLEAR · +1 TEAM LIFE" : "WAVE CLEAR · TEAM LIVES FULL";
        RiotNoticeSeconds=6;RiotBreather=6;
        NetworkEffects.RiotCue("bank_move");
    }
    internal bool LoseRiotLife(PaintballCombatant actor)
    {
        if(!Authority || !IsPopRiot || !InRound || Remaining<=0 || RiotLives<=0 || !actor.IsValid() || !actor.AcceptHits)return false;
        RiotLives--;RiotCombo=0;ComboSeconds=0;
        var name=actor.Components.Get<PlayerProfile>()?.DisplayName
            ?? RosterSelection.Names[actor.Components.Get<ArenaOpponent>()?.CharacterIndex ?? 0];
        RiotNotice=$"{name.ToUpperInvariant()} HIT · -1 TEAM LIFE";RiotNoticeSeconds=2.5f;
        NetworkEffects.RiotCue("spill");
        if(RiotLives==0){RiotResult="TEAM OUT OF LIVES — ONE MORE ROUND?";Remaining=0;}
        return true;
    }
    internal PopRiotTarget SpawnRiotTarget(Vector3 position,PopKind kind)
    {
        if(!Authority || !IsCooperative || !InRound || Remaining<=0)return null;
        if(kind==PopKind.Glob ? RiotTargets.Count(t=>t.Kind==PopKind.Glob)>=10 : RiotTargets.Count(t=>t.Kind!=PopKind.Glob)>=RiotCapacity)return null;
        float radius=PopRiotTarget.SizeFor(kind);
        if(Scene.Trace.Sphere(radius,position,position+Vector3.Up*.1f).WithoutTags("paintball_actor","paintball_debris","pop_target").Run().Hit)return null;
        var go=new GameObject(false,$"Pop Riot {kind}"){WorldPosition=position};
        var t=go.Components.Create<PopRiotTarget>();t.Kind=kind;t.Round=RoundId;t.Seed=++riotSeed;
        t.InitializeHealth();t.Anchor=position;
        go.Enabled=true;
        if(Online){go.NetworkMode=NetworkMode.Object;go.Network.SetOwnerTransfer(OwnerTransfer.Fixed);go.NetworkSpawn();}
        return t;
    }
    internal void RiotHit(PaintballCombatant shooter)
    {
        if(!Authority || !InRound || !IsCooperative || !shooter.IsValid())return;
        RiotCombo++;ComboSeconds=3.5f;shooter.PopHits++;
        if(Online)shooter.Components.Get<NetworkPawn>()?.ConfirmHit();
    }
    internal void RiotMiss()
    {
        if(!Authority || !InRound || !IsCooperative)return;
        RiotCombo=Math.Max(0,RiotCombo-2);
    }
    internal void RiotPop(PopRiotTarget target,PaintballCombatant shooter,bool chained)
    {
        if(!Authority || !InRound || !IsCooperative)return;
        int points=(target.Kind==PopKind.Giant ? 150 : target.Kind==PopKind.Blob ? 30 : target.Kind==PopKind.Glob ? 5 : 10)*RiotMultiplier;
        if(IsOverdrive){OverdriveKill(shooter,points,target.WorldPosition);return;}
        TeamScore+=points;
        if(shooter.IsValid()){shooter.PopScore+=points;shooter.TargetsPopped++;}
        if(target.Kind==PopKind.Blob)RiotWaveCleared=Math.Min(RiotWaveTotal,RiotWaveCleared+1);
        var tint=target.PaintTint;
        // A disabled drone keeps its airframe until the falling flight reaches an impact.
        if(target.Kind!=PopKind.Blob)
        {
            NetworkEffects.RiotBurst(target.WorldPosition,target.Radius,tint,target.Kind==PopKind.Giant,chained,RoundId);
            var floor=Scene.Trace.Ray(target.WorldPosition,target.WorldPosition-Vector3.Up*240)
                .WithoutTags("paintball_actor","paintball_debris","pop_target").Run();
            if(floor.Hit)PaintImpactSystem.Find(Scene)?.Spawn(floor,tint);
        }
        if(target.Kind==PopKind.Chain)
            foreach(var next in RiotTargets.Where(t=>t!=target && t.Kind!=PopKind.Giant && (t.WorldPosition-target.WorldPosition).Length<235).ToArray())
            {
                var sight=Scene.Trace.Ray(target.WorldPosition,next.WorldPosition).WithoutTags("paintball_actor","paintball_debris","pop_target").Run();
                if(!sight.Hit)next.Pop(shooter,true);
            }

    }
}