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