TacticalCharacters.cs

Part of the football match simulation. Holds each team's tactical style, maps styles to approach and tactics settings, and modifies pass values and off-ball movement based on the chosen style. Also runs a per-second update that turns pressing surges on and off based on score, minute, cup stage and stamina.

using System;
using System.Linq;

namespace Socer;

public enum TacticalCharacter { None, Possession, Wide, Counter, HighPress, Compact }

public sealed partial class MatchSimulation
{
    public TacticalCharacter[] TacticalCharacters={TacticalCharacter.None,TacticalCharacter.None};
    readonly bool[] manualTacticalPlan=new bool[2];
    public int CupStage;
    public bool[] TacticalSurge=new bool[2];
    public int[] TacticalSurges=new int[2];
    readonly float[] counterAttackUntil=new float[2];
    public int[] CounterAttacks=new int[2];
    float nextTacticalPlan;
    public static string TacticalName(TacticalCharacter style,bool russian)=>style switch
    {
        TacticalCharacter.Possession=>russian?"Контроль и комбинации":"Possession & combinations",
        TacticalCharacter.Wide=>russian?"Фланги и навесы":"Wing play & crosses",
        TacticalCharacter.Counter=>russian?"Быстрые контратаки":"Fast counterattacks",
        TacticalCharacter.HighPress=>russian?"Высокий прессинг":"High press",
        TacticalCharacter.Compact=>russian?"Компактная оборона":"Compact defence",
        _=>russian?"Сбалансированная игра":"Balanced play"
    };
    public void SetTacticalIdentity(int team,TacticalCharacter style)
    {
        TacticalCharacters[team]=style;
        manualTacticalPlan[team]=false;
        Approach[team]=style switch{TacticalCharacter.Wide=>1,TacticalCharacter.Counter=>2,TacticalCharacter.HighPress=>3,_=>0};
        Tactics[team]=style is TacticalCharacter.Counter or TacticalCharacter.Compact?0:style==TacticalCharacter.HighPress?2:1;
        nextTacticalPlan=0;
    }
    public void SetClubPlan(int plan,int team=0)
    {
        plan=Math.Clamp(plan,0,2);
        SetTacticalIdentity(team,plan==0?TacticalCharacter.Counter:plan==2?TacticalCharacter.HighPress:TacticalCharacter.None);
        manualTacticalPlan[team]=true;Tactics[team]=plan;Approach[team]=plan==0?2:plan==2?3:0;
    }
    // Tactical preparation changes risks and positioning, never card attributes
    // or the result. Short pressing waves require enough stamina to execute.
    void UpdateTacticalCharacters()
    {
        if(Seconds<nextTacticalPlan)return;
        nextTacticalPlan=Seconds+1;
        for(int team=0;team<2;team++)
        {
            var style=TacticalCharacters[team];if(style==TacticalCharacter.None || manualTacticalPlan[team])continue;
            bool losing=Goals[team]<Goals[1-team],leading=Goals[team]>Goals[1-team];
            float energy=ActivePlayers.Where(p=>p.Team==team && !p.Keeper).Select(p=>p.Stamina).DefaultIfEmpty().Average();
            int period=CupStage>0?Math.Max(9,18-CupStage*3):20;
            int phase=(Minute+(Seed%5)+team*3)%period;
            bool surge=energy>.34f && !leading &&
                (style==TacticalCharacter.HighPress?phase<4: CupStage>0 && phase<2 || losing && Minute>=70 && phase<4);
            if(surge && !TacticalSurge[team])TacticalSurges[team]++;
            TacticalSurge[team]=surge;
            Tactics[team]=surge?2:style is TacticalCharacter.Counter or TacticalCharacter.Compact?0:1;
            Approach[team]=style switch{TacticalCharacter.Wide=>1,TacticalCharacter.Counter=>2,TacticalCharacter.HighPress when surge=>3,_=>0};
            if(losing && Minute>=75){Tactics[team]=2;if(style!=TacticalCharacter.Wide)Approach[team]=2;}
            if(leading && Minute>=80){Tactics[team]=0;if(style==TacticalCharacter.HighPress)Approach[team]=0;}
        }
    }
    static bool BackLine(Footballer p)=>p.Profile.SlotRole is "CB" or "LB" or "RB" ||
        p.Profile.SlotRole=="" && p.Number<=5;
    static bool AttackUnit(Footballer p)=>!p.Keeper && !BackLine(p);
    static bool StrikerRole(Footballer p)=>p.Profile.SlotRole=="ST" || p.Profile.SlotRole=="" && p.Number>=10;
    Point RoleMovement(Footballer p,Point target,int possessionTeam)
    {
        // Unfamiliar positions cost recognition time, not sprint speed. Keep
        // emergencies, receiving the actual ball and explicit field orders responsive.
        if(p.Keeper || p.Id==Owner || p.Profile.RoleFamiliarity>=.98f || Owner<0 ||
            p.DirectOrder!=DirectOrderKind.None || DuelDefender==p.Id || WindupPlayer==p.Id ||
            (Ball-p.Position).Length<2.3f)return target;
        if(p.RolePossession!=possessionTeam || Seconds>=p.RoleThinkingUntil)
        {
            p.RoleMovementTarget=target;p.RolePossession=possessionTeam;
            p.RoleThinkingUntil=Seconds+.08f+(1-p.Profile.RoleFamiliarity)*1.6f;
        }
        return p.RoleMovementTarget;
    }
    float TacticalPassValue(Footballer p,Footballer q,float distance,float gain,float safety)
    {
        float value=TacticalCharacters[p.Team] switch
        {
            TacticalCharacter.Possession=>distance<19 && safety>.55f?2.4f:distance>30?-3:0,
            TacticalCharacter.Wide=>Math.Abs(q.Position.Y-34)>18 && gain>0?3:0,
            TacticalCharacter.Counter=>gain>7 && safety>.5f?(counterAttackUntil[p.Team]>Seconds?5.5f:3.5f):gain< -3?-2:0,
            TacticalCharacter.Compact=>safety<.55f?-3:gain>5?1:0,
            _=>0
        };
        if(p.Team==0 && (FieldAttackUntil>Seconds || CoachOrder==2 && CoachUntil>Seconds) &&
            gain>2 && safety>.55f && (q.FootballRunUntil>Seconds || q.RunUntil>Seconds))value+=3;
        return value;
    }
    Point TacticalMovement(Footballer p,Point target,int possessionTeam)
    {
        if(p.Keeper || p.Id==Owner || Owner<0 || WindupPlayer==p.Id || DuelDefender==p.Id ||
            p.BoxRole>0 || p.RunUntil>Seconds || p.FootballRunUntil>Seconds)return target;
        var style=TacticalCharacters[p.Team];
        if(possessionTeam==p.Team)
        {
            if(style==TacticalCharacter.Wide && Math.Abs(p.Home.Y-34)>18)
                target.Y=World(new Point(0,p.Home.Y<34?7:61),p.Team).Y;
            if(style==TacticalCharacter.Compact && BackLine(p))
                target.X-=Direction(p.Team)*3;
        }
        else if(style is TacticalCharacter.Compact or TacticalCharacter.Counter && p.Id!=chasers[p.Team] && p.Id!=secondChasers[p.Team])
        {
            var local=World(target,p.Team);var ball=World(Ball,p.Team);
            if(StrikerRole(p))
            {
                // A low block still keeps a forward outlet. Pulling every player
                // behind the ball removed the counterattack from a counter team.
                local.X=Math.Clamp(ball.X+(style==TacticalCharacter.Counter?20:15),38,72);
                local.Y=Math.Clamp(local.Y,20,48);
                return World(local,p.Team);
            }
            local.X=Math.Min(local.X,Math.Max(12,ball.X-4));
            local.Y=34+(local.Y-34)*.78f;
            target=World(local,p.Team);
        }
        return target;
    }
}