Files
commander-ai-lab/unity-client/Assets/Scripts/Services/ApiClient.cs
T
KoalaTrapLord 3f5d6d12e8 feat: 4-player Commander Battleground UI (issues #138–#151)
* chore: add battleground UI task tracker (2026-03-28)

* feat(#142): add [NonSerialized] battleground runtime state to CardModel

* feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache

* feat(#148): add GameStateManager singleton and GameState/SeatState POCOs

* feat(#138): add BattlegroundController scene orchestrator

* feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter

* feat(#140): add PhaseTrackerWidget and PhaseStepButton

* feat(#141): add TurnOrderBar and TurnOrderPill

* feat(#142): add CardZone and CardView battlefield zone components

* feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display

* feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView

* chore: mark issues 1-8 as complete in task tracker

* feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay

* feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController

* feat(#151): add Play Live button to SimulationController

* feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch

* fix: add PostGameAction to ApiClient + update TODO.md all issues complete

- ApiClient.cs: add PostGameAction() wrapper → POST /api/game/action
  (fixes compile error in HumanActionBar.cs)
- TODO.md: mark issues #145, #146, #147, #149, #150, #151 as ✅
  all 14 battleground issues now committed

* feat(game): add routes/game.py + wire lab_api + fix GameWebSocketClient game_id URL

- routes/game.py: POST /api/game/start, POST /api/game/action (new)
- lab_api.py: import + register game_router
- GameWebSocketClient.cs: read game_id from PlayerPrefs, build
  ws://{host}/ws/game/{game_id} — fixes URL mismatch with ws_game.py

* fix(lobby): wire OnConfirm to POST /api/game/start, save game_id to PlayerPrefs

- OnConfirm() now calls ApiClient.PostGameStart() with 4-seat config
- On success: saves BattleSeats + GameId + WsUrl to PlayerPrefs,
  loads Battleground scene
- On failure: shows inline error label (red), re-enables Confirm button
- Added StartGameRequest/Response POCOs matching routes/game.py schema
- Added _errorLabel [SerializeField] for user-facing error feedback
2026-03-29 05:35:29 -04:00

298 lines
13 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
namespace CommanderAILab.Services
{
/// <summary>
/// Singleton HTTP client for Commander AI Lab FastAPI backend.
/// All REST calls go through this service.
/// </summary>
public class ApiClient : MonoBehaviour
{
public static ApiClient Instance { get; private set; }
[SerializeField] private string baseUrl = "http://localhost:8080";
[SerializeField] private float timeoutSeconds = 30f;
public string BaseUrl
{
get => baseUrl;
set
{
baseUrl = value.TrimEnd('/');
PlayerPrefs.SetString("ApiBaseUrl", baseUrl);
}
}
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
baseUrl = PlayerPrefs.GetString("ApiBaseUrl", baseUrl);
}
// -- Generic Helpers ------------------------------------------------
public Coroutine Get<T>(string path, Action<T> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetCoroutine(path, onSuccess, onError));
}
public Coroutine Post<TReq, TRes>(string path, TReq body, Action<TRes> onSuccess, Action<string> onError = null)
{
return StartCoroutine(PostCoroutine(path, body, onSuccess, onError));
}
public Coroutine PostRaw(string path, string json, Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(PostRawCoroutine(path, json, onSuccess, onError));
}
public Coroutine GetRaw(string path, Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetRawCoroutine(path, onSuccess, onError));
}
public Coroutine PatchRaw(string path, string json, Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(PatchRawCoroutine(path, json, onSuccess, onError));
}
public Coroutine Delete(string path, Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(DeleteCoroutine(path, onSuccess, onError));
}
// -- Coroutine implementations --------------------------------------
private IEnumerator GetCoroutine<T>(string path, Action<T> onSuccess, Action<string> onError)
{
var url = $"{baseUrl}{path}";
using var request = UnityWebRequest.Get(url);
request.timeout = (int)timeoutSeconds;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[ApiClient] GET {path} failed: {request.error}");
onError?.Invoke(request.error);
yield break;
}
var obj = JsonUtility.FromJson<T>(request.downloadHandler.text);
onSuccess?.Invoke(obj);
}
private IEnumerator PostCoroutine<TReq, TRes>(string path, TReq body, Action<TRes> onSuccess, Action<string> onError)
{
var url = $"{baseUrl}{path}";
var json = JsonUtility.ToJson(body);
using var request = new UnityWebRequest(url, "POST");
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.timeout = (int)timeoutSeconds;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[ApiClient] POST {path} failed: {request.error}");
onError?.Invoke(request.error);
yield break;
}
var obj = JsonUtility.FromJson<TRes>(request.downloadHandler.text);
onSuccess?.Invoke(obj);
}
private IEnumerator PostRawCoroutine(string path, string json, Action<string> onSuccess, Action<string> onError)
{
var url = $"{baseUrl}{path}";
using var request = new UnityWebRequest(url, "POST");
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.timeout = (int)timeoutSeconds;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[ApiClient] POST {path} failed: {request.error}");
onError?.Invoke(request.error);
yield break;
}
onSuccess?.Invoke(request.downloadHandler.text);
}
private IEnumerator PatchRawCoroutine(string path, string json, Action<string> onSuccess, Action<string> onError)
{
var url = $"{baseUrl}{path}";
using var request = new UnityWebRequest(url, "PATCH");
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.timeout = (int)timeoutSeconds;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[ApiClient] PATCH {path} failed: {request.error}");
onError?.Invoke(request.error);
yield break;
}
onSuccess?.Invoke(request.downloadHandler.text);
}
private IEnumerator DeleteCoroutine(string path, Action<string> onSuccess, Action<string> onError)
{
var url = $"{baseUrl}{path}";
using var request = UnityWebRequest.Delete(url);
request.downloadHandler = new DownloadHandlerBuffer();
request.timeout = (int)timeoutSeconds;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[ApiClient] DELETE {path} failed: {request.error}");
onError?.Invoke(request.error);
yield break;
}
onSuccess?.Invoke(request.downloadHandler.text);
}
// -- Raw GET helper (returns JSON string) ---------------------------
private IEnumerator GetRawCoroutine(string path, Action<string> onSuccess, Action<string> onError)
{
var url = $"{baseUrl}{path}";
using var request = UnityWebRequest.Get(url);
request.timeout = (int)timeoutSeconds;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[ApiClient] GET {path} failed: {request.error}");
onError?.Invoke(request.error);
yield break;
}
onSuccess?.Invoke(request.downloadHandler.text);
}
// -- Health Check ---------------------------------------------------
public Coroutine HealthCheck(Action<bool> callback)
{
return StartCoroutine(HealthCheckCoroutine(callback));
}
private IEnumerator HealthCheckCoroutine(Action<bool> callback)
{
using var request = UnityWebRequest.Get($"{baseUrl}/api/health");
request.timeout = 5;
yield return request.SendWebRequest();
callback?.Invoke(request.result == UnityWebRequest.Result.Success);
}
// -- Collection -----------------------------------------------------
public Coroutine GetCollection(Action<string> onSuccess, Action<string> onError = null, string search = null)
{
var path = string.IsNullOrEmpty(search)
? "/api/collection"
: $"/api/collection?search={UnityWebRequest.EscapeURL(search)}";
return StartCoroutine(GetRawCoroutine(path, onSuccess, onError));
}
// -- Decks ----------------------------------------------------------
public Coroutine GetDecks(Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetRawCoroutine("/api/lab/decks", onSuccess, onError));
}
public Coroutine CreateDeck(string deckJson, Action<string> onSuccess, Action<string> onError = null)
{
return PostRaw("/api/decks", deckJson, onSuccess, onError);
}
// -- 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)
{
return PostRaw("/api/lab/start", configJson, onSuccess, onError);
}
public Coroutine GetSimStatus(string simId, Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetRawCoroutine($"/api/lab/status?batchId={simId}", onSuccess, onError));
}
public Coroutine GetSimResults(string simId, Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetRawCoroutine($"/api/lab/result?batchId={simId}", onSuccess, onError));
}
// -- Coach ----------------------------------------------------------
public Coroutine CoachChat(string messageJson, Action<string> onSuccess, Action<string> onError = null)
{
return PostRaw("/api/coach/chat", messageJson, onSuccess, onError);
}
// -- Deck Generator -------------------------------------------------
public Coroutine GenerateDeck(string requestJson, Action<string> onSuccess, Action<string> onError = null)
{
return PostRaw("/api/deckgen/v3", requestJson, onSuccess, onError);
}
// -- Meta -----------------------------------------------------------
public Coroutine GetCommanders(Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetRawCoroutine("/api/lab/meta/commanders", onSuccess, onError));
}
public Coroutine GetPrecons(Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(GetRawCoroutine("/api/lab/precons", onSuccess, onError));
}
// -- Scanner --------------------------------------------------------
public Coroutine ScanCard(byte[] imageBytes, Action<string> onSuccess, Action<string> onError = null)
{
return StartCoroutine(ScanCardCoroutine(imageBytes, onSuccess, onError));
}
private IEnumerator ScanCardCoroutine(byte[] imageBytes, Action<string> onSuccess, Action<string> onError)
{
var url = $"{baseUrl}/api/scanner/scan";
var form = new WWWForm();
form.AddBinaryData("file", imageBytes, "card.jpg", "image/jpeg");
using var request = UnityWebRequest.Post(url, form);
request.timeout = (int)timeoutSeconds;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[ApiClient] POST /api/scanner/scan failed: {request.error}");
onError?.Invoke(request.error);
yield break;
}
onSuccess?.Invoke(request.downloadHandler.text);
}
// -- Game (Live Battleground) ---------------------------------------
/// <summary>POST /api/game/action — submit a human player action during a live game.</summary>
public Coroutine PostGameAction(string json, Action<string> onSuccess, Action<string> onError = null)
=> PostRaw("/api/game/action", json, onSuccess, onError);
/// <summary>POST /api/game/start — start a new live game with the given lobby config.</summary>
public Coroutine PostGameStart(string json, Action<string> onSuccess, Action<string> onError = null)
=> PostRaw("/api/game/start", json, onSuccess, onError);
}
}