From fae04a9336931c7de1e6a6e131daa06284bb1448 Mon Sep 17 00:00:00 2001
From: KoalaTrapLord <265133236+KoalaTrapLord@users.noreply.github.com>
Date: Sat, 28 Mar 2026 19:54:01 -0400
Subject: [PATCH] feat(#148): add GameStateManager singleton and
GameState/SeatState POCOs
---
.../Assets/Scripts/Models/GameState.cs | 38 ++++++
.../UI/Battleground/GameStateManager.cs | 119 ++++++++++++++++++
2 files changed, 157 insertions(+)
create mode 100644 unity-client/Assets/Scripts/Models/GameState.cs
create mode 100644 unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs
diff --git a/unity-client/Assets/Scripts/Models/GameState.cs b/unity-client/Assets/Scripts/Models/GameState.cs
new file mode 100644
index 0000000..18a115d
--- /dev/null
+++ b/unity-client/Assets/Scripts/Models/GameState.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using CommanderAILab.UI;
+
+namespace CommanderAILab.Models
+{
+ /// Full live game state snapshot pushed by the server on state_update events.
+ [Serializable]
+ public class GameState
+ {
+ [JsonProperty("seats")] public List seats;
+ [JsonProperty("active_seat")] public int activeSeat;
+ [JsonProperty("turn_number")] public int turnNumber;
+ [JsonProperty("phase")] public string phase;
+ [JsonProperty("stack")] public List stack;
+ }
+
+ /// Per-seat state within a GameState snapshot.
+ [Serializable]
+ public class SeatState
+ {
+ [JsonProperty("seat_index")] public int seatIndex;
+ [JsonProperty("player_name")] public string playerName;
+ [JsonProperty("life")] public int life;
+ [JsonProperty("hand")] public List hand;
+ [JsonProperty("battlefield")] public List battlefield;
+ [JsonProperty("graveyard")] public List graveyard;
+ [JsonProperty("exile")] public List exile;
+ [JsonProperty("commander")] public CardModel commander;
+ [JsonProperty("commander_cast_count")] public int commanderCastCount;
+ /// Cumulative commander damage received from each seat index (length 4).
+ [JsonProperty("commander_damage_from")] public int[] commanderDamageFrom;
+ [JsonProperty("poison_counters")] public int poisonCounters;
+ [JsonProperty("mana_pool")] public Dictionary manaPool;
+ [JsonProperty("is_eliminated")] public bool isEliminated;
+ }
+}
diff --git a/unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs b/unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs
new file mode 100644
index 0000000..b0af078
--- /dev/null
+++ b/unity-client/Assets/Scripts/UI/Battleground/GameStateManager.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using Newtonsoft.Json;
+using CommanderAILab.Models;
+
+namespace CommanderAILab.UI
+{
+ ///
+ /// Singleton event hub for the live battleground game state.
+ /// All UI widgets subscribe to these events instead of polling.
+ /// Receives parsed server events from GameWebSocketClient.
+ ///
+ public class GameStateManager : MonoBehaviour
+ {
+ public static GameStateManager Instance { get; private set; }
+
+ // ── Events ────────────────────────────────────────────────
+ public event Action OnTurnChanged; // activeSeat
+ public event Action OnPhaseChanged; // phase, seat, turnNumber
+ public event Action OnPriorityChanged; // seatIndex
+ public event Action OnLifeChanged; // seatIndex, newLife
+ public event Action OnPlayerEliminated; // seatIndex, reason
+ public event Action> OnStackUpdated;
+ public event Action OnFullStateSync;
+
+ // ── State ────────────────────────────────────────────────
+ public int TurnNumber { get; private set; } = 1;
+ private GameState _state;
+
+ // ── Lifecycle ─────────────────────────────────────────────
+ private void Awake()
+ {
+ if (Instance != null && Instance != this) { Destroy(gameObject); return; }
+ Instance = this;
+ }
+
+ // ── Server event dispatch ───────────────────────────────────
+ /// Called by GameWebSocketClient on the Unity main thread.
+ public void ApplyServerEvent(string type, string payload)
+ {
+ try
+ {
+ switch (type)
+ {
+ case "state_update":
+ _state = JsonConvert.DeserializeObject(payload);
+ if (_state != null) OnFullStateSync?.Invoke(_state);
+ break;
+
+ case "turn_start":
+ var ts = JsonConvert.DeserializeObject(payload);
+ if (ts != null) { TurnNumber = ts.turnNumber; OnTurnChanged?.Invoke(ts.seat); }
+ break;
+
+ case "phase_change":
+ var pc = JsonConvert.DeserializeObject(payload);
+ if (pc != null)
+ OnPhaseChanged?.Invoke((PhaseTrackerWidget.Phase)pc.phaseIndex, pc.seat, TurnNumber);
+ break;
+
+ case "priority":
+ var pv = JsonConvert.DeserializeObject(payload);
+ if (pv != null) OnPriorityChanged?.Invoke(pv.seat);
+ break;
+
+ case "life_change":
+ var lc = JsonConvert.DeserializeObject(payload);
+ if (lc != null) OnLifeChanged?.Invoke(lc.seat, lc.newLife);
+ break;
+
+ case "stack_update":
+ var su = JsonConvert.DeserializeObject(payload);
+ if (su?.items != null) OnStackUpdated?.Invoke(su.items);
+ break;
+
+ case "player_elim":
+ var pe = JsonConvert.DeserializeObject(payload);
+ if (pe != null) EliminatePlayer(pe.seat, pe.reason);
+ break;
+
+ default:
+ Debug.LogWarning($"[GameStateManager] Unknown event type: {type}");
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[GameStateManager] ApplyServerEvent failed ({type}): {ex.Message}");
+ }
+ }
+
+ /// Trigger elimination (called by LifeTrackerWidget, CommanderDamageRow, or server event).
+ public void EliminatePlayer(int seatIndex, string reason) =>
+ OnPlayerEliminated?.Invoke(seatIndex, reason);
+
+ // ── Debug helpers (editor only) ─────────────────────────────
+#if UNITY_EDITOR
+ private void OnGUI()
+ {
+ GUILayout.BeginArea(new Rect(10, 10, 220, 200));
+ GUILayout.Label("[GSM Debug]");
+ if (GUILayout.Button("Fake Turn 1 Start")) ApplyServerEvent("turn_start", "{\"seat\":0,\"turnNumber\":1}");
+ if (GUILayout.Button("Advance Phase Main1")) ApplyServerEvent("phase_change","{\"phaseIndex\":3,\"seat\":0}");
+ if (GUILayout.Button("Priority Seat 0")) ApplyServerEvent("priority", "{\"seat\":0}");
+ if (GUILayout.Button("Elim Seat 2")) EliminatePlayer(2, "life");
+ GUILayout.EndArea();
+ }
+#endif
+
+ // ── Private event POCOs ───────────────────────────────────
+ [Serializable] private class TurnStartEvent { public int seat; public int turnNumber; }
+ [Serializable] private class PhaseChangeEvent { public int phaseIndex; public int seat; }
+ [Serializable] private class PriorityEvent { public int seat; }
+ [Serializable] private class LifeChangeEvent { public int seat; public int newLife; }
+ [Serializable] private class StackUpdateEvent { public List items; }
+ [Serializable] private class EliminationEvent { public int seat; public string reason; }
+ }
+}