feat: implement SimulationController, fix API paths, wire Simulator scene

This commit is contained in:
rgriffith committed 2026-03-19 07:46:44 +00:00
1 parent d8e355de2b
commit ce80b6d9c5
4 files changed
+756 -53

No files matched your search

@@ -14,11 +14,13 @@ public static class CreateSimulatorScene
{ {
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single); var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
// ── Services ──────────────────────────────────────────────────────────
var services = new GameObject("[Services]"); var services = new GameObject("[Services]");
services.AddComponent<CommanderAILab.Services.ApiClient>(); services.AddComponent<CommanderAILab.Services.ApiClient>();
// ── Canvas ────────────────────────────────────────────────────────────
var canvasGo = new GameObject("Canvas"); var canvasGo = new GameObject("Canvas");
var canvas = canvasGo.AddComponent<Canvas>(); var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.AddComponent<CanvasScaler>(); var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
@@ -32,56 +34,166 @@ public static class CreateSimulatorScene
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>(); es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
} }
// ── Background ────────────────────────────────────────────────────────
CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true); CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true);
// Header // ── Header (Top Bar) ──────────────────────────────────────────────────
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f)); var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f, 0.95f));
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f)); SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f)); var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f)); SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f));
var title = CreateTMPText(header.transform, "TitleText", "Simulator", 28, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(title.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
// Config Panel var titleTxt = CreateTMPText(header.transform, "TitleText", "Simulator", 28, FontStyles.Bold, TextAlignmentOptions.Center);
var configPanel = CreatePanel(canvasGo.transform, "ConfigPanel", new Color(0.1f, 0.1f, 0.16f)); SetAnchors(titleTxt.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
SetAnchors(configPanel.GetComponent<RectTransform>(), new Vector2(0.05f, 0.35f), new Vector2(0.5f, 0.93f));
var startSimBtn = CreateTMPButton(header.transform, "StartSimButton", "Start Simulation", new Color(0.2f, 0.55f, 0.2f));
SetAnchors(startSimBtn.GetComponent<RectTransform>(), new Vector2(0.82f, 0.1f), new Vector2(0.99f, 0.9f));
// ── Config Panel (left) ───────────────────────────────────────────────
var configPanel = CreatePanel(canvasGo.transform, "ConfigPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(configPanel.GetComponent<RectTransform>(), new Vector2(0.01f, 0.3f), new Vector2(0.49f, 0.93f));
var cfgVlg = configPanel.AddComponent<VerticalLayoutGroup>(); var cfgVlg = configPanel.AddComponent<VerticalLayoutGroup>();
cfgVlg.padding = new RectOffset(16, 16, 16, 16); cfgVlg.spacing = 10; cfgVlg.padding = new RectOffset(16, 16, 16, 16);
cfgVlg.childForceExpandWidth = true; cfgVlg.childForceExpandHeight = false; cfgVlg.spacing = 10;
cfgVlg.childForceExpandWidth = true;
cfgVlg.childForceExpandHeight = false;
CreateTMPText(configPanel.transform, "ConfigTitle", "Simulation Config", 20, FontStyles.Bold, TextAlignmentOptions.Center); CreateTMPText(configPanel.transform, "ConfigTitle", "Simulation Config", 20, FontStyles.Bold, TextAlignmentOptions.Center);
// Deck selector // Deck dropdowns
var deckLabel = CreateTMPText(configPanel.transform, "DeckLabel", "Deck:", 16, FontStyles.Normal, TextAlignmentOptions.Left); var deckDropdown0 = CreateTMPDropdown(configPanel.transform, "DeckDropdown0", new[] { "-- Select Deck --" });
var deckInput = CreateTMPInputField(configPanel.transform, "DeckInput", "Deck ID or name..."); var deckDropdown1 = CreateTMPDropdown(configPanel.transform, "DeckDropdown1", new[] { "-- Select Deck --" });
var deckDropdown2 = CreateTMPDropdown(configPanel.transform, "DeckDropdown2", new[] { "-- Select Deck --" });
var deckDropdown3 = CreateTMPDropdown(configPanel.transform, "DeckDropdown3", new[] { "-- Select Deck --" });
// Num games // Num players slider
var gamesLabel = CreateTMPText(configPanel.transform, "GamesLabel", "Games:", 16, FontStyles.Normal, TextAlignmentOptions.Left); var numPlayersLabel = CreateTMPText(configPanel.transform, "NumPlayersLabel", "Players: 2", 16, FontStyles.Normal, TextAlignmentOptions.Left);
var gamesInput = CreateTMPInputField(configPanel.transform, "GamesInput", "100"); var numPlayersSlider = CreateSlider(configPanel.transform, "NumPlayersSlider");
// Start/Stop buttons // Num games input
var startBtn = CreateTMPButton(configPanel.transform, "StartButton", "Start Simulation", new Color(0.2f, 0.55f, 0.2f)); CreateTMPText(configPanel.transform, "NumGamesLabel", "Number of Games:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
var stopBtn = CreateTMPButton(configPanel.transform, "StopButton", "Stop", new Color(0.55f, 0.2f, 0.2f)); var numGamesInput = CreateTMPInputField(configPanel.transform, "NumGamesInput", "100");
// Results Panel // Threads input
var resultsPanel = CreatePanel(canvasGo.transform, "ResultsPanel", new Color(0.1f, 0.1f, 0.16f)); CreateTMPText(configPanel.transform, "ThreadsLabel", "Threads:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(resultsPanel.GetComponent<RectTransform>(), new Vector2(0.52f, 0.35f), new Vector2(0.95f, 0.93f)); var threadsInput = CreateTMPInputField(configPanel.transform, "ThreadsInput", "4");
var resultsTitle = CreateTMPText(resultsPanel.transform, "ResultsTitle", "Results", 20, FontStyles.Bold, TextAlignmentOptions.Center); // AI Profile dropdown
SetAnchors(resultsTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.90f), new Vector2(1f, 1f)); CreateTMPText(configPanel.transform, "AiProfileLabel", "AI Profile:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
var aiProfileDropdown = CreateTMPDropdown(configPanel.transform, "AiProfileDropdown", new[] { "Default" });
var statusText = CreateTMPText(resultsPanel.transform, "StatusText", "Ready", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft); // ── Status Panel (center, hidden initially) ───────────────────────────
SetAnchors(statusText.GetComponent<RectTransform>(), new Vector2(0.03f, 0.03f), new Vector2(0.97f, 0.90f)); var statusPanel = CreatePanel(canvasGo.transform, "StatusPanel", new Color(0.09f, 0.09f, 0.14f, 0.97f));
SetAnchors(statusPanel.GetComponent<RectTransform>(), new Vector2(0.01f, 0.06f), new Vector2(0.49f, 0.29f));
statusPanel.SetActive(false);
var stVlg = statusPanel.AddComponent<VerticalLayoutGroup>();
stVlg.padding = new RectOffset(12, 12, 10, 10);
stVlg.spacing = 6;
stVlg.childForceExpandWidth = true;
stVlg.childForceExpandHeight = false;
// Progress bar var statusText = CreateTMPText(statusPanel.transform, "StatusText", "Ready", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
var progressBg = CreatePanel(canvasGo.transform, "ProgressBar", new Color(0.2f, 0.2f, 0.25f)); var progressBar = CreateSlider(statusPanel.transform, "ProgressBar");
SetAnchors(progressBg.GetComponent<RectTransform>(), new Vector2(0.05f, 0.28f), new Vector2(0.95f, 0.33f)); var progressText = CreateTMPText(statusPanel.transform, "ProgressText", "0%", 14, FontStyles.Normal, TextAlignmentOptions.Center);
var progressFill = CreatePanel(progressBg.transform, "ProgressFill", new Color(0.2f, 0.6f, 0.3f)); var elapsedText = CreateTMPText(statusPanel.transform, "ElapsedText", "Elapsed: 0s", 14, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(progressFill.GetComponent<RectTransform>(), new Vector2(0f, 0f), new Vector2(0f, 1f)); var simsPerSecText = CreateTMPText(statusPanel.transform, "SimsPerSecText", "0 sims/s", 14, FontStyles.Normal, TextAlignmentOptions.Left);
var progressText = CreateTMPText(progressBg.transform, "ProgressText", "0%", 14, FontStyles.Normal, TextAlignmentOptions.Center);
SetAnchors(progressText.GetComponent<RectTransform>(), new Vector2(0f, 0f), new Vector2(1f, 1f));
// -- Save -- // ── Results Panel (right, hidden initially) ───────────────────────────
var resultsPanel = CreatePanel(canvasGo.transform, "ResultsPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(resultsPanel.GetComponent<RectTransform>(), new Vector2(0.51f, 0.06f), new Vector2(0.99f, 0.93f));
resultsPanel.SetActive(false);
CreateTMPText(resultsPanel.transform, "ResultsTitle", "Results", 22, FontStyles.Bold, TextAlignmentOptions.Center);
var winnerText = CreateTMPText(resultsPanel.transform, "WinnerText", "Winner: —", 18, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(winnerText.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(1f, 0.95f));
// Results scroll view
var resultsScroll = new GameObject("ResultsScroll");
resultsScroll.transform.SetParent(resultsPanel.transform, false);
SetAnchors(resultsScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.15f), new Vector2(1f, 0.88f));
ScrollRect resSr;
var resultsContent = CreateScrollContent(resultsScroll, out resSr);
var historyBtn = CreateTMPButton(resultsPanel.transform, "HistoryButton", "View History", new Color(0.2f, 0.4f, 0.7f));
SetAnchors(historyBtn.GetComponent<RectTransform>(), new Vector2(0.2f, 0.03f), new Vector2(0.8f, 0.13f));
// ── Error Panel (hidden initially) ────────────────────────────────────
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.15f, 0.05f, 0.05f, 0.97f));
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.25f, 0.35f), new Vector2(0.75f, 0.65f));
errorPanel.SetActive(false);
CreateTMPText(errorPanel.transform, "ErrorTitle", "Error", 22, FontStyles.Bold, TextAlignmentOptions.Center);
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0.05f, 0.25f), new Vector2(0.95f, 0.85f));
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.55f, 0.2f, 0.2f));
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.3f, 0.05f), new Vector2(0.7f, 0.22f));
// ── Result Row Prefab (disabled child object used as prefab) ──────────
var resultRowPrefab = new GameObject("ResultRowPrefab");
resultRowPrefab.transform.SetParent(canvasGo.transform, false);
resultRowPrefab.SetActive(false);
var rowImg = resultRowPrefab.AddComponent<Image>();
rowImg.color = new Color(0.15f, 0.15f, 0.2f, 0.9f);
var rowHlg = resultRowPrefab.AddComponent<HorizontalLayoutGroup>();
rowHlg.padding = new RectOffset(8, 8, 4, 4);
rowHlg.spacing = 12;
rowHlg.childForceExpandWidth = false;
rowHlg.childForceExpandHeight = true;
var rowLe = resultRowPrefab.AddComponent<LayoutElement>();
rowLe.minHeight = 40;
string[] colNames = { "DeckName", "WinRate", "Wins", "Losses" };
float[] colWidths = { 300f, 120f, 100f, 100f };
for (int i = 0; i < colNames.Length; i++)
{
var cell = new GameObject(colNames[i]);
cell.transform.SetParent(resultRowPrefab.transform, false);
var cellTxt = cell.AddComponent<TextMeshProUGUI>();
cellTxt.text = colNames[i];
cellTxt.fontSize = 16;
cellTxt.color = Color.white;
cellTxt.alignment = TextAlignmentOptions.Left;
var cellLe = cell.AddComponent<LayoutElement>();
cellLe.minWidth = colWidths[i];
cellLe.preferredWidth = colWidths[i];
cellLe.flexibleWidth = i == 0 ? 1f : 0f;
}
// ── Wire SimulationController via reflection ───────────────────────────
var controllerGo = new GameObject("SimulationController");
controllerGo.transform.SetParent(canvasGo.transform, false);
var controller = controllerGo.AddComponent<CommanderAILab.UI.SimulationController>();
SetField(controller, "backButton", backBtn.GetComponent<Button>());
SetField(controller, "startSimButton", startSimBtn.GetComponent<Button>());
SetField(controller, "deckDropdown0", deckDropdown0.GetComponent<TMP_Dropdown>());
SetField(controller, "deckDropdown1", deckDropdown1.GetComponent<TMP_Dropdown>());
SetField(controller, "deckDropdown2", deckDropdown2.GetComponent<TMP_Dropdown>());
SetField(controller, "deckDropdown3", deckDropdown3.GetComponent<TMP_Dropdown>());
SetField(controller, "numGamesInput", numGamesInput.GetComponent<TMP_InputField>());
SetField(controller, "numPlayersSlider", numPlayersSlider.GetComponent<Slider>());
SetField(controller, "numPlayersLabel", numPlayersLabel.GetComponent<TMP_Text>());
SetField(controller, "threadsInput", threadsInput.GetComponent<TMP_InputField>());
SetField(controller, "aiProfileDropdown", aiProfileDropdown.GetComponent<TMP_Dropdown>());
SetField(controller, "statusText", statusText.GetComponent<TMP_Text>());
SetField(controller, "progressBar", progressBar.GetComponent<Slider>());
SetField(controller, "progressText", progressText.GetComponent<TMP_Text>());
SetField(controller, "elapsedText", elapsedText.GetComponent<TMP_Text>());
SetField(controller, "simsPerSecText", simsPerSecText.GetComponent<TMP_Text>());
SetField(controller, "resultsParent", resultsContent);
SetField(controller, "resultRowPrefab", resultRowPrefab);
SetField(controller, "winnerText", winnerText.GetComponent<TMP_Text>());
SetField(controller, "historyButton", historyBtn.GetComponent<Button>());
SetField(controller, "errorPanel", errorPanel);
SetField(controller, "errorText", errorText.GetComponent<TMP_Text>());
SetField(controller, "retryButton", retryBtn.GetComponent<Button>());
// ── Save ──────────────────────────────────────────────────────────────
if (!AssetDatabase.IsValidFolder("Assets/Scenes")) if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
AssetDatabase.CreateFolder("Assets", "Scenes"); AssetDatabase.CreateFolder("Assets", "Scenes");
string path = "Assets/Scenes/Simulator.unity"; string path = "Assets/Scenes/Simulator.unity";
@@ -93,7 +205,15 @@ public static class CreateSimulatorScene
Debug.Log("[CreateSimulatorScene] Simulator scene created and saved to " + path); Debug.Log("[CreateSimulatorScene] Simulator scene created and saved to " + path);
} }
// -- Helpers -- // ── Helpers ───────────────────────────────────────────────────────────────
static void SetField(object target, string fieldName, object value)
{
var field = target.GetType().GetField(fieldName,
BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null) field.SetValue(target, value);
else Debug.LogWarning("[CreateSimulatorScene] Could not find field: " + fieldName);
}
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max) static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
{ {
@@ -119,11 +239,11 @@ public static class CreateSimulatorScene
var go = new GameObject(name); var go = new GameObject(name);
go.transform.SetParent(parent, false); go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>(); var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text; tmp.text = text;
tmp.fontSize = fontSize; tmp.fontSize = fontSize;
tmp.fontStyle = style; tmp.fontStyle = style;
tmp.alignment = alignment; tmp.alignment = alignment;
tmp.color = Color.white; tmp.color = Color.white;
var rt = go.GetComponent<RectTransform>(); var rt = go.GetComponent<RectTransform>();
SetAnchors(rt, Vector2.zero, Vector2.one); SetAnchors(rt, Vector2.zero, Vector2.one);
return go; return go;
@@ -139,16 +259,16 @@ public static class CreateSimulatorScene
btn.targetGraphic = img; btn.targetGraphic = img;
var colors = btn.colors; var colors = btn.colors;
colors.highlightedColor = bgColor * 1.2f; colors.highlightedColor = bgColor * 1.2f;
colors.pressedColor = bgColor * 0.8f; colors.pressedColor = bgColor * 0.8f;
btn.colors = colors; btn.colors = colors;
var textGo = new GameObject("Text"); var textGo = new GameObject("Text");
textGo.transform.SetParent(go.transform, false); textGo.transform.SetParent(go.transform, false);
var tmp = textGo.AddComponent<TextMeshProUGUI>(); var tmp = textGo.AddComponent<TextMeshProUGUI>();
tmp.text = label; tmp.text = label;
tmp.fontSize = 22; tmp.fontSize = 22;
tmp.alignment = TextAlignmentOptions.Center; tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white; tmp.color = Color.white;
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one); SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var le = go.AddComponent<LayoutElement>(); var le = go.AddComponent<LayoutElement>();
@@ -172,29 +292,154 @@ public static class CreateSimulatorScene
var placeholder = new GameObject("Placeholder"); var placeholder = new GameObject("Placeholder");
placeholder.transform.SetParent(textArea.transform, false); placeholder.transform.SetParent(textArea.transform, false);
var phTmp = placeholder.AddComponent<TextMeshProUGUI>(); var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
phTmp.text = defaultText; phTmp.text = defaultText;
phTmp.fontSize = 18; phTmp.fontSize = 18;
phTmp.fontStyle = FontStyles.Italic; phTmp.fontStyle = FontStyles.Italic;
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f); phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
phTmp.alignment = TextAlignmentOptions.Left; phTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one); SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputText = new GameObject("Text"); var inputText = new GameObject("Text");
inputText.transform.SetParent(textArea.transform, false); inputText.transform.SetParent(textArea.transform, false);
var itTmp = inputText.AddComponent<TextMeshProUGUI>(); var itTmp = inputText.AddComponent<TextMeshProUGUI>();
itTmp.fontSize = 18; itTmp.fontSize = 18;
itTmp.color = Color.white; itTmp.color = Color.white;
itTmp.alignment = TextAlignmentOptions.Left; itTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one); SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputField = go.AddComponent<TMP_InputField>(); var inputField = go.AddComponent<TMP_InputField>();
inputField.textViewport = textArea.GetComponent<RectTransform>(); inputField.textViewport = textArea.GetComponent<RectTransform>();
inputField.textComponent = itTmp; inputField.textComponent = itTmp;
inputField.placeholder = phTmp; inputField.placeholder = phTmp;
inputField.text = defaultText; inputField.text = defaultText;
inputField.fontAsset = itTmp.font; inputField.fontAsset = itTmp.font;
var le = go.AddComponent<LayoutElement>();
le.minHeight = 45;
return go; return go;
} }
static GameObject CreateTMPDropdown(Transform parent, string name, string[] options)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
var labelGo = new GameObject("Label");
labelGo.transform.SetParent(go.transform, false);
var labelTmp = labelGo.AddComponent<TextMeshProUGUI>();
labelTmp.text = options.Length > 0 ? options[0] : "";
labelTmp.fontSize = 16;
labelTmp.alignment = TextAlignmentOptions.Left;
labelTmp.color = Color.white;
SetAnchors(labelGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var dd = go.AddComponent<TMP_Dropdown>();
dd.captionText = labelTmp;
dd.ClearOptions();
dd.AddOptions(new System.Collections.Generic.List<string>(options));
var le = go.AddComponent<LayoutElement>();
le.minHeight = 40;
return go;
}
static GameObject CreateSlider(Transform parent, string name)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
// Background track
var bg = new GameObject("Background");
bg.transform.SetParent(go.transform, false);
var bgImg = bg.AddComponent<Image>();
bgImg.color = new Color(0.2f, 0.2f, 0.25f);
SetAnchors(bg.GetComponent<RectTransform>(), new Vector2(0f, 0.25f), new Vector2(1f, 0.75f));
// Fill area
var fillArea = new GameObject("Fill Area");
fillArea.transform.SetParent(go.transform, false);
SetAnchors(fillArea.GetComponent<RectTransform>(), new Vector2(0f, 0.25f), new Vector2(1f, 0.75f));
var fill = new GameObject("Fill");
fill.transform.SetParent(fillArea.transform, false);
var fillImg = fill.AddComponent<Image>();
fillImg.color = new Color(0.2f, 0.6f, 0.3f);
var fillRt = fill.GetComponent<RectTransform>();
fillRt.anchorMin = Vector2.zero;
fillRt.anchorMax = Vector2.one;
fillRt.offsetMin = Vector2.zero;
fillRt.offsetMax = Vector2.zero;
// Handle
var handleArea = new GameObject("Handle Slide Area");
handleArea.transform.SetParent(go.transform, false);
SetAnchors(handleArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var handle = new GameObject("Handle");
handle.transform.SetParent(handleArea.transform, false);
var handleImg = handle.AddComponent<Image>();
handleImg.color = new Color(0.8f, 0.8f, 0.9f);
var handleRt = handle.GetComponent<RectTransform>();
handleRt.sizeDelta = new Vector2(20f, 20f);
var slider = go.AddComponent<Slider>();
slider.fillRect = fillRt;
slider.handleRect = handleRt;
slider.targetGraphic = handleImg;
slider.direction = Slider.Direction.LeftToRight;
slider.minValue = 0f;
slider.maxValue = 1f;
slider.value = 0f;
var le = go.AddComponent<LayoutElement>();
le.minHeight = 30;
return go;
}
/// <summary>
/// Creates a Viewport + Content hierarchy inside the given scroll GameObject,
/// wires up the ScrollRect, and returns the Content RectTransform.
/// </summary>
static RectTransform CreateScrollContent(GameObject scrollGo, out ScrollRect scrollRect)
{
scrollRect = scrollGo.GetComponent<ScrollRect>();
if (scrollRect == null)
scrollRect = scrollGo.AddComponent<ScrollRect>();
var viewport = new GameObject("Viewport");
viewport.transform.SetParent(scrollGo.transform, false);
viewport.AddComponent<RectMask2D>();
var vpRect = viewport.GetComponent<RectTransform>();
vpRect.anchorMin = Vector2.zero;
vpRect.anchorMax = Vector2.one;
vpRect.offsetMin = Vector2.zero;
vpRect.offsetMax = Vector2.zero;
var content = new GameObject("Content");
content.transform.SetParent(viewport.transform, false);
var contentRect = content.AddComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.offsetMin = Vector2.zero;
contentRect.offsetMax = Vector2.zero;
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var vlg = content.AddComponent<VerticalLayoutGroup>();
vlg.spacing = 4;
vlg.padding = new RectOffset(4, 4, 4, 4);
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = false;
scrollRect.viewport = vpRect;
scrollRect.content = contentRect;
scrollRect.horizontal = false;
return contentRect;
}
} }
#endif #endif
@@ -204,7 +204,7 @@ namespace CommanderAILab.Services
public Coroutine GetDecks(Action<string> onSuccess, Action<string> onError = null) public Coroutine GetDecks(Action<string> onSuccess, Action<string> onError = null)
{ {
return StartCoroutine(GetRawCoroutine("/api/decks", onSuccess, onError)); return StartCoroutine(GetRawCoroutine("/api/lab/decks", onSuccess, onError));
} }
public Coroutine CreateDeck(string deckJson, Action<string> onSuccess, Action<string> onError = null) public Coroutine CreateDeck(string deckJson, Action<string> onSuccess, Action<string> onError = null)
@@ -214,6 +214,11 @@ namespace CommanderAILab.Services
// -- Simulator ------------------------------------------------------ // -- Simulator ------------------------------------------------------
public Coroutine GetProfiles(Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetRawCoroutine("/api/lab/profiles", onSuccess, onError));
}
public Coroutine StartSim(string configJson, Action<string> onSuccess, Action<string> onError = null) public Coroutine StartSim(string configJson, Action<string> onSuccess, Action<string> onError = null)
{ {
return PostRaw("/api/lab/start", configJson, onSuccess, onError); return PostRaw("/api/lab/start", configJson, onSuccess, onError);
@@ -221,12 +226,12 @@ namespace CommanderAILab.Services
public Coroutine GetSimStatus(string simId, Action<string> onSuccess, Action<string> onError = null) public Coroutine GetSimStatus(string simId, Action<string> onSuccess, Action<string> onError = null)
{ {
return StartCoroutine(GetRawCoroutine($"/api/lab/status/{simId}", onSuccess, onError)); return StartCoroutine(GetRawCoroutine($"/api/lab/status?batchId={simId}", onSuccess, onError));
} }
public Coroutine GetSimResults(string simId, Action<string> onSuccess, Action<string> onError = null) public Coroutine GetSimResults(string simId, Action<string> onSuccess, Action<string> onError = null)
{ {
return StartCoroutine(GetRawCoroutine($"/api/lab/results/{simId}", onSuccess, onError)); return StartCoroutine(GetRawCoroutine($"/api/lab/result?batchId={simId}", onSuccess, onError));
} }
// -- Coach ---------------------------------------------------------- // -- Coach ----------------------------------------------------------
@@ -0,0 +1,442 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using TMPro;
using Newtonsoft.Json;
using CommanderAILab.Models;
using CommanderAILab.Services;
namespace CommanderAILab.UI
{
/// <summary>
/// Simulator scene — configure and run multi-player simulations,
/// poll status, display results per deck.
/// </summary>
public class SimulationController : MonoBehaviour
{
// ── Navigation ───────────────────────────────────────────────────────
[Header("Navigation")]
[SerializeField] private Button backButton;
[SerializeField] private Button startSimButton;
// ── Config Panel ──────────────────────────────────────────────────────
[Header("Config")]
[SerializeField] private TMP_Dropdown deckDropdown0;
[SerializeField] private TMP_Dropdown deckDropdown1;
[SerializeField] private TMP_Dropdown deckDropdown2;
[SerializeField] private TMP_Dropdown deckDropdown3;
[SerializeField] private TMP_InputField numGamesInput;
[SerializeField] private Slider numPlayersSlider;
[SerializeField] private TMP_Text numPlayersLabel;
[SerializeField] private TMP_InputField threadsInput;
[SerializeField] private TMP_Dropdown aiProfileDropdown;
// ── Status Panel ──────────────────────────────────────────────────────
[Header("Status")]
[SerializeField] private TMP_Text statusText;
[SerializeField] private Slider progressBar;
[SerializeField] private TMP_Text progressText;
[SerializeField] private TMP_Text elapsedText;
[SerializeField] private TMP_Text simsPerSecText;
// ── Results Panel ─────────────────────────────────────────────────────
[Header("Results")]
[SerializeField] private Transform resultsParent;
[SerializeField] private GameObject resultRowPrefab;
[SerializeField] private TMP_Text winnerText;
[SerializeField] private Button historyButton;
// ── Error ─────────────────────────────────────────────────────────────
[Header("Error")]
[SerializeField] private GameObject errorPanel;
[SerializeField] private TMP_Text errorText;
[SerializeField] private Button retryButton;
// ── State ─────────────────────────────────────────────────────────────
private ApiClient _api;
private List<LabDeckEntry> _decks = new();
private List<AiProfile> _profiles = new();
private string _currentBatchId;
private bool _isPolling;
private readonly List<GameObject> _resultRowObjects = new();
// ── Lifecycle ─────────────────────────────────────────────────────────
private void Start()
{
_api = FindObjectOfType<ApiClient>();
backButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
startSimButton.onClick.AddListener(OnStartSim);
retryButton.onClick.AddListener(OnStartSim);
numPlayersSlider.minValue = 2;
numPlayersSlider.maxValue = 4;
numPlayersSlider.wholeNumbers = true;
numPlayersSlider.value = 2;
numPlayersSlider.onValueChanged.AddListener(OnNumPlayersChanged);
OnNumPlayersChanged(numPlayersSlider.value);
errorPanel.SetActive(false);
// Check for a deck exported from DeckBuilder
string simDeck = PlayerPrefs.GetString("SimDeck", "");
if (!string.IsNullOrEmpty(simDeck))
{
PlayerPrefs.DeleteKey("SimDeck");
// Pre-populate deck dropdown 0 after decks load — stored for later
PlayerPrefs.SetString("_SimDeckPending", simDeck);
}
LoadDecks();
LoadAiProfiles();
}
// ── Player Count ──────────────────────────────────────────────────────
private void OnNumPlayersChanged(float value)
{
int count = Mathf.RoundToInt(value);
if (numPlayersLabel != null)
numPlayersLabel.text = $"Players: {count}";
// Show/hide optional deck dropdowns based on count
if (deckDropdown2 != null) deckDropdown2.gameObject.SetActive(count >= 3);
if (deckDropdown3 != null) deckDropdown3.gameObject.SetActive(count >= 4);
}
// ── Deck List ─────────────────────────────────────────────────────────
private void LoadDecks()
{
_api.GetDecks(
json =>
{
try
{
var wrapper = JsonConvert.DeserializeObject<DecksWrapper>(json);
_decks = wrapper?.decks ?? new List<LabDeckEntry>();
}
catch { _decks = new List<LabDeckEntry>(); }
PopulateDeckDropdowns();
},
err => ShowError($"Failed to load decks:\n{err}"));
}
private void PopulateDeckDropdowns()
{
var options = new List<string> { "-- Select Deck --" };
foreach (var d in _decks) options.Add(d.name);
void Fill(TMP_Dropdown dd)
{
if (dd == null) return;
dd.ClearOptions();
dd.AddOptions(options);
dd.value = 0;
}
Fill(deckDropdown0);
Fill(deckDropdown1);
Fill(deckDropdown2);
Fill(deckDropdown3);
// Apply any pending deck from DeckBuilder export
string pending = PlayerPrefs.GetString("_SimDeckPending", "");
if (!string.IsNullOrEmpty(pending))
{
PlayerPrefs.DeleteKey("_SimDeckPending");
try
{
// Try to match by name from the JSON
var exported = JsonConvert.DeserializeObject<LabDeckEntry>(pending);
if (exported != null && !string.IsNullOrEmpty(exported.name))
{
int idx = options.IndexOf(exported.name);
if (idx >= 0 && deckDropdown0 != null)
deckDropdown0.value = idx;
}
}
catch { }
}
}
// ── AI Profiles ───────────────────────────────────────────────────────
private void LoadAiProfiles()
{
_api.GetProfiles(
json =>
{
try
{
var wrapper = JsonConvert.DeserializeObject<ProfilesWrapper>(json);
_profiles = wrapper?.profiles ?? new List<AiProfile>();
}
catch { _profiles = new List<AiProfile>(); }
PopulateProfileDropdown();
},
err =>
{
// Non-fatal: profiles may not be available yet
_profiles = new List<AiProfile>();
PopulateProfileDropdown();
});
}
private void PopulateProfileDropdown()
{
if (aiProfileDropdown == null) return;
aiProfileDropdown.ClearOptions();
var options = new List<string>();
foreach (var p in _profiles) options.Add(p.name);
if (options.Count == 0) options.Add("Default");
aiProfileDropdown.AddOptions(options);
aiProfileDropdown.value = 0;
}
// ── Start Simulation ──────────────────────────────────────────────────
private void OnStartSim()
{
errorPanel.SetActive(false);
int numPlayers = Mathf.RoundToInt(numPlayersSlider.value);
// Collect selected deck names (index 0 = "-- Select Deck --")
var selectedDecks = new List<string>();
void TryAdd(TMP_Dropdown dd)
{
if (dd == null || !dd.gameObject.activeSelf) return;
if (dd.value <= 0) return;
int deckIdx = dd.value - 1;
if (deckIdx < _decks.Count)
selectedDecks.Add(_decks[deckIdx].name);
}
TryAdd(deckDropdown0);
TryAdd(deckDropdown1);
TryAdd(deckDropdown2);
TryAdd(deckDropdown3);
if (selectedDecks.Count < 2)
{
ShowError("Please select at least 2 decks to simulate.");
return;
}
if (selectedDecks.Count < numPlayers)
{
ShowError($"Please select {numPlayers} decks for {numPlayers}-player simulation.");
return;
}
if (!int.TryParse(numGamesInput.text, out int numGames) || numGames <= 0)
numGames = 100;
if (!int.TryParse(threadsInput.text, out int threads) || threads <= 0)
threads = 4;
string policyStyle = aiProfileDropdown != null && _profiles.Count > 0
? _profiles[Mathf.Clamp(aiProfileDropdown.value, 0, _profiles.Count - 1)].name
: "Default";
var request = new StartRequest
{
decks = selectedDecks,
numGames = numGames,
threads = threads,
useLearnedPolicy = false,
policyStyle = policyStyle,
seed = 0,
clock = 0
};
string json = JsonConvert.SerializeObject(request);
SetStatusPanelVisible(true);
SetResultsPanelVisible(false);
if (statusText) statusText.text = "Starting simulation...";
if (progressBar) progressBar.value = 0f;
if (progressText) progressText.text = "0%";
startSimButton.interactable = false;
_api.StartSim(json,
respJson =>
{
SimStartResponse resp;
try { resp = JsonConvert.DeserializeObject<SimStartResponse>(respJson); }
catch { ShowError("Failed to parse start response."); startSimButton.interactable = true; return; }
if (resp == null || string.IsNullOrEmpty(resp.batchId))
{
ShowError("Invalid response from server.");
startSimButton.interactable = true;
return;
}
_currentBatchId = resp.batchId;
if (!_isPolling)
StartCoroutine(PollStatus(_currentBatchId));
},
err => { ShowError($"Failed to start simulation:\n{err}"); startSimButton.interactable = true; });
}
// ── Poll Status ───────────────────────────────────────────────────────
private IEnumerator PollStatus(string batchId)
{
_isPolling = true;
while (true)
{
yield return new WaitForSeconds(1.5f);
bool done = false;
string errorMsg = null;
SimStatusModel latestStatus = null;
_api.GetSimStatus(batchId,
json =>
{
try
{
var status = JsonConvert.DeserializeObject<SimStatusModel>(json);
if (status == null) { done = true; errorMsg = "Empty status response."; return; }
latestStatus = status;
if (!string.IsNullOrEmpty(status.error))
{
done = true;
errorMsg = status.error;
return;
}
int total = status.total > 0 ? status.total
: status.totalGames > 0 ? status.totalGames : 1;
int completed = status.completed > 0 ? status.completed
: status.gamesCompleted;
float progress = total > 0 ? (float)completed / total : 0f;
if (progressBar) progressBar.value = progress;
if (progressText) progressText.text = $"{Mathf.RoundToInt(progress * 100)}%";
if (statusText) statusText.text = $"Running... {completed}/{total} games";
if (elapsedText) elapsedText.text = $"Elapsed: {status.elapsedMs / 1000f:F1}s";
if (simsPerSecText) simsPerSecText.text = $"{status.simsPerSec:F1} sims/s";
if (!status.running) done = true;
}
catch (Exception ex)
{
done = true;
errorMsg = $"Status parse error: {ex.Message}";
}
},
err => { done = true; errorMsg = err; });
// Wait one frame so callbacks have fired
yield return null;
if (!string.IsNullOrEmpty(errorMsg))
{
ShowError($"Simulation error:\n{errorMsg}");
startSimButton.interactable = true;
_isPolling = false;
yield break;
}
if (done)
{
if (statusText) statusText.text = "Simulation complete!";
if (progressBar) progressBar.value = 1f;
if (progressText) progressText.text = "100%";
startSimButton.interactable = true;
_isPolling = false;
LoadResults(batchId);
yield break;
}
}
}
// ── Load Results ──────────────────────────────────────────────────────
private void LoadResults(string batchId)
{
_api.GetSimResults(batchId,
json =>
{
SimResult result;
try { result = JsonConvert.DeserializeObject<SimResult>(json); }
catch { ShowError("Failed to parse results."); return; }
if (result?.summary?.perDeck == null) { ShowError("No result data returned."); return; }
SetResultsPanelVisible(true);
BuildResultRows(result.summary.perDeck);
},
err => ShowError($"Failed to load results:\n{err}"));
}
private void BuildResultRows(List<ResultEntry> entries)
{
foreach (var go in _resultRowObjects) if (go) Destroy(go);
_resultRowObjects.Clear();
ResultEntry winner = null;
foreach (var e in entries)
if (winner == null || e.winRate > winner.winRate) winner = e;
if (winnerText && winner != null)
winnerText.text = $"Winner: {winner.deckName} ({winner.winRate * 100f:F1}% win rate)";
foreach (var entry in entries)
{
var row = Instantiate(resultRowPrefab, resultsParent);
row.SetActive(true);
_resultRowObjects.Add(row);
var labels = row.GetComponentsInChildren<TMP_Text>();
if (labels.Length > 0) labels[0].text = entry.deckName;
if (labels.Length > 1) labels[1].text = $"{entry.winRate * 100f:F1}%";
if (labels.Length > 2) labels[2].text = $"W: {entry.wins}";
if (labels.Length > 3) labels[3].text = $"L: {entry.losses}";
}
}
// ── Panel Visibility ──────────────────────────────────────────────────
private void SetStatusPanelVisible(bool visible)
{
if (statusText) statusText.transform.parent?.gameObject.SetActive(visible);
if (progressBar) progressBar.gameObject.SetActive(visible);
if (progressText) progressText.gameObject.SetActive(visible);
if (elapsedText) elapsedText.gameObject.SetActive(visible);
if (simsPerSecText) simsPerSecText.gameObject.SetActive(visible);
}
private void SetResultsPanelVisible(bool visible)
{
if (resultsParent) resultsParent.gameObject.SetActive(visible);
if (winnerText) winnerText.gameObject.SetActive(visible);
if (historyButton) historyButton.gameObject.SetActive(visible);
}
// ── Error ─────────────────────────────────────────────────────────────
private void ShowError(string msg)
{
errorPanel.SetActive(true);
errorText.text = msg;
}
// ── Helper Models (private inner classes) ─────────────────────────────
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
[Serializable] private class ProfilesWrapper { public List<AiProfile> profiles; }
[Serializable] private class AiProfile { public string name; public string description; }
[Serializable] private class StartRequest
{
public List<string> decks; public int numGames; public int threads;
public bool useLearnedPolicy; public string policyStyle;
public int seed; public int clock;
}
[Serializable] private class SimResult { public Summary summary; }
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
[Serializable] private class ResultEntry { public string deckName; public float winRate; public int wins; public int losses; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3c22d6ab09a0439a9e596b46c9d8adaa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: