From 857e172a5f8be4a2802f498645966d0c0244af4d Mon Sep 17 00:00:00 2001 From: Joshua Higgins Date: Tue, 2 Dec 2025 19:37:21 -0500 Subject: [PATCH] feat: connection backbone --- project.godot | 4 + scripts/Connection.cs | 412 ++++++++++++++++++++++++++++++++++++++ scripts/Connection.cs.uid | 1 + scripts/MatchData.cs | 13 ++ scripts/MatchData.cs.uid | 1 + scripts/PlayerData.cs | 13 ++ scripts/PlayerData.cs.uid | 1 + 7 files changed, 445 insertions(+) create mode 100644 scripts/Connection.cs create mode 100644 scripts/Connection.cs.uid create mode 100644 scripts/MatchData.cs create mode 100644 scripts/MatchData.cs.uid create mode 100644 scripts/PlayerData.cs create mode 100644 scripts/PlayerData.cs.uid diff --git a/project.godot b/project.godot index 2e4428e..1639448 100644 --- a/project.godot +++ b/project.godot @@ -15,6 +15,10 @@ run/main_scene="uid://cr8fi0e4r88s8" config/features=PackedStringArray("4.5", "C#", "Forward Plus") config/icon="res://icon.svg" +[autoload] + +Connection="*res://scripts/Connection.cs" + [dotnet] project/assembly_name="connect4-moderator-observer" diff --git a/scripts/Connection.cs b/scripts/Connection.cs new file mode 100644 index 0000000..fa1b1fd --- /dev/null +++ b/scripts/Connection.cs @@ -0,0 +1,412 @@ +using Godot; +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Threading; + +public partial class Connection : Node +{ + private const string WS_DEFAULT_ADDRESS = "wss://connect4.abunchofknowitalls.com"; + + public static Connection Instance { get; private set; } + + private WebSocketPeer _webSocket = new WebSocketPeer(); + private Thread _gameListThread; + private bool _gameListThreadRunning; + + public event Action OnConnected; + public event Action OnReadyAcknowledged; + public event Action OnGameStart; + public event Action OnGameWin; + public event Action OnGameLoss; + public event Action OnGameDraw; + public event Action OnGameTerminated; + public event Action OnOpponentMove; + + public event Action OnObserveWin; + public event Action OnObserveDraw; + public event Action OnObserveTerminated; + public event Action OnObserveMove; + public event Action> OnUpdatedMatches; + public event Action> OnUpdatedPlayers; + public event Action> OnTournamentEnd; + + // Already prints to console + public event Action OnError; + + public bool IsAdmin { get; private set; } + public bool IsPlayer { get; private set; } + + private bool IsSocketOpen => _webSocket.GetReadyState() == WebSocketPeer.State.Open; + + public override void _Ready() + { + Instance = this; + } + + public void Connect(string address) + { + Error error = _webSocket.ConnectToUrl(address); + while (error != Error.Ok) + { + // TODO: back off so we don't DDOS + error = _webSocket.ConnectToUrl(address); + } + } + + public override void _ExitTree() + { + StopGameListRefreshLoop(); + } + + public override void _Process(double delta) + { + _webSocket.Poll(); + WebSocketPeer.State state = _webSocket.GetReadyState(); + if (state == WebSocketPeer.State.Closing || state == WebSocketPeer.State.Closed) + { + StopGameListRefreshLoop(); + GetTree().Quit(); + } + + if (IsSocketOpen) + { + StartGameListRefreshLoop(); + while (_webSocket.GetAvailablePacketCount() > 0) + { + string message = _webSocket.GetPacket().GetStringFromUtf8(); + HandleServerMessage(message); + } + } + } + + // Player commands + public void SendConnect(string clientId) + { + if (string.IsNullOrWhiteSpace(clientId)) + { + GD.PrintErr("Client ID is required to CONNECT."); + return; + } + + clientId = clientId.Trim(); + + if (clientId.Contains(":")) + { + GD.PrintErr("Client ID cannot contain ':' characters."); + return; + } + + SendCommand("CONNECT", clientId); + } + public void SendReady() + { + SendCommand("READY"); + } + public void SendPlay(int column) + { + SendCommand("PLAY", column.ToString()); + } + + // Observer commands + public void UpdateGameList() + { + SendCommand("GAME", "LIST"); + } + private void StartGameListRefreshLoop() + { + if (_gameListThreadRunning) + { + return; + } + + _gameListThreadRunning = true; + _gameListThread = new Thread(() => + { + while (_gameListThreadRunning) + { + if (IsSocketOpen) + { + UpdateGameList(); + UpdatePlayerList(); + } + + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + }) + { + IsBackground = true + }; + _gameListThread.Start(); + } + private void StopGameListRefreshLoop() + { + if (!_gameListThreadRunning) + { + return; + } + + _gameListThreadRunning = false; + _gameListThread?.Join(); + _gameListThread = null; + } + public void UpdatePlayerList() + { + SendCommand("PLAYER", "LIST"); + } + public void SendWatchGame(int matchID) + { + SendCommand("GAME", "WATCH:" + matchID); + } + public void AdminAuth(string password) + { + if (IsAdmin) return; + SendCommand("ADMIN", "AUTH:" + password); + } + + + // Admin commands + public void KickPlayer(string playerId) + { + if (!IsAdmin) return; + SendCommand("ADMIN", "KICK:" + playerId); + } + public void StartTournament() + { + if (!IsAdmin) return; + SendCommand("TOURNAMENT", "START"); + } + public void TerminateGame() + { + if (!IsAdmin) return; + SendCommand("GAME", "TERMINATE"); + } + public void SetTournamentWait(float waitTime) + { + if (!IsAdmin) return; + SendCommand("TOURNAMENT", "WAIT:" + waitTime); + } + + private void SendCommand(string header, string body = "") + { + if (!IsSocketOpen) + { + GD.PrintErr($"Cannot send {header}, socket is not open."); + return; + } + + string payload = string.IsNullOrEmpty(body) ? header : $"{header}:{body}"; + _webSocket.SendText(payload); + } + + private void HandleServerMessage(string message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + message = message.Trim(); + + string header; + string body; + int separatorIndex = message.IndexOf(':'); + if (separatorIndex >= 0) + { + header = message.Substring(0, separatorIndex).Trim(); + body = message.Substring(separatorIndex + 1).Trim(); + } + else + { + header = message.Trim(); + body = string.Empty; + } + + header = header.ToUpperInvariant(); + + switch (header) + { + case "CONNECT": + if (body.Equals("ACK", StringComparison.OrdinalIgnoreCase)) + { + IsPlayer = true; + OnConnected?.Invoke(); + } + break; + case "READY": + if (body.Equals("ACK", StringComparison.OrdinalIgnoreCase)) + { + OnReadyAcknowledged?.Invoke(); + } + break; + case "GAME": + HandleGameMessage(body); + break; + case "PLAYER": + HandlePlayerList(body); + break; + case "OPPONENT": + if (int.TryParse(body, out int column)) + { + OnOpponentMove?.Invoke(column); + } + else + { + GD.PrintErr($"Invalid opponent column: {body}"); + } + break; + case "ADMIN": + if (body == "AUTH:ACK") + { + IsAdmin = true; + } + break; + case "TOURNAMENT": + HandleTournamentMessage(body); + break; + case "ERROR": + HandleErrorMessage(body); + GD.PrintErr($"Error: {body}"); + break; + default: + GD.Print($"Unhandled server message: {message}"); + break; + } + } + private void HandleTournamentMessage(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return; + } + + string[] segments = body.Split(':'); + string command = segments[0].Trim().ToUpperInvariant(); + string argument = segments.Length > 1 ? segments[1].Trim() : string.Empty; + + switch (command) + { + case "END": + { + List<(string, int)> playerScoreboard = new List<(string, int)>(); + string[] entries = segments[1].Split("|"); + foreach (string entry in entries) + { + string[] data = entry.Split(','); + playerScoreboard.Add((data[0], int.Parse(data[1]))); + } + OnTournamentEnd?.Invoke(playerScoreboard); + break; + } + } + } + private void HandlePlayerList(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return; + } + + string[] segments = body.Split(':'); + string command = segments[0].Trim().ToUpperInvariant(); + string argument = segments.Length > 1 ? segments[1].Trim() : string.Empty; + + switch (command) + { + case "LIST": + { + List players = new List(); + string[] entries = segments[1].Split("|"); + foreach (string entry in entries) + { + string[] data = entry.Split(','); + players.Add(new PlayerData(data[0], bool.Parse(data[1]), bool.Parse(data[2]))); + } + OnUpdatedPlayers?.Invoke(players); + break; + } + } + } + private void HandleGameMessage(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return; + } + + string[] segments = body.Split(':'); + string command = segments[0].Trim().ToUpperInvariant(); + string argument = segments.Length > 1 ? segments[1].Trim() : string.Empty; + + if (IsPlayer) + { + switch (command) + { + case "START": + bool isFirst = argument == "1" || argument.Equals("TRUE", StringComparison.OrdinalIgnoreCase); + OnGameStart?.Invoke(isFirst); + break; + case "WINS": + OnGameWin?.Invoke(); + break; + case "LOSS": + OnGameLoss?.Invoke(); + break; + case "DRAW": + OnGameDraw?.Invoke(); + break; + case "TERMINATED": + OnGameTerminated?.Invoke(); + break; + default: + GD.Print($"Unhandled GAME message: {body}"); + break; + } + } + else // Regular observer/admin + { + switch (command) + { + case "WIN": + OnObserveWin?.Invoke(segments[1]); + break; + case "MOVE": + OnObserveMove?.Invoke(segments[1], int.Parse(segments[2])); + break; + case "DRAW": + OnObserveDraw?.Invoke(); + break; + case "TERMINATED": + OnObserveTerminated?.Invoke(); + break; + case "LIST": + List matches = new List(); + string[] entries = segments[1].Split("|"); + foreach (string entry in entries) + { + string[] data = entry.Split(','); + matches.Add(new MatchData(int.Parse(data[0]), data[1], data[2])); + } + OnUpdatedMatches?.Invoke(matches); + break; + default: + GD.Print($"Unhandled GAME message: {body}"); + break; + } + } + } + private void HandleErrorMessage(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + OnError?.Invoke("UNKNOWN", string.Empty); + return; + } + + string[] segments = body.Split(':'); + string code = segments.Length > 0 ? segments[0].Trim().ToUpperInvariant() : "UNKNOWN"; + string detail = segments.Length > 1 ? string.Join(":", segments, 1, segments.Length - 1).Trim() : string.Empty; + + OnError?.Invoke(code, detail); + } +} diff --git a/scripts/Connection.cs.uid b/scripts/Connection.cs.uid new file mode 100644 index 0000000..922b49f --- /dev/null +++ b/scripts/Connection.cs.uid @@ -0,0 +1 @@ +uid://bslvhif47asuo diff --git a/scripts/MatchData.cs b/scripts/MatchData.cs new file mode 100644 index 0000000..e055e2d --- /dev/null +++ b/scripts/MatchData.cs @@ -0,0 +1,13 @@ +public class MatchData +{ + public int matchId { get; private set; } + public string player1 { get; private set; } + public string player2 { get; private set; } + + public MatchData(int matchId, string player1, string player2) + { + this.matchId = matchId; + this.player1 = player1; + this.player2 = player2; + } +} diff --git a/scripts/MatchData.cs.uid b/scripts/MatchData.cs.uid new file mode 100644 index 0000000..8158bab --- /dev/null +++ b/scripts/MatchData.cs.uid @@ -0,0 +1 @@ +uid://c8o7yluqfu841 diff --git a/scripts/PlayerData.cs b/scripts/PlayerData.cs new file mode 100644 index 0000000..369d358 --- /dev/null +++ b/scripts/PlayerData.cs @@ -0,0 +1,13 @@ +public class PlayerData +{ + public string username { get; private set; } + public bool isReady { get; private set; } + public bool isPlaying { get; private set; } + + public PlayerData(string username, bool isReady, bool isPlaying) + { + this.username = username; + this.isReady = isReady; + this.isPlaying = isPlaying; + } +} \ No newline at end of file diff --git a/scripts/PlayerData.cs.uid b/scripts/PlayerData.cs.uid new file mode 100644 index 0000000..5f8a62f --- /dev/null +++ b/scripts/PlayerData.cs.uid @@ -0,0 +1 @@ +uid://bvsyrefvlnavh