LegendaryAbilities.cs

Static catalog of eight football 'legendary' signature abilities plus a partial MatchSimulation that gates, activates and drives them (carry, coordinated runs, pass, interception, keeper recovery). Also maps each ability to detail stat overrides.

using System;
using System.Collections.Generic;
using System.Linq;

namespace Socer;

// A red edition's signature is authored, permanent and independent of the
// three upgradeable skills. Deriving it from identity also migrates old saves.
public enum LegendaryAbility { None, LaserFinish, OrderSeven, OtherGravity, RoyalReception, ChessPass, EntryDenied, NotToday, SkySiu }

public sealed record LegendaryAbilityInfo(LegendaryAbility Kind,string CatalogId,string Key,
    string EnglishName,string RussianName,string EnglishNickname,string RussianNickname,
    string EnglishDescription,string RussianDescription,string EnglishTrigger,string RussianTrigger,
    string EnglishCounter,string RussianCounter,int OverallRating,float StaminaCost,float CooldownSeconds,float VisualSeconds)
{
    public string Name(bool russian)=>russian?RussianName:EnglishName;
    public string Nickname(bool russian)=>russian?RussianNickname:EnglishNickname;
    public string Description(bool russian)=>russian?RussianDescription:EnglishDescription;
    public string Trigger(bool russian)=>russian?RussianTrigger:EnglishTrigger;
    public string Counter(bool russian)=>russian?RussianCounter:EnglishCounter;
    public float MinimumStamina=>StaminaCost+.28f;
}

public static class LegendaryAbilities
{
    static readonly LegendaryAbilityInfo[] definitions={
        new(LegendaryAbility.LaserFinish,"PRO-12","laser-finish","Laser finish","Лазерное завершение","The Nordic superhero","Северный супергерой",
            "Plants his feet and aims a driven finish away from the keeper. Yellow eye beams mark the preparation and the ball trail.","Ставит опорную ногу и направляет удар низом в свободный от вратаря угол. Жёлтые лучи из глаз отмечают подготовку и полёт мяча.",
            "A balanced shot from 8–24 metres with an open lane.","Подготовленный удар с 8–24 метров при свободной линии.",
            "Close down the windup or cover the shooting lane; blocks and saves remain physical.","Сблизьтесь во время замаха или перекройте удар: блоки и сейвы остаются физическими.",99,.17f,125,2.1f),
        new(LegendaryAbility.OrderSeven,"PRO-24","order-seven","Order No. 7","Приказ №7","The commander","Командир",
            "After a pass, directs two teammates: one stretches the defence, the other attacks the opened corridor.","После передачи направляет двух партнёров: один растягивает защиту, второй атакует открывшийся коридор.",
            "A pass in the attacking half with two available, onside teammates.","Передача в атакующей половине и два доступных партнёра без офсайда.",
            "Follow the threatening runner and intercept the receiver; the order ends on a turnover.","Следите за опасным забеганием и перехватывайте передачу: при потере мяча приказ прекращается.",99,.16f,110,3.4f),
        new(LegendaryAbility.OtherGravity,"PRO-23","other-gravity","Other gravity","Другая гравитация","The Messian","Мессианин",
            "Performs a tight two-touch lateral escape with the ball close to his boots. Small orbital arcs follow the touches.","Выполняет тесную обводку двумя короткими касаниями, удерживая мяч возле бутс. За касаниями остаются орбитальные дуги.",
            "A defender ahead at 1.4–4.3 metres, a reachable ball and an open lateral exit.","Защитник впереди на расстоянии 1,4–4,3 метра, мяч у ног и свободный выход в сторону.",
            "Contain instead of lunging, or cover the lateral exit with a second defender.","Сдерживайте вместо выпада или закройте боковой выход вторым защитником.",99,.17f,105,1.9f),
        new(LegendaryAbility.RoyalReception,"PRO-54","royal-reception","Royal reception","Королевский приём","His Zlatan Majesty","Его Златанство",
            "Finishes a reachable high delivery with an acrobatic raised-foot strike. A golden slash follows the ball.","Завершает доступную высокую подачу акробатическим ударом поднятой ногой. За мячом остаётся золотой росчерк.",
            "An aerial delivery at 1.6–2.3 metres within 20 metres of goal.","Верховая подача на высоте 1,6–2,3 метра не далее 20 метров от ворот.",
            "Win the delivery first, contest the aerial contact or deny the cross.","Первым доберитесь до подачи, навяжите верховую борьбу или не допустите навеса.",98,.20f,140,1.8f),
        new(LegendaryAbility.ChessPass,"PRO-55","chess-pass","Check and pass","Шах и пас","Don Calm","Дон Спокойствие",
            "Prepares a runner and a decoy, then plays into the opened corridor. The pass still travels through ordinary interceptions.","Готовит забегание и отвлекающее открывание, затем отдаёт пас в освободившийся коридор. Передача проходит обычную проверку перехватов.",
            "Possession in midfield, an onside forward option and space beyond the first defensive line.","Владение в середине поля, партнёр впереди без офсайда и пространство за первой линией защиты.",
            "Press the passer or cover the target lane; the plan cancels if the receiver becomes unavailable.","Прессингуйте распасовщика или закройте целевой коридор; недоступный получатель отменяет план.",97,.15f,110,3.4f),
        new(LegendaryAbility.EntryDenied,"PRO-56","entry-denied","Entry denied","Вход закрыт","Face control","Фейс-контроль",
            "Reads a travelling pass early and steps into its reachable corridor before stretching a foot to intercept.","Раньше читает летящую передачу и выходит в доступный коридор, затем выставляет ногу для перехвата.",
            "An opposing low pass that can be reached in 0.2–0.85 seconds.","Низкая передача соперника, до которой можно добраться за 0,2–0,85 секунды.",
            "Switch the attack, loft over the lane or exploit the space he leaves behind.","Переведите атаку, перебросьте коридор или используйте оставленное им пространство.",98,.14f,100,1.7f),
        new(LegendaryAbility.NotToday,"PRO-57","not-today","Not today","Не сегодня","Retirement cancelled","Пенсия отменяется",
            "After parrying a shot, gets his knees and gloves back under him faster to face a rebound. The first save is not guaranteed.","После отражённого удара быстрее возвращает колени и перчатки в рабочую позицию для добивания. Первый сейв не гарантирован.",
            "A genuine parried shot with sufficient stamina.","Настоящий отбитый удар при достаточной стамине.",
            "Finish into the opposite corner or pass the rebound across goal.","Добивайте в противоположный угол или отдайте отскок поперёк ворот.",98,.15f,95,2.3f),
        new(LegendaryAbility.SkySiu,"PRO-53","sky-siu","Sky SIUU","Небесное СИУУ","The celebration machine","Машина празднований",
            "Rises for a powerful, placed header with a white-and-red flight trail. The delivery must still reach him.","Поднимается для сильного прицельного удара головой с бело-красным следом. Подача должна физически дойти до него.",
            "An attacking high delivery within 20 metres of goal.","Высокая атакующая подача не далее 20 метров от ворот.",
            "Deny the delivery, occupy his landing zone or contest the header.","Не допустите подачи, займите зону контакта или навяжите борьбу за верховой мяч.",98,.19f,135,1.8f)
    };
    static readonly IReadOnlyList<LegendaryAbilityInfo> view=Array.AsReadOnly(definitions);
    static readonly Dictionary<string,LegendaryAbilityInfo> byIdentity=definitions.ToDictionary(d=>d.CatalogId,StringComparer.Ordinal);
    static readonly LegendaryAbilityInfo[] byKind=BuildKindLookup();
    static LegendaryAbilityInfo[] BuildKindLookup()
    {
        var lookup=new LegendaryAbilityInfo[(int)LegendaryAbility.SkySiu+1];
        foreach(var definition in definitions)
        {
            int index=(int)definition.Kind;
            if(index<=0 || index>=lookup.Length || lookup[index]!=null)
                throw new InvalidOperationException("Duplicate or invalid legendary signature kind.");
            lookup[index]=definition;
        }
        for(int index=1;index<lookup.Length;index++)
            if(lookup[index]==null)throw new InvalidOperationException("Missing legendary signature definition.");
        return lookup;
    }
    public static IReadOnlyList<LegendaryAbilityInfo> All=>view;
    public static LegendaryAbilityInfo Get(LegendaryAbility kind)=>(uint)kind<(uint)byKind.Length?byKind[(int)kind]:null;
    public static LegendaryAbilityInfo Get(string catalogId,string edition)=>edition==CardRatings.Legendary && catalogId!=null && byIdentity.TryGetValue(catalogId,out var info)?info:null;
    public static LegendaryAbilityInfo Get(PlayerCard card)=>card==null?null:Get(card.CatalogId,card.Edition);
    public static LegendaryAbilityInfo Get(PlayerProfile profile)=>profile==null?null:Get(profile.CatalogId,profile.Edition);

    public static void ApplyDetailProfile(PlayerCard card,PlayerDetailStats stats)
    {
        switch(Get(card)?.Kind)
        {
            case LegendaryAbility.OtherGravity:stats.Agility=99;stats.Balance=99;stats.BallControl=99;stats.Dribbling=99;stats.Curve=98;break;
            case LegendaryAbility.RoyalReception:stats.Volleys=99;stats.HeadingAccuracy=98;stats.Jumping=98;stats.ShotPower=99;break;
            case LegendaryAbility.ChessPass:stats.Vision=99;stats.ShortPassing=99;stats.LongPassing=99;stats.Curve=98;stats.FreeKickAccuracy=97;break;
            case LegendaryAbility.EntryDenied:stats.Interceptions=99;stats.DefensiveAwareness=99;stats.StandingTackle=99;stats.SlidingTackle=98;break;
            case LegendaryAbility.NotToday:stats.GkDiving=98;stats.GkHandling=97;stats.GkReflexes=99;stats.GkPositioning=99;stats.GkKicking=88;break;
            case LegendaryAbility.SkySiu:stats.HeadingAccuracy=99;stats.Jumping=99;stats.Positioning=99;break;
        }
    }
}

public sealed partial class MatchSimulation
{
    public LegendaryAbility BallLegendaryAbility;
    public int LegendaryActivations;
    public readonly int[] LegendaryAbilityActivations=new int[9];
    public int LegendaryCoordinatedRuns,LegendaryAcrobaticShots,LegendaryKeeperRecoveries;

    internal bool LegendaryReady(Footballer p,LegendaryAbility kind)
    {
        var info=LegendaryAbilities.Get(p.Profile);
        return info?.Kind==kind && !p.SentOff && p.Profile.RoleFamiliarity>=.7f &&
            Seconds>=p.LegendaryReadyAt && Seconds>=p.SkillActivationGateUntil && Seconds>=p.FallUntil &&
            p.Stamina>=info.MinimumStamina && !RehearsingStrategy && !PassiveTrainingOpponent(p);
    }
    internal bool ActivateLegendary(Footballer p,LegendaryAbility kind)
    {
        if(!LegendaryReady(p,kind))return false;
        var info=LegendaryAbilities.Get(kind);
        p.Stamina=Math.Max(.08f,p.Stamina-info.StaminaCost);
        p.ActiveLegendaryAbility=kind;p.LegendaryActivatedAt=Seconds;
        p.LegendaryActiveUntil=Seconds+info.VisualSeconds;p.LegendaryReadyAt=Seconds+info.CooldownSeconds;
        p.SkillActivationGateUntil=Seconds+Math.Max(2.8f,info.VisualSeconds);
        p.LegendaryAbilityActivations++;LegendaryActivations++;LegendaryAbilityActivations[(int)kind]++;
        Emit("legendary",p,info.RussianName.ToUpperInvariant(),info.VisualSeconds);
        return true;
    }
    bool LegendaryActive(Footballer p,LegendaryAbility kind)=>p.ActiveLegendaryAbility==kind && Seconds<p.LegendaryActiveUntil && LegendaryAbilities.Get(p.Profile)?.Kind==kind;

    internal bool TryLegendaryCarry(Footballer p)
    {
        if(!LegendaryReady(p,LegendaryAbility.OtherGravity) || Owner!=p.Id || p.Keeper || p.Held<.35f || !BallAtFoot(p) ||
            WindupPlayer>=0 || p.ShotSetupUntil>0 || Seconds<p.FeintUntil || Seconds<p.FeintReadyAt || Seconds<p.ReceiveUntil || ClearFinishingChance(p))return false;
        var axis=p.Velocity.Length>1?p.Velocity.Unit:new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
        Footballer rival=null;float nearest=4.3f;
        foreach(var candidate in ActivePlayers)
        {
            if(candidate.Team==p.Team || candidate.Keeper || Seconds<candidate.FallUntil)continue;
            float distance=(candidate.Position-p.Position).Length;
            if(distance>1.4f && distance<nearest && Point.Dot((candidate.Position-p.Position).Unit,axis)>.1f)
            {nearest=distance;rival=candidate;}
        }
        if(rival==null)return false;
        var lateral=new Point(-axis.Y,axis.X);
        int side=Point.Dot(rival.Position-p.Position,lateral)>0?-1:1;
        var exit=(axis*.45f+lateral*side*.9f).Unit;
        if(CarryLane(p,p.Position+exit*2.5f)<1){side=-side;exit=(axis*.45f+lateral*side*.9f).Unit;}
        if(CarryLane(p,p.Position+exit*2.5f)<1 || !ActivateLegendary(p,LegendaryAbility.OtherGravity))return false;
        p.LegendaryTargetA=p.Position+exit*2.5f;p.LegendaryTargetPlayerA=rival.Id;
        BeginSkillMove(p,rival,axis,side,exit,SkillMoves.Get(SkillMoveKind.LaCroqueta));
        p.FeintReadyAt=Seconds+2.7f;
        return true;
    }

    void LegendaryRun(Footballer runner,Point target,FootballRun kind)
    {
        target=OnsideRunTarget(runner,Clamp(target));
        runner.RunTarget=target;runner.RunUntil=Seconds+3.4f;
        runner.FootballRunTarget=target;runner.FootballRun=kind;runner.FootballRunUntil=Seconds+3.4f;
        runner.LegendaryRunTarget=target;runner.LegendaryRunUntil=Seconds+3.4f;
        runner.Action=kind==FootballRun.Decoy?"Отвлекающее открывание":"Атакует открытый коридор";
        LegendaryCoordinatedRuns++;CoordinatedRuns[runner.Team]++;
    }

    internal int LegendaryPassChoice(Footballer p)
    {
        if(LegendaryActive(p,LegendaryAbility.ChessPass) && p.LegendaryTargetPlayerA>=0)
        {
            var chosen=Players[p.LegendaryTargetPlayerA];
            if(!chosen.SentOff && Seconds>=chosen.FallUntil && IsOnside(chosen,p.Position) && SeesPartner(p,chosen) &&
                PassSafety(p,chosen,PassMeetingPoint(p,chosen))>.35f)return chosen.Id;
            p.LegendaryActiveUntil=Seconds;return -1;
        }
        if(!LegendaryReady(p,LegendaryAbility.ChessPass) || Owner!=p.Id || p.Held<.32f || !BallAtFoot(p) ||
            World(p.Position,p.Team).X is <28 or >80 || CarrierPressure(p)<1.8f)return -1;
        Footballer receiver=null,decoy=null;Point receivePoint=new();float best=float.MinValue;
        int dir=Direction(p.Team);
        foreach(var q in ActivePlayers)
        {
            if(q.Team!=p.Team || q.Id==p.Id || q.Keeper || q.Profile.RoleFamiliarity<.7f || Seconds<q.FallUntil ||
                !SeesPartner(p,q) || !IsOnside(q,p.Position) || (q.Position-p.Position).Length is <8 or >32 ||
                (q.Position.X-p.Position.X)*dir is <3 or >24)continue;
            for(int side=-1;side<=1;side+=2)
            {
                var target=OnsideRunTarget(q,Clamp(q.Position+new Point(dir*6,side*4)));
                float lane=PerceivedLane(p,target),space=PerceivedSpace(p,target);
                if(lane<1.1f || space<2.4f || PassSafety(p,q,target)<.4f)continue;
                float value=space*.65f+lane*.8f+q.Profile.OffBall*3+(target.X-p.Position.X)*dir*.08f;
                if(value>best){best=value;receiver=q;receivePoint=target;}
            }
        }
        if(receiver==null)return -1;
        float closest=25;
        foreach(var q in ActivePlayers)
            if(q.Team==p.Team && q.Id!=p.Id && q.Id!=receiver.Id && !q.Keeper && q.Home.X>34 && Seconds>=q.FallUntil &&
                q.Profile.RoleFamiliarity>=.7f && IsOnside(q,p.Position) && SeesPartner(p,q) && (q.Position-receiver.Position).Length<closest)
            {closest=(q.Position-receiver.Position).Length;decoy=q;}
        if(decoy==null || !ActivateLegendary(p,LegendaryAbility.ChessPass))return -1;
        LegendaryRun(receiver,receivePoint,FootballRun.Behind);
        var decoyTarget=decoy.Position+new Point(dir*3,decoy.Position.Y<receivePoint.Y?-6:6);
        LegendaryRun(decoy,decoyTarget,FootballRun.Decoy);
        p.LegendaryTargetPlayerA=receiver.Id;p.LegendaryTargetPlayerB=decoy.Id;
        p.LegendaryTargetA=receiver.RunTarget;p.LegendaryTargetB=decoy.RunTarget;
        return receiver.Id;
    }

    internal void AfterLegendaryPass(Footballer p,Footballer receiver)
    {
        if(!LegendaryReady(p,LegendaryAbility.OrderSeven) || receiver.Keeper || World(p.Position,p.Team).X is <35 or >85 ||
            (receiver.Position-p.Position).Length is <5 or >35)return;
        Footballer first=null,second=null;float a=float.MaxValue,b=float.MaxValue;
        foreach(var q in ActivePlayers)
        {
            if(q.Team!=p.Team || q.Id==p.Id || q.Id==receiver.Id || q.Keeper || q.Home.X<35 || q.Profile.RoleFamiliarity<.7f ||
                Seconds<q.FallUntil || !IsOnside(q,p.Position) || !SeesPartner(p,q))continue;
            float score=(q.Position-receiver.Position).Length-q.Profile.OffBall*5;
            if(score<a){second=first;b=a;first=q;a=score;}else if(score<b){second=q;b=score;}
        }
        if(first==null || second==null || a>28 || b>28 || !ActivateLegendary(p,LegendaryAbility.OrderSeven))return;
        int dir=Direction(p.Team);float side=first.Position.Y<receiver.Position.Y?-1:1;
        LegendaryRun(first,first.Position+new Point(dir*7,side*4),FootballRun.Behind);
        LegendaryRun(second,second.Position+new Point(dir*4,-side*6),FootballRun.Decoy);
        p.LegendaryTargetPlayerA=first.Id;p.LegendaryTargetPlayerB=second.Id;
        p.LegendaryTargetA=first.RunTarget;p.LegendaryTargetB=second.RunTarget;
    }

    internal bool PrepareLegendaryInterception(Footballer p)
    {
        if(!LegendaryReady(p,LegendaryAbility.EntryDenied) || Owner>=0 || p.Team==LastTouch || BallSpeed<6 || shot || BallHeight>1.2f)return false;
        float time=Point.Dot(p.Position-Ball,BallVelocity)/Math.Max(1,Point.Dot(BallVelocity,BallVelocity));
        if(time is <.2f or >.85f)return false;
        var meeting=Ball+BallVelocity*time;
        float height=BallHeight+VerticalSpeed*time-4.905f*time*time;
        float distance=(meeting-p.Position).Length;
        if(height>1.25f || distance<.9f || distance>.42f+EffectivePace(p)*time*.75f || !ActivateLegendary(p,LegendaryAbility.EntryDenied))return false;
        p.LegendaryTargetA=Clamp(meeting);p.LegendaryTargetPlayerA=Passer;
        p.LegendaryRunTarget=p.LegendaryTargetA;p.LegendaryRunUntil=Seconds+time+.35f;
        p.Action="Заранее перекрывает коридор передачи";
        return true;
    }

    Point LegendaryMovement(Footballer p,Point target,int possessionTeam)
    {
        if(Seconds>=p.LegendaryRunUntil || p.Id==Owner || WindupPlayer==p.Id || p.Keeper || p.SentOff || Seconds<p.FallUntil ||
            p.DirectOrderUntil>Seconds || RestartRemaining>0 || Seconds<p.InterceptUntil)return target;
        if(LegendaryActive(p,LegendaryAbility.EntryDenied))
        {
            if(Owner>=0 || LastTouch==p.Team){p.LegendaryRunUntil=0;return target;}
            p.Action="Читает передачу · выходит в коридор";return p.LegendaryRunTarget;
        }
        if(possessionTeam!=p.Team){p.LegendaryRunUntil=0;return target;}
        p.Action=p.FootballRun==FootballRun.Decoy?"Отвлекающее открывание":"Забегает по команде легенды";
        return OnsideRunTarget(p,p.LegendaryRunTarget);
    }

    internal void OnLegendaryKeeperParry(Footballer p)
    {
        if(!ActivateLegendary(p,LegendaryAbility.NotToday))return;
        // Compress recovery, never teleport him upright or guarantee another save.
        p.DiveRemaining=Math.Min(p.DiveRemaining,.36f);
        p.Recovery=Math.Min(p.Recovery,p.DiveRemaining+.12f);
        p.KeeperBlockUntil=Math.Min(p.KeeperBlockUntil,Seconds+.24f);
        LegendaryKeeperRecoveries++;
    }
}