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

/// <summary>Opt-in observation of the ordinary five-bot match; no injected hits.</summary>
public sealed class AiMatchObservation : Component
{
    private float elapsed;
    private double previous;
    private PlayerController player;
    private readonly List<double> frames=new();
    private readonly HashSet<string> seenTactics=new();
    public bool Done { get; private set; }
    public object Result { get; private set; }
    protected override void OnStart()
    {
        Scene.GetAllComponents<PaintballControls>().First().StartMatch();
        player=Scene.GetAllComponents<PlayerController>().First();
        player.UseInputControls=player.UseLookControls=false;
        previous=RealTime.NowDouble;
    }
    protected override void OnUpdate()
    {
        if(Done)return;
        elapsed+=Time.Delta;
        var now=RealTime.NowDouble;
        if(elapsed>3)frames.Add((now-previous)*1000);
        previous=now;
        var bots=Scene.GetAllComponents<ArenaOpponent>().ToArray();
        foreach(var bot in bots)seenTactics.Add(bot.Tactic);
        if(elapsed<63)return;
        var sorted=frames.OrderBy(x=>x).ToArray();
        Result=new{Seconds=elapsed,BotCount=bots.Length,ShootingBots=bots.Count(x=>x.ShotsFired>0),
            Shots=bots.Sum(x=>x.ShotsFired),ShotsAtPlayers=bots.Sum(x=>x.ShotsAtPlayers),PlayerPaint=player.Components.Get<PaintballCombatant>()?.PaintHits,CoverShots=bots.Sum(x=>x.ShotsFromCover),
            ShelteredShots=bots.Sum(x=>x.ShotsWhileSheltered),CoverChoices=bots.Sum(x=>x.CoverSelections),
            Relocations=bots.Sum(x=>x.CoverRelocations),Flanks=bots.Sum(x=>x.FlankSelections),
            Searches=bots.Sum(x=>x.SearchesStarted),HeardShots=bots.Sum(x=>x.HeardShots),
            Recoveries=bots.Sum(x=>x.NavigationRecoveries),AvoidedBlockedShots=bots.Sum(x=>x.BlockedShotsAvoided),
            Tactics=seenTactics.ToArray(),Frames=frames.Count,MeanMs=frames.Average(),P99Ms=sorted[(int)(sorted.Length*.99)],MaxMs=sorted.Last(),
            Over33Ms=frames.Count(x=>x>33.333),Standings=Scene.GetAllComponents<ArenaMatch>().First().Standings};
        Done=true;Log.Info("AI_MATCH_OBSERVATION "+Json.Serialize(Result));
    }
    protected override void OnDestroy()
    {
        if(player.IsValid())player.UseInputControls=player.UseLookControls=true;
    }
}