diff --git a/TableturfBattleClient/index.html b/TableturfBattleClient/index.html index 3c801db..0bcaa8e 100644 --- a/TableturfBattleClient/index.html +++ b/TableturfBattleClient/index.html @@ -645,6 +645,9 @@ + + + @@ -652,6 +655,7 @@

Stage switch:

+

diff --git a/TableturfBattleClient/src/Config.ts b/TableturfBattleClient/src/Config.ts index 5293719..743e033 100644 --- a/TableturfBattleClient/src/Config.ts +++ b/TableturfBattleClient/src/Config.ts @@ -29,6 +29,7 @@ interface CustomRoomConfig { stageSelectionMethodAfterDraw: StageSelectionMethod | null; forceSameDecksAfterDraw: boolean; stageSwitch: number[]; + spectate: boolean; } declare var config: AppConfig; diff --git a/TableturfBattleClient/src/Pages/PreGamePage.ts b/TableturfBattleClient/src/Pages/PreGamePage.ts index 94514ef..c92f822 100644 --- a/TableturfBattleClient/src/Pages/PreGamePage.ts +++ b/TableturfBattleClient/src/Pages/PreGamePage.ts @@ -24,6 +24,7 @@ const stageSelectionRuleAfterDrawBox = document.getElementById('stageSelectionRu const stageSwitch = document.getElementById('stageSwitch')!; const stageSwitchButtons: HTMLButtonElement[] = [ ]; const gameSetupForceSameDeckAfterDrawBox = document.getElementById('gameSetupForceSameDeckAfterDrawBox') as HTMLInputElement; +const gameSetupSpectateBox = document.getElementById('gameSetupSpectateBox') as HTMLInputElement; const gameSetupSubmitButton = document.getElementById('gameSetupSubmitButton') as HTMLButtonElement; const optionsColourLock = document.getElementById('optionsColourLock') as HTMLInputElement; @@ -160,7 +161,8 @@ function createRoom(useOptionsForm: boolean) { stageSelectionMethodAfterWin: stageSelectionRuleAfterWinBox.value == 'Inherit' ? null : StageSelectionMethod[stageSelectionRuleAfterWinBox.value as keyof typeof StageSelectionMethod], stageSelectionMethodAfterDraw: stageSelectionRuleAfterWinBox.value == 'Inherit' ? null : StageSelectionMethod[stageSelectionRuleAfterDrawBox.value as keyof typeof StageSelectionMethod], forceSameDecksAfterDraw: gameSetupForceSameDeckAfterDrawBox.checked, - stageSwitch: stageSwitchButtons.map(b => parseInt(b.dataset.status!)) + stageSwitch: stageSwitchButtons.map(b => parseInt(b.dataset.status!)), + spectate: gameSetupSpectateBox.checked }; userConfig.lastCustomRoomConfig = settings; saveSettings(); @@ -187,7 +189,8 @@ function createRoom(useOptionsForm: boolean) { data.append('stageSelectionRuleFirst', JSON.stringify(stageSelectionRuleFirst)); data.append('stageSelectionRuleAfterWin', JSON.stringify(stageSelectionRuleAfterWin)); data.append('stageSelectionRuleAfterDraw', JSON.stringify(stageSelectionRuleAfterDraw)); - data.append('ForceSameDeckAfterDrawBox', settings.forceSameDecksAfterDraw.toString()); + data.append('forceSameDeckAfterDraw', settings.forceSameDecksAfterDraw.toString()); + data.append('spectate', settings.forceSameDecksAfterDraw.toString()); } request.send(data.toString()); setLoadingMessage('Creating a room...'); diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs index 0415d4b..3092265 100644 --- a/TableturfBattleServer/Program.cs +++ b/TableturfBattleServer/Program.cs @@ -1,690 +1,700 @@ -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net; -using System.Reflection; -using System.Text; -using System.Text.RegularExpressions; -using System.Timers; -using System.Web; -using Newtonsoft.Json; -using TableturfBattleServer.DTO; -using WebSocketSharp.Server; -using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest; -using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse; -using Timer = System.Timers.Timer; - -namespace TableturfBattleServer; - -internal class Program { - internal static HttpServer? httpServer; - - internal static Dictionary games = new(); - internal static Dictionary inactiveGames = new(); - internal static readonly Timer timer = new(1000); - private static bool lockdown; - - private const int InactiveGameLimit = 1000; - private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5); - - private static string? GetClientRootPath() { - var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - while (true) { - if (directory == null) return null; - var directory2 = Path.Combine(directory, "TableturfBattleClient"); - if (Directory.Exists(directory2)) return directory2; - directory = Path.GetDirectoryName(directory); - } - } - - internal static void Main(string[] args) { - httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() }; - - timer.Elapsed += Timer_Elapsed; - - httpServer.AddWebSocketService("/api/websocket"); - httpServer.OnGet += HttpServer_OnRequest; - httpServer.OnPost += HttpServer_OnRequest; - httpServer.Start(); - Console.WriteLine($"Listening on http://{httpServer.Address}:{httpServer.Port}"); - if (httpServer.DocumentRootPath != null) - Console.WriteLine($"Serving client files from {httpServer.DocumentRootPath}"); - else - Console.WriteLine($"Client files were not found."); - - while (true) { - var s = Console.ReadLine(); - if (s == null) - Thread.Sleep(Timeout.Infinite); - else { - s = s.Trim().ToLower(); - if (s == "update") { - if (games.Count == 0) - Environment.Exit(2); - lockdown = true; - Console.WriteLine("Locking server for update."); - } - } - } - } - - private static void Timer_Elapsed(object? sender, ElapsedEventArgs e) { - lock (games) { - foreach (var (id, game) in games) { - lock (game.Players) { - game.Tick(); - if (DateTime.UtcNow - game.abandonedSince >= InactiveGameTimeout) { - games.Remove(id); - inactiveGames.Add(id, game); - Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive"); - if (lockdown && games.Count == 0) - Environment.Exit(2); - } - } - } - if (inactiveGames.Count >= InactiveGameLimit) { - foreach (var (k, _) in inactiveGames.Select(e => (e.Key, e.Value.abandonedSince)).OrderBy(e => e.abandonedSince).Take(InactiveGameLimit / 2)) - inactiveGames.Remove(k); - Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive"); - } - } - } - - private static void HttpServer_OnRequest(object? sender, HttpRequestEventArgs e) { - e.Response.AppendHeader("Access-Control-Allow-Origin", "*"); - if (!e.Request.RawUrl.StartsWith("/api/")) { - var path = e.Request.RawUrl == "/" || e.Request.RawUrl.StartsWith("/deckeditor") || e.Request.RawUrl.StartsWith("/game/") || e.Request.RawUrl.StartsWith("/replay/") - ? "index.html" - : HttpUtility.UrlDecode(e.Request.RawUrl[1..]); - if (e.TryReadFile(path, out var bytes)) - SetResponse(e.Response, HttpStatusCode.OK, - Path.GetExtension(path) switch { - ".html" or ".htm" => "text/html", - ".css" => "text/css", - ".js" => "text/javascript", - ".png" => "image/png", - ".webp" => "image/webp", - ".woff" or ".woff2" => "font/woff", - _ => "application/octet-stream" - }, bytes); - else - SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "File not found.")); - return; - } else if (e.Request.RawUrl == "/api/games/new") { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (lockdown) { - SetErrorResponse(e.Response, new(HttpStatusCode.ServiceUnavailable, "ServerLocked", "The server is temporarily locked for an update. Please try again soon.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - try { - var d = DecodeFormData(e.Request.InputStream); - Guid clientToken; - if (!d.TryGetValue("name", out var name)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name.")); - return; - } - if (name.Length > 32) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long.")); - return; - } - var maxPlayers = 2; - if (d.TryGetValue("maxPlayers", out var maxPlayersString)) { - if (!int.TryParse(maxPlayersString, out maxPlayers) || maxPlayers < 2 || maxPlayers > 4) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidMaxPlayers", "Invalid player limit.")); - return; - } - } - int? turnTimeLimit = null; - if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString) && turnTimeLimitString != "") { - if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit.")); - return; - } - turnTimeLimit = turnTimeLimit2; - } - int? goalWinCount = null; - if (d.TryGetValue("goalWinCount", out var goalWinCountString) && goalWinCountString != "") { - if (!int.TryParse(goalWinCountString, out var goalWinCount2) || goalWinCount2 < 1) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGoalWinCount", "Invalid goal win count.")); - return; - } - goalWinCount = goalWinCount2; - } - if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") { - if (!Guid.TryParse(tokenString, out clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token.")); - return; - } - } else - clientToken = Guid.NewGuid(); - - StageSelectionRules? stageSelectionRuleFirst = null, stageSelectionRuleAfterWin = null, stageSelectionRuleAfterDraw = null; - if (d.TryGetValue("stageSelectionRuleFirst", out var json1)) { - if (!TryParseStageSelectionRule(json1, out stageSelectionRuleFirst) || stageSelectionRuleFirst.Method is StageSelectionMethod.Same or StageSelectionMethod.Counterpick) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleFirst was invalid.")); - return; - } - } else - stageSelectionRuleFirst = StageSelectionRules.Default; - if (d.TryGetValue("stageSelectionRuleAfterWin", out var json2)) { - if (!TryParseStageSelectionRule(json2, out stageSelectionRuleAfterWin)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterWin was invalid.")); - return; - } - } else - stageSelectionRuleAfterWin = stageSelectionRuleFirst; - if (d.TryGetValue("stageSelectionRuleAfterDraw", out var json3)) { - if (!TryParseStageSelectionRule(json3, out stageSelectionRuleAfterDraw) || stageSelectionRuleAfterDraw.Method == StageSelectionMethod.Counterpick) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterDraw was invalid.")); - return; - } - } else - stageSelectionRuleAfterDraw = stageSelectionRuleFirst; - - if (d.TryGetValue("forceSameDeckAfterDraw", out var forceSameDeckAfterDrawString) && !bool.TryParse(forceSameDeckAfterDrawString, out var forceSameDeckAfterDraw)) - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "forceSameDeckAfterDraw was invalid.")); - else - forceSameDeckAfterDraw = false; - - var game = new Game(maxPlayers) { GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw }; - game.TryAddPlayer(new(game, name, clientToken), out _, out _); - games.Add(game.ID, game); - timer.Start(); - - SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers })); - Console.WriteLine($"New game started: {game.ID}; {games.Count} games active; {inactiveGames.Count} inactive"); - } catch (ArgumentException) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); - } - } - } else if (e.Request.RawUrl == "/api/cards") { - SetStaticResponse(e.Request, e.Response, CardDatabase.JSON, CardDatabase.Version.ToString(), CardDatabase.LastModified); - } else if (e.Request.RawUrl == "/api/stages") { - SetStaticResponse(e.Request, e.Response, StageDatabase.JSON, StageDatabase.Version.ToString(), StageDatabase.LastModified); - } else { - var m = Regex.Match(e.Request.RawUrl, @"^/api/games/([\w-]+)(?:/(\w+)(?:\?clientToken=([\w-]+))?)?$", RegexOptions.Compiled); - if (m.Success) { - if (!Guid.TryParse(m.Groups[1].Value, out var gameID)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID.")); - return; - } - lock (games) { - if (!TryGetGame(gameID, out var game)) { - SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "GameNotFound", "Game not found.")); - return; - } - lock (game.Players) { - switch (m.Groups[2].Value) { - case "": { - if (e.Request.HttpMethod is not ("GET" or "HEAD")) { - e.Response.AddHeader("Allow", "GET, HEAD"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - return; - } - SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game)); - break; - } - case "playerData": { - if (e.Request.HttpMethod is not ("GET" or "HEAD")) { - e.Response.AddHeader("Allow", "GET, HEAD"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - return; - } - - if (!Guid.TryParse(m.Groups[3].Value, out var clientToken)) - clientToken = Guid.Empty; - - SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { - game, - playerData = game.GetPlayer(clientToken, out var playerIndex, out var player) - ? new PlayerData(playerIndex, player) - : null - })); - break; - } - - case "join": { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - try { - var d = DecodeFormData(e.Request.InputStream); - Guid clientToken; - if (!d.TryGetValue("name", out var name)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name.")); - return; - } - if (name.Length > 32) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long.")); - return; - } - if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") { - if (!Guid.TryParse(tokenString, out clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); - return; - } - } else - clientToken = Guid.NewGuid(); - - if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { - if (game.State != GameState.WaitingForPlayers) { - SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started.")); - return; - } - - player = new Player(game, name, clientToken); - if (!game.TryAddPlayer(player, out playerIndex, out var error)) { - SetErrorResponse(e.Response, error); - return; - } - - game.SendEvent("join", new { playerIndex, player }, false); - } - // If they're already in the game, resend the original join response instead of an error. - SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken })); - timer.Start(); - } catch (ArgumentException) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); - } - } - break; - } - case "setTurnTimeLimit": { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - var d = DecodeFormData(e.Request.InputStream); - if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); - return; - } - if (game.State != GameState.WaitingForPlayers) { - SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started.")); - return; - } - if (!game.GetPlayer(clientToken, out var playerIndex, out var player) || playerIndex != 0) { - SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that.")); - return; - } - if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString)) { - if (turnTimeLimitString == "") - game.TurnTimeLimit = null; - else if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit.")); - return; - } else - game.TurnTimeLimit = turnTimeLimit2; - } else { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit.")); - return; - } - - game.SendEvent("settingsChange", game, false); - } - break; - } - case "chooseStage": { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - var d = DecodeFormData(e.Request.InputStream); - if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); - return; - } - if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game.")); - return; - } - if (!d.TryGetValue("stages", out var stagesString)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stages.")); - return; - } - - var stages = new HashSet(); - foreach (var field in stagesString.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)) { - if (!int.TryParse(field, out var i)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stages.")); - return; - } - stages.Add(i); - } - - if (!game.TryChooseStages(player, stages, out var error)) { - SetErrorResponse(e.Response, error); - return; - } - - e.Response.StatusCode = (int) HttpStatusCode.NoContent; - game.SendPlayerReadyEvent(playerIndex, false); - timer.Start(); - } - break; - } - case "chooseDeck": { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - try { - var d = DecodeFormData(e.Request.InputStream); - if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); - return; - } - if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game.")); - return; - } - if (player.CurrentGameData.Deck != null) { - SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck.")); - return; - } - - if (!d.TryGetValue("deckName", out var deckName)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckName", "Missing deck name.")); - return; - } - var deckSleeves = 0; - if (d.TryGetValue("deckSleeves", out var deckSleevesString) && (!int.TryParse(deckSleevesString, out deckSleeves) || deckSleeves is < 0 or >= 25)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves.")); - return; - } - if (!d.TryGetValue("deckCards", out var deckString)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards.")); - return; - } - var array = deckString.Split(new[] { ',', '+', ' ' }, 15); - if (array.Length != 15) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list.")); - return; - } - int[]? upgrades = null; - if (d.TryGetValue("deckUpgrades", out var deckUpgradesString)) { - upgrades = new int[15]; - var array2 = deckUpgradesString.Split(new[] { ',', '+', ' ' }, 15); - for (var i = 0; i < 15; i++) { - if (int.TryParse(array2[i], out var j) && i is >= 0 and <= 2) - upgrades[i] = j; - else { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckUpgrades", "Invalid deck upgrade list.")); - return; - } - } - } - var cards = new int[15]; - for (var i = 0; i < 15; i++) { - if (!int.TryParse(array[i], out var cardNumber) || !CardDatabase.IsValidCardNumber(cardNumber)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list.")); - return; - } - if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates.")); - return; - } - cards[i] = cardNumber; - } - - player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15)); - e.Response.StatusCode = (int) HttpStatusCode.NoContent; - game.SendPlayerReadyEvent(playerIndex, false); - timer.Start(); - } catch (ArgumentException) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); - } - } - break; - } - case "play": { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - try { - var d = DecodeFormData(e.Request.InputStream); - if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); - return; - } - if (game.State != GameState.Ongoing) { - SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); - return; - } - if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { - SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); - return; - } - - if (player.Move != null) { - SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move.")); - return; - } - - if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidCard", "Missing or invalid card number.")); - return; - } - - var handIndex = player.GetHandIndex(cardNumber); - if (handIndex < 0) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "MissingCard", "You don't have that card.")); - return; - } - - var isTimeout = d.TryGetValue("isTimeout", out var isTimeoutStr) && isTimeoutStr.ToLower() is not ("false" or "0"); - - var card = player.Hand![handIndex]; - if (d.TryGetValue("isPass", out var isPassStr) && isPassStr.ToLower() is not ("false" or "0")) { - player.Move = new(card, true, 0, 0, 0, false, isTimeout); - } else { - var isSpecialAttack = d.TryGetValue("isSpecialAttack", out var isSpecialAttackStr) && isSpecialAttackStr.ToLower() is not ("false" or "0"); - if (!d.TryGetValue("x", out var xs) || !int.TryParse(xs, out var x) - || !d.TryGetValue("y", out var ys) || !int.TryParse(ys, out var y) - || !d.TryGetValue("r", out var rs) || !int.TryParse(rs, out var r)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidPosition", "Missing or invalid position.")); - return; - } - r &= 3; - if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "IllegalMove", "Illegal move")); - return; - } - player.Move = new(card, false, x, y, r, isSpecialAttack, isTimeout); - } - e.Response.StatusCode = (int) HttpStatusCode.NoContent; - game.SendPlayerReadyEvent(playerIndex, isTimeout); - timer.Start(); - } catch (ArgumentException) { - e.Response.StatusCode = (int) HttpStatusCode.BadRequest; - } - } - break; - } - case "redraw": { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - try { - if (game.State != GameState.Redraw) { - SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); - return; - } - - var d = DecodeFormData(e.Request.InputStream); - if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); - return; - } - if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { - SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); - return; - } - - if (player.Move != null) { - SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move.")); - return; - } - - var redraw = d.TryGetValue("redraw", out var redrawStr) && redrawStr.ToLower() is not ("false" or "0"); - player.Move = new(player.Hand![0], false, 0, 0, 0, redraw, false); - e.Response.StatusCode = (int) HttpStatusCode.NoContent; - game.SendPlayerReadyEvent(playerIndex, false); - timer.Start(); - } catch (ArgumentException) { - e.Response.StatusCode = (int) HttpStatusCode.BadRequest; - } - } - break; - } - case "nextGame": { - if (e.Request.HttpMethod != "POST") { - e.Response.AddHeader("Allow", "POST"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else if (e.Request.ContentLength64 >= 65536) { - e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; - } else { - try { - if (game.State is not (GameState.GameEnded or GameState.SetEnded)) { - SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); - return; - } - - var d = DecodeFormData(e.Request.InputStream); - if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); - return; - } - if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { - SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); - return; - } - - if (player.Move == null) { - player.Move = new(player.Hand![0], false, 0, 0, 0, false, false); // Dummy move to indicate that the player is ready. - game.SendPlayerReadyEvent(playerIndex, false); - } - e.Response.StatusCode = (int) HttpStatusCode.NoContent; - timer.Start(); - } catch (ArgumentException) { - e.Response.StatusCode = (int) HttpStatusCode.BadRequest; - } - } - break; - } - case "replay": { - if (e.Request.HttpMethod != "GET") { - e.Response.AddHeader("Allow", "GET"); - SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - } else { - if (game.State != GameState.SetEnded) { - SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set has ended.")); - return; - } - var ms = new MemoryStream(); - game.WriteReplayData(ms); - SetResponse(e.Response, HttpStatusCode.OK, "application/octet-stream", ms.ToArray()); - } - break; - } - default: - SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found.")); - break; - } - } - } - } else - SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found.")); - } - } - - private static void SetErrorResponse(HttpListenerResponse response, Error error) { - var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error)); - SetResponse(response, error.HttpStatusCode, "application/json", bytes); - } - private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) { - var bytes = Encoding.UTF8.GetBytes(content); - SetResponse(response, statusCode, contentType, bytes); - } - private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, byte[] content) { - response.StatusCode = (int) statusCode; - response.ContentType = contentType; - response.ContentLength64 = content.Length; - response.Close(content, true); - } - - private static Dictionary DecodeFormData(Stream stream) { - using var reader = new StreamReader(stream); - var s = reader.ReadToEnd(); - return s != "" - ? s.Split(new[] { '&' }).Select(s => s.Split('=')).Select(a => a.Length == 2 ? a : throw new ArgumentException("Invalid form data")) - .ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1])) - : new(); - } - - private static void SetStaticResponse(HttpListenerRequest request, HttpListenerResponse response, string jsonContent, string eTag, DateTime lastModified) { - if (request.HttpMethod is not ("GET" or "HEAD")) { - response.AddHeader("Allow", "GET, HEAD"); - SetErrorResponse(response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); - return; - } - response.AppendHeader("Cache-Control", "max-age=86400"); - response.AppendHeader("ETag", eTag); - response.AppendHeader("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"")); - - var ifNoneMatch = request.Headers["If-None-Match"]; - if (ifNoneMatch != null) { - if (request.Headers["If-None-Match"] == eTag) - response.StatusCode = (int) HttpStatusCode.NotModified; - else - SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent); - } else { - if (DateTime.TryParseExact(request.Headers["If-Modified-Since"], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTime) - && dateTime >= lastModified.ToUniversalTime()) - response.StatusCode = (int) HttpStatusCode.NotModified; - else - SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent); - } - } - - internal static bool TryGetGame(Guid gameID, [MaybeNullWhen(false)] out Game game) { - if (games.TryGetValue(gameID, out game)) { - game.abandonedSince = DateTime.UtcNow; - return true; - } else if (inactiveGames.TryGetValue(gameID, out game)) { - inactiveGames.Remove(gameID); - games[gameID] = game; - game.abandonedSince = DateTime.UtcNow; - Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive"); - return true; - } - return false; - } - - private static bool TryParseStageSelectionRule(string json, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) { - try { - stageSelectionRule = JsonUtils.Deserialise(json); - return stageSelectionRule != null; - } catch (JsonSerializationException) { - stageSelectionRule = null; - return false; - } - } -} +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Timers; +using System.Web; +using Newtonsoft.Json; +using TableturfBattleServer.DTO; +using WebSocketSharp.Server; +using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest; +using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse; +using Timer = System.Timers.Timer; + +namespace TableturfBattleServer; + +internal class Program { + internal static HttpServer? httpServer; + + internal static Dictionary games = new(); + internal static Dictionary inactiveGames = new(); + internal static readonly Timer timer = new(1000); + private static bool lockdown; + + private const int InactiveGameLimit = 1000; + private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5); + + private static string? GetClientRootPath() { + var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + while (true) { + if (directory == null) return null; + var directory2 = Path.Combine(directory, "TableturfBattleClient"); + if (Directory.Exists(directory2)) return directory2; + directory = Path.GetDirectoryName(directory); + } + } + + internal static void Main(string[] args) { + httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() }; + + timer.Elapsed += Timer_Elapsed; + + httpServer.AddWebSocketService("/api/websocket"); + httpServer.OnGet += HttpServer_OnRequest; + httpServer.OnPost += HttpServer_OnRequest; + httpServer.Start(); + Console.WriteLine($"Listening on http://{httpServer.Address}:{httpServer.Port}"); + if (httpServer.DocumentRootPath != null) + Console.WriteLine($"Serving client files from {httpServer.DocumentRootPath}"); + else + Console.WriteLine($"Client files were not found."); + + while (true) { + var s = Console.ReadLine(); + if (s == null) + Thread.Sleep(Timeout.Infinite); + else { + s = s.Trim().ToLower(); + if (s == "update") { + if (games.Count == 0) + Environment.Exit(2); + lockdown = true; + Console.WriteLine("Locking server for update."); + } + } + } + } + + private static void Timer_Elapsed(object? sender, ElapsedEventArgs e) { + lock (games) { + foreach (var (id, game) in games) { + lock (game.Players) { + game.Tick(); + if (DateTime.UtcNow - game.abandonedSince >= InactiveGameTimeout) { + games.Remove(id); + inactiveGames.Add(id, game); + Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive"); + if (lockdown && games.Count == 0) + Environment.Exit(2); + } + } + } + if (inactiveGames.Count >= InactiveGameLimit) { + foreach (var (k, _) in inactiveGames.Select(e => (e.Key, e.Value.abandonedSince)).OrderBy(e => e.abandonedSince).Take(InactiveGameLimit / 2)) + inactiveGames.Remove(k); + Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive"); + } + } + } + + private static void HttpServer_OnRequest(object? sender, HttpRequestEventArgs e) { + e.Response.AppendHeader("Access-Control-Allow-Origin", "*"); + if (!e.Request.RawUrl.StartsWith("/api/")) { + var path = e.Request.RawUrl == "/" || e.Request.RawUrl.StartsWith("/deckeditor") || e.Request.RawUrl.StartsWith("/game/") || e.Request.RawUrl.StartsWith("/replay/") + ? "index.html" + : HttpUtility.UrlDecode(e.Request.RawUrl[1..]); + if (e.TryReadFile(path, out var bytes)) + SetResponse(e.Response, HttpStatusCode.OK, + Path.GetExtension(path) switch { + ".html" or ".htm" => "text/html", + ".css" => "text/css", + ".js" => "text/javascript", + ".png" => "image/png", + ".webp" => "image/webp", + ".woff" or ".woff2" => "font/woff", + _ => "application/octet-stream" + }, bytes); + else + SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "File not found.")); + return; + } else if (e.Request.RawUrl == "/api/games/new") { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (lockdown) { + SetErrorResponse(e.Response, new(HttpStatusCode.ServiceUnavailable, "ServerLocked", "The server is temporarily locked for an update. Please try again soon.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + try { + var d = DecodeFormData(e.Request.InputStream); + Guid clientToken; + if (!d.TryGetValue("name", out var name)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name.")); + return; + } + if (name.Length > 32) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long.")); + return; + } + var maxPlayers = 2; + if (d.TryGetValue("maxPlayers", out var maxPlayersString)) { + if (!int.TryParse(maxPlayersString, out maxPlayers) || maxPlayers < 2 || maxPlayers > 4) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidMaxPlayers", "Invalid player limit.")); + return; + } + } + int? turnTimeLimit = null; + if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString) && turnTimeLimitString != "") { + if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit.")); + return; + } + turnTimeLimit = turnTimeLimit2; + } + int? goalWinCount = null; + if (d.TryGetValue("goalWinCount", out var goalWinCountString) && goalWinCountString != "") { + if (!int.TryParse(goalWinCountString, out var goalWinCount2) || goalWinCount2 < 1) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGoalWinCount", "Invalid goal win count.")); + return; + } + goalWinCount = goalWinCount2; + } + if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") { + if (!Guid.TryParse(tokenString, out clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token.")); + return; + } + } else + clientToken = Guid.NewGuid(); + + StageSelectionRules? stageSelectionRuleFirst = null, stageSelectionRuleAfterWin = null, stageSelectionRuleAfterDraw = null; + if (d.TryGetValue("stageSelectionRuleFirst", out var json1)) { + if (!TryParseStageSelectionRule(json1, out stageSelectionRuleFirst) || stageSelectionRuleFirst.Method is StageSelectionMethod.Same or StageSelectionMethod.Counterpick) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleFirst was invalid.")); + return; + } + } else + stageSelectionRuleFirst = StageSelectionRules.Default; + if (d.TryGetValue("stageSelectionRuleAfterWin", out var json2)) { + if (!TryParseStageSelectionRule(json2, out stageSelectionRuleAfterWin)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterWin was invalid.")); + return; + } + } else + stageSelectionRuleAfterWin = stageSelectionRuleFirst; + if (d.TryGetValue("stageSelectionRuleAfterDraw", out var json3)) { + if (!TryParseStageSelectionRule(json3, out stageSelectionRuleAfterDraw) || stageSelectionRuleAfterDraw.Method == StageSelectionMethod.Counterpick) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterDraw was invalid.")); + return; + } + } else + stageSelectionRuleAfterDraw = stageSelectionRuleFirst; + + var forceSameDeckAfterDraw = false; + if (d.TryGetValue("forceSameDeckAfterDraw", out var forceSameDeckAfterDrawString)) { + if (!bool.TryParse(forceSameDeckAfterDrawString, out forceSameDeckAfterDraw)) + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "forceSameDeckAfterDraw was invalid.")); + } else + forceSameDeckAfterDraw = false; + + var spectate = false; + if (d.TryGetValue("spectate", out var spectateString)) { + if (!bool.TryParse(spectateString, out spectate)) + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "spectate was invalid.")); + } else + spectate = false; + + var game = new Game(maxPlayers) { GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw }; + if (!spectate) + game.TryAddPlayer(new(game, name, clientToken), out _, out _); + games.Add(game.ID, game); + timer.Start(); + + SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers })); + Console.WriteLine($"New game started: {game.ID}; {games.Count} games active; {inactiveGames.Count} inactive"); + } catch (ArgumentException) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); + } + } + } else if (e.Request.RawUrl == "/api/cards") { + SetStaticResponse(e.Request, e.Response, CardDatabase.JSON, CardDatabase.Version.ToString(), CardDatabase.LastModified); + } else if (e.Request.RawUrl == "/api/stages") { + SetStaticResponse(e.Request, e.Response, StageDatabase.JSON, StageDatabase.Version.ToString(), StageDatabase.LastModified); + } else { + var m = Regex.Match(e.Request.RawUrl, @"^/api/games/([\w-]+)(?:/(\w+)(?:\?clientToken=([\w-]+))?)?$", RegexOptions.Compiled); + if (m.Success) { + if (!Guid.TryParse(m.Groups[1].Value, out var gameID)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID.")); + return; + } + lock (games) { + if (!TryGetGame(gameID, out var game)) { + SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "GameNotFound", "Game not found.")); + return; + } + lock (game.Players) { + switch (m.Groups[2].Value) { + case "": { + if (e.Request.HttpMethod is not ("GET" or "HEAD")) { + e.Response.AddHeader("Allow", "GET, HEAD"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + return; + } + SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game)); + break; + } + case "playerData": { + if (e.Request.HttpMethod is not ("GET" or "HEAD")) { + e.Response.AddHeader("Allow", "GET, HEAD"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + return; + } + + if (!Guid.TryParse(m.Groups[3].Value, out var clientToken)) + clientToken = Guid.Empty; + + SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { + game, + playerData = game.GetPlayer(clientToken, out var playerIndex, out var player) + ? new PlayerData(playerIndex, player) + : null + })); + break; + } + + case "join": { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + try { + var d = DecodeFormData(e.Request.InputStream); + Guid clientToken; + if (!d.TryGetValue("name", out var name)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name.")); + return; + } + if (name.Length > 32) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long.")); + return; + } + if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") { + if (!Guid.TryParse(tokenString, out clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + } else + clientToken = Guid.NewGuid(); + + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + if (game.State != GameState.WaitingForPlayers) { + SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started.")); + return; + } + + player = new Player(game, name, clientToken); + if (!game.TryAddPlayer(player, out playerIndex, out var error)) { + SetErrorResponse(e.Response, error); + return; + } + + game.SendEvent("join", new { playerIndex, player }, false); + } + // If they're already in the game, resend the original join response instead of an error. + SetResponse(e.Response, HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken })); + timer.Start(); + } catch (ArgumentException) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); + } + } + break; + } + case "setTurnTimeLimit": { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + var d = DecodeFormData(e.Request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (game.State != GameState.WaitingForPlayers) { + SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player) || playerIndex != 0) { + SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that.")); + return; + } + if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString)) { + if (turnTimeLimitString == "") + game.TurnTimeLimit = null; + else if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit.")); + return; + } else + game.TurnTimeLimit = turnTimeLimit2; + } else { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidTurnTimeLimit", "Invalid turn time limit.")); + return; + } + + game.SendEvent("settingsChange", game, false); + } + break; + } + case "chooseStage": { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + var d = DecodeFormData(e.Request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game.")); + return; + } + if (!d.TryGetValue("stages", out var stagesString)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stages.")); + return; + } + + var stages = new HashSet(); + foreach (var field in stagesString.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)) { + if (!int.TryParse(field, out var i)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stages.")); + return; + } + stages.Add(i); + } + + if (!game.TryChooseStages(player, stages, out var error)) { + SetErrorResponse(e.Response, error); + return; + } + + e.Response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, false); + timer.Start(); + } + break; + } + case "chooseDeck": { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + try { + var d = DecodeFormData(e.Request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game.")); + return; + } + if (player.CurrentGameData.Deck != null) { + SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck.")); + return; + } + + if (!d.TryGetValue("deckName", out var deckName)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckName", "Missing deck name.")); + return; + } + var deckSleeves = 0; + if (d.TryGetValue("deckSleeves", out var deckSleevesString) && (!int.TryParse(deckSleevesString, out deckSleeves) || deckSleeves is < 0 or >= 25)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves.")); + return; + } + if (!d.TryGetValue("deckCards", out var deckString)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards.")); + return; + } + var array = deckString.Split(new[] { ',', '+', ' ' }, 15); + if (array.Length != 15) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list.")); + return; + } + int[]? upgrades = null; + if (d.TryGetValue("deckUpgrades", out var deckUpgradesString)) { + upgrades = new int[15]; + var array2 = deckUpgradesString.Split(new[] { ',', '+', ' ' }, 15); + for (var i = 0; i < 15; i++) { + if (int.TryParse(array2[i], out var j) && i is >= 0 and <= 2) + upgrades[i] = j; + else { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckUpgrades", "Invalid deck upgrade list.")); + return; + } + } + } + var cards = new int[15]; + for (var i = 0; i < 15; i++) { + if (!int.TryParse(array[i], out var cardNumber) || !CardDatabase.IsValidCardNumber(cardNumber)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list.")); + return; + } + if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates.")); + return; + } + cards[i] = cardNumber; + } + + player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15)); + e.Response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, false); + timer.Start(); + } catch (ArgumentException) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); + } + } + break; + } + case "play": { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + try { + var d = DecodeFormData(e.Request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (game.State != GameState.Ongoing) { + SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); + return; + } + + if (player.Move != null) { + SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move.")); + return; + } + + if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidCard", "Missing or invalid card number.")); + return; + } + + var handIndex = player.GetHandIndex(cardNumber); + if (handIndex < 0) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "MissingCard", "You don't have that card.")); + return; + } + + var isTimeout = d.TryGetValue("isTimeout", out var isTimeoutStr) && isTimeoutStr.ToLower() is not ("false" or "0"); + + var card = player.Hand![handIndex]; + if (d.TryGetValue("isPass", out var isPassStr) && isPassStr.ToLower() is not ("false" or "0")) { + player.Move = new(card, true, 0, 0, 0, false, isTimeout); + } else { + var isSpecialAttack = d.TryGetValue("isSpecialAttack", out var isSpecialAttackStr) && isSpecialAttackStr.ToLower() is not ("false" or "0"); + if (!d.TryGetValue("x", out var xs) || !int.TryParse(xs, out var x) + || !d.TryGetValue("y", out var ys) || !int.TryParse(ys, out var y) + || !d.TryGetValue("r", out var rs) || !int.TryParse(rs, out var r)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidPosition", "Missing or invalid position.")); + return; + } + r &= 3; + if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) { + SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "IllegalMove", "Illegal move")); + return; + } + player.Move = new(card, false, x, y, r, isSpecialAttack, isTimeout); + } + e.Response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, isTimeout); + timer.Start(); + } catch (ArgumentException) { + e.Response.StatusCode = (int) HttpStatusCode.BadRequest; + } + } + break; + } + case "redraw": { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + try { + if (game.State != GameState.Redraw) { + SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); + return; + } + + var d = DecodeFormData(e.Request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); + return; + } + + if (player.Move != null) { + SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move.")); + return; + } + + var redraw = d.TryGetValue("redraw", out var redrawStr) && redrawStr.ToLower() is not ("false" or "0"); + player.Move = new(player.Hand![0], false, 0, 0, 0, redraw, false); + e.Response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, false); + timer.Start(); + } catch (ArgumentException) { + e.Response.StatusCode = (int) HttpStatusCode.BadRequest; + } + } + break; + } + case "nextGame": { + if (e.Request.HttpMethod != "POST") { + e.Response.AddHeader("Allow", "POST"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (e.Request.ContentLength64 >= 65536) { + e.Response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + try { + if (game.State is not (GameState.GameEnded or GameState.SetEnded)) { + SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); + return; + } + + var d = DecodeFormData(e.Request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); + return; + } + + if (player.Move == null) { + player.Move = new(player.Hand![0], false, 0, 0, 0, false, false); // Dummy move to indicate that the player is ready. + game.SendPlayerReadyEvent(playerIndex, false); + } + e.Response.StatusCode = (int) HttpStatusCode.NoContent; + timer.Start(); + } catch (ArgumentException) { + e.Response.StatusCode = (int) HttpStatusCode.BadRequest; + } + } + break; + } + case "replay": { + if (e.Request.HttpMethod != "GET") { + e.Response.AddHeader("Allow", "GET"); + SetErrorResponse(e.Response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else { + if (game.State != GameState.SetEnded) { + SetErrorResponse(e.Response, new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set has ended.")); + return; + } + var ms = new MemoryStream(); + game.WriteReplayData(ms); + SetResponse(e.Response, HttpStatusCode.OK, "application/octet-stream", ms.ToArray()); + } + break; + } + default: + SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found.")); + break; + } + } + } + } else + SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found.")); + } + } + + private static void SetErrorResponse(HttpListenerResponse response, Error error) { + var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error)); + SetResponse(response, error.HttpStatusCode, "application/json", bytes); + } + private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) { + var bytes = Encoding.UTF8.GetBytes(content); + SetResponse(response, statusCode, contentType, bytes); + } + private static void SetResponse(HttpListenerResponse response, HttpStatusCode statusCode, string contentType, byte[] content) { + response.StatusCode = (int) statusCode; + response.ContentType = contentType; + response.ContentLength64 = content.Length; + response.Close(content, true); + } + + private static Dictionary DecodeFormData(Stream stream) { + using var reader = new StreamReader(stream); + var s = reader.ReadToEnd(); + return s != "" + ? s.Split(new[] { '&' }).Select(s => s.Split('=')).Select(a => a.Length == 2 ? a : throw new ArgumentException("Invalid form data")) + .ToDictionary(a => HttpUtility.UrlDecode(a[0]), a => HttpUtility.UrlDecode(a[1])) + : new(); + } + + private static void SetStaticResponse(HttpListenerRequest request, HttpListenerResponse response, string jsonContent, string eTag, DateTime lastModified) { + if (request.HttpMethod is not ("GET" or "HEAD")) { + response.AddHeader("Allow", "GET, HEAD"); + SetErrorResponse(response, new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + return; + } + response.AppendHeader("Cache-Control", "max-age=86400"); + response.AppendHeader("ETag", eTag); + response.AppendHeader("Last-Modified", lastModified.ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"")); + + var ifNoneMatch = request.Headers["If-None-Match"]; + if (ifNoneMatch != null) { + if (request.Headers["If-None-Match"] == eTag) + response.StatusCode = (int) HttpStatusCode.NotModified; + else + SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent); + } else { + if (DateTime.TryParseExact(request.Headers["If-Modified-Since"], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dateTime) + && dateTime >= lastModified.ToUniversalTime()) + response.StatusCode = (int) HttpStatusCode.NotModified; + else + SetResponse(response, HttpStatusCode.OK, "application/json", jsonContent); + } + } + + internal static bool TryGetGame(Guid gameID, [MaybeNullWhen(false)] out Game game) { + if (games.TryGetValue(gameID, out game)) { + game.abandonedSince = DateTime.UtcNow; + return true; + } else if (inactiveGames.TryGetValue(gameID, out game)) { + inactiveGames.Remove(gameID); + games[gameID] = game; + game.abandonedSince = DateTime.UtcNow; + Console.WriteLine($"{games.Count} games active; {inactiveGames.Count} inactive"); + return true; + } + return false; + } + + private static bool TryParseStageSelectionRule(string json, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) { + try { + stageSelectionRule = JsonUtils.Deserialise(json); + return stageSelectionRule != null; + } catch (JsonSerializationException) { + stageSelectionRule = null; + return false; + } + } +}