StrategyLab.cs

Partial MatchController class implementing the strategy lab, a playbook editor for drawing phases, routes and passes with save/load to JSON in the data folder. It runs trial simulations, seeks to phases, and computes batch statistics over 20 seeded runs.

Rough CodeFile Access
using Sandbox;
using Socer;
using System;
using System.Collections.Generic;
using System.Linq;

public sealed class StrategyAutoPreference
{
    public bool Enabled {get;set;}=true;
}

public sealed partial class MatchController
{
    public static bool StrategyEditorAvailable=>false;
    public bool StrategyLabOpen {get;private set;}
    public bool StrategyBrowserOpen {get;private set;}
    public bool StrategyDraftAvailable {get;private set;}
    bool strategyBrowserPreservesBoard;
    public bool StrategyTesting {get;private set;}
    public bool StrategyDummyMode {get;private set;}=true;
    public bool StrategyCameraManual {get;private set;}
    public void PanStrategyCamera(Point delta){StrategyCameraManual=true;Broadcast.Center=new(Math.Clamp(Broadcast.Center.X+delta.X,-5,110),Math.Clamp(Broadcast.Center.Y+delta.Y,-8,76));}
    public void ZoomStrategyCamera(float amount){StrategyCameraManual=true;Broadcast.ViewWidth=Math.Clamp(Broadcast.ViewWidth*MathF.Exp(-amount*.12f),32,150);}
    public void FocusStrategyCamera(){StrategyCameraManual=false;Broadcast.Center=Match.Ball;Broadcast.ViewWidth=78;}
    StrategyPlan drawingOriginal;
    Point[] drawingPositions;
    Point drawingBall;
    int drawingOwner,drawingUndoCount;
    public void BeginStrategyDrawing(){if(drawingOriginal!=null)return;drawingOriginal=StrategyDraft.Copy();drawingPositions=Match.Players.Select(p=>p.Position).ToArray();drawingBall=Match.Ball;drawingOwner=Match.Owner;drawingUndoCount=strategyUndo.Count;}
    public void CommitStrategyDrawing(){drawingOriginal=null;drawingPositions=null;StrategyTool=0;}
    public void CancelStrategyDrawing(){if(drawingOriginal!=null){StrategyDraft=drawingOriginal;for(int i=0;i<Match.Players.Count;i++)Match.Players[i].Position=drawingPositions[i];Match.Ball=drawingBall;Match.Owner=drawingOwner;if(strategyUndo.Count>drawingUndoCount)strategyUndo.RemoveRange(drawingUndoCount,strategyUndo.Count-drawingUndoCount);}CommitStrategyDrawing();StrategyNotice="Drawing cancelled. Previous actions restored.";}
    public bool Strategy3D {get;set;}=true;
    public bool StrategySeeking {get;private set;}
    bool StrategySeekCanFinish=>Match.StrategyBoundaryReached || (Match.Strategy?.Done==true && (!Match.Strategy.Status.StartsWith("Выход на удар") || (Match.WindupPlayer<0 && !Match.ShotInFlight && Match.Seconds-Match.Strategy.EndedAt>2.5f)));
    public void ToggleStrategyPlayback(){if(StrategySeeking || StrategyTesting)Paused=!Paused;}
    public int StrategySeekTarget {get;private set;}
    public bool StrategySeekFailed {get;private set;}
    public StrategyPlan StrategyDraft {get;private set;}=StrategyPlan.Example();
    public List<StrategyPlan> StrategyLibrary {get;private set;}=new();
    public int StrategyPhaseIndex {get;set;}
    public int StrategyRole {get;set;}
    public int StrategyTool {get;set;} // select, place, route, pass, alternative
    public int MatchStrategyIndex {get;set;}
    public bool AutoStrategies {get;private set;}=true;
    MatchSimulation strategyBoundMatch;
    List<StrategyPlan> strategyBoundLibrary;
    void SyncAutomaticStrategies()
    {
        if(IsRulesPreview || StrategyLabOpen){Match.AutomaticStrategiesEnabled=false;return;}
        if(strategyBoundMatch!=Match || strategyBoundLibrary!=StrategyLibrary)
        {Match.SetAutomaticStrategies(StrategyLibrary);strategyBoundMatch=Match;strategyBoundLibrary=StrategyLibrary;}
        Match.AutomaticStrategiesEnabled=AutoStrategies;
    }
    public void ToggleAutomaticStrategies()
    {
        AutoStrategies=!AutoStrategies;SyncAutomaticStrategies();
        try{FileSystem.Data.WriteJson("strategy-auto-v1.json",new StrategyAutoPreference{Enabled=AutoStrategies});}
        catch(Exception e){Log.Warning(e.Message);ClubNotice="Настройка действует до выхода: не удалось её сохранить.";}
    }
    public StrategyPlan SelectedMatchStrategy=>StrategyLibrary.Count==0?null:StrategyLibrary[Math.Clamp(MatchStrategyIndex,0,StrategyLibrary.Count-1)];
    public int StrategyDefence {get;set;}=1;
    public void ToggleStrategyDummyMode()
    {
        StrategyDummyMode=!StrategyDummyMode;
        if(StrategyTesting)RunStrategyTrial(true);else SelectStrategyPhase(0);
        StrategyNotice=StrategyDummyMode?"Walking dummies: defenders move slowly and do not challenge.":"Active defence: opponents play normally.";
    }
    public int StrategySeed {get;private set;}
    public string StrategyNotice {get;private set;}="Select a player, then choose an action on the right.";
    public string StrategyReport {get;private set;}="";
    bool strategySaveBlocked;
    readonly List<StrategyPlan> strategyUndo=new();
    public StrategyPhase DraftPhase=>StrategyDraft.Phases[Math.Clamp(StrategyPhaseIndex,0,StrategyDraft.Phases.Count-1)];
    public void LoadStrategies()
    {
        try
        {
            var saved=FileSystem.Data.ReadJson<List<StrategyPlan>>("strategy-book-v1.json");
            if(saved!=null){if(saved.Any(p=>p==null || p.Validate()!=""))throw new Exception("Invalid strategy library");StrategyLibrary=saved;}
        }
        catch(Exception e){strategySaveBlocked=true;StrategyNotice="Could not read the playbook. Saving is disabled to protect it.";Log.Warning(e.Message);}
        try{AutoStrategies=FileSystem.Data.ReadJson<StrategyAutoPreference>("strategy-auto-v1.json")?.Enabled??true;}
        catch(Exception e){Log.Warning(e.Message);}
        SyncAutomaticStrategies();
    }
    public void OpenStrategyLab()
    {
        if(!StrategyEditorAvailable || MediaLeagueActive || StrategyLabOpen || QuickMatchRunning)return;
        ShowRulesPreview("football-ai");StrategyLabOpen=true;StrategyBrowserOpen=true;strategyBrowserPreservesBoard=false;StrategyPhaseIndex=0;
    }
    public void CloseStrategyLab()
    {StrategyLabOpen=false;StrategyBrowserOpen=false;strategyBrowserPreservesBoard=false;StrategyTesting=false;StrategySeeking=false;EndRulesPreview();}
    public void ShowStrategyBrowser()
    {CommitStrategyDrawing();EditStrategy();StrategyDraftAvailable=true;strategyBrowserPreservesBoard=true;StrategyBrowserOpen=true;}
    public void ResumeStrategyDraft()
    {if(!StrategyDraftAvailable)return;StrategyBrowserOpen=false;if(!strategyBrowserPreservesBoard)ResetStrategyBoard();strategyBrowserPreservesBoard=false;}
    public void EditStrategy()
    {Paused=true;StrategyTesting=false;Strategy3D=true;StrategySeeking=false;}
    public void RememberStrategy()
    {if(StrategyTesting)EditStrategy();strategyUndo.Add(StrategyDraft.Copy());if(strategyUndo.Count>40)strategyUndo.RemoveAt(0);}
    public void UndoStrategy()
    {if(strategyUndo.Count==0)return;EditStrategy();StrategyDraft=strategyUndo[^1];strategyUndo.RemoveAt(strategyUndo.Count-1);StrategyPhaseIndex=Math.Min(StrategyPhaseIndex,StrategyDraft.Phases.Count-1);SelectStrategyPhase(StrategyPhaseIndex,true);}
    public void NewStrategy()
    {RememberStrategy();StrategyDraft=new(){Phases=new(){new(){Action=2}}};StrategyDraftAvailable=true;StrategyBrowserOpen=false;StrategyPhaseIndex=0;ResetStrategyBoard();StrategyNotice="Select a player, then choose an action on the right.";}
    public void LoadStrategy(StrategyPlan p)
    {RememberStrategy();StrategyDraft=p.Copy();StrategyDraftAvailable=true;StrategyBrowserOpen=false;StrategyPhaseIndex=0;ResetStrategyBoard();StrategyNotice="Play loaded. Select a player to edit their actions.";}
    public void CallSavedStrategy()
    {if(SelectedMatchStrategy!=null && !Match.StartStrategy(SelectedMatchStrategy))ClubNotice="Для схемы нужны владение мячом, пять доступных полевых игроков и отсутствие остановки.";}
    public void SaveStrategy()
    {
        string error=StrategyDraft.Validate();if(error!=""){StrategyNotice=error;return;}
        if(strategySaveBlocked){StrategyNotice="Saving is disabled because the playbook could not be read.";return;}
        var book=StrategyLibrary.Select(p=>p.Copy()).ToList();
        int index=book.FindIndex(p=>p.Name==StrategyDraft.Name);
        if(index<0)book.Add(StrategyDraft.Copy());else book[index]=StrategyDraft.Copy();
        try{FileSystem.Data.WriteJson("strategy-book-v1.json",book);StrategyLibrary=book;StrategyNotice="Saved. This play is available in the match playbook.";}
        catch(Exception e){StrategyNotice="Could not save the playbook. The play remains in the editor.";Log.Warning(e.Message);}
    }
    public void ResetStrategyBoard()
    {
        CommitStrategyDrawing();StrategyCameraManual=false;
        if(StrategySeed==0)StrategySeed=MatchSimulation.CreateSeed();
        Match=MatchSimulation.StrategyTrial(StrategyDraft,StrategySeed,StrategyDefence,walkthrough:true,dummies:StrategyDummyMode);
        Director=new(){Automatic=false,ManualSpeed=1};Broadcast=new(){Center=new(72,34),ViewWidth=88};
        Replay.Reset();accumulator=0;observedGoals=0;replayAfterGoal=0;
        EditStrategy();StrategyPhaseIndex=0;StrategySeekFailed=false;StrategyTool=0;
        StrategyReport="";
    }
    public void SelectStrategyPhase(int index,bool rebuild=false)
    {
        CommitStrategyDrawing();
        if(StrategySeeking && index!=0){Paused=false;StrategyNotice="Rehearsal resumed. Use To start to stop it.";return;}
        index=Math.Clamp(index,0,StrategyDraft.Phases.Count-1);
        if(index==0){ResetStrategyBoard();return;}
        if(StrategyDraft.Validate()!=""){StrategyNotice=StrategyDraft.Validate();return;}
        // Forward construction continues the actual stopped state, including velocity and ball flight.
        // Going backwards replays the same trial from its start; later edits cannot contaminate it.
        bool resume=!rebuild && !StrategyTesting && !StrategySeekFailed && Match.Strategy!=null && !Match.Strategy.Done && Match.Strategy.Phase==StrategyPhaseIndex && index>StrategyPhaseIndex;
        if(!resume)ResetStrategyBoard();
        Match.Strategy.Plan=StrategyDraft.Copy();Match.StrategyPauseAtPhase=index;
        StrategyReport="";StrategySeeking=true;StrategySeekTarget=index;StrategyTesting=false;StrategySeekFailed=false;Paused=false;StrategyTool=0;
        StrategyNotice=$"Rehearsing phase {index+1} without interceptions. Use Trial to test opposition.";
    }
    void FinishStrategySeek()
    {
        Paused=true;StrategySeeking=false;
        var s=Match.Strategy;
        StrategySeekFailed=!Match.StrategyBoundaryReached;
        StrategyPhaseIndex=Math.Clamp(s?.Phase??0,0,StrategyDraft.Phases.Count-1);
        StrategyNotice=StrategySeekFailed?$"Could not reach this phase: {UiEnglish.Text(s?.Status,"Play interrupted")}. Return to the start and adjust the play.":$"Phase {StrategyPhaseIndex+1} paused. Select players and continue building.";
        StrategyReport=StrategySeekFailed?UiEnglish.Text(s?.Status,"Play interrupted"):"";
        Match.StrategyPauseAtPhase=-1;
    }
    public void AdvanceStrategyPhase()
    {
        if(StrategySeeking){Paused=false;StrategyNotice="Continuing to the selected phase.";return;}
        if(StrategyTesting)return;
        if(DraftPhase.Action==1)return;
        if(StrategyPhaseIndex+1<StrategyDraft.Phases.Count)SelectStrategyPhase(StrategyPhaseIndex+1);
    }
    public void AddStrategyPhase()
    {
        if(StrategySeeking || StrategyTesting)return;
        if(StrategyDraft.Phases.Count>=16)return;
        var prev=StrategyDraft.Phases[^1];
        if(StrategyDraft.Phases.Any(f=>f.Action==1)){StrategyNotice="A shot ends the play. Change or remove the shot before adding another phase.";return;}
        RememberStrategy();
        int actor=prev.Action==0?prev.Receiver:prev.Actor;
        StrategyDraft.Phases.Add(new(){Actor=actor,Receiver=(actor+1)%5,Action=2});
        SelectStrategyPhase(StrategyDraft.Phases.Count-1);
    }
    public void DeleteStrategyPhase()
    {if(StrategySeeking || StrategyDraft.Phases.Count<=1)return;RememberStrategy();StrategyDraft.Phases.RemoveAt(StrategyPhaseIndex);SelectStrategyPhase(Math.Min(StrategyPhaseIndex,StrategyDraft.Phases.Count-1),true);}
    public bool HasStrategyPhasesAfterShot
    {get{int shot=StrategyDraft.Phases.FindIndex(p=>p.Action==1);return shot>=0 && shot<StrategyDraft.Phases.Count-1;}}
    public void RemoveStrategyPhasesAfterShot()
    {
        if(StrategySeeking || StrategyTesting || !HasStrategyPhasesAfterShot)return;
        int shot=StrategyDraft.Phases.FindIndex(p=>p.Action==1);
        RememberStrategy();StrategyDraft.Phases.RemoveRange(shot+1,StrategyDraft.Phases.Count-shot-1);
        SelectStrategyPhase(shot,true);
        StrategyNotice="Removed phases after the shot.";
    }
    public Point DraftPosition(int role)=>Match.Players[role<5?6+role:12+role-5].Position;
    public void MoveStrategyStart(int role,Point point)
    {
        if(StrategyTesting || StrategySeeking || StrategyPhaseIndex!=0 || role<0 || role>=10)return;
        point=new(Math.Clamp(point.X,0,105),Math.Clamp(point.Y,0,68));
        if(role<5){StrategyDraft.Start[role]=new(point.X,point.Y);Match.Players[6+role].Position=point;if(Match.Owner==6+role)Match.Ball=point;}
        else{StrategyDraft.Defence[role-5]=new(point.X,point.Y);Match.Players[12+role-5].Position=point;}
    }
    public void ToggleStrategyDefender(int role)
    {
        if(StrategyTesting || StrategySeeking || StrategyPhaseIndex!=0 || role<0 || role>=5)return;
        RememberStrategy();StrategyDraft.DefenderEnabled??=new(){true,true,true,true,true};
        StrategyDraft.DefenderEnabled[role]=!StrategyDraft.DefenderEnabled[role];
        ResetStrategyBoard();
    }
    public void StrategyFieldClick(Point p)
    {
        if(StrategyTesting || StrategySeeking || StrategySeekFailed)return;
        p=new(Math.Clamp(p.X,0,105),Math.Clamp(p.Y,0,68));
        int near=Enumerable.Range(0,10).OrderBy(i=>(DraftPosition(i)-p).Length).First();
        bool hit=(DraftPosition(near)-p).Length<3;
        if(StrategyTool==0){if(hit)StrategyRole=near;return;}
        if(StrategyTool==3 || StrategyTool==4)
        {if(!hit || near>=5 || near==DraftPhase.Actor)return;RememberStrategy();if(StrategyTool==3){DraftPhase.Receiver=near;DraftPhase.Action=0;DraftPhase.PassZone=null;}else DraftPhase.Alternative=near;return;}
        RememberStrategy();
        if(StrategyTool==1)
        {
            if(StrategyPhaseIndex==0)MoveStrategyStart(StrategyRole,p);
            else {StrategyNotice="Change starting positions in phase 1. Draw routes in later phases.";}
        }
        else if(StrategyTool==2 && StrategyRole<5)
        {var route=DraftPhase.Routes.FirstOrDefault(r=>r.Role==StrategyRole);if(route==null){route=new(){Role=StrategyRole};DraftPhase.Routes.Add(route);}if(route.Points.Count<32)route.Points.Add(new(p.X,p.Y));}
    }
    public void RunStrategyTrial(bool repeat=false)
    {
        CommitStrategyDrawing();
        string error=StrategyDraft.Validate();if(error!=""){StrategyNotice=error;return;}
        if(!repeat || StrategySeed==0)StrategySeed=MatchSimulation.CreateSeed(StrategySeed);
        Match=MatchSimulation.StrategyTrial(StrategyDraft,StrategySeed,StrategyDefence,dummies:StrategyDummyMode);Director=new(){Automatic=false,ManualSpeed=1};Broadcast=new();
        Replay.Reset();accumulator=0;observedGoals=0;replayAfterGoal=0;StrategyTesting=true;StrategyPhaseIndex=0;StrategySeeking=false;StrategySeekFailed=false;Paused=false;
        StrategyNotice=repeat?"Repeating the trial with the same conditions.":StrategyDummyMode?"New trial against walking dummies.":"New trial against active defence.";
    }
    void TickStrategyLab()
    {
        if(!StrategyLabOpen || !StrategyTesting)return;
        StrategyPhaseIndex=Math.Clamp(Match.Strategy?.Phase??0,0,StrategyDraft.Phases.Count-1);
        // Let the final shot resolve and show the net before freezing the training scene.
        if(Match.Strategy is {Done:true} s && !Match.ShotInFlight && Match.WindupPlayer<0 && Match.Seconds-s.EndedAt>2.5f)
        {Paused=true;StrategyReport=Match.Goals[0]>0?"Goal":UiEnglish.Text(s.Status,"Play finished");}
        if(Match.Strategy!=null && Match.Seconds-Match.Strategy.Started>45){Paused=true;StrategyReport="Trial ended after 45 seconds";}
    }
    public void StrategyBatch()
    {
        string error=StrategyDraft.Validate();if(error!=""){StrategyNotice=error;return;}
        int goals=0,shots=0,losses=0,completed=0;
        for(int n=0;n<20;n++)
        {
            var m=MatchSimulation.StrategyTrial(StrategyDraft,MatchSimulation.CreateSeed(n),StrategyDefence,dummies:StrategyDummyMode);
            for(int i=0;i<1800;i++){m.Step(1f/60);if(m.Strategy.Done && !m.ShotInFlight && m.WindupPlayer<0 && m.Seconds-m.Strategy.EndedAt>2.5f)break;}
            goals+=m.Goals[0]>0?1:0;shots+=m.Shots[0]>0?1:0;losses+=m.Strategy.Status.StartsWith("Потеря")?1:0;completed+=m.Strategy.Status=="Схема выполнена"?1:0;
        }
        StrategyReport=$"20 trials · goals {goals} · shots {shots} · completed plays {completed} · interceptions {losses}";
    }
}