UniqueSkills.cs

The unique skill catalog for a soccer game. Defines skill metadata and localization, loadout normalization, reroll and grant logic, grade and power helpers, and some match simulation movement behavior for skills such as Cover Guard and Poacher.

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

namespace Socer;

public enum UniqueSkill
{
    // Existing values are save identifiers and must retain their numbers.
    None, CurveComet, PhantomCut, SteelPress, Counterstep, ReflexWall, AerialGuardian,
    Conditioning, Sniper, PowerDrive, VolleyMaster, AerialFinisher, Poacher,
    WallPass, SpaceHunter, FirstTouch, PressResistant, ThreadNeedle, LongDistributor,
    OutletRunner, CoverGuard, Interceptor, RecoveryBurst, ShoulderWall, TimingTackle,
    SweeperKeeper, SafeHands, KeeperDistributor
}

public sealed class SkillInstance
{
    public UniqueSkill Skill { get; set; }
    public int Grade { get; set; }=1;
}

public sealed record SkillUpgradeResult(int Slot,SkillInstance Previous,SkillInstance Granted);

public sealed record UniqueSkillInfo(UniqueSkill Skill,string EnglishName,string RussianName,
    string EnglishDescription,string RussianDescription,string EnglishTrigger,string RussianTrigger,
    string EnglishRole,string RussianRole,float StaminaCost,float CooldownSeconds,float VisualSeconds,
    bool IsPassive=false,string Icon="✦",string EnglishSynergy="",string RussianSynergy="")
{
    public string Name(bool russian)=>russian?RussianName:EnglishName;
    public string Description(bool russian)=>russian?RussianDescription:EnglishDescription;
    public string Trigger(bool russian)=>russian?RussianTrigger:EnglishTrigger;
    public string Role(bool russian)=>russian?RussianRole:EnglishRole;
    public string Synergy(bool russian)=>russian?RussianSynergy:EnglishSynergy;
    public IReadOnlyList<UniqueSkill> LinkedSkills=>UniqueSkills.LinkedSkills(Skill);
    public bool IsActive=>!IsPassive;
    public string IconPath=>UniqueSkills.ArtPath(Skill);
    public string Bonus(bool russian,int grade=1)=>UniqueSkills.BonusDescription(Skill,grade,russian);
    public float MinimumStamina=>StaminaCost+UniqueSkills.StaminaReserve;
}

public static class UniqueSkills
{
    public const float StaminaReserve=.28f;
    public static string ArtPath(UniqueSkill skill,int grade=1)=>Get(skill)!=null
        ?$"/skill-icons/metal-v1/{skill.ToString().ToLowerInvariant()}-{(grade>=2?"gold":"normal")}.png"
        :"/ui-icons/plus.svg";
    static readonly UniqueSkillInfo[] definitions=
    {
        new(UniqueSkill.CurveComet,"Curve Comet","Кручёная комета",
            "Adds stronger spin and tighter aim to a curl shot. The ball leaves a purple and yellow trail.",
            "Усиливает вращение и точность кручёного удара. За мячом остаётся фиолетово-жёлтый след.",
            "A shot from 10–28 metres with sufficient curve.",
            "Удар с 10–28 метров при достаточном умении закручивать мяч.",
            "Attack","Атака",.17f,260,1.6f),
        new(UniqueSkill.PhantomCut,"Phantom Cut","Призрачный финт",
            "Improves the chance of beating a defender with a skill move. A purple trail marks the exit.",
            "Повышает шанс обыграть защитника финтом. Выход из финта отмечен фиолетовым следом.",
            "A skill move with the ball against a nearby defender.",
            "Финт с мячом против защитника рядом.",
            "Attack","Атака",.14f,310,1.6f),
        new(UniqueSkill.SteelPress,"Steel Press","Стальной прессинг",
            "Improves the chance of winning a tackle. An amber trail marks the challenge.",
            "Повышает шанс выиграть отбор. Во время отбора появляется янтарный след.",
            "A tackle by a player with sufficient defending ability.",
            "Отбор игроком с достаточным навыком защиты.",
            "Defence","Защита",.13f,290,1.6f),
        new(UniqueSkill.Counterstep,"Counterstep","Контрфинт",
            "Helps a defender escape pressure with a skill move while carrying the ball. A purple trail marks the exit.",
            "Помогает защитнику уйти от прессинга финтом при ведении мяча. Выход отмечен фиолетовым следом.",
            "A skill move with the ball against a nearby opponent.",
            "Финт с мячом против соперника рядом.",
            "Defence","Защита",.14f,310,1.6f),
        new(UniqueSkill.ReflexWall,"Reflex Wall","Стена реакции",
            "Extends the goalkeeper's reach for a save. A turquoise effect marks the attempt.",
            "Увеличивает досягаемость вратаря при сейве. Попытка отмечена бирюзовым эффектом.",
            "A goalkeeper reacting to a shot.",
            "Реакция вратаря на удар по воротам.",
            "Goalkeeper","Вратарь",.17f,310,1.5f),
        new(UniqueSkill.AerialGuardian,"Aerial Guardian","Хозяин воздуха",
            "Lets the goalkeeper contest higher crosses under pressure. A turquoise effect marks the claim.",
            "Позволяет вратарю выходить за более высоким мячом в борьбе с соперником. Выход отмечен бирюзовым эффектом.",
            "A reachable airborne ball inside the penalty area.",
            "Достижимый верховой мяч в штрафной площади.",
            "Goalkeeper","Вратарь",.14f,320,1.5f),
        new(UniqueSkill.Conditioning,"Conditioning","Физическая подготовка",
            "Adds 2.5 points to stamina, strength and acceleration. Slows fatigue during repeated bursts.",
            "Добавляет по 2,5 очка выносливости, силе и ускорению. Снижает усталость при повторных рывках.",
            "Throughout the match, especially during sprints.","Весь матч, особенно во время рывков.",
            "Any role","Любая позиция",0,0,0,true,"↟"),
        new(UniqueSkill.Sniper,"Sniper","Снайпер",
            "Adds 2.5 points to finishing and composure. A controlled shot has a narrower aiming spread.",
            "Добавляет по 2,5 очка завершению и хладнокровию. Подготовленный удар имеет меньший разброс.",
            "A controlled shot with time to aim.","Подготовленный удар, когда есть время прицелиться.",
            "Attack","Атака",0,0,0,true,"◎"),
        new(UniqueSkill.PowerDrive,"Power Drive","Мощный удар",
            "Adds 2 points to shot power. A prepared power shot travels faster and leaves an amber trail.",
            "Добавляет 2 очка силе удара. Подготовленный силовой удар летит быстрее и оставляет янтарный след.",
            "A power shot after a clear preparation.","Сильный удар после подготовки.",
            "Attack","Атака",.15f,235,1.6f,false,"↗"),
        new(UniqueSkill.VolleyMaster,"Volley Master","Мастер ударов с лёта",
            "Adds 2.5 points to volleys. Improves timing and aim when striking an airborne delivery.",
            "Добавляет 2,5 очка ударам с лёта. Улучшает выбор момента и точность удара по верховому мячу.",
            "A volley from an airborne pass.","Удар с лёта после верховой передачи.",
            "Attack","Атака",0,0,0,true,"✧"),
        new(UniqueSkill.AerialFinisher,"Aerial Finisher","Воздушный бомбардир",
            "Adds 2.5 points to heading and jumping. Improves the timing and placement of attacking headers.",
            "Добавляет по 2,5 очка игре головой и прыжкам. Улучшает выбор момента и направление атакующего удара головой.",
            "An attacking aerial duel or header.","Атакующая верховая борьба или удар головой.",
            "Attack","Атака",.13f,260,1.5f,false,"↑",
            "Works with Long Distributor deliveries into the penalty area.","Взаимодействует с навесами Дальнего распасовщика в штрафную."),
        new(UniqueSkill.Poacher,"Poacher","Чутьё бомбардира",
            "Anticipates rebounds and makes earlier runs into a finishing lane.",
            "Раньше замечает отскоки и открывается в свободную зону для завершения.",
            "An attack near the penalty area or a loose ball after a shot.","Атака возле штрафной или отскок после удара.",
            "Attack","Атака",0,0,0,true,"⌖",
            "A Wall Pass teammate recognises this player's return run earlier.","Партнёр со скиллом Стеночка раньше замечает ответное открывание этого игрока."),
        new(UniqueSkill.WallPass,"Wall Pass","Стеночка",
            "Recognises a short give-and-go and releases a return pass earlier.",
            "Распознаёт короткую комбинацию пас — ответный пас и раньше возвращает мяч.",
            "A nearby teammate continues a run after a short pass.","Партнёр рядом продолжает забегание после короткой передачи.",
            "Outfield","Полевой игрок",0,0,0,true,"⇄",
            "Stronger combination with Space Hunter, Poacher or Outlet Runner.","Лучше работает с Поиском пространства, Чутьём бомбардира или Выходом из обороны."),
        new(UniqueSkill.SpaceHunter,"Space Hunter","Поиск пространства",
            "Reads open passing lanes earlier and varies supporting runs instead of following the ball.",
            "Раньше видит свободные линии передач и разнообразнее открывается, вместо движения прямо к мячу.",
            "Off the ball while the team controls possession.","Без мяча, когда команда владеет мячом.",
            "Outfield","Полевой игрок",0,0,0,true,"◇",
            "Creates targets for Thread Needle and Long Distributor.","Создаёт варианты передач для Разрезающей передачи и Дальнего распасовщика."),
        new(UniqueSkill.FirstTouch,"First Touch","Первое касание",
            "Adds 2 points to close ball control. Cushions a difficult delivery and reduces the receiving touch distance.",
            "Добавляет 2 очка контролю мяча. Смягчает сложную передачу и сокращает отскок при приёме.",
            "Receiving a pass or settling a loose ball.","Приём передачи или обработка свободного мяча.",
            "Outfield","Полевой игрок",0,0,0,true,"○"),
        new(UniqueSkill.PressResistant,"Press Resistant","Устойчивость к прессингу",
            "Keeps close touches under pressure and improves the chance of protecting possession in a duel.",
            "Сохраняет короткие касания под прессингом и лучше защищает владение в единоборстве.",
            "Carrying the ball with an opponent close enough to challenge.","Ведение мяча, когда соперник уже может вступить в борьбу.",
            "Outfield","Полевой игрок",0,0,0,true,"◈"),
        new(UniqueSkill.ThreadNeedle,"Thread Needle","Разрезающая передача",
            "Adds 2.5 points to short passing and vision. Sends a more accurate through pass with a turquoise trail.",
            "Добавляет по 2,5 очка короткому пасу и видению поля. Делает точнее разрезающую передачу с бирюзовым следом.",
            "A through ball to a teammate running into space.","Передача вразрез партнёру, открывающемуся в свободную зону.",
            "Outfield","Полевой игрок",.12f,210,1.4f,false,"➝",
            "Reads Space Hunter and Outlet Runner movement especially well.","Особенно хорошо читает движение Поиска пространства и Выхода из обороны."),
        new(UniqueSkill.LongDistributor,"Long Distributor","Дальний распасовщик",
            "Adds 2.5 points to long passing. Improves long delivery accuracy and leaves a turquoise trail.",
            "Добавляет 2,5 очка длинному пасу. Повышает точность дальней передачи и оставляет бирюзовый след.",
            "A long pass or switch of play.","Дальняя передача или перевод игры на другой фланг.",
            "Outfield","Полевой игрок",.11f,220,1.6f,false,"⇗",
            "Finds Space Hunter, Outlet Runner and Aerial Finisher teammates.","Лучше находит партнёров с Поиском пространства, Выходом из обороны и Воздушным бомбардиром."),
        new(UniqueSkill.OutletRunner,"Outlet Runner","Выход из обороны",
            "Opens a forward outlet earlier after winning possession and makes an explosive supporting run.",
            "Раньше открывается вперёд после возврата владения и ускоряет поддерживающий забег.",
            "A transition from defence to attack.","Переход из защиты в атаку.",
            "Outfield","Полевой игрок",0,0,0,true,"»",
            "Pairs with Wall Pass, Thread Needle and distributors.","Взаимодействует со Стеночкой, Разрезающей передачей и распасовщиками."),
        new(UniqueSkill.CoverGuard,"Cover Guard","Подстраховка",
            "Reads a teammate's pressing move and closes the dangerous passing lane behind them.",
            "Читает выход партнёра в прессинг и закрывает опасную линию передачи за его спиной.",
            "A teammate leaves the defensive shape to press.","Партнёр выходит из защитной линии в прессинг.",
            "Defence","Защита",0,0,0,true,"⬡",
            "Coordinates with Steel Press or Interceptor teammates.","Согласует действия с партнёрами со Стальным прессингом или Перехватчиком."),
        new(UniqueSkill.Interceptor,"Interceptor","Перехватчик",
            "Anticipates a nearby pass, moves into its lane and reaches the ball more decisively.",
            "Предугадывает передачу рядом, входит в её линию и решительнее дотягивается до мяча.",
            "An opponent's pass crossing the defender's reachable lane.","Передача соперника через доступную защитнику линию.",
            "Defence","Защита",.10f,215,1.4f,false,"⊣",
            "A Cover Guard teammate protects the space left behind.","Партнёр с Подстраховкой закрывает оставленную за спиной зону."),
        new(UniqueSkill.RecoveryBurst,"Recovery Burst","Возвратный рывок",
            "Makes a stronger recovery sprint after being passed, helping the defender regain a useful challenge position.",
            "Делает более сильный возвратный рывок после обыгрыша, чтобы вернуть позицию для отбора.",
            "Chasing an opponent who has escaped the defensive line.","Погоня за соперником, который прошёл защитную линию.",
            "Defence","Защита",.13f,205,1.5f,false,"↶"),
        new(UniqueSkill.ShoulderWall,"Shoulder Wall","Игра корпусом",
            "Protects the ball better in shoulder contact and makes legal pressure from the side more effective.",
            "Лучше защищает мяч плечом и эффективнее оказывает разрешённое давление сбоку.",
            "A shoulder-to-shoulder duel at running speed.","Единоборство плечом в плечо во время бега.",
            "Defence","Защита",.12f,215,1.5f,false,"▣"),
        new(UniqueSkill.TimingTackle,"Timing Tackle","Чистый отбор",
            "Times a standing tackle more precisely, improving success while reducing the chance of a foul.",
            "Точнее выбирает момент отбора стоя, повышая успех и снижая вероятность нарушения.",
            "A standing tackle when the ball is reachable.","Отбор стоя, когда можно дотянуться до мяча.",
            "Defence","Защита",.10f,225,1.5f,false,"✓"),
        new(UniqueSkill.SweeperKeeper,"Sweeper Keeper","Вратарь-либеро",
            "Reads a breakaway earlier and advances more decisively to close the attacker's shooting angle.",
            "Раньше читает выход один на один и решительнее сокращает угол удара выходом вперёд.",
            "A reachable one-on-one outside the goalkeeper's line.","Достижимый выход один на один перед вратарём.",
            "Goalkeeper","Вратарь",.12f,230,1.5f,false,"↥"),
        new(UniqueSkill.SafeHands,"Safe Hands","Надёжные руки",
            "Adds 2.5 points to handling. More often holds a manageable shot instead of parrying it.",
            "Добавляет 2,5 очка ловле мяча. Чаще удерживает доступный удар вместо отбивания.",
            "Saving a shot within a comfortable reach.","Сейв удара в пределах уверенной досягаемости.",
            "Goalkeeper","Вратарь",.11f,225,1.5f,false,"∪"),
        new(UniqueSkill.KeeperDistributor,"Keeper Distributor","Вратарь-распасовщик",
            "Finds a forward outlet after a save and delivers a more accurate throw or kick.",
            "После сейва находит выход вперёд и точнее вводит мяч рукой или ногой.",
            "Releasing the ball after a catch or a goalkeeper restart.","Ввод мяча после ловли или вратарского стандарта.",
            "Goalkeeper","Вратарь",.10f,210,1.5f,false,"⇢",
            "Connects with Outlet Runner or Space Hunter for a quick transition.","Связывается с Выходом из обороны или Поиском пространства для быстрой атаки.")
    };
    public static IReadOnlyList<UniqueSkillInfo> All=>definitions;
    public static UniqueSkillInfo Get(UniqueSkill skill)=>skill>=UniqueSkill.CurveComet && skill<=UniqueSkill.KeeperDistributor?
        definitions[(int)skill-1]:null;
    public static string Name(UniqueSkill skill,bool russian)=>Get(skill)?.Name(russian)??"";
    public static string Name(UniqueSkill skill)=>Name(skill,false);
    public static string Description(UniqueSkill skill,bool russian)=>Get(skill)?.Description(russian)??"";
    public static string Trigger(UniqueSkill skill,bool russian)=>Get(skill)?.Trigger(russian)??"";

    // Explicit partners for the combinations used by TeamCombinations,
    // BallDecision, KeeperDistribution and LoadoutMovement. UI never parses
    // translated descriptions to discover a skill, and both partners can show
    // the connection. This is presentation metadata, not another stat bonus.
    static IReadOnlyList<UniqueSkill> Partners(params UniqueSkill[] skills)=>Array.AsReadOnly(skills);
    static readonly IReadOnlyDictionary<UniqueSkill,IReadOnlyList<UniqueSkill>> linkedSkills=
        new Dictionary<UniqueSkill,IReadOnlyList<UniqueSkill>>
        {
            [UniqueSkill.AerialFinisher]=Partners(UniqueSkill.LongDistributor),
            [UniqueSkill.Poacher]=Partners(UniqueSkill.WallPass),
            [UniqueSkill.WallPass]=Partners(UniqueSkill.SpaceHunter,UniqueSkill.Poacher,UniqueSkill.OutletRunner),
            [UniqueSkill.SpaceHunter]=Partners(UniqueSkill.ThreadNeedle,UniqueSkill.LongDistributor,UniqueSkill.WallPass,UniqueSkill.KeeperDistributor),
            [UniqueSkill.ThreadNeedle]=Partners(UniqueSkill.SpaceHunter,UniqueSkill.OutletRunner),
            [UniqueSkill.LongDistributor]=Partners(UniqueSkill.SpaceHunter,UniqueSkill.OutletRunner,UniqueSkill.AerialFinisher),
            [UniqueSkill.OutletRunner]=Partners(UniqueSkill.WallPass,UniqueSkill.ThreadNeedle,UniqueSkill.LongDistributor,UniqueSkill.KeeperDistributor),
            [UniqueSkill.CoverGuard]=Partners(UniqueSkill.SteelPress,UniqueSkill.Interceptor),
            [UniqueSkill.SteelPress]=Partners(UniqueSkill.CoverGuard),
            [UniqueSkill.Interceptor]=Partners(UniqueSkill.CoverGuard),
            [UniqueSkill.KeeperDistributor]=Partners(UniqueSkill.OutletRunner,UniqueSkill.SpaceHunter)
        };
    public static IReadOnlyList<UniqueSkill> LinkedSkills(UniqueSkill skill)=>
        linkedSkills.TryGetValue(skill,out var partners)?partners:Array.Empty<UniqueSkill>();

    public static string GradeName(int grade,bool russian)=>grade>=2?russian?"Золотой":"Gold":russian?"Обычный":"Normal";
    public static string SkillName(SkillInstance instance,bool russian)=>instance==null?"":Name(instance.Skill,russian)+(instance.Grade>=2?" ★":"");
    public static string EffectDescription(SkillInstance instance,bool russian)
    {
        if(instance==null)return "";
        string description=Description(instance.Skill,russian);
        if(instance.Grade<2)return description;
        description=russian?description.Replace("2,5 очка","3,38 очка").Replace("2 очка","2,7 очка"):
            description.Replace("2.5 points","3.38 points").Replace("2 points","2.7 points");
        return description+(russian?" Золотая версия усиливает эффект на 35%.":" The gold version strengthens the effect by 35%.");
    }
    public static string BonusDescription(UniqueSkill skill,int grade,bool russian)
    {
        float power=grade>=2?1.35f:1;
        float points=skill is UniqueSkill.PowerDrive or UniqueSkill.FirstTouch?2f:2.5f;
        string attributes=skill switch
        {
            UniqueSkill.Conditioning=>russian?"выносливость, сила, ускорение":"stamina, strength, acceleration",
            UniqueSkill.Sniper=>russian?"завершение, хладнокровие":"finishing, composure",
            UniqueSkill.PowerDrive=>russian?"сила удара":"shot power",
            UniqueSkill.VolleyMaster=>russian?"удары с лёта":"volleys",
            UniqueSkill.AerialFinisher=>russian?"игра головой, прыжки":"heading, jumping",
            UniqueSkill.FirstTouch=>russian?"контроль мяча":"ball control",
            UniqueSkill.ThreadNeedle=>russian?"короткий пас, видение поля":"short passing, vision",
            UniqueSkill.LongDistributor=>russian?"длинный пас":"long passing",
            UniqueSkill.SafeHands=>russian?"ловля мяча":"handling",
            _=>""
        };
        return string.IsNullOrEmpty(attributes)?"":$"+{points*power:0.##} · {attributes}";
    }
    public static int UpgradeGoldChance(TrainingCardItem card)=>Math.Clamp(card?.Grade??1,1,3) switch{1=>8,2=>28,_=>65};
    public static int MaxSlots(PlayerCard card)=>card?.Rarity switch{"BRONZE"=>1,"SILVER"=>2,_=>3};
    public static List<SkillInstance> CloneLoadout(IEnumerable<SkillInstance> source)=>source?.Where(s=>s!=null)
        .Select(s=>new SkillInstance{Skill=s.Skill,Grade=s.Grade}).ToList()??new();
    public static int Grade(IReadOnlyList<SkillInstance> loadout,UniqueSkill skill)
    {
        if(loadout==null || skill==UniqueSkill.None)return 0;
        for(int i=0;i<loadout.Count;i++)if(loadout[i]?.Skill==skill)return Math.Clamp(loadout[i].Grade,1,2);
        return 0;
    }
    public static int Grade(PlayerCard card,UniqueSkill skill)=>Grade(card?.SkillLoadout,skill)>0?Grade(card.SkillLoadout,skill):
        card?.SkillLoadoutVersion<1 && card.UniqueSkill==skill && Get(skill)!=null?1:0;
    public static int Grade(PlayerProfile profile,UniqueSkill skill)=>Grade(profile?.SkillLoadout,skill)>0?Grade(profile.SkillLoadout,skill):
        (profile?.SkillLoadout?.Count??0)==0 && profile?.UniqueSkill==skill && Get(skill)!=null?1:0;
    public static bool Has(PlayerCard card,UniqueSkill skill)=>Grade(card,skill)>0;
    public static bool Has(PlayerProfile profile,UniqueSkill skill)=>Grade(profile,skill)>0;
    public static float Power(PlayerProfile profile,UniqueSkill skill)=>Grade(profile,skill) switch{2=>1.35f,1=>1f,_=>0};
    public static float Power(PlayerCard card,UniqueSkill skill)=>Grade(card,skill) switch{2=>1.35f,1=>1f,_=>0};

    // Match profiles are freshly rebuilt by ApplyCard. Bonuses are temporary
    // match attributes; owned card stats, detailed ratings and OVR stay intact.
    public static void ApplyProfileBonuses(Footballer p)
    {
        float Bonus(UniqueSkill skill,float points)=>Power(p.Profile,skill)*points/99f;
        float Add(float value,float bonus)=>bonus>0?Math.Clamp(value+bonus,0,1):value;
        float conditioning=Bonus(UniqueSkill.Conditioning,2.5f);
        p.Profile.Endurance=Add(p.Profile.Endurance,conditioning);
        p.Profile.Physical=Add(p.Profile.Physical,conditioning);
        p.Profile.Acceleration=Add(p.Profile.Acceleration,conditioning);
        float sniper=Bonus(UniqueSkill.Sniper,2.5f);
        p.Profile.Finishing=Add(p.Profile.Finishing,sniper);
        p.Profile.Composure=Add(p.Profile.Composure,sniper);
        p.Profile.ShotPower=Add(p.Profile.ShotPower,Bonus(UniqueSkill.PowerDrive,2));
        p.Profile.Volleys=Add(p.Profile.Volleys,Bonus(UniqueSkill.VolleyMaster,2.5f));
        float aerial=Bonus(UniqueSkill.AerialFinisher,2.5f);
        p.Profile.HeadingAccuracy=Add(p.Profile.HeadingAccuracy,aerial);
        p.Profile.Jumping=Add(p.Profile.Jumping,aerial);
        p.Skill=Add(p.Skill,Bonus(UniqueSkill.FirstTouch,2));
        float threading=Bonus(UniqueSkill.ThreadNeedle,2.5f);
        p.Profile.Passing=Add(p.Profile.Passing,threading);
        p.Profile.Vision=Add(p.Profile.Vision,threading);
        p.Profile.LongPassing=Add(p.Profile.LongPassing,Bonus(UniqueSkill.LongDistributor,2.5f));
        p.Profile.GkHandling=Add(p.Profile.GkHandling,Bonus(UniqueSkill.SafeHands,2.5f));
    }

    // All slots are contiguous. A previous saved ability is migrated once;
    // clearing a loadout later must never resurrect that compatibility field.
    public static void Normalize(PlayerCard card)
    {
        if(card==null)return;
        card.SkillLoadout??=new();
        if(card.SkillLoadoutVersion<1 && card.SkillLoadout.Count==0 && Get(card.UniqueSkill)!=null)
            card.SkillLoadout.Add(new SkillInstance{Skill=card.UniqueSkill,Grade=1});
        var seen=new HashSet<UniqueSkill>();
        card.SkillLoadout=card.SkillLoadout.Where(s=>s!=null && Get(s.Skill)!=null && seen.Add(s.Skill))
            .Take(MaxSlots(card)).Select(s=>new SkillInstance{Skill=s.Skill,Grade=Math.Clamp(s.Grade,1,2)}).ToList();
        card.SkillLoadoutVersion=1;
        card.SkillRerolls=Math.Max(0,card.SkillRerolls);
        card.UniqueSkill=card.SkillLoadout.FirstOrDefault()?.Skill??UniqueSkill.None;
    }

    static readonly UniqueSkill[] common={UniqueSkill.Conditioning};
    static readonly UniqueSkill[] outfield={UniqueSkill.WallPass,UniqueSkill.SpaceHunter,UniqueSkill.FirstTouch,
        UniqueSkill.PressResistant,UniqueSkill.ThreadNeedle,UniqueSkill.LongDistributor,UniqueSkill.OutletRunner};
    static readonly UniqueSkill[] attack={UniqueSkill.CurveComet,UniqueSkill.PhantomCut,UniqueSkill.Sniper,
        UniqueSkill.PowerDrive,UniqueSkill.VolleyMaster,UniqueSkill.AerialFinisher,UniqueSkill.Poacher};
    static readonly UniqueSkill[] defence={UniqueSkill.SteelPress,UniqueSkill.Counterstep,UniqueSkill.CoverGuard,
        UniqueSkill.Interceptor,UniqueSkill.RecoveryBurst,UniqueSkill.ShoulderWall,UniqueSkill.TimingTackle};
    static readonly UniqueSkill[] keeper={UniqueSkill.ReflexWall,UniqueSkill.AerialGuardian,UniqueSkill.SweeperKeeper,
        UniqueSkill.SafeHands,UniqueSkill.KeeperDistributor};
    public static IReadOnlyList<UniqueSkill> Pool(PlayerCard card)
    {
        string role=PlayerPositions.Card(card);
        if(role=="GK")return common.Concat(keeper).ToArray();
        if(role is "CB" or "LB" or "RB" or "LWB" or "RWB" or "CDM")return common.Concat(outfield).Concat(defence).ToArray();
        if(role is "CM" or "CAM" or "LM" or "RM")return common.Concat(outfield).Concat(attack).Concat(new[]{UniqueSkill.Interceptor,UniqueSkill.CoverGuard}).ToArray();
        return common.Concat(outfield).Concat(attack).ToArray();
    }

    public static bool CanReroll(PlayerCard card)=>
        card!=null && !card.IsAcademyLoan && Pool(card).Any(skill=>!Has(card,skill));

    public static SkillUpgradeResult Reroll(PlayerCard card,TrainingCardItem item)
    {
        if(item==null || !CanReroll(card))return null;
        Normalize(card);
        var excluded=card.SkillLoadout.Select(s=>s.Skill).ToHashSet();
        var available=Pool(card).Where(skill=>!excluded.Contains(skill)).ToArray();
        if(available.Length==0)return null;
        // Item identity and the persisted consumption counter determine both
        // draws. Loading again cannot change which occupied slot is replaced.
        string seed=$"skill-upgrade:{card.CatalogId}:{card.Id}:{card.SkillRerolls}:{item.Id}";
        // Mix the hash bits before modulo: FNV's low bit can stay constant
        // when both the inventory ID and reroll counter advance together.
        uint slotRoll=PlayerCatalog.Hash(seed+":slot");
        slotRoll^=slotRoll>>16;slotRoll*=0x85ebca6bu;
        slotRoll^=slotRoll>>13;slotRoll*=0xc2b2ae35u;slotRoll^=slotRoll>>16;
        int slot=card.SkillLoadout.Count<MaxSlots(card)?card.SkillLoadout.Count:
            (int)(slotRoll%(uint)card.SkillLoadout.Count);
        UniqueSkill skill=available[PlayerCatalog.Hash(seed+":type")%(uint)available.Length];
        int grade=PlayerCatalog.Hash(seed+":grade")%100<UpgradeGoldChance(item)?2:1;
        var previous=slot<card.SkillLoadout.Count?card.SkillLoadout[slot]:null;
        var granted=new SkillInstance{Skill=skill,Grade=grade};
        if(slot==card.SkillLoadout.Count)card.SkillLoadout.Add(granted);else card.SkillLoadout[slot]=granted;
        card.SkillRerolls++;
        card.UniqueSkill=card.SkillLoadout[0].Skill;
        return new SkillUpgradeResult(slot,previous==null?null:new SkillInstance{Skill=previous.Skill,Grade=previous.Grade},
            new SkillInstance{Skill=granted.Skill,Grade=granted.Grade});
    }

    public static UniqueSkill Grant(PlayerCard card,int trainingCardId)
    {
        var pool=Pool(card);
        uint roll=PlayerCatalog.Hash($"unique:{card.CatalogId}:{card.Id}:{trainingCardId}");
        return pool[(int)(roll%(uint)pool.Count)];
    }
}

public sealed partial class MatchSimulation
{
    public UniqueSkill BallUniqueSkill;
    public int BallSkillGrade;
    public int UniqueSkillActivations;

    float SkillStrength(Footballer p,UniqueSkill skill)=>UniqueSkills.Power(p.Profile,skill);
    bool SkillActive(Footballer p,UniqueSkill skill)=>p.ActiveUniqueSkill==skill && Seconds<p.UniqueSkillActiveUntil;

    int EquippedSkillSlot(Footballer p,UniqueSkill skill)
    {
        var equipped=p.Profile.SkillLoadout;
        if(equipped!=null && equipped.Count>0)
        {
            for(int slot=0;slot<equipped.Count && slot<p.SkillReadyAt.Length;slot++)
                if(equipped[slot]?.Skill==skill)return slot;
            return -1;
        }
        return p.Profile.UniqueSkill==skill?0:-1;
    }

    bool SkillReady(Footballer p,UniqueSkill skill)
    {
        var info=UniqueSkills.Get(skill);int slot=EquippedSkillSlot(p,skill);
        if(info==null || info.IsPassive || slot<0 || !p.InPlay || MedicalActive || Seconds<p.FallUntil || Seconds<p.SkillActivationGateUntil ||
            Seconds<p.SkillReadyAt[slot] || p.Stamina<info.MinimumStamina || RehearsingStrategy || PassiveTrainingOpponent(p))return false;
        // Legacy laboratory profiles use the former single-slot timestamp.
        return (p.Profile.SkillLoadout?.Count??0)>0 || Seconds>=p.UniqueSkillReadyAt;
    }

    // Compatibility overloads keep active callers valid through hotload. The
    // displayed cost and cooldown and the runtime rules share one definition.
    bool SkillReady(Footballer p,UniqueSkill skill,float cost)=>SkillReady(p,skill);
    bool ActivateSkill(Footballer p,UniqueSkill skill,float cost,float cooldown,float visualSeconds)=>
        ActivateSkill(p,skill,visualSeconds);

    bool ActivateSkill(Footballer p,UniqueSkill skill,float visualSeconds=0)
    {
        if(!SkillReady(p,skill))return false;
        var info=UniqueSkills.Get(skill);
        int slot=EquippedSkillSlot(p,skill);
        visualSeconds=Math.Max(info.VisualSeconds,visualSeconds);
        p.Stamina=Math.Max(.08f,p.Stamina-info.StaminaCost);
        p.UniqueSkillReadyAt=Seconds+info.CooldownSeconds;
        p.SkillReadyAt[slot]=Seconds+info.CooldownSeconds;
        p.SkillActivationGateUntil=Seconds+Math.Max(2.8f,visualSeconds);
        p.ActiveUniqueSkill=skill;p.ActiveSkillGrade=UniqueSkills.Grade(p.Profile,skill);
        p.ActiveSkillRemaining=visualSeconds;
        p.UniqueSkillActiveUntil=Seconds+visualSeconds;
        UniqueSkillActivations++;
        Emit("skill",p,(UniqueSkills.Name(skill)+(p.ActiveSkillGrade==2?" ★":"")).ToUpperInvariant(),visualSeconds);
        return true;
    }

    // Formation neighbours supply synergy while close enough for this episode.
    // Movement never creates a new skill link or a team-wide OVR bonus.
    float NearbyPartnerSkill(Footballer p,UniqueSkill skill,float distance)
    {
        if(p.Profile.RoleFamiliarity<.7f)return 0;
        float strongest=0;
        for(int i=0;i<Players.Count;i++)
        {
            var partner=Players[i];
            if(partner.Id==p.Id || partner.Team!=p.Team || !SkillNeighbors(p,partner) || partner.SentOff || Seconds<partner.FallUntil || partner.Profile.RoleFamiliarity<.7f ||
                (partner.Position-p.Position).Length>distance || !SeesPartner(p,partner))continue;
            strongest=Math.Max(strongest,SkillStrength(partner,skill)*partner.Profile.RoleFamiliarity);
        }
        return strongest;
    }

    Point LoadoutMovement(Footballer p,Point target,int possessionTeam)
    {
        if(p.Keeper || p.SentOff || Seconds<p.FallUntil || p.Profile.RoleFamiliarity<.7f || Owner==p.Id || DuelDefender==p.Id || WindupPlayer==p.Id ||
            p.DirectOrderUntil>Seconds || RestartRemaining>0 || GoalHoldRemaining>0)return target;
        if(Owner<0 && Seconds<p.InterceptUntil)return target;
        if(FollowLinkedCoverPass(p,target,out var coveredPass))return coveredPass;
        if(Owner>=0 && possessionTeam==p.Team)
        {
            var carrier=Players[Owner];float separation=(carrier.Position-p.Position).Length;
            float outlet=SkillStrength(p,UniqueSkill.OutletRunner);
            if(carrier.Keeper && outlet>0 && separation is >8 and <42 && World(p.Position,p.Team).X<65)
            {
                float partner=SkillNeighbors(p,carrier)?SkillStrength(carrier,UniqueSkill.KeeperDistributor):0;
                float side=p.Position.Y<34?-1:1;
                var receive=OnsideRunTarget(p,World(new Point(Math.Clamp(World(p.Position,p.Team).X+5+outlet*2+partner,22,65),34+side*17),p.Team));
                if(PerceivedSpace(p,receive)>2.1f && PerceivedLane(carrier,receive)>1)
                {p.RunTarget=receive;p.RunUntil=Seconds+.5f;p.Action="Открывается под ввод вратаря";return receive;}
            }
        }
        if(Owner>=0 && possessionTeam!=p.Team && !Players[Owner].Keeper && p.Id!=chasers[p.Team])
        {
            float cover=SkillStrength(p,UniqueSkill.CoverGuard);
            var carrier=Players[Owner];float separation=(carrier.Position-p.Position).Length;
            if(cover>0 && separation<22)
            {
                var linkedPresser=FindLinkedCoverPresser(p,carrier,out float linkedStrength);
                if(linkedPresser!=null)return LinkedCoverTarget(p,target,linkedPresser,carrier.Position,carrier.Id,possessionTeam,cover,linkedStrength);
            }
            int pressId=chasers[p.Team];
            if(cover>0 && separation<22 && pressId>=0 && Players[pressId].Team==p.Team && !Players[pressId].SentOff &&
                Seconds>=Players[pressId].FallUntil && Players[pressId].Profile.RoleFamiliarity>=.7f && (Players[pressId].Position-carrier.Position).Length<8)
            {
                var presser=Players[pressId];
                if(DangerousCoverMark(p,out _,out _))return target;
                var goalward=(World(new Point(0,34),p.Team)-carrier.Position).Unit;
                var coverPoint=carrier.Position+goalward*(4.8f-cover*.5f)+
                    new Point(0,carrier.Position.Y<34?2.2f:-2.2f);
                p.Action="Подстраховывает за мячом";
                return target+(Clamp(coverPoint)-target)*Math.Clamp(.34f*cover,0,.67f);
            }
        }
        if(Owner<0 && LastTouch!=p.Team && SkillStrength(p,UniqueSkill.CoverGuard)>0 && BallSpeed>3)
        {
            Footballer interceptor=null;float nearest=18;
            for(int i=0;i<Players.Count;i++)
            {
                var partner=Players[i];float separation=(partner.Position-p.Position).Length;
                if(partner.Team==p.Team && partner.Id!=p.Id && SkillNeighbors(p,partner) && !partner.SentOff && Seconds>=partner.FallUntil &&
                    partner.Profile.RoleFamiliarity>=.7f && SkillActive(partner,UniqueSkill.Interceptor) &&
                    separation<nearest && SeesPartner(p,partner)){interceptor=partner;nearest=separation;}
            }
            if(interceptor!=null)
            {
                return LinkedCoverTarget(p,target,interceptor,Ball,Passer>=0?Passer:interceptor.Id,LastTouch,
                    SkillStrength(p,UniqueSkill.CoverGuard),SkillStrength(interceptor,UniqueSkill.Interceptor),true);
            }
        }
        if(Owner<0 && (LastTouch==p.Team || secondChanceTeam==p.Team && Seconds<secondChanceUntil) && SkillStrength(p,UniqueSkill.Poacher)>0 &&
            World(Ball,p.Team).X>82 && Math.Abs(Ball.Y-34)<18 && (Ball-p.Position).Length<14 && BallHeight<1.4f)
        {
            int nearer=0;
            for(int i=0;i<Players.Count;i++)
                if(Players[i].Team==p.Team && !Players[i].SentOff && !Players[i].Keeper &&
                    (Players[i].Position-Ball).Length+.1f<(p.Position-Ball).Length)nearer++;
            if(nearer<2){p.Action="Читает отскок под добивание";return LooseBallMeeting(p);}
        }
        return target;
    }
}