CareerMatchEngine.cs

Career match simulation helper. Builds a MatchSimulation per fixture from club, squad and tactic state, runs all fixtures of a round on an injected background runner, and exposes per-club match statistics.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

namespace Socer;

public sealed class ClubMatchStatistics
{
    public int Seed {get;set;}
    public int[] Shots {get;set;}
    public int[] OnTarget {get;set;}
    public int[] Passes {get;set;}
    public int[] Completed {get;set;}
    public int[] Losses {get;set;}
    public int[] Tackles {get;set;}
    public int[] Fouls {get;set;}
    public int[] Yellow {get;set;}
    public int[] Red {get;set;}
    public int[] Corners {get;set;}
    public int[] Combinations {get;set;}
    public int[] WallPasses {get;set;}
    public int[] Saves {get;set;}
    public int[] ShotsWide {get;set;}
    public int[] ShotsOver {get;set;}
    public int[] Offsides {get;set;}
    public int[] ThirdManPasses {get;set;}
    public int[] DribbleAttempts {get;set;}
    public int[] DribblesWon {get;set;}
    public int[] CornerDeliveries {get;set;}
    public float[] Possession {get;set;}
    public static ClubMatchStatistics From(MatchSimulation m,int home=0)
    {
        int away=1-home;
        int[] Pair(int[] value)=>new[]{value[home],value[away]};
        return new(){Seed=m.Seed,Shots=Pair(m.Shots),OnTarget=new[]{m.Goals[home]+m.Saves[away],m.Goals[away]+m.Saves[home]},
            Passes=Pair(m.Passes),Completed=Pair(m.Completed),Losses=Pair(m.PossessionLosses),Tackles=Pair(m.Tackles),
            Fouls=Pair(m.Fouls),Yellow=Pair(m.YellowCards),Red=Pair(m.RedCards),Corners=Pair(m.Corners),
            Combinations=Pair(m.CombinationsStarted),WallPasses=Pair(m.WallPassesCompleted),
            Saves=Pair(m.Saves),ShotsWide=Pair(m.ShotsWide),ShotsOver=Pair(m.ShotsOver),Offsides=Pair(m.Offsides),
            ThirdManPasses=Pair(m.ThirdManPassesCompleted),DribbleAttempts=Pair(m.DribbleAttemptsByTeam),
            DribblesWon=Pair(m.DribblesWonByTeam),CornerDeliveries=Pair(m.CornerDeliveries),
            Possession=new[]{m.Possession[home],m.Possession[away]}};
    }
}

public sealed partial class ClubCareer
{
    // Only pure football state goes to the worker. No scene, catalogue lookup,
    // career mutation or file access is allowed there. One worker per round.
    [JsonIgnore] public Func<Action,Task> RunBackground { get; set; }
    readonly Dictionary<ClubFixture,MatchSimulation> roundMatches=new();
    Task roundTask;
    bool roundPrepared;
    string roundPreparationError="";
    [JsonIgnore] public bool RoundResultsReady=>string.IsNullOrEmpty(roundPreparationError) && (roundTask==null || roundTask.IsCompletedSuccessfully);
    [JsonIgnore] public string RoundResultsError
    {
        get
        {
            if(!string.IsNullOrEmpty(roundPreparationError))return roundPreparationError;
            if(roundTask?.IsCanceled==true)return "The background match calculation was canceled.";
            if(roundTask?.IsFaulted!=true)return "";
            string error=roundTask.Exception?.GetBaseException().Message;
            return string.IsNullOrWhiteSpace(error)?"The background match calculation failed.":error;
        }
    }
    [JsonIgnore] public bool CanRetryRoundResults=>Pending!=null && roundPrepared &&
        (!string.IsNullOrEmpty(roundPreparationError) || roundTask is {IsCompleted:true,IsCompletedSuccessfully:false});

    public int FixtureSeed(ClubFixture f,CupTournament tournament=null)=>
        (int)(PlayerCatalog.Hash($"fixture-{ClubSeed}-{Season}-{tournament?.Id??"league"}-{f.Round}-{ClubId(f.Home,tournament)}-{ClubId(f.Away,tournament)}")%2147483646)+1;

    public TacticalCharacter TacticalIdentity(int slot,CupTournament tournament=null)=>slot==0?TacticalCharacter.None:
        (TacticalCharacter)(1+PlayerCatalog.Hash($"tactics-{ClubId(slot,tournament)}")%5);

    public MatchSimulation CreateFixtureSimulation(ClubFixture fixture,CupTournament tournament=null)
    {
        var m=new MatchSimulation(FixtureSeed(fixture,tournament));
        m.HomeName=TeamName(fixture.Home,tournament);m.AwayName=TeamName(fixture.Away,tournament);
        m.CupStage=tournament==null?0:fixture.Round;
        m.ShootoutRequired=tournament!=null;
        foreach(int team in new[]{0,1})
        {
            int club=team==0?fixture.Home:fixture.Away;
            ConfigureMatchSide(m,team,club,tournament);
        }
        return m;
    }
    void ConfigureMatchSide(MatchSimulation m,int team,int club,CupTournament tournament=null)
    {
        var shape=club==0?Shape:Formation.All[SquadFor(club,tournament).FormationIndex];
        m.SetSkillFormation(team,shape);
        var cards=club==0?Enumerable.Range(0,11).Select(Card).ToArray():OpponentCards(club,tournament);
        m.TeamCohesion[team]=club==0?SquadCohesion:
            Math.Clamp(.42f+(float)(cards.Average(c=>c.Overall)-50)*.01f+Math.Min(Season-1,5)*.018f,.45f,.9f);
        for(int slot=0;slot<11;slot++)
        {
            var p=m.Players[team*11+slot];p.Home=shape.Homes[slot];
            p.Position=m.World(new Point(Math.Min(p.Home.X,48),p.Home.Y),team);p.Velocity=new();
            p.Instruction=club==0?Instructions[slot]:0;
            ApplyCard(p,cards[slot],slot,shape);
        }
        var startingIds=cards.Take(11).Select(c=>c.CatalogId).ToHashSet();
        var reserveCards=club==0?Cards.Where(c=>!startingIds.Contains(c.CatalogId)):cards.Skip(11);
        var reserves=new List<Footballer>();
        foreach(var card in reserveCards)
        {
            int bestSlot=card.Position=="ВР"?0:Enumerable.Range(1,10).OrderByDescending(s=>Formation.Fit(card,shape,s)).First();
            var reserve=new Footballer{Id=team*11+bestSlot,Team=team,Number=bestSlot+1,Home=shape.Homes[bestSlot]};
            ApplyCard(reserve,card,bestSlot,shape);reserves.Add(reserve);
        }
        m.SetInjuryReserves(team,reserves);
        m.SetTacticalIdentity(team,TacticalIdentity(club,tournament));
        if(club==0)m.SetBroadcastPlan(CoachTempo,CoachMentality<0?CoachTactic*.5f:CoachMentality,team);
    }

    public void PrepareRoundResults()
    {
        if(Pending==null || roundPrepared)return;
        roundPrepared=true;
        try
        {
            var current=ActiveFixture;var tournament=Pending.Cup?MatchCup:null;
            var games=Pending.Cup?tournament.Games:League;
            foreach(var f in games.Where(f=>f.Round==current.Round && !f.Played && f.Home!=0 && f.Away!=0))
                roundMatches.Add(f,CreateFixtureSimulation(f,tournament));
            if(RunBackground==null || roundMatches.Count==0)return;
            var snapshots=roundMatches.Values.ToArray();
            roundTask=RunBackground(()=>{foreach(var m in snapshots)RunFixture(m);});
            if(roundTask==null)roundPreparationError="The background match calculation did not start.";
        }
        catch(Exception error)
        {
            // A dispatcher may throw before returning a Task. Treat that the
            // same way as a failed worker; never settle unfinished snapshots.
            roundPreparationError=string.IsNullOrWhiteSpace(error.Message)?"The background match calculation failed.":error.Message;
        }
    }
    public bool RetryRoundResults()
    {
        // A running worker still owns its snapshots. Only a terminal failure
        // can be replaced, and only the other clubs' uncommitted matches reset.
        if(!CanRetryRoundResults)return false;
        ClearRoundResults();
        PrepareRoundResults();
        return true;
    }
    static void RunFixture(MatchSimulation m)
    {
        int steps=0;
        while(!m.Finished)
        {
            // A legal sudden-death series has no fixed number of attempts.
            // Keep the normal-match watchdog, with room for unusually long PK.
            if(++steps>(m.ShootoutActive?200000:50000))throw new InvalidOperationException($"Fixture {m.Seed} failed to finish");
            m.Step(1f/60f);
        }
    }
    MatchSimulation PreparedFixture(ClubFixture f,CupTournament tournament)
    {
        if(!roundMatches.TryGetValue(f,out var m))m=CreateFixtureSimulation(f,tournament);
        if(!m.Finished)RunFixture(m); // Headless verification has no engine worker.
        return m;
    }
    void ClearRoundResults(){roundMatches.Clear();roundTask=null;roundPrepared=false;roundPreparationError="";}
}