ArenaMatch.cs
using Sandbox;
using System;
namespace PaintballBaddies;

/// <summary>Local view of the single host-owned match; all competitors use MultiplayerSession.</summary>
public sealed class ArenaMatch : Component
{
    internal static float? RoundAfterReload;
    private float startupTime;
    // Retained for marker animation review scenes; playable matches always use unlimited paint.
    [Property] public bool TimedContest {get;set;}=true;
    [Property] public float RoundDuration {get;set;}=180;
    internal static readonly int[] RoundMinuteOptions={1,2,3,5};
    private MultiplayerSession OnlineSession=>MultiplayerSession.Online ? MultiplayerSession.Find(Scene) : null;
    private PlayerController player;
    public PaintballCombatant PlayerState {get;private set;}
    public int PlayedMinutes=>OnlineSession?.ActiveMinutes ?? (int)(RoundDuration/60);
    public int RoundSerial=>OnlineSession?.RoundId ?? 0;
    public bool InRound=>OnlineSession?.InRound ?? false;
    public string Status=>OnlineSession?.Status ?? "TRAINING";
    public float Remaining=>OnlineSession?.Remaining ?? 0;
    public int MarkerSoundAssignments {get;private set;}=-1;
    public int OpponentCount=>Math.Max(0,Standings.Length-1);
    public int OpponentsRemaining=>OpponentCount;
    public int OpponentsEliminated=>0;
    public string EliminationNotice=>"";
    public sealed record Standing(string Name,int Landed,int Received,bool IsPlayer,int Knockdowns=0,int KnockedDown=0,bool IsAi=false,int Carried=0,int Banked=0,bool PopRiot=false,int SausageKills=0,int HeadshotKills=0,int Downs=0,bool Survival=false)
    {
        public int Points=>PopRiot ? Banked : Landed-Received+PaintballCombatant.KnockdownPoints*(Knockdowns-KnockedDown);
    }
    public Standing[] Standings=>OnlineSession?.Standings ?? Array.Empty<Standing>();
    public int RankOf(Standing row)=>1+Standings.Count(x=>x.Points>row.Points || (!row.PopRiot && x.Points==row.Points && x.Received<row.Received));
    public int StandingsHash=>string.Join("|",Standings.Select(x=>$"{x.Name}:{x.Landed}:{x.Received}:{x.Knockdowns}:{x.KnockedDown}:{x.IsAi}:{x.IsPlayer}:{x.Carried}:{x.Banked}:{x.PopRiot}:{x.SausageKills}:{x.HeadshotKills}:{x.Downs}:{x.Survival}")).GetHashCode();
    public static bool SupportsRoundMinutes(int minutes)=>RoundMinuteOptions.Contains(minutes);
    public bool CycleRoundMinutes()=>SetRoundMinutes(RoundMinuteOptions[(Array.IndexOf(RoundMinuteOptions,(int)(RoundDuration/60))+1)%RoundMinuteOptions.Length]);
    public bool SetRoundMinutes(int minutes)
    {
        if(!SupportsRoundMinutes(minutes))return false;
        if(OnlineSession.IsValid())
        {
            if(!Networking.IsHost)return false;
            OnlineSession.SetMinutes(minutes);return true;
        }
        RoundDuration=minutes*60;return true;
    }
    protected override void OnStart()
    {
        player=Components.Get<PlayerController>();
        PlayerState=Components.Get<PaintballCombatant>() ?? Components.Create<PaintballCombatant>();
        if(OnlineSession.IsValid())return;
        MarkerSoundAssignments=CharacterMarkerSound.Roll();
        PlayerState.Team=0;PlayerState.HitLimit=5;PlayerState.StayInMatch=true;
    }
    protected override void OnUpdate()
    {
        if(IsProxy)return;
        if(OnlineSession.IsValid())RoundDuration=OnlineSession.Minutes*60;
        startupTime+=Time.Delta;
        if(RoundAfterReload is float duration && startupTime>.5f)
        {
            RoundAfterReload=null;RoundDuration=duration;StartRound();
        }
        if(Input.Pressed("flashlight"))StartRound();
        if(!InRound && Input.Pressed("roundlength"))CycleRoundMinutes();
    }
    public void StartRound()
    {
        if(OnlineSession.IsValid()){OnlineSession.StartRound();return;}
        MultiplayerSession.Find(Scene)?.QuickPlay();
    }
    // One cue per crossed boundary; a long frame must never queue a burst of ticks.
    internal static string CountdownCue(float previous, float current)
    {
        if (current <= 0 || current >= previous) return null;
        if (current <= 5 && MathF.Ceiling(current) < MathF.Ceiling(previous)) return "countdown_tick";
        return previous > 10 && current <= 10 ? "time_warning" : null;
    }
    public bool ReturnToTraining(bool endCurrentRound = false)
    {
        if(OnlineSession.IsValid())return false;
        if (player is null) return false;
        ArenaShield.ResetArena(Scene);
        Components.Get<VaultController>()?.Cancel();
        Components.Get<CoverController>()?.Leave();
        foreach(var pod in Scene.GetAllComponents<DroppedReloadPod>().ToArray())pod.GameObject.Destroy();
        foreach(var ball in Scene.GetAllComponents<OpponentPaintball>().ToArray()) ball.GameObject.Destroy();
        PlayerState.ResetPaintHits();PlayerState.HitLimit=5;
        PlayerState.StayInMatch=TimedContest;
        player.WorldPosition=ArenaMap.PlayerSpawn(Scene,0,new Vector3(-630,0,4));player.Body.Velocity=Vector3.Zero;
        player.WishVelocity=Vector3.Zero;
        player.UseInputControls=true;
        var weapon=Components.Get<PaintballMarker>();weapon.ResetTraining();weapon.AcceptInput=true;
        Scene.GetAllComponents<TrainingRange>().FirstOrDefault()?.SetTargetsVisible(true);
        return true;
    }
}