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="";}
}