ArenaMatchChecks.cs
using Sandbox;

namespace PaintballBaddies;

public sealed class ArenaMatchChecks : Component
{
    public bool CoveredModels { get; set; }
    public int PlayerCharacterIndex { get; set; } = -1;
    private string ModelPath( ArenaOpponent bot ) => RosterSelection.GetModelPath( bot.CharacterIndex );
    private bool ExpectedEquipment( ArenaOpponent bot )
    {
        if(bot.Components.Get<CitizenOpponentPresentation>() is {} citizen)
            return citizen.IsReady && citizen.VisualActive && citizen.ProtectiveGearReady
                && citizen.EquippedSuit==CitizenRosterAssets.Suit(bot.CharacterIndex)
                && citizen.EquippedHelmet==CitizenRosterAssets.Helmet(bot.CharacterIndex)
                && bot.PresentationAnchor.IsValid() && !bot.Body.Enabled;
        var path = ModelPath( bot );
        var integrated = CoveredRosterAssets.CharacterIndex( path ) >= 0;
        var track = CoveredRosterAssets.ReloadTrack( path );
        return bot.Body.Model == Model.Load( path )
            && bot.Components.Get<RosterHelmet>()?.Equipped == !integrated
            && (track is null ? !bot.Components.Get<ReloadPresentationTrack>().IsValid()
                : bot.Components.Get<ReloadPresentationTrack>() is { } gear && gear.TrackPath == track);
    }
    private float time;
    private int stage;
    private int timeoutAmmo, timeoutReserve, timeoutShots;
    private RosterHelmet[] defeatedGear;
    private ArenaOpponent[] defeatedBots;
    private int[] defeatedAmmo, defeatedReserve;
    private GameObject shotReceiver;
    private void Check( string name, bool pass ) => Log.Info( $"MATCH_CHECK {(pass ? "PASS" : "FAIL")} {name}" );
    protected override void OnUpdate()
    {
        time += Time.Delta;
        var match = Components.Get<ArenaMatch>();
        if ( match is null ) return;
        if ( stage == 0 && time > 2 )
        {
            if ( PlayerCharacterIndex >= 0 )
            {
                Components.Get<RosterSelection>().Select(PlayerCharacterIndex);
                Check("requested player model selected", Components.Get<PlayerController>().Renderer.Model?.Name == RosterSelection.GetModelPath(PlayerCharacterIndex));
            }
            var controller=Components.Get<PlayerController>();
            controller.Body.Velocity=new Vector3(200,80,120);
            controller.WishVelocity=new Vector3(200,80,0);
            match.StartRound();
            Check("round spawn clears previous physical and requested movement",controller.Body.Velocity.Length<.001f && controller.WishVelocity.Length<.001f);
            Check("countdown warns at ten seconds", ArenaMatch.CountdownCue(10.01f,9.99f)=="time_warning" && ArenaMatch.CountdownCue(9.99f,9.98f)==null);
            Check("countdown ticks once at final second boundaries", ArenaMatch.CountdownCue(5.01f,4.99f)=="countdown_tick" && ArenaMatch.CountdownCue(4.99f,4.98f)==null && ArenaMatch.CountdownCue(7,2)=="countdown_tick");
            Check("countdown silent on expiry and timer reset", ArenaMatch.CountdownCue(.01f,0)==null && ArenaMatch.CountdownCue(0,180)==null);
            Check("opponent vision sees forward within range", ArenaOpponent.InSightCone(Vector3.Zero,Vector3.Forward,new Vector3(500,0,0)) && ArenaOpponent.InSightCone(Vector3.Zero,Vector3.Forward,new Vector3(200,300,0)));
            Check("opponent vision rejects rear flank and distant targets", !ArenaOpponent.InSightCone(Vector3.Zero,Vector3.Forward,new Vector3(-100,0,0)) && !ArenaOpponent.InSightCone(Vector3.Zero,Vector3.Forward,new Vector3(0,500,0)) && !ArenaOpponent.InSightCone(Vector3.Zero,Vector3.Forward,new Vector3(1100,0,0)));
            // Keep the unattended player alive while measuring AI navigation and fire.
            // Restore normal durability before testing the loss transition.
            match.PlayerState.HitLimit = 1000;
            Check( "round starts with five opponents", match.InRound && match.OpponentsRemaining == 5 );
            Check( "friendly paint ignored", !match.PlayerState.RegisterHit( 0 ) && match.PlayerState.PaintHits == 0 );
            var magazine = new OpponentMagazine();
            magazine.TryFire();
            Check( "opponent shot cooldown", !magazine.TryFire() );
            magazine.Advance( .2f ); magazine.TryFire();
            magazine.Advance( .2f ); magazine.TryFire();
            magazine.Advance( .2f );
            Check( "three-shot burst has pause", !magazine.TryFire() && magazine.Ammo == 27 );
            for ( int i = 0; i < 27; i++ ) { magazine.Advance( 1 ); magazine.TryFire(); }
            Check( "empty opponent magazine reloads", magazine.Ammo == 0 && magazine.ReloadRemaining > 0 && !magazine.TryFire() );
            magazine.Advance( 1 );
            Check( "opponent cannot fire during reload", !magazine.TryFire() );
            magazine.Advance( 1.3f );
            Check( "opponent reload conserves ammunition", magazine.Ammo == 30 && magazine.Reserve == 90 && magazine.Reloads == 1 );
            stage++;
        }
        else if ( stage == 1 && time > 3 )
        {
            var fittedBots=Scene.GetAllComponents<ArenaOpponent>().ToArray();
            Check("all opponents use expected protective equipment",fittedBots.Length==5 && fittedBots.All(ExpectedEquipment) && fittedBots.Select(x=>x.CharacterIndex).Distinct().Count()==5 && fittedBots.All(x=>x.CharacterIndex != Components.Get<RosterSelection>().Selected));
            Check("opponents use fitted roster bodies",fittedBots.All(x=>x.Body.Model==Model.Load(ModelPath(x))));
            shotReceiver=new GameObject(true,"Match projectile receiver");
            shotReceiver.WorldPosition=new Vector3(-420,0,55);
            var receiverShape=shotReceiver.Components.Create<BoxCollider>();
            receiverShape.Scale=new Vector3(24,24,40);
            shotReceiver.Components.Create<PaintballCombatant>().Team=1;
            Check("player shot launches toward enemy receiver",Components.Get<PaintballMarker>().FireAt(shotReceiver.WorldPosition));
            var shot = new GameObject( true, "Test physical opponent paintball" );
            shot.WorldPosition = WorldPosition + Vector3.Up * 40 + Vector3.Backward * 60;
            var ball = shot.Components.Create<OpponentPaintball>();
            ball.Shooter = shot;
            ball.Velocity = Vector3.Forward * 2283;
            stage++;
        }
        else if ( stage == 2 && time > 4 )
        {
            Check( "physical opponent paintball hits player", match.PlayerState.PaintHits > 0 );
            var marker=Components.Get<PaintballMarker>();
            Check("physical player shot updates enemy damage and statistics",shotReceiver.Components.Get<PaintballCombatant>().PaintHits==1 && marker.Shots==1 && marker.Hits==1);
            Check("physical shots produce paint effects",PaintImpactSystem.Find(Scene)?.ActiveCount>0);
            shotReceiver.Destroy();
            stage++;
        }
        else if ( stage == 3 && time > 15 )
        {
            var bots = Scene.GetAllComponents<ArenaOpponent>().ToArray();
            Log.Info( $"MATCH_CHECK OBSERVATION status={match.Status} bots={bots.Length} receivedHits={match.PlayerState.PaintHits}" );
            Check( "opponents navigate", bots.Any( x => x.DistanceTravelled > 100 ) );
            Check( "all live opponents avoid obstacle crossings", bots.Length == 5 && bots.All( x => x.ObstructedMoves == 0 ) );
            foreach ( var bot in bots )
                Log.Info( $"MATCH_CHECK OBSERVATION navigation character={bot.Character} travelled={bot.DistanceTravelled} crossings={bot.ObstructedMoves} coverSelections={bot.CoverSelections}" );
            Log.Info( $"MATCH_CHECK OBSERVATION opponent shots={bots.Sum( x => x.ShotsFired )}" );
            Check( "opponents fire after acquiring player", bots.Sum( x => x.ShotsFired ) > 0 );
            defeatedGear=bots.Select(x=>x.Components.Get<RosterHelmet>()).ToArray();
            // Arrange an active reload on each actual opponent before tagging out.
            foreach (var bot in bots)
            {
                bot.Magazine.CancelReload();
                bot.Magazine.Advance(1);
                bot.Magazine.TryFire();
                bot.Magazine.BeginReload();
            }
            Check("opponents reloading before elimination", bots.All(x=>x.Magazine.ReloadRemaining>0));
            defeatedBots=bots;
            defeatedAmmo=bots.Select(x=>x.Magazine.Ammo).ToArray();
            defeatedReserve=bots.Select(x=>x.Magazine.Reserve).ToArray();
            foreach ( var bot in bots )
                for ( int i = 0; i < 3; i++ ) bot.Components.Get<PaintballCombatant>().RegisterHit( 0 );
            stage++;
        }
        else if ( stage == 4 && time > 16 )
        {
            Check( "eliminating opponents wins round", match.Status == "VICTORY" && !match.InRound );
            Check("elimination cancels opponent reload without transferring paint", defeatedBots.Select((bot,i)=>
                bot.Magazine.ReloadRemaining==0 && bot.Magazine.Ammo==defeatedAmmo[i] && bot.Magazine.Reserve==defeatedReserve[i]).All(x=>x));
            Check( "elimination notice names defeated opponents", new[]{"IMANI","FREYA","LEILANI"}.All(name=>match.EliminationNotice.Contains(name)) );
            Check("eliminated opponents clear equipment",defeatedGear.All(x=>x.IsValid() && !x.Equipped)
                && !Scene.GetAllComponents<ArenaOpponent>().Any(x=>x.Components.Get<ReloadPresentationTrack>().IsValid()));
            match.StartRound();
            var impacts=PaintImpactSystem.Find(Scene);
            Check("round restart clears paint decals and droplets",impacts is not null && impacts.ActiveCount==0 && impacts.ActiveDroplets==0);
            match.PlayerState.HitLimit = 5;
            for ( int i = 0; i < 5; i++ ) match.PlayerState.RegisterHit( 1 );
            stage++;
        }
        else if ( stage == 5 && time > 17 )
        {
            Check( "player elimination ends round", match.Status == "ELIMINATED" && !match.InRound );
            match.StartRound();
            stage++;
        }
        else if ( stage == 6 && time > 18 )
        {
            Check( "restart restores player and opponents", match.InRound && match.PlayerState.PaintHits == 0 && match.OpponentsRemaining == 5 );
            Check( "restart clears elimination notice", match.EliminationNotice == "" );
            Check( "restart preserves five opponents only", Scene.GetAllComponents<ArenaOpponent>().Count() == 5 );
            Check("restart restores opponent equipment",Scene.GetAllComponents<ArenaOpponent>().All(ExpectedEquipment));
            match.RoundDuration = .25f;
            match.StartRound();
            var weapon = Components.Get<PaintballMarker>();
            Check("timeout fixture launches paintball", weapon.FireAt(WorldPosition + Vector3.Up*1000) && weapon.ActivePaintballs > 0);
            weapon.Reload();
            Check("timeout fixture begins reload", weapon.ReloadRemaining > 0);
            timeoutAmmo=weapon.Ammo; timeoutReserve=weapon.Reserve; timeoutShots=weapon.Shots;
            stage++;
        }
        else if ( stage == 7 && time > 19 )
        {
            Check("timeout ends round and preserves surviving count", !match.InRound && match.Status == "TIME UP" && match.OpponentsRemaining == 5);
            Check("timeout blocks player input", !Components.Get<PlayerController>().UseInputControls && !Components.Get<PaintballMarker>().AcceptInput);
            var weapon = Components.Get<PaintballMarker>();
            Check("timeout clears airborne paint and reload", weapon.ActivePaintballs == 0 && weapon.ReloadRemaining == 0);
            weapon.Reload();
            Check("result rejects firing and reload without changing ammo", !weapon.FireAt(WorldPosition + Vector3.Up*1000) && weapon.ReloadRemaining == 0 && weapon.Ammo == timeoutAmmo && weapon.Reserve == timeoutReserve && weapon.Shots == timeoutShots);
            match.RoundDuration = 180;
            match.StartRound();
            stage++;
        }
        else if ( stage == 8 && time > 20 )
        {
            Check("restart after timeout restores timer and input", match.InRound && match.Remaining > 175 && Components.Get<PlayerController>().UseInputControls && Components.Get<PaintballMarker>().AcceptInput);
            Check("cannot exit a live round into training",!match.ReturnToTraining() && match.InRound);
            for(int i=0;i<5;i++)match.PlayerState.RegisterHit(1);
            stage++;
        }
        else if(stage==9 && time>21)
        {
            Check("result returns to training",match.ReturnToTraining() && match.Status=="TRAINING" && !match.InRound && match.OpponentsRemaining==0);
            var weapon=Components.Get<PaintballMarker>();
            Check("training restores health ammo movement and targets",match.PlayerState.PaintHits==0 && weapon.Ammo==40 && weapon.Reserve==160 && weapon.AcceptInput && Components.Get<PlayerController>().UseInputControls && Scene.GetAllComponents<PaintballTarget>().Any());
            Check("training removes round opponents and allows roster choice",!Scene.GetAllComponents<ArenaOpponent>().Any() && Components.Get<RosterSelection>().Select(2));
            Log.Info( "MATCH_CHECK COMPLETE" );
            Destroy();
        }
    }
}