GameLogic/crowd/CrowdEvaluator.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

public sealed class CrowdEvaluator : Component
{
    public static CrowdEvaluator Instance { get; private set; }

    [Property] public CrowdConfig Config { get; set; }
    [Property] public CrowdAudioController CrowdAudio { get; set; }

    // Candidats potentiels au SparedFighter (Combattant -> Temps depuis le dernier coup)
    private readonly Dictionary<Fighter, RealTimeSince> _sparedCandidates = new();

    public static event Action<CrowdEventPayload, CrowdActionDefinition> OnEvaluatedCrowdAction;

    protected override void OnAwake()
    {
        Instance = this;
        CrowdAudio ??= Scene.GetAllComponents<CrowdAudioController>().FirstOrDefault();
    }

    protected override void OnStart()
    {
        CombatEvents.OnPlayerWeaponThrowHit += HandleWeaponThrowHit;
        CombatEvents.OnPlayerKillEnemy += HandlePlayerKill;
        CombatEvents.OnPlayerAirKnockdown += HandleAirKnockdown;
        CombatEvents.OnPlayerReboundStrike += HandleReboundStrike;
        CombatEvents.OnFighterDamaged += HandleFighterDamaged;
        CombatEvents.OnFighterRecoveredFromStaggerOrKnockdown += HandleFighterRecovered;
        CombatEvents.OnFighterKilled += HandleAnyFighterKilled;
    }

    protected override void OnDestroy()
    {
        CombatEvents.OnPlayerWeaponThrowHit -= HandleWeaponThrowHit;
        CombatEvents.OnPlayerKillEnemy -= HandlePlayerKill;
        CombatEvents.OnPlayerAirKnockdown -= HandleAirKnockdown;
        CombatEvents.OnPlayerReboundStrike -= HandleReboundStrike;
        CombatEvents.OnFighterDamaged -= HandleFighterDamaged;
        CombatEvents.OnFighterRecoveredFromStaggerOrKnockdown -= HandleFighterRecovered;
        CombatEvents.OnFighterKilled -= HandleAnyFighterKilled;
    }

    private void HandleWeaponThrowHit( Weapon weapon, Fighter victim, DamageContext ctx, bool willImpale )
    {
        if ( willImpale )
            RegisterAction( ctx.Attacker, CrowdAction.Impale, victim );
        else
            RegisterAction( ctx.Attacker, CrowdAction.PrecisionThrow, victim );
    }

    private void HandlePlayerKill( Fighter victim, DamageContext ctx, bool victimWasUnarmed )
    {
        _sparedCandidates.Remove( victim );

        if ( victimWasUnarmed )
        {
            RegisterAction( ctx.Attacker, CrowdAction.UnfairFightKill, victim );
        }
        else if ( ctx.Zone == HitZone.Head && ctx.Type == DamageType.Slash )
        {
            RegisterAction( ctx.Attacker, CrowdAction.Decapitation, victim );
        }
        else if ( ctx.ForceKnockdown && ctx.Type == DamageType.Pierce )
        {
            RegisterAction( ctx.Attacker, CrowdAction.Impale, victim );
        }
        else
        {
            RegisterAction( ctx.Attacker, CrowdAction.Finisher, victim );
        }
    }

    private void HandleAirKnockdown( Fighter player, Fighter victim, DamageContext ctx )
    {
        RegisterAction( player, CrowdAction.AirKnockdown, victim );
    }

    private void HandleReboundStrike( Fighter player, Fighter victim, DamageContext ctx )
    {
        RegisterAction( player, CrowdAction.ReboundStrike, victim );
    }

    private void HandleFighterDamaged( Fighter victim, DamageContext ctx )
    {
        bool attackerIsPlayer = ctx.Attacker != null && ctx.Attacker.Components.Get<PlayerController>() != null;
        if ( !attackerIsPlayer || victim == ctx.Attacker ) return;

        // Condition Spared : agresser un ennemi qui se retrouve low HP (<= 25%) et Staggered
        if ( victim.CurrentHealth <= 25f && victim.Status == FighterStatus.Staggered )
        {
            _sparedCandidates[victim] = 0;
        }
        else if ( _sparedCandidates.ContainsKey( victim ) )
        {
            _sparedCandidates.Remove( victim );
        }
    }

    private void HandleFighterRecovered( Fighter fighter )
    {
        if ( _sparedCandidates.TryGetValue( fighter, out var timeSinceLastHit ) )
        {
            _sparedCandidates.Remove( fighter );

            if ( timeSinceLastHit >= 2.0f && !fighter.IsDead )
            {
                var player = Scene.GetAllComponents<PlayerController>().FirstOrDefault()?.Components.Get<Fighter>();
                if ( player != null )
                {
                    RegisterAction( player, CrowdAction.SparedFighter, fighter );
                }
            }
        }
    }

    private void HandleAnyFighterKilled( Fighter victim, DamageContext ctx )
    {
        bool isPlayerAttacker = ctx.Attacker?.Components.Get<PlayerController>() != null;
        if ( !isPlayerAttacker && ctx.Attacker != null )
        {
            ctx.Attacker.Components.Get<FighterAura>()?.ApplyAuraDelta( 10f );
            CrowdAudio?.PlayBotHighlightReaction();
        }
    }

    public void RegisterAction( Fighter actor, CrowdAction action, Fighter target = null )
    {
        if ( actor == null ) return;

        var def = Config?.GetDefinition( action ) ?? GetDefaultDefinition( action );
        float delta = def.AuraDelta;

        actor.Components.Get<FighterAura>()?.ApplyAuraDelta( delta );

        if ( def.CrowdSound != null )
            CrowdAudio?.PlayReaction( def.CrowdSound, delta >= 0 );

        OnEvaluatedCrowdAction?.Invoke( new CrowdEventPayload( actor, action, target ), def );
    }

    private CrowdActionDefinition GetDefaultDefinition( CrowdAction action )
    {
        var meta = CrowdEventDisplay.GetMetadata( action );
        float defaultDelta = action switch
        {
            CrowdAction.Decapitation => +15f,
            CrowdAction.Impale => +15f,
            CrowdAction.Finisher => +12f,
            CrowdAction.PrecisionThrow => +8f,
            CrowdAction.ReboundStrike => +12f,
            CrowdAction.AirKnockdown => +14f,
            CrowdAction.SparedFighter => +25f,
            CrowdAction.UnfairFightKill => -8f,
            CrowdAction.UnsatisfyingKill => -15f,
            _ => 0f
        };

        return new CrowdActionDefinition
        {
            Action = action,
            AuraDelta = defaultDelta,
            Title = meta.Title,
            ModifierText = meta.ModifierText,
            DisplayColor = meta.DisplayColor,
            ShowInFeed = meta.ShowInHudToast
        };
    }
}