From 242ff60a9de0bed027c168e38576e4063cd8dbc9 Mon Sep 17 00:00:00 2001 From: Andrio Celos Date: Thu, 29 Feb 2024 11:39:04 +1100 Subject: [PATCH] Reorganise server code; move API endpoints into separate methods --- TableturfBattleServer/.vscode/launch.json | 2 +- TableturfBattleServer/ApiEndpointAttribute.cs | 14 + TableturfBattleServer/ApiEndpoints.cs | 529 ++++++++++++ TableturfBattleServer/Game.cs | 2 +- TableturfBattleServer/HttpRequestHelper.cs | 81 ++ TableturfBattleServer/Program.cs | 803 ++---------------- TableturfBattleServer/Server.cs | 55 ++ .../TableturfWebSocketBehaviour.cs | 2 +- 8 files changed, 765 insertions(+), 723 deletions(-) create mode 100644 TableturfBattleServer/ApiEndpointAttribute.cs create mode 100644 TableturfBattleServer/ApiEndpoints.cs create mode 100644 TableturfBattleServer/HttpRequestHelper.cs create mode 100644 TableturfBattleServer/Server.cs diff --git a/TableturfBattleServer/.vscode/launch.json b/TableturfBattleServer/.vscode/launch.json index 3b4da5c..6f3e35e 100644 --- a/TableturfBattleServer/.vscode/launch.json +++ b/TableturfBattleServer/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/bin/Debug/net6.0/TableturfBattleServer.dll", + "program": "${workspaceFolder}/bin/Debug/net8.0/TableturfBattleServer.dll", "args": [], "cwd": "${workspaceFolder}", // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console diff --git a/TableturfBattleServer/ApiEndpointAttribute.cs b/TableturfBattleServer/ApiEndpointAttribute.cs new file mode 100644 index 0000000..02776e4 --- /dev/null +++ b/TableturfBattleServer/ApiEndpointAttribute.cs @@ -0,0 +1,14 @@ +namespace TableturfBattleServer; +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +internal class ApiEndpointAttribute(ApiEndpointNamespace endpointNamespace, string path, string allowedMethod) : Attribute { + public ApiEndpointNamespace Namespace { get; } = endpointNamespace; + public string Path { get; } = path; + public string AllowedMethod { get; } = allowedMethod; + + public ApiEndpointAttribute(string path, string allowedMethod) : this(ApiEndpointNamespace.ApiRoot, path, allowedMethod) { } +} + +internal enum ApiEndpointNamespace { + ApiRoot, + Game +} \ No newline at end of file diff --git a/TableturfBattleServer/ApiEndpoints.cs b/TableturfBattleServer/ApiEndpoints.cs new file mode 100644 index 0000000..6d9f428 --- /dev/null +++ b/TableturfBattleServer/ApiEndpoints.cs @@ -0,0 +1,529 @@ +#pragma warning disable IDE0060 // Remove unused parameter + +using System.Net; +using TableturfBattleServer.DTO; +using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest; +using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse; + +namespace TableturfBattleServer; +internal static class ApiEndpoints { + internal static readonly char[] DELIMITERS = [',', ' ']; + internal const int CUSTOM_CARD_START = -10000; + internal const int RECEIVED_CUSTOM_CARD_START = -20000; + + [ApiEndpoint("/games/new", "POST")] + public static void ApiGamesNew(HttpListenerRequest request, HttpListenerResponse response) { + if (request.HttpMethod != "POST") { + response.AddHeader("Allow", "POST"); + response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + } else if (Server.Instance.Lockdown) { + response.SetErrorResponse(new(HttpStatusCode.ServiceUnavailable, "ServerLocked", "The server is temporarily locked for an update. Please try again soon.")); + } else if (request.ContentLength64 >= 65536) { + response.StatusCode = (int) HttpStatusCode.RequestEntityTooLarge; + } else { + try { + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + Guid clientToken; + if (!d.TryGetValue("name", out var name)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name.")); + return; + } + if (name.Length > 32) { + response.SetErrorResponse(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) { + response.SetErrorResponse(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) { + response.SetErrorResponse(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) { + response.SetErrorResponse(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)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidClientToken", "Invalid client token.")); + return; + } + } else + clientToken = Guid.NewGuid(); + + bool allowUpcomingCards; + if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) { + if (!bool.TryParse(allowUpcomingCardsString, out allowUpcomingCards)) + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowUpcomingCards was invalid.")); + } else + allowUpcomingCards = true; + + bool allowCustomCards; + if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) { + if (!bool.TryParse(allowCustomCardsString, out allowCustomCards)) + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowCustomCards was invalid.")); + } else + allowCustomCards = false; + + StageSelectionRules? stageSelectionRuleFirst = null, stageSelectionRuleAfterWin = null, stageSelectionRuleAfterDraw = null; + if (d.TryGetValue("stageSelectionRuleFirst", out var json1)) { + if (!HttpRequestHelper.TryParseStageSelectionRule(json1, maxPlayers, out stageSelectionRuleFirst) || stageSelectionRuleFirst.Method is StageSelectionMethod.Same or StageSelectionMethod.Counterpick) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleFirst was invalid.")); + return; + } + } else + stageSelectionRuleFirst = StageSelectionRules.Default; + if (d.TryGetValue("stageSelectionRuleAfterWin", out var json2)) { + if (!HttpRequestHelper.TryParseStageSelectionRule(json2, maxPlayers, out stageSelectionRuleAfterWin)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "stageSelectionRuleAfterWin was invalid.")); + return; + } + } else + stageSelectionRuleAfterWin = stageSelectionRuleFirst; + if (d.TryGetValue("stageSelectionRuleAfterDraw", out var json3)) { + if (!HttpRequestHelper.TryParseStageSelectionRule(json3, maxPlayers, out stageSelectionRuleAfterDraw) || stageSelectionRuleAfterDraw.Method == StageSelectionMethod.Counterpick) { + response.SetErrorResponse(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)) + response.SetErrorResponse(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)) + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "spectate was invalid.")); + } else + spectate = false; + + var game = new Game(maxPlayers) { HostClientToken = clientToken, GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, AllowUpcomingCards = allowUpcomingCards, AllowCustomCards = allowCustomCards, StageSelectionRuleFirst = stageSelectionRuleFirst, StageSelectionRuleAfterWin = stageSelectionRuleAfterWin, StageSelectionRuleAfterDraw = stageSelectionRuleAfterDraw, ForceSameDeckAfterDraw = forceSameDeckAfterDraw }; + if (!spectate) + game.TryAddPlayer(new(game, name, clientToken), out _, out _); + Server.Instance.games.Add(game.ID, game); + Server.Instance.timer.Start(); + + response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { gameID = game.ID, clientToken, maxPlayers })); + Console.WriteLine($"New game started: {game.ID}; {Server.Instance.games.Count} games active; {Server.Instance.inactiveGames.Count} inactive"); + } catch (ArgumentException) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); + } + } + } + + [ApiEndpoint("/cards", "GET")] + public static void ApiCards(HttpListenerRequest request, HttpListenerResponse response) + => HttpRequestHelper.SetStaticResponse(request, response, CardDatabase.JSON, CardDatabase.Version.ToString(), CardDatabase.LastModified); + + [ApiEndpoint("/stages", "GET")] + public static void ApiStages(HttpListenerRequest request, HttpListenerResponse response) + => HttpRequestHelper.SetStaticResponse(request, response, StageDatabase.JSON, StageDatabase.Version.ToString(), StageDatabase.LastModified); + + [ApiEndpoint(ApiEndpointNamespace.Game, "/", "GET")] + public static void ApiGameRoot(Game game, HttpListenerRequest request, HttpListenerResponse response) + => response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(game)); + + [ApiEndpoint(ApiEndpointNamespace.Game, "/playerData", "GET")] + public static void ApiGamePlayerData(Game game, HttpListenerRequest request, HttpListenerResponse response) { + if (request.QueryString["clientToken"] is not string s || !Guid.TryParse(s, out var clientToken)) + clientToken = Guid.Empty; + + response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { + game, + playerData = game.GetPlayer(clientToken, out var playerIndex, out var player) + ? new PlayerData(playerIndex, player) + : null + })); + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/join", "POST")] + public static void ApiGameJoin(Game game, HttpListenerRequest request, HttpListenerResponse response) { + try { + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + Guid clientToken; + if (!d.TryGetValue("name", out var name)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Missing name.")); + return; + } + if (name.Length > 32) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidName", "Name is too long.")); + return; + } + if (d.TryGetValue("clientToken", out var tokenString) && tokenString != "") { + if (!Guid.TryParse(tokenString, out clientToken)) { + response.SetErrorResponse(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) { + response.SetErrorResponse(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)) { + response.SetErrorResponse(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. + response.SetResponse(HttpStatusCode.OK, "application/json", JsonUtils.Serialise(new { playerIndex, clientToken })); + Server.Instance.timer.Start(); + } catch (ArgumentException) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); + } + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/setGameSettings", "POST")] + public static void ApiGameSetGameSettings(Game game, HttpListenerRequest request, HttpListenerResponse response) { + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (clientToken != game.HostClientToken) { + response.SetErrorResponse(new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that.")); + return; + } + if (game.State != GameState.WaitingForPlayers) { + response.SetErrorResponse(new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started.")); + return; + } + + if (d.TryGetValue("turnTimeLimit", out var turnTimeLimitString)) { + if (turnTimeLimitString == "") + game.TurnTimeLimit = null; + else if (!int.TryParse(turnTimeLimitString, out var turnTimeLimit2) || turnTimeLimit2 < 10) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid turn time limit.")); + return; + } else + game.TurnTimeLimit = turnTimeLimit2; + } + + if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) { + if (!bool.TryParse(allowUpcomingCardsString, out var allowUpcomingCards)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowUpcomingCards.")); + return; + } else + game.AllowUpcomingCards = allowUpcomingCards; + } + + if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) { + if (!bool.TryParse(allowCustomCardsString, out var allowCustomCards)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowCustomCards.")); + return; + } else + game.AllowCustomCards = allowCustomCards; + } + + game.SendEvent("settingsChange", game, false); + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/chooseStage", "POST")] + public static void ApiGameChooseStage(Game game, HttpListenerRequest request, HttpListenerResponse response) { + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game.")); + return; + } + if (!d.TryGetValue("stages", out var stagesString)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidStage", "Missing stages.")); + return; + } + + var stages = new HashSet(); + foreach (var field in stagesString.Split(DELIMITERS, StringSplitOptions.RemoveEmptyEntries)) { + if (!int.TryParse(field, out var i)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stages.")); + return; + } + stages.Add(i); + } + + if (!game.TryChooseStages(player, stages, out var error)) { + response.SetErrorResponse(error); + return; + } + + response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, false); + Server.Instance.timer.Start(); + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/chooseDeck", "POST")] + public static void ApiGameChooseDeck(Game game, HttpListenerRequest request, HttpListenerResponse response) { + try { + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "NotInGame", "You're not in the game.")); + return; + } + if (player.CurrentGameData.Deck != null) { + response.SetErrorResponse(new(HttpStatusCode.Conflict, "DeckAlreadyChosen", "You've already chosen a deck.")); + return; + } + + if (!d.TryGetValue("deckName", out var deckName)) { + response.SetErrorResponse(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)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckSleeves", "Invalid deck sleeves.")); + return; + } + if (!d.TryGetValue("deckCards", out var deckString)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidDeckCards", "Missing deck cards.")); + return; + } + + Dictionary? userCustomCards = null; + List>? customCardsToAdd = null; + if (d.TryGetValue("customCards", out var customCardsString)) { + if (!game.AllowCustomCards) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "CustomCardsNotAllowed", "Custom cards cannot be used in this game.")); + return; + } + userCustomCards = JsonUtils.Deserialise>(customCardsString); + + // Validate custom cards. + if (userCustomCards is null || userCustomCards.Count > 15 || userCustomCards.Keys.Any(k => k is not (<= -10000 and >= short.MinValue))) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", "Invalid custom cards.")); + return; + } + + customCardsToAdd = new(userCustomCards.Count); + foreach (var (k, v) in userCustomCards) { + if (!v.CheckGrid(out var hasSpecialSpace, out var size)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Custom card {k} is invalid.")); + return; + } + // Allow resending the same custom card, but not a different custom card with the same key. + if (player.customCardMap.TryGetValue(k, out var existingCustomCardNumber)) { + if (!v.Equals(game.customCards[RECEIVED_CUSTOM_CARD_START - existingCustomCardNumber])) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Cannot reuse custom card number {k}.")); + return; + } + } else { + // TODO: Consolidate identical custom cards brought by different players. + var card = v.ToCard(RECEIVED_CUSTOM_CARD_START - (game.customCards.Count + customCardsToAdd.Count), k, !hasSpecialSpace && size >= 8 ? 3 : null); + customCardsToAdd.Add(new(k, card)); + } + } + } + + var array = deckString.Split([',', '+', ' '], 15); + if (array.Length != 15) { + response.SetErrorResponse(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([',', '+', ' '], 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 { + response.SetErrorResponse(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) && (userCustomCards == null || !userCustomCards.ContainsKey(cardNumber)))) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Invalid deck list.")); + return; + } + if (Array.IndexOf(cards, cardNumber, 0, i) >= 0) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "InvalidDeckCards", "Deck cannot have duplicates.")); + return; + } + if (!game.AllowUpcomingCards && cardNumber is < 0 and > CUSTOM_CARD_START && CardDatabase.GetCard(cardNumber).Number < 0) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "ForbiddenDeck", "Upcoming cards cannot be used in this game.")); + return; + } + // Translate custom card numbers from the player to game-scoped card numbers. + cards[i] = player.customCardMap.TryGetValue(cardNumber, out var n) ? n : cardNumber <= CUSTOM_CARD_START && customCardsToAdd?.FirstOrDefault(e => e.Key == cardNumber).Value is Card customCard ? customCard.Number : cardNumber; + } + + if (customCardsToAdd != null) { + foreach (var (userKey, card) in customCardsToAdd) { + player.customCardMap.Add(userKey, card.Number); + game.customCards.Add(card); + } + } + player.CurrentGameData.Deck = game.GetDeck(deckName, deckSleeves, cards, upgrades ?? Enumerable.Repeat(0, 15)); + response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, false); + Server.Instance.timer.Start(); + } catch (ArgumentException) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestData", "Invalid form data")); + } + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/play", "POST")] + public static void ApiGamePlay(Game game, HttpListenerRequest request, HttpListenerResponse response) { + try { + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (game.State != GameState.Ongoing) { + response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); + return; + } + + if (player.Move != null) { + response.SetErrorResponse(new(HttpStatusCode.Conflict, "MoveAlreadyChosen", "You've already chosen a move.")); + return; + } + + if (!d.TryGetValue("cardNumber", out var cardNumberStr) || !int.TryParse(cardNumberStr, out var cardNumber)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidCard", "Missing or invalid card number.")); + return; + } + + var handIndex = player.GetHandIndex(cardNumber); + if (handIndex < 0) { + response.SetErrorResponse(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)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidPosition", "Missing or invalid position.")); + return; + } + r &= 3; + if (!game.CanPlay(playerIndex, card, x, y, r, isSpecialAttack)) { + response.SetErrorResponse(new(HttpStatusCode.UnprocessableEntity, "IllegalMove", "Illegal move")); + return; + } + player.Move = new(card, false, x, y, r, isSpecialAttack, isTimeout); + } + response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, isTimeout); + Server.Instance.timer.Start(); + } catch (ArgumentException) { + response.StatusCode = (int) HttpStatusCode.BadRequest; + } + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/redraw", "POST")] + public static void ApiGameRedraw(Game game, HttpListenerRequest request, HttpListenerResponse response) { + try { + if (game.State != GameState.Redraw) { + response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); + return; + } + + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + response.SetErrorResponse(new(HttpStatusCode.Forbidden, "NotInGame", "You're not in the game.")); + return; + } + + if (player.Move != null) { + response.SetErrorResponse(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); + response.StatusCode = (int) HttpStatusCode.NoContent; + game.SendPlayerReadyEvent(playerIndex, false); + Server.Instance.timer.Start(); + } catch (ArgumentException) { + response.StatusCode = (int) HttpStatusCode.BadRequest; + } + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/nextGame", "POST")] + public static void ApiGameNextGame(Game game, HttpListenerRequest request, HttpListenerResponse response) { + try { + if (game.State is not (GameState.GameEnded or GameState.SetEnded)) { + response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameNotSetUp", "You can't do that in this game state.")); + return; + } + + var d = HttpRequestHelper.DecodeFormData(request.InputStream); + if (!d.TryGetValue("clientToken", out var tokenString) || !Guid.TryParse(tokenString, out var clientToken)) { + response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidClientToken", "Invalid client token.")); + return; + } + if (!game.GetPlayer(clientToken, out var playerIndex, out var player)) { + response.SetErrorResponse(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); + } + response.StatusCode = (int) HttpStatusCode.NoContent; + Server.Instance.timer.Start(); + } catch (ArgumentException) { + response.StatusCode = (int) HttpStatusCode.BadRequest; + } + } + + [ApiEndpoint(ApiEndpointNamespace.Game, "/replay", "GET")] + public static void ApiGameReplay(Game game, HttpListenerRequest request, HttpListenerResponse response) { + if (game.State != GameState.SetEnded) { + response.SetErrorResponse(new(HttpStatusCode.Conflict, "GameInProgress", "You can't see the replay until the set has ended.")); + return; + } + var ms = new MemoryStream(); + game.WriteReplayData(ms); + response.SetResponse(HttpStatusCode.OK, "application/octet-stream", ms.ToArray()); + } +} diff --git a/TableturfBattleServer/Game.cs b/TableturfBattleServer/Game.cs index ca66c0f..404c75e 100644 --- a/TableturfBattleServer/Game.cs +++ b/TableturfBattleServer/Game.cs @@ -137,7 +137,7 @@ public class Game(int maxPlayers) { public Deck GetDeck(string name, int sleeves, IEnumerable cardNumbers, IEnumerable cardUpgrades) { var deck = this.deckCache.FirstOrDefault(d => d.Name == name && d.Sleeves == sleeves && cardNumbers.SequenceEqual(from c in d.Cards select c.Number) && cardUpgrades.SequenceEqual(d.Upgrades)); if (deck == null) { - deck = new(name, sleeves, (from i in cardNumbers select i <= Program.RECEIVED_CUSTOM_CARD_START ? customCards[Program.RECEIVED_CUSTOM_CARD_START - i] : CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray()); + deck = new(name, sleeves, (from i in cardNumbers select i <= ApiEndpoints.RECEIVED_CUSTOM_CARD_START ? customCards[ApiEndpoints.RECEIVED_CUSTOM_CARD_START - i] : CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray()); this.deckCache.Add(deck); } return deck; diff --git a/TableturfBattleServer/HttpRequestHelper.cs b/TableturfBattleServer/HttpRequestHelper.cs new file mode 100644 index 0000000..b061c86 --- /dev/null +++ b/TableturfBattleServer/HttpRequestHelper.cs @@ -0,0 +1,81 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net; +using System.Text; +using System.Web; +using Newtonsoft.Json; +using HttpListenerRequest = WebSocketSharp.Net.HttpListenerRequest; +using HttpListenerResponse = WebSocketSharp.Net.HttpListenerResponse; + +namespace TableturfBattleServer; +internal static class HttpRequestHelper { + internal static readonly char[] DELIMITERS = [',', ' ']; + internal const int CUSTOM_CARD_START = -10000; + internal const int RECEIVED_CUSTOM_CARD_START = -20000; + + internal static void SetErrorResponse(this HttpListenerResponse response, Error error) { + var bytes = Encoding.UTF8.GetBytes(JsonUtils.Serialise(error)); + SetResponse(response, error.HttpStatusCode, "application/json", bytes); + } + internal static void SetResponse(this HttpListenerResponse response, HttpStatusCode statusCode, string contentType, string content) { + var bytes = Encoding.UTF8.GetBytes(content); + SetResponse(response, statusCode, contentType, bytes); + } + internal static void SetResponse(this HttpListenerResponse response, HttpStatusCode statusCode, string contentType, byte[] content) { + response.StatusCode = (int) statusCode; + response.ContentType = contentType; + response.ContentLength64 = content.Length; + response.Close(content, true); + } + + internal static Dictionary DecodeFormData(Stream stream) => DecodeFormData(new StreamReader(stream).ReadToEnd()); + internal static Dictionary DecodeFormData(TextReader reader) => DecodeFormData(reader.ReadToEnd()); + internal static Dictionary DecodeFormData(string s) { + if (s.StartsWith('?')) s = s[1..]; + return s != "" + ? s.Split(['&']).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])) + : []; + } + + internal 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 TryParseStageSelectionRule(string json, int maxPlayers, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) { + try { + stageSelectionRule = JsonUtils.Deserialise(json); + if (stageSelectionRule == null) return false; + stageSelectionRule.AddUnavailableStages(maxPlayers); + // Check that at least one stage is allowed. + for (var i = 0; i < StageDatabase.Stages.Count; i++) { + if (!stageSelectionRule.BannedStages.Contains(i)) return true; + } + return false; + } catch (JsonSerializationException) { + stageSelectionRule = null; + return false; + } + } +} diff --git a/TableturfBattleServer/Program.cs b/TableturfBattleServer/Program.cs index 10f399b..74affab 100644 --- a/TableturfBattleServer/Program.cs +++ b/TableturfBattleServer/Program.cs @@ -1,33 +1,21 @@ -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Net; +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 delegate void ApiEndpointGlobalHandler(HttpListenerRequest request, HttpListenerResponse response); +internal delegate void ApiEndpointGameHandler(Game game, HttpListenerRequest request, HttpListenerResponse response); + internal partial class Program { internal static HttpServer? httpServer; - internal static Dictionary games = []; - internal static Dictionary inactiveGames = []; - internal static readonly Timer timer = new(1000); - private static bool lockdown; - - private const int InactiveGameLimit = 1000; - private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5); - internal static readonly char[] DELIMITERS = [',', ' ']; - internal const int CUSTOM_CARD_START = -10000; - internal const int RECEIVED_CUSTOM_CARD_START = -20000; + private static readonly Dictionary apiGlobalHandlers = []; + private static readonly Dictionary apiGameHandlers = []; + private static readonly HashSet spaPaths = [ "/", "/deckeditor", "/cardlist", "/game" , "/replay" ]; private static string? GetClientRootPath() { var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -40,9 +28,16 @@ internal partial class Program { } internal static void Main(string[] args) { - httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() }; + foreach (var method in typeof(ApiEndpoints).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) { + var attribute = method.GetCustomAttribute(); + if (attribute == null) continue; + if (attribute.Namespace == ApiEndpointNamespace.ApiRoot) + apiGlobalHandlers[attribute.Path] = (attribute, method.CreateDelegate()); + else + apiGameHandlers[attribute.Path] = (attribute, method.CreateDelegate()); + } - timer.Elapsed += Timer_Elapsed; + httpServer = new(args.Contains("--open") ? IPAddress.Any : IPAddress.Loopback, 3333) { DocumentRootPath = GetClientRootPath() }; httpServer.AddWebSocketService("/api/websocket"); httpServer.OnGet += HttpServer_OnRequest; @@ -61,727 +56,95 @@ internal partial class Program { else { s = s.Trim().ToLower(); if (s == "update") { - if (games.Count == 0) + if (Server.Instance.games.Count == 0) Environment.Exit(2); - lockdown = true; + Server.Instance.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("/cardlist") || 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.")); + if (!e.Request.RawUrl.StartsWith('/')) { + e.Response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidRequestUrl", "Invalid request URL.")); 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(); + } - bool allowUpcomingCards; - if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) { - if (!bool.TryParse(allowUpcomingCardsString, out allowUpcomingCards)) - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowUpcomingCards was invalid.")); - } else - allowUpcomingCards = true; - - bool allowCustomCards; - if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) { - if (!bool.TryParse(allowCustomCardsString, out allowCustomCards)) - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "allowCustomCards was invalid.")); - } else - allowCustomCards = false; - - StageSelectionRules? stageSelectionRuleFirst = null, stageSelectionRuleAfterWin = null, stageSelectionRuleAfterDraw = null; - if (d.TryGetValue("stageSelectionRuleFirst", out var json1)) { - if (!TryParseStageSelectionRule(json1, maxPlayers, 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, maxPlayers, 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, maxPlayers, 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) { HostClientToken = clientToken, GoalWinCount = goalWinCount, TurnTimeLimit = turnTimeLimit, AllowUpcomingCards = allowUpcomingCards, AllowCustomCards = allowCustomCards, 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")); - } + if (!e.Request.RawUrl.StartsWith("/api/")) { + // Static files + if (e.Request.HttpMethod is not ("GET" or "HEAD")) { + e.Response.AddHeader("Allow", "GET, HEAD"); + e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + return; } - } 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); + var pos = e.Request.RawUrl.IndexOf('/', 1); + var topLevelFileName = pos < 0 ? e.Request.RawUrl : e.Request.RawUrl[..pos]; + var path = spaPaths.Contains(topLevelFileName) ? "index.html" : HttpUtility.UrlDecode(e.Request.RawUrl[1..]); + if (e.TryReadFile(path, out var bytes)) + e.Response.SetResponse(HttpStatusCode.OK, Path.GetExtension(path) switch { + ".html" or ".htm" => "text/html", + ".css" => "text/css", + ".js" => "text/javascript", + ".png" => "image/png", + ".tar" => "application/x-tar", + ".webp" => "image/webp", + ".woff" or ".woff2" => "font/woff", + _ => "application/octet-stream" + }, bytes); + else + e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "File not found.")); } else { - var m = GamePathRegex().Match(e.Request.RawUrl); - if (m.Success) { - if (!Guid.TryParse(m.Groups[1].Value, out var gameID)) { - SetErrorResponse(e.Response, new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID.")); + var pos = e.Request.RawUrl.IndexOf('?', 5); + var path = pos < 0 ? e.Request.RawUrl[4..] : e.Request.RawUrl[4..pos]; + if (apiGlobalHandlers.TryGetValue(path, out var entry)) { + if ((e.Request.HttpMethod == "HEAD" ? "GET" : e.Request.HttpMethod) != entry.attribute.AllowedMethod) { + e.Response.AddHeader("Allow", entry.attribute.AllowedMethod == "GET" ? "GET, HEAD" : entry.attribute.AllowedMethod); + e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); return; } - lock (games) { - if (!TryGetGame(gameID, out var game)) { - SetErrorResponse(e.Response, new(HttpStatusCode.NotFound, "GameNotFound", "Game not found.")); + if (e.Request.ContentLength64 >= 65536) { + e.Response.SetErrorResponse(new(HttpStatusCode.RequestEntityTooLarge, "ContentTooLarge", "Request content is too large.")); + return; + } + entry.handler(e.Request, e.Response); + } else { + if (!path.StartsWith("/games/")) { + e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found.")); + return; + } + pos = path.IndexOf('/', 7); + var gameIdString = path[7..(pos < 0 ? ^0 : pos)]; + path = pos < 0 ? "/" : path[pos..]; + if (!Guid.TryParse(gameIdString, out var gameID)) { + e.Response.SetErrorResponse(new(HttpStatusCode.BadRequest, "InvalidGameID", "Invalid game ID.")); + return; + } + + lock (Server.Instance.games) { + if (!Server.Instance.TryGetGame(gameID, out var game)) { + e.Response.SetErrorResponse(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 "setGameSettings": { - 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 (clientToken != game.HostClientToken) { - SetErrorResponse(e.Response, new(HttpStatusCode.Forbidden, "AccessDenied", "Only the host can do that.")); - return; - } - if (game.State != GameState.WaitingForPlayers) { - SetErrorResponse(e.Response, new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started.")); - 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, "InvalidGameSettings", "Invalid turn time limit.")); - return; - } else - game.TurnTimeLimit = turnTimeLimit2; - } - - if (d.TryGetValue("allowUpcomingCards", out var allowUpcomingCardsString)) { - if (!bool.TryParse(allowUpcomingCardsString, out var allowUpcomingCards)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowUpcomingCards.")); - return; - } else - game.AllowUpcomingCards = allowUpcomingCards; - } - - if (d.TryGetValue("allowCustomCards", out var allowCustomCardsString)) { - if (!bool.TryParse(allowCustomCardsString, out var allowCustomCards)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidGameSettings", "Invalid allowCustomCards.")); - return; - } else - game.AllowCustomCards = allowCustomCards; - } - - 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(DELIMITERS, 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; - } - - Dictionary? userCustomCards = null; - List>? customCardsToAdd = null; - if (d.TryGetValue("customCards", out var customCardsString)) { - if (!game.AllowCustomCards) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "CustomCardsNotAllowed", "Custom cards cannot be used in this game.")); - return; - } - userCustomCards = JsonUtils.Deserialise>(customCardsString); - - // Validate custom cards. - if (userCustomCards is null || userCustomCards.Count > 15 || userCustomCards.Keys.Any(k => k is not (<= -10000 and >= short.MinValue))) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", "Invalid custom cards.")); - return; - } - - customCardsToAdd = new(userCustomCards.Count); - foreach (var (k, v) in userCustomCards) { - if (!v.CheckGrid(out var hasSpecialSpace, out var size)) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Custom card {k} is invalid.")); - return; - } - // Allow resending the same custom card, but not a different custom card with the same key. - if (player.customCardMap.TryGetValue(k, out var existingCustomCardNumber)) { - if (!v.Equals(game.customCards[RECEIVED_CUSTOM_CARD_START - existingCustomCardNumber])) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "InvalidCustomCards", $"Cannot reuse custom card number {k}.")); - return; - } - } else { - // TODO: Consolidate identical custom cards brought by different players. - var card = v.ToCard(RECEIVED_CUSTOM_CARD_START - (game.customCards.Count + customCardsToAdd.Count), k, !hasSpecialSpace && size >= 8 ? 3 : null); - customCardsToAdd.Add(new(k, card)); - } - } - } - - var array = deckString.Split([',', '+', ' '], 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([',', '+', ' '], 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) && (userCustomCards == null || !userCustomCards.ContainsKey(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; - } - if (!game.AllowUpcomingCards && cardNumber is < 0 and > CUSTOM_CARD_START && CardDatabase.GetCard(cardNumber).Number < 0) { - SetErrorResponse(e.Response, new(HttpStatusCode.UnprocessableEntity, "ForbiddenDeck", "Upcoming cards cannot be used in this game.")); - return; - } - // Translate custom card numbers from the player to game-scoped card numbers. - cards[i] = player.customCardMap.TryGetValue(cardNumber, out var n) ? n : cardNumber <= CUSTOM_CARD_START && customCardsToAdd?.FirstOrDefault(e => e.Key == cardNumber).Value is Card customCard ? customCard.Number : cardNumber; - } - - if (customCardsToAdd != null) { - foreach (var (userKey, card) in customCardsToAdd) { - player.customCardMap.Add(userKey, card.Number); - game.customCards.Add(card); - } - } - 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; + if (!apiGameHandlers.TryGetValue(path, out var entry2)) { + e.Response.SetErrorResponse(new(HttpStatusCode.NotFound, "NotFound", "Endpoint not found.")); + return; } + if ((e.Request.HttpMethod == "HEAD" ? "GET" : e.Request.HttpMethod) != entry2.attribute.AllowedMethod) { + e.Response.AddHeader("Allow", entry2.attribute.AllowedMethod == "GET" ? "GET, HEAD" : entry2.attribute.AllowedMethod); + e.Response.SetErrorResponse(new(HttpStatusCode.MethodNotAllowed, "MethodNotAllowed", "Invalid request method for this endpoint.")); + return; + } + if (e.Request.ContentLength64 >= 65536) { + e.Response.SetErrorResponse(new(HttpStatusCode.RequestEntityTooLarge, "ContentTooLarge", "Request content is too large.")); + return; + } + entry2.handler(game, e.Request, e.Response); } } - } 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(['&']).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])) - : []; - } - - 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, int maxPlayers, [MaybeNullWhen(false)] out StageSelectionRules stageSelectionRule) { - try { - stageSelectionRule = JsonUtils.Deserialise(json); - if (stageSelectionRule == null) return false; - stageSelectionRule.AddUnavailableStages(maxPlayers); - // Check that at least one stage is allowed. - for (var i = 0; i < StageDatabase.Stages.Count; i++) { - if (!stageSelectionRule.BannedStages.Contains(i)) return true; } - return false; - } catch (JsonSerializationException) { - stageSelectionRule = null; - return false; } } - - [GeneratedRegex(@"^/api/games/([\w-]+)(?:/(\w+)(?:\?clientToken=([\w-]+))?)?$", RegexOptions.Compiled)] - private static partial Regex GamePathRegex(); } diff --git a/TableturfBattleServer/Server.cs b/TableturfBattleServer/Server.cs new file mode 100644 index 0000000..a0eff03 --- /dev/null +++ b/TableturfBattleServer/Server.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using System.Timers; +using Timer = System.Timers.Timer; + +namespace TableturfBattleServer; +internal class Server { + public static Server Instance { get; } = new(); + + internal Dictionary games = []; + internal Dictionary inactiveGames = []; + public bool Lockdown { get; set; } + internal readonly Timer timer = new(1000); + + private const int InactiveGameLimit = 1000; + private static readonly TimeSpan InactiveGameTimeout = TimeSpan.FromMinutes(5); + + private Server() => this.timer.Elapsed += this.Timer_Elapsed; + + internal 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 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"); + } + } + } + +} \ No newline at end of file diff --git a/TableturfBattleServer/TableturfWebSocketBehaviour.cs b/TableturfBattleServer/TableturfWebSocketBehaviour.cs index f95353a..28820c9 100644 --- a/TableturfBattleServer/TableturfWebSocketBehaviour.cs +++ b/TableturfBattleServer/TableturfWebSocketBehaviour.cs @@ -18,7 +18,7 @@ internal class TableturfWebSocketBehaviour : WebSocketBehavior { this.ClientToken = clientToken; // Send an initial state payload. - if (Program.TryGetGame(this.GameID, out var game)) { + if (Server.Instance.TryGetGame(this.GameID, out var game)) { DTO.PlayerData? playerData = null; for (int i = 0; i < game.Players.Count; i++) { var player = game.Players[i];